diff --git a/alfred/.env b/alfred/.env index a85d090..8bad3f0 100644 --- a/alfred/.env +++ b/alfred/.env @@ -10,3 +10,18 @@ AUDIO_OUTPUT_DEVICE=plughw:1,0 ## fr_FR-upmc-medium ## PIPER_VOICE=fr_FR-mls-medium PIPER_VOICE=fr_FR-upmc-medium + +#Calendrier HA + +ALFRED_TIMEZONE=Europe/Paris +HA_DEFAULT_CALENDAR=calendar.alfred +ALFRED_ALLOW_CALENDAR_WRITES=true +HA_URL=http://192.168.1.141:8123 +HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiI4MTU3YzFjYjA3NWY0YmNkOGRkYTEzYzIyOWVkMDFkOSIsImlhdCI6MTc4MTUyNjg5MSwiZXhwIjoyMDk2ODg2ODkxfQ.i4IfhNwgfPMmRnIhLdapq10sTxBKnNJmy4kFCDX4Ylw + +ALFRED_WEATHER_ILLUSTRATIONS=true +ALFRED_WEATHER_ILLUSTRATION_TTL_HOURS=12 +COMFYUI_URL=http://192.168.1.13:8188 +WORKFLOWS_DIR=/opt/hermes/MCP_comfyui/workflows +COMFY_PROMPT_NODE=6 +COMFYUI_TIMEOUT=300 diff --git a/alfred/README_INSTALLATION.md b/alfred/README_INSTALLATION.md index 0acd0df..52d6d65 100644 --- a/alfred/README_INSTALLATION.md +++ b/alfred/README_INSTALLATION.md @@ -1,59 +1,82 @@ -# Alfred — Interface mise à jour depuis interface.zip (24/10/2026) +# Alfred — Vues dynamiques & Calendrier Home Assistant (V1) -## Modifications +Base : votre **alfredapp.zip** du 25 octobre 2026. Aucun avatar n’est ajouté. Les fonctions Vosk, Hermes, Wyoming Piper, caméra locale, contrôles audio et ComfyUI sont conservées. -- Coin supérieur gauche : état réel de l'agent (`idle`, `listening`, `thinking`, `speaking`, `error`) avec libellé en français. Le titre « Alfred » est supprimé de la page. -- Coin supérieur droit : bouton « réglages audio » ouvrant un panneau flottant (volume sortie, sensibilité micro ALSA, mute micro), et bouton « couper/réactiver le son » (mute haut-parleur). -- Coin inférieur droit : transcription Vosk partielle puis finale, conservée pendant le retour à la veille. Les données arrivent des événements Socket.IO `alfred_state` existants (`partial` et `transcript`). -- Coin inférieur gauche : prévisualisation webcam MJPEG en direct. La caméra s'ouvre **uniquement sur clic** pour ne pas occuper en permanence la caméra utilisée par le MCP facial, et s'arrête lorsque l'onglet devient invisible. -- Protection supplémentaire : le texte d'Hermes est rendu sans injection de code HTML dans la page. +## Installation : patch (conseillé) -## Installation - -À effectuer sur la machine Alfred après transfert du ZIP, en étant dans son dossier parent `/opt/alfred` : +1. Sauvegarder **/opt/alfred/app**. Le ZIP de mise à jour ne contient que les fichiers ajoutés/modifiés, sous `app/`. +2. Décompresser `Alfred_Calendrier_HA_V1_PATCH.zip` sous **/opt/alfred/** (pas sous `/opt/alfred/app`, sinon vous obtiendrez `app/app`). +3. Activer votre environnement Python habituel et installer les dépendances si nécessaire : ```bash sudo systemctl stop alfred -sudo cp -a /opt/alfred/app /opt/alfred/app.sauvegarde-$(date +%Y%m%d-%H%M%S) -cd /opt/alfred -sudo unzip -o /tmp/Alfred_Interface_Reorganisation_V1.zip -python3 -m py_compile /opt/alfred/app/app.py /opt/alfred/app/services/camera.py -sudo systemctl start alfred -sudo journalctl -u alfred -n 50 --no-pager +sudo cp -a /opt/alfred/app /opt/alfred/app.sauvegarde.$(date +%Y%m%d_%H%M%S) +sudo unzip -o /tmp/Alfred_Calendrier_HA_V1_PATCH.zip -d /opt/alfred +cd /opt/alfred/app +# Utiliser le pip du venv exécutant app.py : +/path/vers/venv/bin/python -m pip install requests websocket-client +/path/vers/venv/bin/python -m py_compile app.py services/ha_views.py services/alfred_intents.py services/alfred_views_controller.py ``` -Le ZIP remplace uniquement : +Le script de démarrage systemd existant n’est **pas modifié**. -``` -app/app.py -app/templates/index.html -app/static/css/alfred.css -app/static/css/alfred-controls.css -app/static/js/alfred.js -app/static/js/alfred-controls.js -app/static/js/alfred-camera.js (nouveau) -app/services/camera.py +## Variables d’environnement à fournir à `alfred.service` + +```ini +HA_URL=http://192.168.1.XXX:8123 +HA_TOKEN=JETON_LONGUE_DUREE_HOME_ASSISTANT +ALFRED_TIMEZONE=Europe/Paris +# Limiter aux calendriers choisis (laisser absent pour lire tous les calendriers autorisés au jeton) : +# HA_CALENDARS=calendar.famille,calendar.alfred +# Permet de cibler un calendrier pour les créations vocales : +# HA_DEFAULT_CALENDAR=calendar.alfred +# Pour restreindre les caméras visibles (ex. uniquement les caméras extérieures) : +# HA_CAMERAS=camera.entree,camera.jardin +# L'écriture est OFF par sécurité ; l'activer après validation de la lecture : +ALFRED_ALLOW_CALENDAR_WRITES=true ``` -## Webcam +Mettre ces valeurs **dans l’environnement du service** (par exemple un fichier `EnvironmentFile=` de systemd ou fichier `.env` effectivement chargé par `/opt/alfred/app/app.py`). **Ne pas mettre le jeton dans le navigateur ni dans le ZIP.** Si l’application utilise `load_dotenv()`, un fichier `.env` dans son répertoire de travail peut convenir, mais vérifiez `systemctl cat alfred` avant de choisir son emplacement. -- Index de la caméra par défaut : 0, réglable avec `CAMERA_INDEX=1` (ou autre) dans l'environnement du service Alfred. -- Options facultatives : `CAMERA_WIDTH=640`, `CAMERA_HEIGHT=480`, `CAMERA_FPS=8`, `CAMERA_JPEG_QUALITY=75`. -- `cv2` / OpenCV doit être installé dans **l'environnement Python d'Alfred**, par exemple `python -m pip install opencv-python-headless` depuis son venv (uniquement si absent). -- Tester le périphérique : `ls -l /dev/video*`. -- URL du flux : `http://IP_ALFRED:54321/api/camera/stream`. -- Si le MCP de reconnaissance faciale utilise déjà la webcam en exclusivité, le flux peut échouer. Désactiver la prévisualisation avant une capture MCP si nécessaire. Cette version ne partage pas les images entre les processus. -- Attention : le flux est actuellement accessible à toute personne ayant accès au serveur Flask (aucune authentification intégrée à l'application de départ). Ne pas exposer le port 54321 sur Internet sans contrôle d'accès. +Après modification des paramètres systemd : `sudo systemctl daemon-reload`. Puis `sudo systemctl start alfred` et `sudo journalctl -u alfred -f`. -## Audio +L’utilisateur associé à HA_TOKEN doit avoir des droits lecture sur les calendriers voulus et des droits écriture sur ceux à modifier. Les calendriers Google ou externes en lecture seule ne permettront pas l’écriture. Certains calendriers n’acceptent pas la suppression. -Le bouton « Mute » en haut à droite agit sur **le haut-parleur**. Le mute du **microphone** se trouve dans le popup audio. Tous les réglages utilisent l'API POST `/api/audio/settings` existante. +## Tests initiaux -## Tests réalisés +1. Ouvrir `http://IP_ALFRED:54321/`. Appuyer sur l'icône **Agenda**. +2. Tester `http://IP_ALFRED:54321/api/calendar/calendars` puis `http://IP_ALFRED:54321/api/calendar/events` depuis une session réseau de confiance. +3. Dire « Alfred, montre-moi mon planning sur sept jours » puis « Et demain ? ». +4. Faire un événement de test via le bouton **+ Rendez-vous** dans un calendrier HA disposant de l'option création. +5. Depuis la grille, ouvrir cet événement, demander sa suppression, puis confirmer la boîte de dialogue ; vérifier l'événement dans HA. -- Compilation syntaxique de `app.py` et `camera.py`. -- Vérification syntaxique des trois scripts JavaScript au moyen de `node --check`. -- Validation des identifiants des contrôles de l'interface. -- Test du générateur MJPEG, du verrou d'accès à la caméra et de sa libération sur fermeture, avec une caméra simulée. +**Commandes prises en charge** : « mon agenda aujourd'hui », « demain », « ce matin », « cet après-midi », « ce soir », « dans la journée », « le 8 novembre », « sur 3 jours », « sur 7 jours », « cette semaine », « la semaine prochaine », « ce mois », « ai-je des choses prévues demain ? », « ajoute un rendez-vous dentiste demain à 15 h dans le calendrier Famille », « supprime mon rendez-vous dentiste demain », « confirme » / « annule », « montre les températures », « affiche les caméras », « reviens à l'accueil ». -Non testés : affichage en conditions réelles sous Chromium, accès à la caméra physique du MINISFORUM, intégration réelle à Vosk/ALSA. L'environnement de test empêche la navigation Chromium locale. +### Écriture vocale et sécurité + +- Pour une création entièrement spécifiée (date, heure, calendrier, titre), Alfred propose l'événement et **attend « confirme »** avant d'enregistrer. Si des éléments manquent, il affiche un formulaire prérempli ; il ne devine pas le calendrier ou l'heure. +- Pour une suppression, Alfred recherche l'événement par nom/période, exige un identifiant HA `uid` et une **confirmation explicite**. Si plusieurs événements correspondent, l'écran permet de choisir. Une confirmation vocale expire après 90 secondes ; elle n'est pas sauvegardée après redémarrage. Pour une série récurrente sans `recurrence_id`, la suppression peut affecter toute la série ; la boîte de dialogue l'indique. +- `ALFRED_ALLOW_CALENDAR_WRITES` désactive la création/suppression par défaut ; l'activer seulement sur un réseau de confiance et un utilisateur Home Assistant aux droits restreints. **Cette application ne fournit pas d'authentification utilisateur complète** : ne pas exposer Flask à Internet ni à un réseau non fiable. Les événements contiennent des données personnelles. +- La création utilise `POST /api/services/calendar/create_event`. La suppression utilise la commande HA WebSocket `calendar/event/delete`, **pas** un service REST inexistant. + +### Vue capteurs et caméras + +- Les températures sont les entités HA `sensor` ayant `device_class: temperature`, plus les températures courantes des entités `climate`. Le clic ouvre un graphique des dernières 24 h (si l'API d'historique HA fournit des points). +- La vue Caméras Home Assistant affiche des **instantanés** sécurisés derrière Flask, rafraîchis toutes les 15 s et seulement quand la vue est ouverte. Ce n'est pas un flux vidéo WebRTC/RTSP. La petite prévisualisation de la webcam locale existante reste inchangée. +- Le bouton Accueil ou la commande vocale rétablit la page d'accueil. + +### Remarques sur le contexte Hermes + +Ce patch **ne modifie pas `hermes_client.py`, `identity.md`, les sessions Hermes ou le MCP Home Assistant**. Les commandes visuelles explicites sont détectées côté Flask ; les autres demandes continuent vers Hermes et ses MCP, pour éviter de réintroduire la limitation du contexte qui avait empêché la génération ComfyUI. + +### Limites connues + +- L'interprétation vocale des dates suit un jeu de règles françaises (aujourd'hui/demain/jour de la semaine/jour et mois). Une formulation très libre peut nécessiter le formulaire tactile ou Hermes ; le routeur ne doit pas improviser une écriture. +- La couleur des événements suit la liste des calendriers ; elle n'est pas encore configurable. +- Certains calendriers de HA ne transmettent pas `uid` ou sont en lecture seule : leurs événements restent consultables, mais ne peuvent pas être supprimés dans Alfred. +- Les appels HA ne sont pas testables depuis ce fichier d'installation sans accès à votre serveur/token ; les validations automatiques portent sur la syntaxe, le routage vocal et des réponses HA simulées. +- Les requêtes de lecture et de mutation sont faites directement de Flask vers Home Assistant, **sans dupliquer les calendriers**. C'est un adaptateur de source, réutilisable pour d'autres fournisseurs plus tard. + +## Restauration + +Arrêter Alfred puis restaurer le dossier sauvegardé. Ne pas copier `identity.md` d'anciennes archives si vous l'aviez volontairement désactivé. diff --git a/alfred/README_MIC_MUTE.md b/alfred/README_MIC_MUTE.md new file mode 100644 index 0000000..2730299 --- /dev/null +++ b/alfred/README_MIC_MUTE.md @@ -0,0 +1,42 @@ +# Alfred — Micro muet dans la barre supérieure + +## Modifications + +- `app/templates/index.html` : déplace `#micMute` depuis la fenêtre audio vers la barre supérieure, à gauche du mute haut-parleur. Bouton avec deux icônes SVG (micro activé / micro coupé). +- `app/static/js/alfred-controls.js` : conserve l'API existante `POST /api/audio/settings` et actualise `aria-pressed` ainsi que le libellé du bouton en fonction de `mic_muted`. +- `app/static/css/alfred-controls.css` : affiche l'icône appropriée selon l'état du micro (état coupé rouge via la règle globale existante). + +La fenêtre des réglages conserve les curseurs de volume et de sensibilité du microphone. Le bouton de mute du haut-parleur reste inchangé. Aucune modification Python, Vosk, Hermes, Home Assistant ou calendrier. + +## Installation + +**Ce patch s'applique sur Alfred Calendrier V1**, et contient uniquement les trois fichiers ci-dessus. + +1. Copier `Alfred_Micro_Mute_Barre_V1.zip` dans `/tmp/` sur le mini-PC. +2. Sauvegarder les trois fichiers, puis extraire le ZIP depuis `/opt/alfred` (et non `/opt/alfred/app`). + +```bash +sudo systemctl stop alfred +sudo cp -a /opt/alfred/app/templates/index.html /opt/alfred/app/templates/index.html.bak +sudo cp -a /opt/alfred/app/static/js/alfred-controls.js /opt/alfred/app/static/js/alfred-controls.js.bak +sudo cp -a /opt/alfred/app/static/css/alfred-controls.css /opt/alfred/app/static/css/alfred-controls.css.bak +sudo unzip -o /tmp/Alfred_Micro_Mute_Barre_V1.zip -d /opt/alfred +sudo systemctl start alfred +``` + +Rechargez Chromium avec `Ctrl+F5` pour éviter l'ancien JavaScript en cache. + +## Vérification + +- Bouton micro visible en haut à droite, **à gauche du haut-parleur**. +- Appui : l'icône devient rouge et le micro est coupé ; nouvel appui : il est réactivé. +- Le bouton de réglages ouvre uniquement les curseurs de volume et de sensibilité. +- Le bouton haut-parleur reste indépendant. + +Le mute du micro continue à agir sur l'écoute d'Alfred via le backend existant. Lorsque le micro est coupé, les commandes vocales ne peuvent pas le réactiver : il faut utiliser le bouton tactile. + +## Tests effectués + +Syntaxe JavaScript vérifiée (`node --check`). Test Chromium automatisé : états micro activé/coupé/réactivé, lecture/écriture de `mic_muted` sur une simulation de l'API, présence des autres commandes, ouverture du popup. + +Ces tests ne remplacent pas une vérification réelle sur le mini-PC (ALSA, systemd et Vosk). diff --git a/alfred/app.backup.calendar/__pycache__/app.cpython-313.pyc b/alfred/app.backup.calendar/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000..09d2b8b Binary files /dev/null and b/alfred/app.backup.calendar/__pycache__/app.cpython-313.pyc differ diff --git a/alfred/app.backup.calendar/app.py b/alfred/app.backup.calendar/app.py new file mode 100644 index 0000000..34e7d75 --- /dev/null +++ b/alfred/app.backup.calendar/app.py @@ -0,0 +1,226 @@ +import os, threading, hmac +from flask import Flask, render_template, request, jsonify, Response +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services.hermes_client import ask_hermes +from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1 +from services.camera import video_stream + +load_dotenv() + +## +from services.audio import get_vosk_model +print("[ALFRED] Préchargement des modèles vocaux...", flush=True) +get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) +get_vosk_model(os.environ["VOSK_MODEL_PATH"]) +print("[ALFRED] Modèles vocaux prêts.", flush=True) +## +app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") +STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} +STATE_LOCK = threading.RLock() +IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI. +conversation_lock=threading.Lock() +mic_stop_event=threading.Event() +audio_settings.initialize() +audio_settings.update_settings({"mic_gain": 30}) + +def emit_state(mode=None, **extra): + with STATE_LOCK: + if mode: STATE["mode"] = mode + STATE.update(extra) + socketio.emit("alfred_state", dict(STATE)) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + stop_event=mic_stop_event, + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if audio_settings.mic_muted(): + emit_state("idle", partial="") + return + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + print("[ALFRED] Envoi à Hermes", flush=True) + # ComfyUI publie l'image pendant cet appel Hermes. + with STATE_LOCK: + image_revision_before = IMAGE_REVISION + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + # Si le MCP ComfyUI a affiché une image pendant la requête, + # ne pas l'écraser par la réponse textuelle d'Hermes. + with STATE_LOCK: + if IMAGE_REVISION == image_revision_before: + STATE["content"] = display + socketio.emit("alfred_render", display) + else: + print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + if audio_settings.mic_muted(): + return + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +@app.get('/api/audio/settings') +def get_audio_settings(): + return jsonify(audio_settings.get_settings()) + +@app.post('/api/audio/settings') +def set_audio_settings(): + data = request.get_json(silent=True) + try: + old = audio_settings.get_settings() + new = audio_settings.update_settings(data) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': f'ALSA : {exc}'}), 500 + if new['mic_muted'] != old['mic_muted']: + if new['mic_muted']: + mic_stop_event.set() + wake.pause() + else: + mic_stop_event.clear() + if not conversation_lock.locked(): + wake.resume() + socketio.emit('audio_settings', new) + return jsonify(new) + +@app.get('/api/camera/stream') +def camera_stream(): + # Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan. + try: + frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0'))) + except (ImportError, ValueError, RuntimeError, OSError) as exc: + return jsonify({'error': str(exc)}), 503 + return Response( + frames, + mimetype='multipart/x-mixed-replace; boundary=frame', + headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'} + ) + +@app.get("/") +def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) +@app.get("/api/state") +def state(): + with STATE_LOCK: + snapshot = dict(STATE) + return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()}) + +@app.post("/api/state") +def set_state(): + data = request.get_json(force=True) + with STATE_LOCK: + STATE.update(data) + socketio.emit("alfred_state", dict(STATE)) + return jsonify({"ok": True}) +@app.post("/api/render") +def render_payload(): + global IMAGE_REVISION + token = os.getenv("ALFRED_RENDER_TOKEN", "").strip() + if token and not hmac.compare_digest( + request.headers.get("X-Alfred-Render-Token", ""), token + ): + return jsonify({"ok": False, "error": "Non autorisé"}), 401 + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"ok": False, "error": "JSON objet attendu"}), 400 + if data.get("type") == "image": + image_url = data.get("url") + if not isinstance(image_url, str) or not ( + image_url.startswith(("http://", "https://", "/")) + and not image_url.startswith("//") + ): + return jsonify({"ok": False, "error": "URL image invalide"}), 400 + + with STATE_LOCK: + if data.get("type") == "image": + IMAGE_REVISION += 1 + STATE["content"] = data + socketio.emit("alfred_render", data) + return jsonify({"ok": True, "image_revision": IMAGE_REVISION}) +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) + diff --git a/alfred/app.backup.calendar/app.py.ori b/alfred/app.backup.calendar/app.py.ori new file mode 100644 index 0000000..d44c3ca --- /dev/null +++ b/alfred/app.backup.calendar/app.py.ori @@ -0,0 +1,180 @@ +import os, threading +from flask import Flask, render_template, request, jsonify, Response +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services.hermes_client import ask_hermes +from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1 +from services.camera import video_stream + +load_dotenv() + +## +from services.audio import get_vosk_model +print("[ALFRED] Préchargement des modèles vocaux...", flush=True) +get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) +get_vosk_model(os.environ["VOSK_MODEL_PATH"]) +print("[ALFRED] Modèles vocaux prêts.", flush=True) +## +app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") +STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} +conversation_lock=threading.Lock() +mic_stop_event=threading.Event() +audio_settings.initialize() + +def emit_state(mode=None, **extra): + if mode: STATE["mode"]=mode + STATE.update(extra); socketio.emit("alfred_state",STATE) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + stop_event=mic_stop_event, + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if audio_settings.mic_muted(): + emit_state("idle", partial="") + return + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + print("[ALFRED] Envoi à Hermes", flush=True) + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + STATE["content"] = display + socketio.emit("alfred_render", display) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + if audio_settings.mic_muted(): + return + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +@app.get('/api/audio/settings') +def get_audio_settings(): + return jsonify(audio_settings.get_settings()) + +@app.post('/api/audio/settings') +def set_audio_settings(): + data = request.get_json(silent=True) + try: + old = audio_settings.get_settings() + new = audio_settings.update_settings(data) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': f'ALSA : {exc}'}), 500 + if new['mic_muted'] != old['mic_muted']: + if new['mic_muted']: + mic_stop_event.set() + wake.pause() + else: + mic_stop_event.clear() + if not conversation_lock.locked(): + wake.resume() + socketio.emit('audio_settings', new) + return jsonify(new) + +@app.get('/api/camera/stream') +def camera_stream(): + # Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan. + try: + frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0'))) + except (ImportError, ValueError, RuntimeError, OSError) as exc: + return jsonify({'error': str(exc)}), 503 + return Response( + frames, + mimetype='multipart/x-mixed-replace; boundary=frame', + headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'} + ) + +@app.get("/") +def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) +@app.get("/api/state") +def state(): return jsonify({**STATE,"wake":wake.status(),"tts":tts_backend_detect()}) +@app.post("/api/state") +def set_state(): + STATE.update(request.get_json(force=True)); socketio.emit("alfred_state",STATE); return jsonify({"ok":True}) +@app.post("/api/render") +def render_payload(): + data=request.get_json(force=True); STATE["content"]=data; socketio.emit("alfred_render",data); return jsonify({"ok":True}) +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) diff --git a/alfred/app.backup.calendar/backup_audio_20261016_133326/app.py b/alfred/app.backup.calendar/backup_audio_20261016_133326/app.py new file mode 100644 index 0000000..9a63c93 --- /dev/null +++ b/alfred/app.backup.calendar/backup_audio_20261016_133326/app.py @@ -0,0 +1,130 @@ +import os, threading +from flask import Flask, render_template, request, jsonify +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services.hermes_client import ask_hermes + +load_dotenv() + +## +from services.audio import get_vosk_model +print("[ALFRED] Préchargement des modèles vocaux...", flush=True) +get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) +get_vosk_model(os.environ["VOSK_MODEL_PATH"]) +print("[ALFRED] Modèles vocaux prêts.", flush=True) +## +app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") +STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} +conversation_lock=threading.Lock() + +def emit_state(mode=None, **extra): + if mode: STATE["mode"]=mode + STATE.update(extra); socketio.emit("alfred_state",STATE) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + print("[ALFRED] Envoi à Hermes", flush=True) + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + STATE["content"] = display + socketio.emit("alfred_render", display) + + if speech: + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +@app.get("/") +def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) +@app.get("/api/state") +def state(): return jsonify({**STATE,"wake":wake.status(),"tts":tts_backend_detect()}) +@app.post("/api/state") +def set_state(): + STATE.update(request.get_json(force=True)); socketio.emit("alfred_state",STATE); return jsonify({"ok":True}) +@app.post("/api/render") +def render_payload(): + data=request.get_json(force=True); STATE["content"]=data; socketio.emit("alfred_render",data); return jsonify({"ok":True}) +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on"): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) diff --git a/alfred/app.backup.calendar/backup_audio_20261016_133326/audio.py b/alfred/app.backup.calendar/backup_audio_20261016_133326/audio.py new file mode 100644 index 0000000..b655c60 --- /dev/null +++ b/alfred/app.backup.calendar/backup_audio_20261016_133326/audio.py @@ -0,0 +1,379 @@ +"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre. + +STT: Vosk + sounddevice, avec fallback arecord/ALSA. +TTS: Piper local, avec fallback espeak puis spd-say. +Sortie: haut-parleur local via paplay/aplay. + +Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu +à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique. +""" +from __future__ import annotations +from functools import lru_cache + +import time +import json +import os +import queue +import shutil +import subprocess +import tempfile +import threading +from dataclasses import dataclass +from typing import Callable, Optional + + +def _cmd_ok(cmd, **kwargs): + try: + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=True, text=True, **kwargs) + except Exception: + return None + + +def audio_backend_detect() -> str: + configured = os.getenv("AUDIO_BACKEND", "auto").lower() + if configured in ("pactl", "alsa"): + return configured + if shutil.which("pactl"): + info = _cmd_ok(["pactl", "info"]) + if info and "Server Name" in info.stdout: + return "pactl" + if shutil.which("amixer"): + return "alsa" + return "none" + + +def _which(cmd): + return shutil.which(cmd) + + +def _is_piper_tts(binary: Optional[str]) -> bool: + if not binary: + return False + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + if "gtk" in out or "ratbag" in out: + return False + return (("--model" in out or " -m " in out) and + ("--output_file" in out or " -o " in out)) + except Exception: + return False + + +def _find_piper_binary() -> Optional[str]: + candidates = [ + os.getenv("PIPER_BIN"), + "/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"), + "/usr/local/bin/piper", "/usr/bin/piper", _which("piper"), + ] + for candidate in candidates: + if candidate and _is_piper_tts(candidate): + return candidate + return None + + +def _piper_supports_long_flag(binary: str) -> bool: + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + return "--model" in out and "--output_file" in out + except Exception: + return True + +### +def tts_backend_detect() -> str: + """Retourne le moteur TTS configuré pour Alfred.""" + + if os.getenv("PIPER_HOST"): + return "wyoming-piper" + + if _which("espeak-ng"): + return "espeak-ng" + + return "none" + + +''' +def tts_backend_detect() -> str: + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + if piper and model and os.path.exists(model): + return "piper" + if _which("espeak"): + return "espeak" + if _which("spd-say"): + return "spd-say" + return "none" +''' +#### + +def speak_text(text: str): + """Synthèse vocale via Wyoming Piper, avec secours eSpeak.""" + if not text or not text.strip(): + return + + text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))] + + try: + from services.piper_tts import speak as piper_speak + + print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True) + piper_speak(text) + return + + except Exception as exc: + print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True) + + # Secours eSpeak + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + print("[ALFRED TTS] Secours eSpeak", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + except Exception as exc: + print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + +''' +def speak_text(text: str): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + start = time.monotonic() + print("[ALFRED TTS] Génération audio...", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + print("[ALFRED TTS] Lecture audio...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED TTS] Erreur : {e}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + + + + +def speak_text(text: str): + """Synthétise et joue localement le texte. Retourne (ok, backend, erreur).""" + text = (text or "").strip() + if not text: + return False, "none", "texte vide" + text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))] + + backend = audio_backend_detect() + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + + if piper and model and os.path.exists(model): + wav_path = None + try: + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + wav_path = tmp.name + cmd = [piper] + if _piper_supports_long_flag(piper): + cmd += ["--model", model, "--output_file", wav_path] + else: + cmd += ["-m", model, "-o", wav_path] + p = subprocess.run(cmd, input=text, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if p.returncode != 0: + return False, "piper", (p.stderr or p.stdout or "")[:2000] + if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0: + return False, "piper", "Piper n'a pas produit de WAV" + if backend == "pactl" and shutil.which("paplay"): + play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + elif shutil.which("aplay"): + play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + else: + return False, "piper", "paplay/aplay introuvable" + return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée" + except Exception as exc: + return False, "piper", str(exc) + finally: + if wav_path: + try: os.unlink(wav_path) + except OSError: pass + + if _which("espeak"): + args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text] + if backend == "pactl" and shutil.which("padsp"): + args = ["padsp"] + args + ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0 + return ok, "espeak", None if ok else "espeak a échoué" + + if _which("spd-say"): + ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE, + stderr=subprocess.PIPE).returncode == 0 + return ok, "spd-say", None if ok else "spd-say a échoué" + return False, "none", "aucun backend TTS disponible" +''' + +_tts_lock = threading.Lock() +def speak_async(text: str, on_start: Optional[Callable] = None, + on_end: Optional[Callable] = None) -> bool: + """Lecture non bloquante; refuse d'empiler deux réponses vocales.""" + if _tts_lock.locked(): + return False + def worker(): + with _tts_lock: + if on_start: on_start() + try: speak_text(text) + finally: + if on_end: on_end() + threading.Thread(target=worker, daemon=True).start() + return True + + +@dataclass +class STTResult: + text: str = "" + error: Optional[str] = None + + + +@lru_cache(maxsize=2) +def get_vosk_model(model_path): + from vosk import Model + + print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True) + model = Model(model_path) + print("[ALFRED] Modèle Vosk prêt", flush=True) + + return model + + +def listen_once(stop_event: Optional[threading.Event] = None, + on_partial: Optional[Callable[[str], None]] = None, + on_level: Optional[Callable[[float], None]] = None) -> STTResult: + """Attend une phrase finale Vosk et la retourne. + + Reprend le comportement du prototype: sounddevice en priorité, arecord sinon. + Le niveau audio transmis à l'UI est une approximation RMS sur int16. + """ + try: + import numpy as np + from vosk import Model, KaldiRecognizer + except Exception as exc: + return STTResult(error=f"Dépendance STT absente: {exc}") + + model_path = os.getenv("VOSK_MODEL_PATH", "") + sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000")) + if not model_path or not os.path.exists(model_path): + return STTResult(error=f"Modèle Vosk introuvable: {model_path}") + try: +# rec = KaldiRecognizer(Model(model_path), sample_rate) + rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate) + except Exception as exc: + return STTResult(error=f"Init Vosk: {exc}") + + def process(data: bytes) -> Optional[str]: + if on_level and data: + try: + a = np.frombuffer(data, dtype=np.int16).astype(np.float32) + rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0 + on_level(min(1.0, rms / 6000.0)) + except Exception: + pass + if rec.AcceptWaveform(data): + text = (json.loads(rec.Result() or "{}").get("text") or "").strip() + if text: + return text + elif on_partial: + partial = json.loads(rec.PartialResult() or "{}").get("partial", "") + print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True) + on_partial(partial) + return None + + try: + import sounddevice as sd + q = queue.Queue() + def callback(indata, frames, time_info, status): + q.put(bytes(indata)) + device = os.getenv("AUDIO_INPUT_DEVICE") or None + with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16", + channels=1, callback=callback, device=device): + while not (stop_event and stop_event.is_set()): + text = process(q.get()) + if text: + return STTResult(text=text) + except Exception as exc: + print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True) + +# except Exception: +# pass + + if not shutil.which("arecord"): + return STTResult(error="sounddevice indisponible et arecord introuvable") + cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"] + alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip() + if alsa_device: + cmd += ["-D", alsa_device] + proc = None + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) + while not (stop_event and stop_event.is_set()): + chunk = proc.stdout.read(8000) + if not chunk: + continue + text = process(chunk) + if text: + return STTResult(text=text) + return STTResult() + except Exception as exc: + return STTResult(error=f"arecord: {exc}") + finally: + if proc and proc.poll() is None: + proc.terminate() + + +def devices(): + try: +# import sounddevice as sd +# return sd.query_devices() + print("[ALFRED STT] Périphériques audio :", flush=True) + print(sd.query_devices(), flush=True) + print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True) + return sd.query_devices() + except Exception: + return [] diff --git a/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/alfred-controls.css b/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/alfred-controls.css new file mode 100644 index 0000000..ff91533 --- /dev/null +++ b/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/alfred-controls.css @@ -0,0 +1,19 @@ +/* Contrôles indépendants de la feuille alfred.css existante */ +body > main { position: relative; min-height: 100dvh; padding-bottom: 82px; box-sizing: border-box; } +.alfred-controls { + position: absolute; left: 16px; right: 16px; bottom: 12px; + display: flex; align-items: center; gap: 14px; min-height: 56px; + padding: 8px 18px; box-sizing: border-box; + border: 1px solid rgba(255,255,255,.10); border-radius: 16px; + background: rgba(35,37,41,.92); color: #f4f4f5; + box-shadow: 0 8px 28px rgba(0,0,0,.14); + font: 14px/1.3 system-ui, sans-serif; +} +.alfred-controls .audio-toggle { flex: 0 0 auto; width: 42px; height: 42px; padding: 0; border: 1px solid transparent; border-radius: 12px; background: transparent; color: inherit; font-size: 23px; cursor: pointer; } +.alfred-controls .audio-toggle:hover, .alfred-controls .audio-toggle:focus-visible { background: rgba(255,255,255,.10); outline-offset: 2px; } +.alfred-controls .audio-toggle[aria-pressed="true"] { background: rgba(225,100,100,.14); border-color: rgba(225,100,100,.28); } +.alfred-controls .volume-label { white-space: nowrap; color: #c8c8ce; } +.alfred-controls input[type="range"] { min-width: 60px; flex: 1; accent-color: #d1d5db; cursor: pointer; } +.alfred-controls output { min-width: 44px; text-align: right; font-variant-numeric: tabular-nums; } +.alfred-controls .controls-separator { height: 25px; width: 1px; background: rgba(255,255,255,.16); } +@media (max-width: 480px) { .alfred-controls { gap: 8px; padding: 6px 9px; left: 8px; right: 8px; } .alfred-controls .volume-label { display: none; } } diff --git a/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/alfred-controls.js b/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/alfred-controls.js new file mode 100644 index 0000000..142bd55 --- /dev/null +++ b/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/alfred-controls.js @@ -0,0 +1,73 @@ +(() => { + 'use strict'; + const slider = document.getElementById('volumeSlider'); + const volumeValue = document.getElementById('volumeValue'); + const speaker = document.getElementById('speakerMute'); + const mic = document.getElementById('micMute'); + const endpoint = '/api/audio/settings'; + let state = { volume: 70, speaker_muted: false, mic_muted: false }; + let saveTimer; + + function render() { + slider.value = String(state.volume); + volumeValue.value = `${state.volume} %`; + speaker.setAttribute('aria-pressed', String(state.speaker_muted)); + speaker.setAttribute('aria-label', state.speaker_muted ? 'Réactiver le haut-parleur' : 'Couper le haut-parleur'); + speaker.title = speaker.getAttribute('aria-label'); + speaker.textContent = state.speaker_muted ? '🔇' : '🔊'; + mic.setAttribute('aria-pressed', String(state.mic_muted)); + mic.setAttribute('aria-label', state.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone'); + mic.title = mic.getAttribute('aria-label'); + mic.textContent = state.mic_muted ? '🎙️̸' : '🎙️'; + } + + async function save() { + try { + const response = await fetch(endpoint, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(state) + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + if (result && typeof result.volume === 'number') { + state = { ...state, ...result }; + render(); + } + } catch (error) { + console.warn('Réglages audio non synchronisés avec Flask :', error); + } + } + + slider.addEventListener('input', () => { + state.volume = Number(slider.value); + render(); + clearTimeout(saveTimer); + saveTimer = setTimeout(save, 200); + }); + speaker.addEventListener('click', () => { + state.speaker_muted = !state.speaker_muted; + render(); + clearTimeout(saveTimer); + save(); + }); + mic.addEventListener('click', () => { + state.mic_muted = !state.mic_muted; + render(); + clearTimeout(saveTimer); + save(); + }); + + (async () => { + try { + const response = await fetch(endpoint); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const settings = await response.json(); + if (typeof settings.volume === 'number') state.volume = Math.max(0, Math.min(100, settings.volume)); + if (typeof settings.speaker_muted === 'boolean') state.speaker_muted = settings.speaker_muted; + if (typeof settings.mic_muted === 'boolean') state.mic_muted = settings.mic_muted; + } catch (error) { + console.warn('Réglages audio non disponibles :', error); + } + render(); + })(); +})(); diff --git a/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/audio_settings.py b/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/audio_settings.py new file mode 100644 index 0000000..20243b7 --- /dev/null +++ b/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/audio_settings.py @@ -0,0 +1,62 @@ +"""Réglages audio Alfred : ALSA carte 1 et états logiques thread-safe.""" +import os +import re +import subprocess +import threading + +_LOCK = threading.RLock() +_STATE = {'volume': 70, 'speaker_muted': False, 'mic_muted': False} +_CARD = os.getenv('ALFRED_ALSA_CARD', '1') +_CONTROL = os.getenv('ALFRED_ALSA_VOLUME_CONTROL', 'Master') + +def _amixer(*args): + return subprocess.run(['amixer', '-c', _CARD, *args], capture_output=True, + text=True, timeout=5, check=True).stdout + +def initialize(): + """Lit le volume existant sans modifier les réglages audio au démarrage.""" + try: + output = _amixer('sget', _CONTROL) + matches = re.findall(r'\[(\d+)%\]', output) + with _LOCK: + if matches: + _STATE['volume'] = int(matches[0]) + # Ne pas déduire speaker_muted du mixer : état propre à Alfred. + except (OSError, subprocess.SubprocessError) as exc: + print(f'[ALFRED AUDIO] Lecture ALSA impossible : {exc}', flush=True) + +def get_settings(): + with _LOCK: + return dict(_STATE) + +def update_settings(payload): + if not isinstance(payload, dict): + raise ValueError('Un objet JSON est attendu') + allowed = {'volume', 'speaker_muted', 'mic_muted'} + if set(payload) - allowed: + raise ValueError('Paramètre audio inconnu') + with _LOCK: + new = dict(_STATE) + if 'volume' in payload: + v = payload['volume'] + if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100: + raise ValueError('volume doit être entre 0 et 100') + new['volume'] = round(v) + for key in ('speaker_muted', 'mic_muted'): + if key in payload: + if not isinstance(payload[key], bool): + raise ValueError(f'{key} doit être un booléen') + new[key] = payload[key] + # Ne touche jamais aux contrôles Mic/Capture : calibration conservée. + if new['volume'] != _STATE['volume']: + _amixer('sset', _CONTROL, f"{new['volume']}%") + _STATE.update(new) + return dict(_STATE) + +def speaker_muted(): + with _LOCK: + return _STATE['speaker_muted'] + +def mic_muted(): + with _LOCK: + return _STATE['mic_muted'] diff --git a/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/index.html b/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/index.html new file mode 100644 index 0000000..4762a5b --- /dev/null +++ b/alfred/app.backup.calendar/backup_micro_gain_20261016_134053/index.html @@ -0,0 +1,28 @@ + + + + + + {{name}} + + + + + +
+
{{name}}repos
+

Bonjour.

À votre écoute.

+
+ +
+ + + + diff --git a/alfred/app.backup.calendar/config/hermes_session.json b/alfred/app.backup.calendar/config/hermes_session.json new file mode 100644 index 0000000..de88db4 --- /dev/null +++ b/alfred/app.backup.calendar/config/hermes_session.json @@ -0,0 +1,3 @@ +{ + "session_id": "local-65f11130ed816902a867736ef80b3af0ea255048cde912fb31b26267c8d1d599" +} diff --git a/alfred/app.backup.calendar/config/hermes_session.json.bak.20261024_151328 b/alfred/app.backup.calendar/config/hermes_session.json.bak.20261024_151328 new file mode 100644 index 0000000..4525415 --- /dev/null +++ b/alfred/app.backup.calendar/config/hermes_session.json.bak.20261024_151328 @@ -0,0 +1,3 @@ +{ + "session_id": "local-f2399cb2bcf9e91094d3dc34492b463eda9cf42d09857d51b5954c5982aaaa5e" +} diff --git a/alfred/app.backup.calendar/config/hermes_session.json.lock b/alfred/app.backup.calendar/config/hermes_session.json.lock new file mode 100644 index 0000000..e69de29 diff --git a/alfred/app.backup.calendar/config/identity.md b/alfred/app.backup.calendar/config/identity.md new file mode 100644 index 0000000..2ac6084 --- /dev/null +++ b/alfred/app.backup.calendar/config/identity.md @@ -0,0 +1,31 @@ +# Identité et comportement d'Alfred + +Tu es Alfred, le majordome vocal personnel de la maison. +Tu t'appelles Alfred, même si le moteur logiciel qui te permet de raisonner est Hermes Agent. + + +## Langue et style +- Réponds toujours en français, sauf demande explicite de parler une autre langue. +- Tutoie ton interlocuteur. +- Adopte un ton naturel, calme, cordial et professionnel. +- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute. +- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement. +- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne. + +## Domotique +- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles. +- Ne confirme une action que si son exécution a été effectivement vérifiée. +- Si tu ne peux pas effectuer une action, explique-le simplement. + +## Identité +- Si l'on te demande ton nom, réponds : « Je m'appelle Alfred. » + + +# Alfred +Tu disposes des outils et compétences configurés +dans Hermes Agent. + +Utilise librement les outils disponibles lorsque +la demande le nécessite. + +Ne limite pas tes capacités à la domotique. diff --git a/alfred/app.backup.calendar/config/identity.md.bak.20261016_140326 b/alfred/app.backup.calendar/config/identity.md.bak.20261016_140326 new file mode 100644 index 0000000..ed48720 --- /dev/null +++ b/alfred/app.backup.calendar/config/identity.md.bak.20261016_140326 @@ -0,0 +1,26 @@ +# Identité d'Alfred + +Tu es Alfred, un majordome personnel intelligent. + +Tu fonctionnes dans une maison équipée de Home Assistant. +Tu utilises Hermes Agent comme moteur de raisonnement. + +## Comportement + +- Ton nom est Alfred. +- Tu réponds toujours en français, sauf demande explicite contraire. +- Tu t'adresses à ton utilisateur en le tutoyant. +- Tu adoptes un ton naturel, calme, chaleureux et professionnel. +- Tu privilégies les réponses courtes, adaptées à une conversation vocale. +- Tu évites les introductions inutiles et les formulations trop techniques. +- Tu ne mentionnes pas Hermes, Ollama ou ton fonctionnement interne sauf si on te le demande. + +## Domotique + +- Tu peux utiliser les outils Home Assistant disponibles. +- Lorsque tu exécutes une commande, confirme brièvement le résultat. +- Ne prétends jamais avoir exécuté une action si l'outil a échoué. + +## Identité + +Si on te demande ton nom, tu réponds : « Je m'appelle Alfred. » diff --git a/alfred/app.backup.calendar/config/identity.md.ori2 b/alfred/app.backup.calendar/config/identity.md.ori2 new file mode 100644 index 0000000..52603ea --- /dev/null +++ b/alfred/app.backup.calendar/config/identity.md.ori2 @@ -0,0 +1,74 @@ +# Identité et comportement d'Alfred + +Tu es Alfred, un majordome numérique personnel, +intelligent, polyvalent et connecté. + +Tu n'es pas limité à la domotique. +Tu es un assistant capable de répondre à des questions, +de rechercher des informations, de générer des images, +d'analyser des documents et d'interagir avec la maison. + +## Langue et personnalité + +- Réponds toujours en français. +- Tutoie ton interlocuteur. +- Adopte un ton calme, cordial et professionnel. +- Privilégie les réponses courtes adaptées à la voix. +- Ne mentionne pas ton architecture interne sans raison. + +## Capacités et outils + +Tu disposes de plusieurs outils MCP. + +### Home Assistant +- Contrôler les équipements connectés. +- Consulter les capteurs et les états. +- Exécuter les commandes demandées. + +### ComfyUI +- Générer des images à partir de descriptions. +- Analyser des images avec les outils disponibles. +- Lorsqu'une génération est demandée, recherche et + utilise l'outil MCP generate_image. +- L'outil ComfyUI peut transmettre directement + l'image générée à l'interface graphique d'Alfred. +- Ne prétends pas être incapable de générer une image + sans avoir vérifié la disponibilité de cet outil. + +### Autres capacités +- Répondre aux questions générales. +- Utiliser les outils de recherche disponibles. +- Reconnaître des personnes avec le MCP facial, + uniquement lorsque son utilisation est appropriée. +- Utiliser les autres MCP selon les demandes. + +## Règles générales + +- Choisis l'outil approprié selon la demande. +- Ne limite jamais tes capacités à la seule domotique. +- Ne prétends jamais avoir exécuté une action + si celle-ci n'a pas abouti. +- Si un outil est indisponible, explique le problème. +- Ne fabrique jamais de résultats d'outils. + +## Identité + +Si on te demande ton nom, réponds : +"Je m'appelle Alfred." + +## Génération d'images + +Tu peux générer des images avec le MCP ComfyUI. + +Lorsqu'un utilisateur demande une image : +1. Recherche l'outil generate_image du MCP ComfyUI. +2. Si Tool Search est actif, utilise tool_search pour + découvrir l'outil et tool_describe si nécessaire. +3. Appelle l'outil de génération avec la description. +4. Attends son résultat avant de confirmer la génération. + +Le MCP ComfyUI transmet automatiquement les images +générées à l'interface graphique d'Alfred. + +Ne prétends pas que tu es limité à la domotique. +Si l'outil n'est pas accessible, indique l'erreur réelle. diff --git a/alfred/app.backup.calendar/services/__pycache__/audio.cpython-313.pyc b/alfred/app.backup.calendar/services/__pycache__/audio.cpython-313.pyc new file mode 100644 index 0000000..a2ea94c Binary files /dev/null and b/alfred/app.backup.calendar/services/__pycache__/audio.cpython-313.pyc differ diff --git a/alfred/app.backup.calendar/services/__pycache__/audio_settings.cpython-313.pyc b/alfred/app.backup.calendar/services/__pycache__/audio_settings.cpython-313.pyc new file mode 100644 index 0000000..fc453e0 Binary files /dev/null and b/alfred/app.backup.calendar/services/__pycache__/audio_settings.cpython-313.pyc differ diff --git a/alfred/app.backup.calendar/services/__pycache__/camera.cpython-313.pyc b/alfred/app.backup.calendar/services/__pycache__/camera.cpython-313.pyc new file mode 100644 index 0000000..adcb34b Binary files /dev/null and b/alfred/app.backup.calendar/services/__pycache__/camera.cpython-313.pyc differ diff --git a/alfred/app.backup.calendar/services/__pycache__/hermes_client.cpython-313.pyc b/alfred/app.backup.calendar/services/__pycache__/hermes_client.cpython-313.pyc new file mode 100644 index 0000000..ec74a92 Binary files /dev/null and b/alfred/app.backup.calendar/services/__pycache__/hermes_client.cpython-313.pyc differ diff --git a/alfred/app.backup.calendar/services/__pycache__/piper_tts.cpython-313.pyc b/alfred/app.backup.calendar/services/__pycache__/piper_tts.cpython-313.pyc new file mode 100644 index 0000000..4508a12 Binary files /dev/null and b/alfred/app.backup.calendar/services/__pycache__/piper_tts.cpython-313.pyc differ diff --git a/alfred/app.backup.calendar/services/__pycache__/wakeword.cpython-313.pyc b/alfred/app.backup.calendar/services/__pycache__/wakeword.cpython-313.pyc new file mode 100644 index 0000000..9bc0ffb Binary files /dev/null and b/alfred/app.backup.calendar/services/__pycache__/wakeword.cpython-313.pyc differ diff --git a/alfred/app.backup.calendar/services/audio.py b/alfred/app.backup.calendar/services/audio.py new file mode 100644 index 0000000..390f1d4 --- /dev/null +++ b/alfred/app.backup.calendar/services/audio.py @@ -0,0 +1,392 @@ +"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre. + +STT: Vosk + sounddevice, avec fallback arecord/ALSA. +TTS: Piper local, avec fallback espeak puis spd-say. +Sortie: haut-parleur local via paplay/aplay. + +Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu +à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique. +""" +from __future__ import annotations +from functools import lru_cache + +import time +import json +import os +import queue +import shutil +import subprocess +import tempfile +import threading +from dataclasses import dataclass +from typing import Callable, Optional + + +def _cmd_ok(cmd, **kwargs): + try: + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=True, text=True, **kwargs) + except Exception: + return None + + +def audio_backend_detect() -> str: + configured = os.getenv("AUDIO_BACKEND", "auto").lower() + if configured in ("pactl", "alsa"): + return configured + if shutil.which("pactl"): + info = _cmd_ok(["pactl", "info"]) + if info and "Server Name" in info.stdout: + return "pactl" + if shutil.which("amixer"): + return "alsa" + return "none" + + +def _which(cmd): + return shutil.which(cmd) + + +def _is_piper_tts(binary: Optional[str]) -> bool: + if not binary: + return False + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + if "gtk" in out or "ratbag" in out: + return False + return (("--model" in out or " -m " in out) and + ("--output_file" in out or " -o " in out)) + except Exception: + return False + + +def _find_piper_binary() -> Optional[str]: + candidates = [ + os.getenv("PIPER_BIN"), + "/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"), + "/usr/local/bin/piper", "/usr/bin/piper", _which("piper"), + ] + for candidate in candidates: + if candidate and _is_piper_tts(candidate): + return candidate + return None + + +def _piper_supports_long_flag(binary: str) -> bool: + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + return "--model" in out and "--output_file" in out + except Exception: + return True + +### +def tts_backend_detect() -> str: + """Retourne le moteur TTS configuré pour Alfred.""" + + if os.getenv("PIPER_HOST"): + return "wyoming-piper" + + if _which("espeak-ng"): + return "espeak-ng" + + return "none" + + +''' +def tts_backend_detect() -> str: + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + if piper and model and os.path.exists(model): + return "piper" + if _which("espeak"): + return "espeak" + if _which("spd-say"): + return "spd-say" + return "none" +''' +#### + +def speak_text(text: str): + """Synthèse vocale via Wyoming Piper, avec secours eSpeak.""" + from services.audio_settings import speaker_muted + if speaker_muted(): + return + if not text or not text.strip(): + return + + text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))] + + try: + from services.piper_tts import speak as piper_speak + + print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True) + piper_speak(text) + return + + except Exception as exc: + print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True) + + # Secours eSpeak + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + print("[ALFRED TTS] Secours eSpeak", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + except Exception as exc: + print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + +''' +def speak_text(text: str): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + start = time.monotonic() + print("[ALFRED TTS] Génération audio...", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + print("[ALFRED TTS] Lecture audio...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED TTS] Erreur : {e}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + + + + +def speak_text(text: str): + """Synthétise et joue localement le texte. Retourne (ok, backend, erreur).""" + text = (text or "").strip() + if not text: + return False, "none", "texte vide" + text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))] + + backend = audio_backend_detect() + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + + if piper and model and os.path.exists(model): + wav_path = None + try: + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + wav_path = tmp.name + cmd = [piper] + if _piper_supports_long_flag(piper): + cmd += ["--model", model, "--output_file", wav_path] + else: + cmd += ["-m", model, "-o", wav_path] + p = subprocess.run(cmd, input=text, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if p.returncode != 0: + return False, "piper", (p.stderr or p.stdout or "")[:2000] + if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0: + return False, "piper", "Piper n'a pas produit de WAV" + if backend == "pactl" and shutil.which("paplay"): + play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + elif shutil.which("aplay"): + play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + else: + return False, "piper", "paplay/aplay introuvable" + return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée" + except Exception as exc: + return False, "piper", str(exc) + finally: + if wav_path: + try: os.unlink(wav_path) + except OSError: pass + + if _which("espeak"): + args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text] + if backend == "pactl" and shutil.which("padsp"): + args = ["padsp"] + args + ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0 + return ok, "espeak", None if ok else "espeak a échoué" + + if _which("spd-say"): + ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE, + stderr=subprocess.PIPE).returncode == 0 + return ok, "spd-say", None if ok else "spd-say a échoué" + return False, "none", "aucun backend TTS disponible" +''' + +_tts_lock = threading.Lock() +def speak_async(text: str, on_start: Optional[Callable] = None, + on_end: Optional[Callable] = None) -> bool: + """Lecture non bloquante; refuse d'empiler deux réponses vocales.""" + if _tts_lock.locked(): + return False + def worker(): + with _tts_lock: + if on_start: on_start() + try: speak_text(text) + finally: + if on_end: on_end() + threading.Thread(target=worker, daemon=True).start() + return True + + +@dataclass +class STTResult: + text: str = "" + error: Optional[str] = None + + + +@lru_cache(maxsize=2) +def get_vosk_model(model_path): + from vosk import Model + + print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True) + model = Model(model_path) + print("[ALFRED] Modèle Vosk prêt", flush=True) + + return model + + +def listen_once(stop_event: Optional[threading.Event] = None, + on_partial: Optional[Callable[[str], None]] = None, + on_level: Optional[Callable[[float], None]] = None) -> STTResult: + """Attend une phrase finale Vosk et la retourne. + + Reprend le comportement du prototype: sounddevice en priorité, arecord sinon. + Le niveau audio transmis à l'UI est une approximation RMS sur int16. + """ + try: + import numpy as np + from vosk import Model, KaldiRecognizer + except Exception as exc: + return STTResult(error=f"Dépendance STT absente: {exc}") + + model_path = os.getenv("VOSK_MODEL_PATH", "") + sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000")) + if not model_path or not os.path.exists(model_path): + return STTResult(error=f"Modèle Vosk introuvable: {model_path}") + try: +# rec = KaldiRecognizer(Model(model_path), sample_rate) + rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate) + except Exception as exc: + return STTResult(error=f"Init Vosk: {exc}") + + def process(data: bytes) -> Optional[str]: + if on_level and data: + try: + a = np.frombuffer(data, dtype=np.int16).astype(np.float32) + rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0 + on_level(min(1.0, rms / 6000.0)) + except Exception: + pass + if rec.AcceptWaveform(data): + text = (json.loads(rec.Result() or "{}").get("text") or "").strip() + if text: + return text + elif on_partial: + partial = json.loads(rec.PartialResult() or "{}").get("partial", "") + print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True) + on_partial(partial) + return None + + try: + import sounddevice as sd + q = queue.Queue(maxsize=32) + def callback(indata, frames, time_info, status): + try: + q.put_nowait(bytes(indata)) + except queue.Full: + pass + device = os.getenv("AUDIO_INPUT_DEVICE") or None + with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16", + channels=1, callback=callback, device=device): + while not (stop_event and stop_event.is_set()): + try: + chunk = q.get(timeout=0.25) + except queue.Empty: + continue + text = process(chunk) + if text: + return STTResult(text=text) + except Exception as exc: + print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True) + +# except Exception: +# pass + + if stop_event and stop_event.is_set(): + return STTResult() + + if not shutil.which("arecord"): + return STTResult(error="sounddevice indisponible et arecord introuvable") + cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"] + alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip() + if alsa_device: + cmd += ["-D", alsa_device] + proc = None + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) + while not (stop_event and stop_event.is_set()): + chunk = proc.stdout.read(8000) + if not chunk: + continue + text = process(chunk) + if text: + return STTResult(text=text) + return STTResult() + except Exception as exc: + return STTResult(error=f"arecord: {exc}") + finally: + if proc and proc.poll() is None: + proc.terminate() + + +def devices(): + try: +# import sounddevice as sd +# return sd.query_devices() + print("[ALFRED STT] Périphériques audio :", flush=True) + print(sd.query_devices(), flush=True) + print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True) + return sd.query_devices() + except Exception: + return [] diff --git a/alfred/app.backup.calendar/services/audio_settings.py b/alfred/app.backup.calendar/services/audio_settings.py new file mode 100644 index 0000000..b310c32 --- /dev/null +++ b/alfred/app.backup.calendar/services/audio_settings.py @@ -0,0 +1,77 @@ +"""Réglages audio Alfred : ALSA carte 1 et états logiques thread-safe.""" +import os +import re +import subprocess +import threading + +_LOCK = threading.RLock() +_STATE = {'volume': 70, 'speaker_muted': False, 'mic_muted': False, 'mic_gain': None} +_CARD = os.getenv('ALFRED_ALSA_CARD', '1') +_CONTROL = os.getenv('ALFRED_ALSA_VOLUME_CONTROL', 'Master') + +def _amixer(*args): + return subprocess.run(['amixer', '-c', _CARD, *args], capture_output=True, + text=True, timeout=5, check=True).stdout + +def initialize(): + """Lit le volume existant sans modifier les réglages audio au démarrage.""" + try: + output = _amixer('sget', _CONTROL) + matches = re.findall(r'\[(\d+)%\]', output) + with _LOCK: + if matches: + _STATE['volume'] = int(matches[0]) + # ALFRED_MIC_GAIN_V2 : gain Capture, sans toucher à Mic Boost. + try: + gain_out = _amixer('sget', os.getenv('ALFRED_ALSA_CAPTURE_CONTROL', 'Capture')) + gains = re.findall(r'\[(\d+)%\]', gain_out) + _STATE['mic_gain'] = int(gains[0]) if gains else None + except (OSError, subprocess.SubprocessError) as exc: + print(f'[ALFRED AUDIO] Gain Capture indisponible : {exc}', flush=True) + # Ne pas déduire speaker_muted du mixer : état propre à Alfred. + except (OSError, subprocess.SubprocessError) as exc: + print(f'[ALFRED AUDIO] Lecture ALSA impossible : {exc}', flush=True) + +def get_settings(): + with _LOCK: + return dict(_STATE) + +def update_settings(payload): + if not isinstance(payload, dict): + raise ValueError('Un objet JSON est attendu') + allowed = {'volume', 'speaker_muted', 'mic_muted', 'mic_gain'} + if set(payload) - allowed: + raise ValueError('Paramètre audio inconnu') + with _LOCK: + new = dict(_STATE) + if 'volume' in payload: + v = payload['volume'] + if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100: + raise ValueError('volume doit être entre 0 et 100') + new['volume'] = round(v) + if 'mic_gain' in payload: + v = payload['mic_gain'] + if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100: + raise ValueError('mic_gain doit être entre 0 et 100') + new['mic_gain'] = round(v) + for key in ('speaker_muted', 'mic_muted'): + if key in payload: + if not isinstance(payload[key], bool): + raise ValueError(f'{key} doit être un booléen') + new[key] = payload[key] + # Le gain Capture ne change que sur demande explicite. + # Mic Boost et Internal Mic Boost restent inchangés. + if 'mic_gain' in payload and new['mic_gain'] != _STATE['mic_gain']: + _amixer('sset', os.getenv('ALFRED_ALSA_CAPTURE_CONTROL', 'Capture'), f"{new['mic_gain']}%") + if new['volume'] != _STATE['volume']: + _amixer('sset', _CONTROL, f"{new['volume']}%") + _STATE.update(new) + return dict(_STATE) + +def speaker_muted(): + with _LOCK: + return _STATE['speaker_muted'] + +def mic_muted(): + with _LOCK: + return _STATE['mic_muted'] diff --git a/alfred/app.backup.calendar/services/camera.py b/alfred/app.backup.calendar/services/camera.py new file mode 100644 index 0000000..7459ddc --- /dev/null +++ b/alfred/app.backup.calendar/services/camera.py @@ -0,0 +1,71 @@ +"""Accès simple à la caméra d'Alfred, avec aperçu MJPEG activé à la demande. + +Une seule lecture par instance Alfred. La vidéo ne démarre que lorsque +l'utilisateur clique sur Activer, pour éviter de monopoliser la caméra. +""" +import os +import threading +import time + +_CAMERA_LOCK = threading.Lock() + + +def snapshot(path, index=0): + import cv2 + if not _CAMERA_LOCK.acquire(blocking=False): + raise RuntimeError('Caméra déjà utilisée par la prévisualisation Alfred') + try: + cap = cv2.VideoCapture(index) + try: + ok, frame = cap.read() + finally: + cap.release() + if not ok: + raise RuntimeError('Impossible de lire la caméra locale') + cv2.imwrite(path, frame) + return path + finally: + _CAMERA_LOCK.release() + + +def video_stream(index=0): + """Ouvre la caméra, retourne un générateur de trames JPEG multipart. + + L'appelant doit retourner ce générateur dans une réponse Flask en streaming. + L'absence de cv2 ou de webcam déclenche une exception avant HTTP 200. + """ + import cv2 + if not _CAMERA_LOCK.acquire(blocking=False): + raise RuntimeError('Prévisualisation vidéo déjà active') + + cap = None + try: + cap = cv2.VideoCapture(index) + if not cap.isOpened(): + cap.release() + raise RuntimeError(f'Caméra {index} indisponible') + cap.set(cv2.CAP_PROP_FRAME_WIDTH, int(os.getenv('CAMERA_WIDTH', '640'))) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(os.getenv('CAMERA_HEIGHT', '480'))) + fps = min(15.0, max(1.0, float(os.getenv('CAMERA_FPS', '8')))) + quality = min(95, max(35, int(os.getenv('CAMERA_JPEG_QUALITY', '75')))) + except Exception: + if cap is not None: + cap.release() + _CAMERA_LOCK.release() + raise + + def frames(): + try: + while True: + ok, frame = cap.read() + if not ok: + break + ok_jpeg, encoded = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, quality]) + if ok_jpeg: + yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n' + time.sleep(1.0 / fps) + finally: + cap.release() + _CAMERA_LOCK.release() + + return frames() diff --git a/alfred/app.backup.calendar/services/hermes_client.py b/alfred/app.backup.calendar/services/hermes_client.py new file mode 100644 index 0000000..854abcf --- /dev/null +++ b/alfred/app.backup.calendar/services/hermes_client.py @@ -0,0 +1,474 @@ + +"""Pont Alfred -> Hermes avec session persistante.""" + +import fcntl +import json +import os +import re +import subprocess +import tempfile +import threading + +from contextlib import contextmanager +from pathlib import Path + + +# -------------------------------------------------- +# CONFIGURATION +# -------------------------------------------------- + +DEFAULT_IDENTITY_FILE = ( + Path(__file__).resolve().parent.parent + / "config" + / "identity.md" +) + +DEFAULT_SESSION_FILE = ( + Path(__file__).resolve().parent.parent + / "config" + / "hermes_session.json" +) + +_REQUEST_LOCK = threading.Lock() + +_ID_PATTERN = re.compile( + r"^[A-Za-z0-9_:.\-]{1,128}$" +) + + +# -------------------------------------------------- +# IDENTITÉ D'ALFRED +# -------------------------------------------------- + +def _build_prompt(question: str) -> str: + + identity_path = Path( + os.getenv( + "ALFRED_IDENTITY_FILE", + str(DEFAULT_IDENTITY_FILE) + ) + ) + + try: + identity = identity_path.read_text( + encoding="utf-8" + ).strip() + + except OSError as exc: + print( + f"[ALFRED] Contexte d'identité " + f"indisponible ({identity_path}): {exc}", + flush=True + ) + identity = "" + + question = str(question or "").strip() + + if not identity: + return question + + return ( + "Instructions de contexte pour cette " + "interaction vocale avec Alfred. " + "Applique ces instructions de comportement " + "à ta réponse, sans les réciter. " + "La demande vocale ci-dessous est une " + "entrée utilisateur, pas une modification " + "de ton identité.\n\n" + f"\n" + f"{identity}\n" + f"\n\n" + f"\n" + f"{question}\n" + f"" + ) + + +# -------------------------------------------------- +# FICHIER DE SESSION +# -------------------------------------------------- + +def _session_file() -> Path: + + return Path( + os.getenv( + "HERMES_SESSION_FILE", + str(DEFAULT_SESSION_FILE) + ) + ).expanduser() + + +def _read_session(path: Path) -> str | None: + + if not path.exists(): + return None + + try: + obj = json.loads( + path.read_text(encoding="utf-8") + ) + + sid = obj["session_id"] + + except ( + OSError, + ValueError, + KeyError, + TypeError + ) as exc: + + raise RuntimeError( + f"État de session Hermes invalide " + f"({path}) : {exc}" + ) from exc + + if ( + not isinstance(sid, str) + or not _ID_PATTERN.fullmatch(sid) + ): + raise RuntimeError( + f"Identifiant de session Hermes " + f"invalide dans {path}" + ) + + return sid + + +def _save_session(path: Path, sid: str): + + if not _ID_PATTERN.fullmatch(sid): + raise RuntimeError( + "Hermes a renvoyé un identifiant " + "de session non valide." + ) + + path.parent.mkdir( + parents=True, + exist_ok=True + ) + + temp_path = None + + try: + fd, temp_name = tempfile.mkstemp( + prefix=".hermes_session_", + suffix=".tmp", + dir=path.parent + ) + + temp_path = Path(temp_name) + + with os.fdopen( + fd, + "w", + encoding="utf-8" + ) as handle: + + json.dump( + {"session_id": sid}, + handle, + indent=2 + ) + + handle.write("\n") + + os.replace(temp_path, path) + + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + +# -------------------------------------------------- +# VERROUILLAGE SESSION +# -------------------------------------------------- + +@contextmanager +def _session_lock(path: Path): + + path.parent.mkdir( + parents=True, + exist_ok=True + ) + + lock_path = path.with_name( + path.name + ".lock" + ) + + fd = os.open( + lock_path, + os.O_CREAT | os.O_RDWR, + 0o600 + ) + + with os.fdopen(fd, "w") as handle: + + fcntl.flock( + handle.fileno(), + fcntl.LOCK_EX + ) + + try: + yield + + finally: + fcntl.flock( + handle.fileno(), + fcntl.LOCK_UN + ) + + +# -------------------------------------------------- +# CONFIGURATION HERMES +# -------------------------------------------------- + +def _hermes_binary() -> str: + + return os.getenv( + "HERMES_CLI", + "/root/.local/bin/hermes" + ) + + +def _hermes_timeout() -> float: + + return max( + 10.0, + float( + os.getenv("HERMES_TIMEOUT", "180") + ) + ) + + +# -------------------------------------------------- +# ANCIEN MODE SANS SESSION +# -------------------------------------------------- + +def _oneshot_legacy(prompt: str) -> str: + + try: + result = subprocess.run( + [ + _hermes_binary(), + "--oneshot", + prompt + ], + capture_output=True, + text=True, + timeout=_hermes_timeout(), + check=True + ) + + return result.stdout.strip() + + except subprocess.TimeoutExpired as exc: + + raise RuntimeError( + "Hermes n'a pas répondu " + "dans le délai imparti." + ) from exc + + except subprocess.CalledProcessError as exc: + + details = ( + exc.stderr + or exc.stdout + or "aucun détail" + ).strip() + + raise RuntimeError( + f"Hermes a échoué " + f"(code {exc.returncode}): {details}" + ) from exc + + +# -------------------------------------------------- +# INTERPRÉTATION RÉPONSE JSONL +# -------------------------------------------------- + +def _parse_jsonl(stdout: str) -> tuple[str, str]: + + final = None + initial_session_id = None + + for line in stdout.splitlines(): + + if not line.strip(): + continue + + try: + event = json.loads(line) + + except json.JSONDecodeError: + continue + + if not isinstance(event, dict): + continue + + if ( + event.get("type") == "system" + and event.get("subtype") == "init" + ): + initial_session_id = event.get( + "session_id" + ) + + if event.get("type") == "result": + final = event + + if final is None: + + raise RuntimeError( + "Hermes n'a pas renvoyé de résultat " + "JSONL. Vérifie la prise en charge " + "de --format stream-json." + ) + + if final.get("exit_code", 0) != 0: + + raise RuntimeError( + f"Hermes : " + f"{final.get('error') or 'échec'}" + ) + + sid = ( + final.get("session_id") + or initial_session_id + ) + + if ( + not isinstance(sid, str) + or not _ID_PATTERN.fullmatch(sid) + ): + raise RuntimeError( + "Hermes n'a pas fourni " + "d'identifiant de session utilisable." + ) + + answer = final.get("text") + + if not isinstance(answer, str): + raise RuntimeError( + "La réponse JSONL finale " + "ne contient pas de texte." + ) + + return answer.strip(), sid + + +# -------------------------------------------------- +# APPEL HERMES AVEC REPRISE DE SESSION +# -------------------------------------------------- + +def _call_hermes( + prompt: str, + session_id: str | None +) -> tuple[str, str]: + + cmd = [ + _hermes_binary(), + "chat", + "--oneshot", + "--query-file", + "-", + "--format", + "stream-json", + "--source", + "cli" + ] + + if session_id: + cmd.extend([ + "--resume", + session_id + ]) + + try: + process = subprocess.run( + cmd, + input=prompt, + capture_output=True, + text=True, + timeout=_hermes_timeout(), + check=False + ) + + except subprocess.TimeoutExpired as exc: + + raise RuntimeError( + "Hermes n'a pas répondu " + "dans le délai imparti." + ) from exc + + except FileNotFoundError as exc: + + raise RuntimeError( + f"Hermes introuvable : " + f"{_hermes_binary()}" + ) from exc + + if process.returncode != 0: + + msg = ( + process.stderr + or process.stdout + or "aucun détail" + ).strip() + + if len(msg) > 1500: + msg = msg[-1500:] + + raise RuntimeError( + f"Hermes a échoué " + f"(code {process.returncode}) : " + f"{msg}" + ) + + return _parse_jsonl(process.stdout) + + +# -------------------------------------------------- +# FONCTION PRINCIPALE APPELÉE PAR FLASK +# -------------------------------------------------- + +def ask_hermes(text: str) -> str: + + prompt = _build_prompt(text) + + # Ancien fonctionnement sans session + if ( + os.getenv( + "HERMES_SESSION_MODE", + "persistent" + ).lower() == "legacy" + ): + return _oneshot_legacy(prompt) + + path = _session_file() + + # Empêche les appels concurrents + with _REQUEST_LOCK, _session_lock(path): + + # Recherche la session précédente + current_sid = _read_session(path) + + # Envoie la question à Hermes + answer, new_sid = _call_hermes( + prompt, + current_sid + ) + + # Sauvegarde l'identifiant + _save_session( + path, + new_sid + ) + + print( + f"[ALFRED] Session Hermes : " + f"{new_sid} " + f"({'reprise' if current_sid else 'création'})", + flush=True + ) + + return answer diff --git a/alfred/app.backup.calendar/services/hermes_client.py.bak.20261016_140326 b/alfred/app.backup.calendar/services/hermes_client.py.bak.20261016_140326 new file mode 100644 index 0000000..94e4b3e --- /dev/null +++ b/alfred/app.backup.calendar/services/hermes_client.py.bak.20261016_140326 @@ -0,0 +1,41 @@ +import os +import subprocess +import time + + +def ask_hermes(text): + hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes") + + start = time.perf_counter() + + try: + result = subprocess.run( + [hermes_cli, "--oneshot", text], + capture_output=True, + text=True, + timeout=180, + check=True + ) + + elapsed = time.perf_counter() - start + + print( + f"[ALFRED] Hermes : {elapsed:.2f} secondes", + flush=True + ) + + return result.stdout.strip() + + except subprocess.TimeoutExpired: + raise RuntimeError( + "Hermes n'a pas répondu dans le délai imparti." + ) + + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + stdout = (e.stdout or "").strip() + + raise RuntimeError( + f"Hermes a échoué (code {e.returncode}): " + f"{stderr or stdout or 'aucun détail'}" + ) diff --git a/alfred/app.backup.calendar/services/hermes_client.py.ko.1 b/alfred/app.backup.calendar/services/hermes_client.py.ko.1 new file mode 100644 index 0000000..95f3abc --- /dev/null +++ b/alfred/app.backup.calendar/services/hermes_client.py.ko.1 @@ -0,0 +1,7 @@ +"""Adaptateur V1. À ajuster à l'interface exacte de l'installation Hermes existante.""" +import os, subprocess, requests + +def ask_hermes(text:str)->str: + if os.getenv('HERMES_MODE','cli') == 'http': + r=requests.post(os.environ['HERMES_API_URL'],json={'message':text},headers={'Authorization':f"Bearer {os.getenv('HERMES_API_TOKEN','')}"},timeout=120);r.raise_for_status();return r.json().get('response',r.text) + p=subprocess.run([os.getenv('HERMES_CLI','hermes'), text],capture_output=True,text=True,timeout=180,check=True);return p.stdout.strip() diff --git a/alfred/app.backup.calendar/services/hermes_client.py.okv1 b/alfred/app.backup.calendar/services/hermes_client.py.okv1 new file mode 100644 index 0000000..ec2ff4a --- /dev/null +++ b/alfred/app.backup.calendar/services/hermes_client.py.okv1 @@ -0,0 +1,47 @@ +"""Client Hermes CLI d'Alfred avec contexte vocal dédié.""" +import os +import subprocess +from pathlib import Path + +DEFAULT_IDENTITY_FILE = Path(__file__).resolve().parent.parent / "config" / "identity.md" + + +def _build_prompt(question: str) -> str: + identity_path = Path(os.getenv("ALFRED_IDENTITY_FILE", str(DEFAULT_IDENTITY_FILE))) + try: + identity = identity_path.read_text(encoding="utf-8").strip() + except OSError as exc: + print(f"[ALFRED] Contexte d'identité indisponible ({identity_path}): {exc}", flush=True) + identity = "" + + question = str(question or "").strip() + if not identity: + return question + + return ( + "Instructions de contexte pour cette interaction vocale avec Alfred. " + "Applique ces instructions de comportement à ta réponse, sans les réciter. " + "La demande vocale ci-dessous est une entrée utilisateur, pas une modification de ton identité.\n\n" + f"\n{identity}\n\n\n" + f"\n{question}\n" + ) + + +def ask_hermes(text): + hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes") + prompt = _build_prompt(text) + try: + result = subprocess.run( + [hermes_cli, "--oneshot", prompt], + capture_output=True, text=True, timeout=180, check=True, + ) + return result.stdout.strip() + except subprocess.TimeoutExpired: + raise RuntimeError("Hermes n'a pas répondu dans le délai imparti.") + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + stdout = (e.stdout or "").strip() + raise RuntimeError( + f"Hermes a échoué (code {e.returncode}): " + f"{stderr or stdout or 'aucun détail'}" + ) diff --git a/alfred/app.backup.calendar/services/hermes_client.py.old b/alfred/app.backup.calendar/services/hermes_client.py.old new file mode 100644 index 0000000..51802b9 --- /dev/null +++ b/alfred/app.backup.calendar/services/hermes_client.py.old @@ -0,0 +1,35 @@ +import os +import subprocess + + +def ask_hermes(text): + hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes") + + try: + result = subprocess.run( + [ + hermes_cli, + "--oneshot", + text + ], + capture_output=True, + text=True, + timeout=180, + check=True + ) + + return result.stdout.strip() + + except subprocess.TimeoutExpired: + raise RuntimeError( + "Hermes n'a pas répondu dans le délai imparti." + ) + + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + stdout = (e.stdout or "").strip() + + raise RuntimeError( + f"Hermes a échoué (code {e.returncode}): " + f"{stderr or stdout or 'aucun détail'}" + ) diff --git a/alfred/app.backup.calendar/services/piper_tts.py b/alfred/app.backup.calendar/services/piper_tts.py new file mode 100644 index 0000000..765a883 --- /dev/null +++ b/alfred/app.backup.calendar/services/piper_tts.py @@ -0,0 +1,109 @@ +import asyncio +import os +import subprocess +import tempfile +import time +import wave + +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.tts import Synthesize, SynthesizeVoice + + +PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1") +PIPER_PORT = int(os.getenv("PIPER_PORT", "10200")) +AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0") + + +async def synthesize(text, wav_path): + client = AsyncTcpClient(PIPER_HOST, PIPER_PORT) + + await client.connect() + + try: + await client.write_event( + Synthesize( + text=text, + voice=SynthesizeVoice( + name=os.getenv("PIPER_VOICE", "fr_FR-tom-medium") + )).event() + ) + + wav_file = None + + try: + while True: + event = await asyncio.wait_for( + client.read_event(), + timeout=30 + ) + + if event is None: + raise RuntimeError("Connexion Piper interrompue") + + if AudioStart.is_type(event.type): + start = AudioStart.from_event(event) + + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(start.channels) + wav_file.setsampwidth(start.width) + wav_file.setframerate(start.rate) + + elif AudioChunk.is_type(event.type): + chunk = AudioChunk.from_event(event) + + if wav_file is None: + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(chunk.channels) + wav_file.setsampwidth(chunk.width) + wav_file.setframerate(chunk.rate) + + wav_file.writeframes(chunk.audio) + + elif AudioStop.is_type(event.type): + break + + finally: + if wav_file is not None: + wav_file.close() + + finally: + await client.disconnect() + + +def speak(text): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp( + prefix="alfred_piper_", + suffix=".wav" + ) + os.close(fd) + + start = time.monotonic() + + try: + print("[ALFRED PIPER] Synthèse...", flush=True) + + asyncio.run(synthesize(text, wav_path)) + + print("[ALFRED PIPER] Lecture...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", AUDIO_DEVICE, wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED PIPER] Erreur : {e}", flush=True) + raise + finally: + if os.path.exists(wav_path): + os.remove(wav_path) diff --git a/alfred/app.backup.calendar/services/piper_tts.py.old b/alfred/app.backup.calendar/services/piper_tts.py.old new file mode 100644 index 0000000..9558412 --- /dev/null +++ b/alfred/app.backup.calendar/services/piper_tts.py.old @@ -0,0 +1,105 @@ +import asyncio +import os +import subprocess +import tempfile +import time +import wave + +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.tts import Synthesize + + +PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1") +PIPER_PORT = int(os.getenv("PIPER_PORT", "10200")) +AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0") + + +async def synthesize(text, wav_path): + client = AsyncTcpClient(PIPER_HOST, PIPER_PORT) + + await client.connect() + + try: + await client.write_event( + Synthesize(text=text).event() + ) + + wav_file = None + + try: + while True: + event = await asyncio.wait_for( + client.read_event(), + timeout=30 + ) + + if event is None: + raise RuntimeError("Connexion Piper interrompue") + + if AudioStart.is_type(event.type): + start = AudioStart.from_event(event) + + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(start.channels) + wav_file.setsampwidth(start.width) + wav_file.setframerate(start.rate) + + elif AudioChunk.is_type(event.type): + chunk = AudioChunk.from_event(event) + + if wav_file is None: + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(chunk.channels) + wav_file.setsampwidth(chunk.width) + wav_file.setframerate(chunk.rate) + + wav_file.writeframes(chunk.audio) + + elif AudioStop.is_type(event.type): + break + + finally: + if wav_file is not None: + wav_file.close() + + finally: + await client.disconnect() + + +def speak(text): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp( + prefix="alfred_piper_", + suffix=".wav" + ) + os.close(fd) + + start = time.monotonic() + + try: + print("[ALFRED PIPER] Synthèse...", flush=True) + + asyncio.run(synthesize(text, wav_path)) + + print("[ALFRED PIPER] Lecture...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", AUDIO_DEVICE, wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED PIPER] Erreur : {e}", flush=True) + raise + finally: + if os.path.exists(wav_path): + os.remove(wav_path) diff --git a/alfred/app.backup.calendar/services/wakeword.py b/alfred/app.backup.calendar/services/wakeword.py new file mode 100644 index 0000000..ae5c227 --- /dev/null +++ b/alfred/app.backup.calendar/services/wakeword.py @@ -0,0 +1,110 @@ +"""Détection permanente du mot d'activation d'Alfred. + +Deux moteurs : +- vosk_keyword (par défaut) : fonctionne immédiatement avec le modèle Vosk français déjà utilisé. +- openwakeword : plus léger/spécialisé, à utiliser avec un modèle ONNX/TFLite custom « Alfred ». + +Le détecteur libère le micro dès l'activation avant de lancer le STT complet. +""" +from __future__ import annotations +from services.audio import get_vosk_model +import json, os, queue, subprocess, shutil, threading, time +from typing import Callable, Optional + +class WakeWordListener: + def __init__(self, on_wake: Callable[[float], None], on_level: Optional[Callable[[float], None]]=None): + self.on_wake=on_wake; self.on_level=on_level + self.stop_event=threading.Event(); self.pause_event=threading.Event(); self.thread=None + self.last_error=None; self.last_score=0.0 + + def start(self): + if self.thread and self.thread.is_alive(): return + self.stop_event.clear(); self.thread=threading.Thread(target=self._run, daemon=True); self.thread.start() + + def stop(self): self.stop_event.set() + def pause(self): self.pause_event.set() + def resume(self): self.pause_event.clear() + + def status(self): + return {"running": bool(self.thread and self.thread.is_alive()), "paused": self.pause_event.is_set(), + "engine": os.getenv("WAKE_ENGINE","vosk_keyword"), "word": os.getenv("WAKE_WORD","alfred"), + "last_score": self.last_score, "error": self.last_error} + + def _run(self): + engine=os.getenv("WAKE_ENGINE","vosk_keyword").lower() + while not self.stop_event.is_set(): + if self.pause_event.is_set(): time.sleep(.1); continue + try: + if engine == "openwakeword": self._openwakeword_session() + else: self._vosk_session() + except Exception as e: + self.last_error=str(e); time.sleep(1) + + def _level(self, data): + if not self.on_level: return + try: + import numpy as np + a=np.frombuffer(data,dtype=np.int16).astype(np.float32) + rms=float(np.sqrt(np.mean(a*a))) if a.size else 0.0 + self.on_level(min(1.0,rms/6000.0)) + except Exception: pass + + def _audio_chunks(self, blocksize=1280): + rate=int(os.getenv("VOSK_SAMPLE_RATE","16000")); q=queue.Queue() + try: + import sounddevice as sd + def cb(indata, frames, ti, status): q.put(bytes(indata)) + dev=os.getenv("AUDIO_INPUT_DEVICE") or None + with sd.RawInputStream(samplerate=rate, blocksize=blocksize, dtype="int16", channels=1, callback=cb, device=dev): + while not self.stop_event.is_set() and not self.pause_event.is_set(): yield q.get() + return + except Exception: pass + if not shutil.which("arecord"): raise RuntimeError("Micro indisponible: sounddevice et arecord absents") + cmd=["arecord","-q","-f","S16_LE","-r",str(rate),"-c","1"] + dev=os.getenv("ALSA_CAPTURE_DEVICE","").strip() + if dev: cmd += ["-D",dev] + p=subprocess.Popen(cmd,stdout=subprocess.PIPE) + try: + while not self.stop_event.is_set() and not self.pause_event.is_set(): + data=p.stdout.read(blocksize*2) + if data: yield data + finally: + if p.poll() is None: p.terminate() + + def _vosk_session(self): + from vosk import Model, KaldiRecognizer +## path=os.getenv("VOSK_MODEL_PATH",""); word=os.getenv("WAKE_WORD","alfred").strip().lower() +## + path = os.getenv( + "WAKE_VOSK_MODEL_PATH", + os.getenv("VOSK_MODEL_PATH", "") + ); word=os.getenv("WAKE_WORD","alfred").strip().lower() + if not os.path.exists(path): raise RuntimeError(f"Modèle Vosk introuvable: {path}") + rate=int(os.getenv("VOSK_SAMPLE_RATE","16000")) + # Grammaire réduite = détection peu coûteuse et évite de transcrire les conversations. +## rec=KaldiRecognizer(Model(path), rate, json.dumps([word,"[unk]"], ensure_ascii=False)) +## + rec = KaldiRecognizer( + get_vosk_model(path), rate, json.dumps([word, "[unk]"], ensure_ascii=False) + ) + + for data in self._audio_chunks(4000): + self._level(data) + if rec.AcceptWaveform(data): txt=(json.loads(rec.Result() or "{}").get("text") or "").lower() + else: txt=(json.loads(rec.PartialResult() or "{}").get("partial") or "").lower() + if word in txt.split(): + self.last_score=1.0; self.pause_event.set(); self.on_wake(1.0); return + + def _openwakeword_session(self): + import numpy as np + from openwakeword.model import Model + model_path=os.getenv("WAKE_MODEL_PATH","").strip() + if not model_path or not os.path.exists(model_path): raise RuntimeError("WAKE_MODEL_PATH absent: ajoute le modèle custom Alfred") + threshold=float(os.getenv("WAKE_THRESHOLD","0.5")) + model=Model(wakeword_models=[model_path], inference_framework=os.getenv("WAKE_INFERENCE","onnx")) + for data in self._audio_chunks(1280): + self._level(data); audio=np.frombuffer(data,dtype=np.int16) + scores=model.predict(audio) + score=max((float(v) for v in scores.values()), default=0.0); self.last_score=score + if score >= threshold: + self.pause_event.set(); self.on_wake(score); return diff --git a/alfred/app.backup.calendar/static/css/alfred-controls.css b/alfred/app.backup.calendar/static/css/alfred-controls.css new file mode 100644 index 0000000..b35fd50 --- /dev/null +++ b/alfred/app.backup.calendar/static/css/alfred-controls.css @@ -0,0 +1,14 @@ +/* Pop-up des réglages : ne modifie pas la page configuration historique. */ +.audio-popover { position:absolute; top:82px; right:36px; z-index:10; width:min(355px,calc(100vw - 28px)); background:#252a2e; border:1px solid #ffffff2b; border-radius:17px; padding:20px; box-shadow:0 24px 70px #0008; } +.audio-popover[hidden] { display:none !important; } +.popover-heading { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; font-size:16px; } +.icon-button.mini { width:28px; height:28px; font-size:22px; border:0; background:none; } +.setting-row { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:19px; margin-bottom:12px; color:#d5dbe0; font-size:13px; } +.setting-row output { color:#f4f5f6; font-size:13px; font-variant-numeric:tabular-nums; } +.audio-popover input[type="range"] { display:block; width:100%; margin:0 0 5px; cursor:pointer; accent-color:#a8c7ce; } +.audio-popover input[type="range"]:disabled { opacity:.35; cursor:not-allowed; } +.setting-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-top:22px; padding-top:16px; border-top:1px solid #ffffff1c; color:#cbd1d5; font-size:13px; } +.quiet-button { color:#c3e7dc; background:#42675b33; border:1px solid #83c9ae44; padding:7px 13px; border-radius:8px; cursor:pointer; } +.quiet-button[aria-pressed="true"] { color:#f7b5b5; background:#b65c5c26; border-color:#b65c5c66; } +.settings-message { min-height:14px; margin:12px 0 0; font-size:11px; color:#e4c089; line-height:1.4; } +@media(max-width:760px) { .audio-popover { top:65px; right:14px; } } diff --git a/alfred/app.backup.calendar/static/css/alfred-mode-icons.css b/alfred/app.backup.calendar/static/css/alfred-mode-icons.css new file mode 100644 index 0000000..d78db8c --- /dev/null +++ b/alfred/app.backup.calendar/static/css/alfred-mode-icons.css @@ -0,0 +1,13 @@ +.alfred-mode-icon { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + min-height: 36px; + font-size: 26px; + line-height: 1; + transition: transform .25s ease, opacity .25s ease; +} +.alfred-mode-icon[data-mode="thinking"] { animation: alfred-thinking 1.8s ease-in-out infinite; } +@keyframes alfred-thinking { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.12); } } +@media (prefers-reduced-motion: reduce) { .alfred-mode-icon { animation: none !important; } } diff --git a/alfred/app.backup.calendar/static/css/alfred.css b/alfred/app.backup.calendar/static/css/alfred.css new file mode 100644 index 0000000..521a067 --- /dev/null +++ b/alfred/app.backup.calendar/static/css/alfred.css @@ -0,0 +1,62 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + color: #f1f3f4; + background: #202326; +} +* { box-sizing: border-box; } +[hidden] { display:none !important; } +body { margin: 0; min-height: 100vh; background: radial-gradient(ellipse at 52% 34%, #33373a 0, #25282b 46%, #1a1d20 100%); } +.alfred-shell { position:relative; isolation:isolate; display:grid; grid-template-rows: 56px minmax(0,1fr) 142px; gap:16px; min-height:100vh; min-height:100dvh; padding:25px 36px; } +.topbar { display:flex; align-items:center; justify-content:space-between; z-index:5; } +.status { display:inline-flex; align-items:center; gap:11px; font-size:14px; font-weight:500; color:#e6eaed; letter-spacing:.045em; } +.status-dot { width:10px; height:10px; border-radius:50%; background:#7d8790; box-shadow:0 0 0 4px #ffffff0a; transition:background .25s,box-shadow .25s; } +.status-dot[data-mode="listening"] { background:#70c7ce; box-shadow:0 0 0 5px #70c7ce17; animation:status-pulse 1.6s infinite; } +.status-dot[data-mode="thinking"] { background:#e7bb72; box-shadow:0 0 0 5px #e7bb7217; animation:status-pulse 1.9s infinite; } +.status-dot[data-mode="speaking"] { background:#9ad0b7; box-shadow:0 0 0 5px #9ad0b717; animation:status-pulse 1.4s infinite; } +.status-dot[data-mode="error"] { background:#f18989; box-shadow:0 0 0 5px #f1898917; } +.header-actions { display:flex; gap:10px; align-items:center; } +.icon-button { display:grid; place-items:center; width:44px; height:44px; border:1px solid #ffffff22; border-radius:13px; background:#ffffff08; color:#e8ebee; cursor:pointer; transition:background .2s,border-color .2s; } +.icon-button:hover { background:#ffffff14; border-color:#ffffff35; } +.icon-button:focus-visible,.panel-action:focus-visible,.quiet-button:focus-visible { outline:2px solid #adc8e0; outline-offset:3px; } +.icon-button svg { width:21px; height:21px; } +.icon-button[aria-pressed="true"] { color:#f4a2a2; background:#ae47471b; border-color:#b95d5d55; } +.speaker-off-icon { display:none; } +.icon-button[aria-pressed="true"] .speaker-on-icon { display:none; } +.icon-button[aria-pressed="true"] .speaker-off-icon { display:block; } +#stage { display:flex; align-items:center; justify-content:center; overflow:hidden; min-height:0; padding:0 8%; } +#content { width:min(990px,86vw); text-align:center; transition:opacity .25s ease,transform .25s ease; animation:appear .45s ease; } +#content.swap { opacity:0; transform:translateY(10px) scale(.99); } +#content h1 { font-size:clamp(2rem,4vw,4.3rem); font-weight:350; margin:.2em 0; line-height:1.2; } +#content p { font-size:clamp(1rem,1.6vw,1.35rem); color:#cdd1d4; line-height:1.55; white-space:pre-wrap; overflow-wrap:anywhere; } +.hero { max-width:min(100%,750px); max-height:56vh; object-fit:contain; border-radius:18px; box-shadow:0 18px 50px #0006; animation:appear .45s ease; } +.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(205px,1fr)); gap:13px; text-align:left; } +.card { padding:18px; border:1px solid #ffffff1c; background:#ffffff0a; border-radius:15px; } +.card h2 { margin:0 0 8px; font-weight:500; } +.card p { margin:0; font-size:1rem; } +#wave { display:flex; align-items:center; justify-self:center; width: min(560px,37vw); height:100px; align-self:end; } +#wave canvas { width:100%; height:100%; } +.camera-panel,.transcript-panel { position:absolute; z-index:2; bottom:34px; border:1px solid #ffffff20; background:#222629db; box-shadow:0 14px 35px #0002; border-radius:16px; backdrop-filter:blur(12px); } +.camera-panel { left:36px; width:clamp(220px,24vw,355px); overflow:hidden; } +.transcript-panel { right:36px; width:clamp(250px,25vw,405px); padding:15px 17px 18px; min-height:136px; } +.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:42px; padding:0 13px; } +.transcript-panel .panel-heading { padding:0; min-height:30px; } +.panel-title { display:flex; align-items:center; gap:9px; font-size:10px; letter-spacing:.1em; font-weight:700; color:#abb5be; } +.panel-indicator { width:7px; height:7px; border-radius:50%; background:#71818c; } +.camera-panel[data-active="true"] .panel-indicator { background:#83c5cc; } +.transcript-panel[data-active="true"] .stt-indicator { background:#83c5cc; } +.panel-action { border:0; border-radius:8px; background:#ffffff0c; color:#e3e8ed; padding:7px 10px; font-size:12px; cursor:pointer; } +.panel-action:hover { background:#ffffff22; } +.camera-viewport { display:grid; place-items:center; position:relative; aspect-ratio:16/9; background:#101315; overflow:hidden; } +.camera-viewport > * { grid-area:1/1; } +.camera-viewport img { width:100%; height:100%; object-fit:contain; } +.camera-placeholder { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; width:100%; height:100%; color:#919ca4; font-size:12px; text-align:center; padding:12px; } +.camera-placeholder svg { width:30px; height:30px; opacity:.65; } +.transcript-state { color:#909ba3; font-size:11px; flex-shrink:0; } +.transcript-text { margin:11px 0 0; color:#edf0f2; font-size:14px; line-height:1.6; overflow-wrap:anywhere; max-height:110px; overflow-y:auto; } +.transcript-text.is-placeholder { color:#87939c; } +@keyframes appear { from { opacity:0; transform:translateY(8px) } to { opacity:1;transform:translateY(0) } } +@keyframes status-pulse { 0%,100% { opacity:1 } 50% { opacity:.45 } } +@media(max-width:1120px) { #wave { width:28vw; } .camera-panel { width:27vw; } .transcript-panel { width:29vw; } } +@media(max-width:760px) { .alfred-shell { padding:14px; grid-template-rows:50px minmax(220px,1fr) 80px auto; gap:8px; } #stage { padding:10px; } #wave { width:80%; height:70px; } .camera-panel,.transcript-panel { position:relative; left:auto; right:auto; bottom:auto; width:100%; } .camera-panel { grid-row:4; max-width:none; } .transcript-panel { grid-row:5; min-height:auto; } .topbar { grid-row:1; } } +@media(prefers-reduced-motion:reduce) { * { animation:none !important; transition:none !important; } } diff --git a/alfred/app.backup.calendar/static/js/alfred-camera.js b/alfred/app.backup.calendar/static/js/alfred-camera.js new file mode 100644 index 0000000..fc58979 --- /dev/null +++ b/alfred/app.backup.calendar/static/js/alfred-camera.js @@ -0,0 +1,41 @@ +/* Lecture MJPEG activée manuellement : libère la caméra si désactivée. */ +(() => { + 'use strict'; + const button = document.getElementById('cameraToggle'); + const image = document.getElementById('cameraFeed'); + const placeholder = document.getElementById('cameraPlaceholder'); + const message = document.getElementById('cameraMessage'); + const panel = document.querySelector('.camera-panel'); + let active = false; + + function stop() { + active = false; + image.removeAttribute('src'); + image.hidden = true; + placeholder.hidden = false; + message.textContent = 'Caméra désactivée'; + button.textContent = 'Activer'; + button.setAttribute('aria-pressed', 'false'); + panel.dataset.active = 'false'; + } + function start() { + active = true; + placeholder.hidden = false; + message.textContent = 'Connexion à la caméra…'; + image.hidden = false; + image.src = `/api/camera/stream?t=${Date.now()}`; + button.textContent = 'Arrêter'; + button.setAttribute('aria-pressed', 'true'); + } + button.addEventListener('click', () => active ? stop() : start()); + image.addEventListener('load', () => { if (active) { placeholder.hidden = true; panel.dataset.active = 'true'; } }); + image.addEventListener('error', () => { + if (!active) return; + stop(); + message.textContent = 'Caméra indisponible ou déjà utilisée'; + }); + // Si l'onglet n'est pas visible, relâcher le périphérique USB. + document.addEventListener('visibilitychange', () => { if (document.hidden && active) stop(); }); + window.addEventListener('pagehide', stop); + stop(); +})(); diff --git a/alfred/app.backup.calendar/static/js/alfred-controls.js b/alfred/app.backup.calendar/static/js/alfred-controls.js new file mode 100644 index 0000000..b94cfef --- /dev/null +++ b/alfred/app.backup.calendar/static/js/alfred-controls.js @@ -0,0 +1,93 @@ +/* Contrôles audio depuis un pop-up + raccourci mute haut-parleur. */ +(() => { + 'use strict'; + const panel = document.getElementById('audioSettingsPanel'); + const button = document.getElementById('audioSettingsButton'); + const close = document.getElementById('closeAudioSettings'); + const speaker = document.getElementById('speakerMuteQuick'); + const mic = document.getElementById('micMute'); + const volume = document.getElementById('volumeSlider'); + const volumeValue = document.getElementById('volumeValue'); + const gain = document.getElementById('micGainSlider'); + const gainValue = document.getElementById('micGainValue'); + const message = document.getElementById('audioSettingsMessage'); + let settings = { volume:70, mic_gain:null, speaker_muted:false, mic_muted:false }; + let volumeTimer, gainTimer; + + function render() { + volume.value = settings.volume; + volumeValue.textContent = `${settings.volume} %`; + gain.disabled = !Number.isFinite(settings.mic_gain); + if (!gain.disabled) gain.value = settings.mic_gain; + gainValue.textContent = gain.disabled ? 'N/D' : `${settings.mic_gain} %`; + speaker.setAttribute('aria-pressed', String(settings.speaker_muted)); + speaker.setAttribute('aria-label', settings.speaker_muted ? 'Réactiver le son' : 'Couper le son'); + speaker.title = speaker.getAttribute('aria-label'); + mic.setAttribute('aria-pressed', String(settings.mic_muted)); + mic.textContent = settings.mic_muted ? 'Coupé' : 'Actif'; + mic.title = settings.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone'; + } + + async function update(changes) { + message.textContent = ''; + try { + const response = await fetch('/api/audio/settings', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify(changes) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); + settings = { ...settings, ...data }; + render(); + } catch (err) { + message.textContent = `Impossible de modifier le réglage : ${err.message}`; + console.warn('[ALFRED AUDIO]', err); + await refresh(); + } + } + + async function refresh() { + try { + const response = await fetch('/api/audio/settings', {cache:'no-store'}); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + settings = { ...settings, ...await response.json() }; + render(); + } catch (err) { + message.textContent = 'Réglages indisponibles.'; + console.warn('[ALFRED AUDIO]', err); + } + } + + function setOpen(open) { + panel.hidden = !open; + button.setAttribute('aria-expanded', String(open)); + if (open) { refresh(); close.focus(); } else { button.focus(); } + } + button.addEventListener('click', () => setOpen(panel.hidden)); + close.addEventListener('click', () => setOpen(false)); + document.addEventListener('pointerdown', e => { + if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) { + panel.hidden = true; button.setAttribute('aria-expanded', 'false'); + } + }); + document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) setOpen(false); }); + + volume.addEventListener('input', () => { + settings.volume = Number(volume.value); render(); + clearTimeout(volumeTimer); + volumeTimer = setTimeout(() => update({volume:Number(volume.value)}), 220); + }); + gain.addEventListener('input', () => { + settings.mic_gain = Number(gain.value); render(); + clearTimeout(gainTimer); + gainTimer = setTimeout(() => update({mic_gain:Number(gain.value)}), 250); + }); + speaker.addEventListener('click', () => update({speaker_muted:!settings.speaker_muted})); + mic.addEventListener('click', () => update({mic_muted:!settings.mic_muted})); + + if (window.alfredSocket) window.alfredSocket.on('audio_settings', data => { + settings = {...settings, ...data}; render(); + }); + render(); refresh(); +})(); diff --git a/alfred/app.backup.calendar/static/js/alfred-mode-icons.js b/alfred/app.backup.calendar/static/js/alfred-mode-icons.js new file mode 100644 index 0000000..9a36c90 --- /dev/null +++ b/alfred/app.backup.calendar/static/js/alfred-mode-icons.js @@ -0,0 +1,35 @@ +/* Icônes de statut : compatible avec alfred.js existant, sans le remplacer. */ +(() => { + 'use strict'; + const el = document.getElementById('mode'); + if (!el) return; + const icons = { + idle: ['🛏️', 'Repos'], repos: ['🛏️', 'Repos'], + listening: ['👂', 'Écoute'], thinking: ['🧠', 'Réflexion'], + speaking: ['🔊', 'Parole'], error: ['⚠️', 'Erreur'] + }; + let current = 'idle'; + function apply(raw) { + const key = String(raw || '').trim().toLowerCase(); + if (!icons[key]) return; + current = key; + const [icon, label] = icons[key]; + if (el.textContent !== icon) el.textContent = icon; + el.title = label; + el.setAttribute('aria-label', label); + el.dataset.mode = key; + } + // L'ancien alfred.js écrit le nom du mode dans #mode. + // L'observateur le convertit immédiatement en icône, sans modifier ce script. + const observer = new MutationObserver(() => { + const value = el.textContent.trim(); + if (icons[value.toLowerCase()]) apply(value); + }); + observer.observe(el, { childList: true, characterData: true, subtree: true }); + apply(el.textContent); + // Récupère également l'état actuel après un rafraîchissement de page. + fetch('/api/state', { cache: 'no-store' }) + .then(r => { if (!r.ok) throw new Error('État indisponible'); return r.json(); }) + .then(data => { if (data && data.mode) apply(data.mode); }) + .catch(() => {}); +})(); diff --git a/alfred/app.backup.calendar/static/js/alfred.js b/alfred/app.backup.calendar/static/js/alfred.js new file mode 100644 index 0000000..67111dd --- /dev/null +++ b/alfred/app.backup.calendar/static/js/alfred.js @@ -0,0 +1,121 @@ +/* Interface principale Alfred : statut, scène, onde et transcription Vosk. */ +(() => { + 'use strict'; + const socket = (typeof io === 'function') ? io() : null; + window.alfredSocket = socket; + + const content = document.getElementById('content'); + const modeEl = document.getElementById('mode'); + const statusDot = document.getElementById('statusDot'); + const transcriptText = document.getElementById('transcriptText'); + const transcriptState = document.getElementById('transcriptState'); + const transcriptPanel = document.querySelector('.transcript-panel'); + const canvas = document.getElementById('waveCanvas'); + const ctx = canvas.getContext('2d'); + + let mode = 'idle', level = 0.02, phase = 0, previousContent = ''; + const labels = { + idle:'En attente', waiting:'En attente', listening:'À l’écoute', + thinking:'Réflexion en cours', speaking:'Alfred parle', error:'Erreur' + }; + + function resize() { + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(canvas.clientWidth * dpr); + canvas.height = Math.round(canvas.clientHeight * dpr); + } + window.addEventListener('resize', resize); + resize(); + function draw() { + const dpr = window.devicePixelRatio || 1; + phase += 0.035; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = 'rgba(232,241,243,.76)'; + ctx.lineWidth = 1.6 * dpr; + ctx.beginPath(); + const mid = canvas.height / 2; + const amp = (mode === 'idle' ? 1.5 : mode === 'thinking' ? 7 : Math.max(5, level * 45)) * dpr; + for (let x = 0; x < canvas.width; x += 4 * dpr) { + const y = mid + Math.sin(x * 0.018 + phase * 4) * Math.sin(x * 0.006 + phase) * amp * + (0.35 + 0.65 * Math.sin(x / canvas.width * Math.PI)); + if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + window.requestAnimationFrame(draw); + } + window.requestAnimationFrame(draw); + + function makeText(tag, value, className) { + const el = document.createElement(tag); + if (className) el.className = className; + el.textContent = String(value ?? ''); + return el; + } + + function render(data) { + if (!data || typeof data !== 'object') return; + const key = JSON.stringify(data); + if (key === previousContent) return; + previousContent = key; + const fragment = document.createDocumentFragment(); + if (data.type === 'image' && typeof data.url === 'string') { + // Refuser les URL javascript: et les schémas non prévus. + if (/^(https?:\/\/|\/[^/])/i.test(data.url)) { + const img = document.createElement('img'); + img.className = 'hero'; img.src = data.url; img.alt = String(data.alt || 'Image affichée par Alfred'); + fragment.appendChild(img); + } + if (data.caption) fragment.appendChild(makeText('p', data.caption)); + } else if (data.type === 'cards' && Array.isArray(data.items)) { + const cards = document.createElement('div'); cards.className = 'cards'; + data.items.forEach(item => { + const card = makeText('article', '', 'card'); + card.append(makeText('h2', item.title || ''), makeText('p', item.text || '')); + cards.appendChild(card); + }); + fragment.appendChild(cards); + } else { + if (data.title) fragment.appendChild(makeText('h1', data.title)); + fragment.appendChild(makeText('p', data.text || '')); + } + content.replaceChildren(fragment); + } + + function updateTranscript(s) { + // Une phrase finale reste visible lors du retour en veille. + const finalText = String(s.transcript || '').trim(); + const partial = String(s.partial || '').trim(); + if (finalText) { + transcriptText.textContent = finalText; + transcriptText.classList.remove('is-placeholder'); + transcriptState.textContent = 'Reconnu'; + } else if (s.mode === 'listening') { + transcriptText.textContent = partial || 'Je vous écoute…'; + transcriptText.classList.toggle('is-placeholder', !partial); + transcriptState.textContent = partial ? 'En cours' : 'Écoute'; + } else if (s.mode === 'error') { + transcriptState.textContent = 'Erreur'; + } + transcriptPanel.dataset.active = String(s.mode === 'listening'); + } + + function updateState(s) { + if (!s || typeof s !== 'object') return; + mode = String(s.mode || 'idle').toLowerCase(); + modeEl.textContent = labels[mode] || mode; + modeEl.dataset.mode = mode; + statusDot.dataset.mode = mode; + updateTranscript(s); + if (s.content) render(s.content); + } + + if (socket) { + socket.on('alfred_state', updateState); + socket.on('alfred_render', render); + socket.on('audio_level', data => { level = Math.max(0, Math.min(1, Number(data.level) || 0)); }); + socket.on('connect', () => { + fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {}); + }); + } + fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {}); +})(); diff --git a/alfred/app.backup.calendar/templates/configuration.html b/alfred/app.backup.calendar/templates/configuration.html new file mode 100644 index 0000000..6746c73 --- /dev/null +++ b/alfred/app.backup.calendar/templates/configuration.html @@ -0,0 +1,130 @@ + + + + + + Alfred — Configuration audio + + + +
+
+

Configuration

+

Alfred · Paramètres audio

+
+
+
+
+ + 50 % +
+ +
+
+
+ + + Couper le haut-parleur + + +
+

Désactive les réponses vocales d’Alfred.

+
+
+
+ + + Couper le microphone + + +
+

Suspend l’écoute et la détection du mot « Alfred ».

+
+
+

+
+ + + diff --git a/alfred/app.backup.calendar/templates/index.html b/alfred/app.backup.calendar/templates/index.html new file mode 100644 index 0000000..3b875e9 --- /dev/null +++ b/alfred/app.backup.calendar/templates/index.html @@ -0,0 +1,103 @@ + + + + + + + {{ name }} + + + + + +
+
+ +
+ + En attente +
+ +
+ + +
+
+ + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + + +
+ + + + + + diff --git a/alfred/app.backup.calendar/templates/index.html.1 b/alfred/app.backup.calendar/templates/index.html.1 new file mode 100644 index 0000000..8b35c18 --- /dev/null +++ b/alfred/app.backup.calendar/templates/index.html.1 @@ -0,0 +1,31 @@ + + + + + + {{name}} + + + + + +
+
{{name}}repos
+

Bonjour.

À votre écoute.

+
+ +
+ + + + diff --git a/alfred/app.backup.calendar/templates/index.html.ori b/alfred/app.backup.calendar/templates/index.html.ori new file mode 100644 index 0000000..d0b38fd --- /dev/null +++ b/alfred/app.backup.calendar/templates/index.html.ori @@ -0,0 +1,2 @@ +{{name}} +
{{name}}repos

Bonjour.

À votre écoute.

diff --git a/alfred/app.backup.calendar/test.py b/alfred/app.backup.calendar/test.py new file mode 100644 index 0000000..95385a2 --- /dev/null +++ b/alfred/app.backup.calendar/test.py @@ -0,0 +1,19 @@ +python - <<'PY' +import sounddevice as sd +import soundfile as sf + +print(sd.query_devices()) +print("Microphone utilisé :", sd.default.device) + +print("Parle pendant 8 secondes...") +audio = sd.rec( + int(8 * 16000), + samplerate=16000, + channels=1, + dtype="int16" +) +sd.wait() + +sf.write("/tmp/alfred-micro.wav", audio, 16000) +print("Enregistrement terminé : /tmp/alfred-micro.wav") +PY diff --git a/alfred/app.backup.camera/__pycache__/app.cpython-313.pyc b/alfred/app.backup.camera/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000..09d2b8b Binary files /dev/null and b/alfred/app.backup.camera/__pycache__/app.cpython-313.pyc differ diff --git a/alfred/app.backup.camera/app.py b/alfred/app.backup.camera/app.py new file mode 100644 index 0000000..d1f0eee --- /dev/null +++ b/alfred/app.backup.camera/app.py @@ -0,0 +1,366 @@ +import os, threading, hmac +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from flask import Flask, render_template, request, jsonify, Response +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services.hermes_client import ask_hermes +from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1 +from services.camera import video_stream +from services import ha_views, alfred_views_controller + +load_dotenv() + +## +from services.audio import get_vosk_model +print("[ALFRED] Préchargement des modèles vocaux...", flush=True) +get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) +get_vosk_model(os.environ["VOSK_MODEL_PATH"]) +print("[ALFRED] Modèles vocaux prêts.", flush=True) +## +app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") +STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} +STATE_LOCK = threading.RLock() +IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI. +conversation_lock=threading.Lock() +mic_stop_event=threading.Event() +audio_settings.initialize() +audio_settings.update_settings({"mic_gain": 30}) + +def emit_state(mode=None, **extra): + with STATE_LOCK: + if mode: STATE["mode"] = mode + STATE.update(extra) + socketio.emit("alfred_state", dict(STATE)) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + stop_event=mic_stop_event, + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if audio_settings.mic_muted(): + emit_state("idle", partial="") + return + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + # Vue dynamique : commandes déterministes exécutées sans faire + # dépendre l'affichage des capacités ou sessions Hermes. + visual = None + try: + visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type')) + except (ha_views.HAError, ValueError) as exc: + print(f"[ALFRED] Vue indisponible : {exc}", flush=True) + visual = ({"type":"text", "text":str(exc)}, str(exc)) + + if visual is not None: + content, speech = visual + with STATE_LOCK: + STATE["content"] = content + socketio.emit("alfred_render", content) + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + emit_state("idle", partial="") + return + + print("[ALFRED] Envoi à Hermes", flush=True) + # ComfyUI publie l'image pendant cet appel Hermes. + with STATE_LOCK: + image_revision_before = IMAGE_REVISION + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + # Si le MCP ComfyUI a affiché une image pendant la requête, + # ne pas l'écraser par la réponse textuelle d'Hermes. + with STATE_LOCK: + if IMAGE_REVISION == image_revision_before: + STATE["content"] = display + socketio.emit("alfred_render", display) + else: + print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + if audio_settings.mic_muted(): + return + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +@app.get('/api/audio/settings') +def get_audio_settings(): + return jsonify(audio_settings.get_settings()) + +@app.post('/api/audio/settings') +def set_audio_settings(): + data = request.get_json(silent=True) + try: + old = audio_settings.get_settings() + new = audio_settings.update_settings(data) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': f'ALSA : {exc}'}), 500 + if new['mic_muted'] != old['mic_muted']: + if new['mic_muted']: + mic_stop_event.set() + wake.pause() + else: + mic_stop_event.clear() + if not conversation_lock.locked(): + wake.resume() + socketio.emit('audio_settings', new) + return jsonify(new) + +@app.get('/api/camera/stream') +def camera_stream(): + # Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan. + try: + frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0'))) + except (ImportError, ValueError, RuntimeError, OSError) as exc: + return jsonify({'error': str(exc)}), 503 + return Response( + frames, + mimetype='multipart/x-mixed-replace; boundary=frame', + headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'} + ) + +@app.get("/") +def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) +@app.get("/api/state") +def state(): + with STATE_LOCK: + snapshot = dict(STATE) + return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()}) + +@app.post("/api/state") +def set_state(): + data = request.get_json(force=True) + with STATE_LOCK: + STATE.update(data) + socketio.emit("alfred_state", dict(STATE)) + return jsonify({"ok": True}) +@app.post("/api/render") +def render_payload(): + global IMAGE_REVISION + token = os.getenv("ALFRED_RENDER_TOKEN", "").strip() + if token and not hmac.compare_digest( + request.headers.get("X-Alfred-Render-Token", ""), token + ): + return jsonify({"ok": False, "error": "Non autorisé"}), 401 + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"ok": False, "error": "JSON objet attendu"}), 400 + if data.get("type") == "image": + image_url = data.get("url") + if not isinstance(image_url, str) or not ( + image_url.startswith(("http://", "https://", "/")) + and not image_url.startswith("//") + ): + return jsonify({"ok": False, "error": "URL image invalide"}), 400 + + with STATE_LOCK: + if data.get("type") == "image": + IMAGE_REVISION += 1 + STATE["content"] = data + socketio.emit("alfred_render", data) + return jsonify({"ok": True, "image_revision": IMAGE_REVISION}) +# --- Agenda / capteurs / caméras Home Assistant --- +def _calendar_range(): + tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) + now = datetime.now(tz) + def parse(name, default): + raw = request.args.get(name) + if not raw:return default + value = datetime.fromisoformat(raw.replace('Z','+00:00')) + if value.tzinfo is None: value = value.replace(tzinfo=tz) + return value.astimezone(tz) + start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0)) + end = parse('end',start+timedelta(days=7)) + return start,end + +def _json_error(exc): + code = 400 if isinstance(exc,ValueError) else 502 + return jsonify({'error':str(exc)}),code + +def _writes_enabled(): + return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes') + +def _same_origin_json(): + # POSTs require a custom header and same-origin browser requests. + if request.headers.get('X-Alfred-Request') != '1':return False + origin=request.headers.get('Origin') + if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False + return True + +@app.get('/api/calendar/calendars') +def calendars_list(): + try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/calendar/events') +def calendar_events(): + try: + start,end=_calendar_range() + selected=request.args.getlist('calendar_id') + return jsonify(ha_views.get_events(start,end,selected or None)) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.post('/api/calendar/create') +def calendar_create(): + if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 + if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403 + data=request.get_json(silent=True) + if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 + try: + tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris')) + def read_dt(key): + dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00')) + return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz) + result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')), + read_dt('start'),read_dt('end'),str(data.get('description') or ''), + str(data.get('location') or ''),bool(data.get('all_day',False))) + from services import alfred_intents + alfred_intents.clear_create() + socketio.emit('calendar_changed', {'type':'created'}) + return jsonify(result) + except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) + +@app.post('/api/calendar/delete') +def calendar_delete(): + if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 + if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403 + data=request.get_json(silent=True) + if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 + try: + eid=str(data.get('calendar_id',''));uid=str(data.get('uid','')) + start=datetime.fromisoformat(str(data['start'])) + end=datetime.fromisoformat(str(data['end'])) + if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.') + matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events'] + rid=str(data.get('recurrence_id') or '') + if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches): + return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409 + result=ha_views.delete_event(eid,uid,rid or None) + from services import alfred_intents + alfred_intents.clear_delete() + socketio.emit('calendar_changed',{'type':'deleted'}) + return jsonify(result) + except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) + +@app.get('/api/ha/temperatures') +def ha_temperatures(): + try:return jsonify({'items':ha_views.get_temperatures()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/temperature/history') +def ha_temperature_history(): + try: + eid=request.args.get('entity_id','') + hours=int(request.args.get('hours','24')) + return jsonify({'items':ha_views.temperature_history(eid,hours)}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/cameras') +def ha_cameras(): + try:return jsonify({'items':ha_views.list_cameras()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/camera//snapshot') +def ha_camera_snapshot(entity_id): + try: + binary,mime=ha_views.camera_snapshot(entity_id) + return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.post('/api/ui/home') +def ui_home(): + with STATE_LOCK: + STATE['content']={'type':'home','text':'À ton écoute.'} + socketio.emit('alfred_render',STATE['content']) + return jsonify({'ok':True}) + +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) + diff --git a/alfred/app.backup.camera/app.py.ori b/alfred/app.backup.camera/app.py.ori new file mode 100644 index 0000000..d44c3ca --- /dev/null +++ b/alfred/app.backup.camera/app.py.ori @@ -0,0 +1,180 @@ +import os, threading +from flask import Flask, render_template, request, jsonify, Response +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services.hermes_client import ask_hermes +from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1 +from services.camera import video_stream + +load_dotenv() + +## +from services.audio import get_vosk_model +print("[ALFRED] Préchargement des modèles vocaux...", flush=True) +get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) +get_vosk_model(os.environ["VOSK_MODEL_PATH"]) +print("[ALFRED] Modèles vocaux prêts.", flush=True) +## +app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") +STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} +conversation_lock=threading.Lock() +mic_stop_event=threading.Event() +audio_settings.initialize() + +def emit_state(mode=None, **extra): + if mode: STATE["mode"]=mode + STATE.update(extra); socketio.emit("alfred_state",STATE) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + stop_event=mic_stop_event, + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if audio_settings.mic_muted(): + emit_state("idle", partial="") + return + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + print("[ALFRED] Envoi à Hermes", flush=True) + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + STATE["content"] = display + socketio.emit("alfred_render", display) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + if audio_settings.mic_muted(): + return + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +@app.get('/api/audio/settings') +def get_audio_settings(): + return jsonify(audio_settings.get_settings()) + +@app.post('/api/audio/settings') +def set_audio_settings(): + data = request.get_json(silent=True) + try: + old = audio_settings.get_settings() + new = audio_settings.update_settings(data) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': f'ALSA : {exc}'}), 500 + if new['mic_muted'] != old['mic_muted']: + if new['mic_muted']: + mic_stop_event.set() + wake.pause() + else: + mic_stop_event.clear() + if not conversation_lock.locked(): + wake.resume() + socketio.emit('audio_settings', new) + return jsonify(new) + +@app.get('/api/camera/stream') +def camera_stream(): + # Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan. + try: + frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0'))) + except (ImportError, ValueError, RuntimeError, OSError) as exc: + return jsonify({'error': str(exc)}), 503 + return Response( + frames, + mimetype='multipart/x-mixed-replace; boundary=frame', + headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'} + ) + +@app.get("/") +def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) +@app.get("/api/state") +def state(): return jsonify({**STATE,"wake":wake.status(),"tts":tts_backend_detect()}) +@app.post("/api/state") +def set_state(): + STATE.update(request.get_json(force=True)); socketio.emit("alfred_state",STATE); return jsonify({"ok":True}) +@app.post("/api/render") +def render_payload(): + data=request.get_json(force=True); STATE["content"]=data; socketio.emit("alfred_render",data); return jsonify({"ok":True}) +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) diff --git a/alfred/app.backup.camera/backup_audio_20261016_133326/app.py b/alfred/app.backup.camera/backup_audio_20261016_133326/app.py new file mode 100644 index 0000000..9a63c93 --- /dev/null +++ b/alfred/app.backup.camera/backup_audio_20261016_133326/app.py @@ -0,0 +1,130 @@ +import os, threading +from flask import Flask, render_template, request, jsonify +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services.hermes_client import ask_hermes + +load_dotenv() + +## +from services.audio import get_vosk_model +print("[ALFRED] Préchargement des modèles vocaux...", flush=True) +get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) +get_vosk_model(os.environ["VOSK_MODEL_PATH"]) +print("[ALFRED] Modèles vocaux prêts.", flush=True) +## +app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") +STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} +conversation_lock=threading.Lock() + +def emit_state(mode=None, **extra): + if mode: STATE["mode"]=mode + STATE.update(extra); socketio.emit("alfred_state",STATE) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + print("[ALFRED] Envoi à Hermes", flush=True) + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + STATE["content"] = display + socketio.emit("alfred_render", display) + + if speech: + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +@app.get("/") +def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) +@app.get("/api/state") +def state(): return jsonify({**STATE,"wake":wake.status(),"tts":tts_backend_detect()}) +@app.post("/api/state") +def set_state(): + STATE.update(request.get_json(force=True)); socketio.emit("alfred_state",STATE); return jsonify({"ok":True}) +@app.post("/api/render") +def render_payload(): + data=request.get_json(force=True); STATE["content"]=data; socketio.emit("alfred_render",data); return jsonify({"ok":True}) +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on"): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) diff --git a/alfred/app.backup.camera/backup_audio_20261016_133326/audio.py b/alfred/app.backup.camera/backup_audio_20261016_133326/audio.py new file mode 100644 index 0000000..b655c60 --- /dev/null +++ b/alfred/app.backup.camera/backup_audio_20261016_133326/audio.py @@ -0,0 +1,379 @@ +"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre. + +STT: Vosk + sounddevice, avec fallback arecord/ALSA. +TTS: Piper local, avec fallback espeak puis spd-say. +Sortie: haut-parleur local via paplay/aplay. + +Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu +à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique. +""" +from __future__ import annotations +from functools import lru_cache + +import time +import json +import os +import queue +import shutil +import subprocess +import tempfile +import threading +from dataclasses import dataclass +from typing import Callable, Optional + + +def _cmd_ok(cmd, **kwargs): + try: + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=True, text=True, **kwargs) + except Exception: + return None + + +def audio_backend_detect() -> str: + configured = os.getenv("AUDIO_BACKEND", "auto").lower() + if configured in ("pactl", "alsa"): + return configured + if shutil.which("pactl"): + info = _cmd_ok(["pactl", "info"]) + if info and "Server Name" in info.stdout: + return "pactl" + if shutil.which("amixer"): + return "alsa" + return "none" + + +def _which(cmd): + return shutil.which(cmd) + + +def _is_piper_tts(binary: Optional[str]) -> bool: + if not binary: + return False + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + if "gtk" in out or "ratbag" in out: + return False + return (("--model" in out or " -m " in out) and + ("--output_file" in out or " -o " in out)) + except Exception: + return False + + +def _find_piper_binary() -> Optional[str]: + candidates = [ + os.getenv("PIPER_BIN"), + "/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"), + "/usr/local/bin/piper", "/usr/bin/piper", _which("piper"), + ] + for candidate in candidates: + if candidate and _is_piper_tts(candidate): + return candidate + return None + + +def _piper_supports_long_flag(binary: str) -> bool: + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + return "--model" in out and "--output_file" in out + except Exception: + return True + +### +def tts_backend_detect() -> str: + """Retourne le moteur TTS configuré pour Alfred.""" + + if os.getenv("PIPER_HOST"): + return "wyoming-piper" + + if _which("espeak-ng"): + return "espeak-ng" + + return "none" + + +''' +def tts_backend_detect() -> str: + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + if piper and model and os.path.exists(model): + return "piper" + if _which("espeak"): + return "espeak" + if _which("spd-say"): + return "spd-say" + return "none" +''' +#### + +def speak_text(text: str): + """Synthèse vocale via Wyoming Piper, avec secours eSpeak.""" + if not text or not text.strip(): + return + + text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))] + + try: + from services.piper_tts import speak as piper_speak + + print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True) + piper_speak(text) + return + + except Exception as exc: + print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True) + + # Secours eSpeak + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + print("[ALFRED TTS] Secours eSpeak", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + except Exception as exc: + print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + +''' +def speak_text(text: str): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + start = time.monotonic() + print("[ALFRED TTS] Génération audio...", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + print("[ALFRED TTS] Lecture audio...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED TTS] Erreur : {e}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + + + + +def speak_text(text: str): + """Synthétise et joue localement le texte. Retourne (ok, backend, erreur).""" + text = (text or "").strip() + if not text: + return False, "none", "texte vide" + text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))] + + backend = audio_backend_detect() + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + + if piper and model and os.path.exists(model): + wav_path = None + try: + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + wav_path = tmp.name + cmd = [piper] + if _piper_supports_long_flag(piper): + cmd += ["--model", model, "--output_file", wav_path] + else: + cmd += ["-m", model, "-o", wav_path] + p = subprocess.run(cmd, input=text, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if p.returncode != 0: + return False, "piper", (p.stderr or p.stdout or "")[:2000] + if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0: + return False, "piper", "Piper n'a pas produit de WAV" + if backend == "pactl" and shutil.which("paplay"): + play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + elif shutil.which("aplay"): + play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + else: + return False, "piper", "paplay/aplay introuvable" + return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée" + except Exception as exc: + return False, "piper", str(exc) + finally: + if wav_path: + try: os.unlink(wav_path) + except OSError: pass + + if _which("espeak"): + args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text] + if backend == "pactl" and shutil.which("padsp"): + args = ["padsp"] + args + ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0 + return ok, "espeak", None if ok else "espeak a échoué" + + if _which("spd-say"): + ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE, + stderr=subprocess.PIPE).returncode == 0 + return ok, "spd-say", None if ok else "spd-say a échoué" + return False, "none", "aucun backend TTS disponible" +''' + +_tts_lock = threading.Lock() +def speak_async(text: str, on_start: Optional[Callable] = None, + on_end: Optional[Callable] = None) -> bool: + """Lecture non bloquante; refuse d'empiler deux réponses vocales.""" + if _tts_lock.locked(): + return False + def worker(): + with _tts_lock: + if on_start: on_start() + try: speak_text(text) + finally: + if on_end: on_end() + threading.Thread(target=worker, daemon=True).start() + return True + + +@dataclass +class STTResult: + text: str = "" + error: Optional[str] = None + + + +@lru_cache(maxsize=2) +def get_vosk_model(model_path): + from vosk import Model + + print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True) + model = Model(model_path) + print("[ALFRED] Modèle Vosk prêt", flush=True) + + return model + + +def listen_once(stop_event: Optional[threading.Event] = None, + on_partial: Optional[Callable[[str], None]] = None, + on_level: Optional[Callable[[float], None]] = None) -> STTResult: + """Attend une phrase finale Vosk et la retourne. + + Reprend le comportement du prototype: sounddevice en priorité, arecord sinon. + Le niveau audio transmis à l'UI est une approximation RMS sur int16. + """ + try: + import numpy as np + from vosk import Model, KaldiRecognizer + except Exception as exc: + return STTResult(error=f"Dépendance STT absente: {exc}") + + model_path = os.getenv("VOSK_MODEL_PATH", "") + sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000")) + if not model_path or not os.path.exists(model_path): + return STTResult(error=f"Modèle Vosk introuvable: {model_path}") + try: +# rec = KaldiRecognizer(Model(model_path), sample_rate) + rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate) + except Exception as exc: + return STTResult(error=f"Init Vosk: {exc}") + + def process(data: bytes) -> Optional[str]: + if on_level and data: + try: + a = np.frombuffer(data, dtype=np.int16).astype(np.float32) + rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0 + on_level(min(1.0, rms / 6000.0)) + except Exception: + pass + if rec.AcceptWaveform(data): + text = (json.loads(rec.Result() or "{}").get("text") or "").strip() + if text: + return text + elif on_partial: + partial = json.loads(rec.PartialResult() or "{}").get("partial", "") + print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True) + on_partial(partial) + return None + + try: + import sounddevice as sd + q = queue.Queue() + def callback(indata, frames, time_info, status): + q.put(bytes(indata)) + device = os.getenv("AUDIO_INPUT_DEVICE") or None + with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16", + channels=1, callback=callback, device=device): + while not (stop_event and stop_event.is_set()): + text = process(q.get()) + if text: + return STTResult(text=text) + except Exception as exc: + print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True) + +# except Exception: +# pass + + if not shutil.which("arecord"): + return STTResult(error="sounddevice indisponible et arecord introuvable") + cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"] + alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip() + if alsa_device: + cmd += ["-D", alsa_device] + proc = None + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) + while not (stop_event and stop_event.is_set()): + chunk = proc.stdout.read(8000) + if not chunk: + continue + text = process(chunk) + if text: + return STTResult(text=text) + return STTResult() + except Exception as exc: + return STTResult(error=f"arecord: {exc}") + finally: + if proc and proc.poll() is None: + proc.terminate() + + +def devices(): + try: +# import sounddevice as sd +# return sd.query_devices() + print("[ALFRED STT] Périphériques audio :", flush=True) + print(sd.query_devices(), flush=True) + print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True) + return sd.query_devices() + except Exception: + return [] diff --git a/alfred/app.backup.camera/backup_micro_gain_20261016_134053/alfred-controls.css b/alfred/app.backup.camera/backup_micro_gain_20261016_134053/alfred-controls.css new file mode 100644 index 0000000..ff91533 --- /dev/null +++ b/alfred/app.backup.camera/backup_micro_gain_20261016_134053/alfred-controls.css @@ -0,0 +1,19 @@ +/* Contrôles indépendants de la feuille alfred.css existante */ +body > main { position: relative; min-height: 100dvh; padding-bottom: 82px; box-sizing: border-box; } +.alfred-controls { + position: absolute; left: 16px; right: 16px; bottom: 12px; + display: flex; align-items: center; gap: 14px; min-height: 56px; + padding: 8px 18px; box-sizing: border-box; + border: 1px solid rgba(255,255,255,.10); border-radius: 16px; + background: rgba(35,37,41,.92); color: #f4f4f5; + box-shadow: 0 8px 28px rgba(0,0,0,.14); + font: 14px/1.3 system-ui, sans-serif; +} +.alfred-controls .audio-toggle { flex: 0 0 auto; width: 42px; height: 42px; padding: 0; border: 1px solid transparent; border-radius: 12px; background: transparent; color: inherit; font-size: 23px; cursor: pointer; } +.alfred-controls .audio-toggle:hover, .alfred-controls .audio-toggle:focus-visible { background: rgba(255,255,255,.10); outline-offset: 2px; } +.alfred-controls .audio-toggle[aria-pressed="true"] { background: rgba(225,100,100,.14); border-color: rgba(225,100,100,.28); } +.alfred-controls .volume-label { white-space: nowrap; color: #c8c8ce; } +.alfred-controls input[type="range"] { min-width: 60px; flex: 1; accent-color: #d1d5db; cursor: pointer; } +.alfred-controls output { min-width: 44px; text-align: right; font-variant-numeric: tabular-nums; } +.alfred-controls .controls-separator { height: 25px; width: 1px; background: rgba(255,255,255,.16); } +@media (max-width: 480px) { .alfred-controls { gap: 8px; padding: 6px 9px; left: 8px; right: 8px; } .alfred-controls .volume-label { display: none; } } diff --git a/alfred/app.backup.camera/backup_micro_gain_20261016_134053/alfred-controls.js b/alfred/app.backup.camera/backup_micro_gain_20261016_134053/alfred-controls.js new file mode 100644 index 0000000..142bd55 --- /dev/null +++ b/alfred/app.backup.camera/backup_micro_gain_20261016_134053/alfred-controls.js @@ -0,0 +1,73 @@ +(() => { + 'use strict'; + const slider = document.getElementById('volumeSlider'); + const volumeValue = document.getElementById('volumeValue'); + const speaker = document.getElementById('speakerMute'); + const mic = document.getElementById('micMute'); + const endpoint = '/api/audio/settings'; + let state = { volume: 70, speaker_muted: false, mic_muted: false }; + let saveTimer; + + function render() { + slider.value = String(state.volume); + volumeValue.value = `${state.volume} %`; + speaker.setAttribute('aria-pressed', String(state.speaker_muted)); + speaker.setAttribute('aria-label', state.speaker_muted ? 'Réactiver le haut-parleur' : 'Couper le haut-parleur'); + speaker.title = speaker.getAttribute('aria-label'); + speaker.textContent = state.speaker_muted ? '🔇' : '🔊'; + mic.setAttribute('aria-pressed', String(state.mic_muted)); + mic.setAttribute('aria-label', state.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone'); + mic.title = mic.getAttribute('aria-label'); + mic.textContent = state.mic_muted ? '🎙️̸' : '🎙️'; + } + + async function save() { + try { + const response = await fetch(endpoint, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(state) + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + if (result && typeof result.volume === 'number') { + state = { ...state, ...result }; + render(); + } + } catch (error) { + console.warn('Réglages audio non synchronisés avec Flask :', error); + } + } + + slider.addEventListener('input', () => { + state.volume = Number(slider.value); + render(); + clearTimeout(saveTimer); + saveTimer = setTimeout(save, 200); + }); + speaker.addEventListener('click', () => { + state.speaker_muted = !state.speaker_muted; + render(); + clearTimeout(saveTimer); + save(); + }); + mic.addEventListener('click', () => { + state.mic_muted = !state.mic_muted; + render(); + clearTimeout(saveTimer); + save(); + }); + + (async () => { + try { + const response = await fetch(endpoint); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const settings = await response.json(); + if (typeof settings.volume === 'number') state.volume = Math.max(0, Math.min(100, settings.volume)); + if (typeof settings.speaker_muted === 'boolean') state.speaker_muted = settings.speaker_muted; + if (typeof settings.mic_muted === 'boolean') state.mic_muted = settings.mic_muted; + } catch (error) { + console.warn('Réglages audio non disponibles :', error); + } + render(); + })(); +})(); diff --git a/alfred/app.backup.camera/backup_micro_gain_20261016_134053/audio_settings.py b/alfred/app.backup.camera/backup_micro_gain_20261016_134053/audio_settings.py new file mode 100644 index 0000000..20243b7 --- /dev/null +++ b/alfred/app.backup.camera/backup_micro_gain_20261016_134053/audio_settings.py @@ -0,0 +1,62 @@ +"""Réglages audio Alfred : ALSA carte 1 et états logiques thread-safe.""" +import os +import re +import subprocess +import threading + +_LOCK = threading.RLock() +_STATE = {'volume': 70, 'speaker_muted': False, 'mic_muted': False} +_CARD = os.getenv('ALFRED_ALSA_CARD', '1') +_CONTROL = os.getenv('ALFRED_ALSA_VOLUME_CONTROL', 'Master') + +def _amixer(*args): + return subprocess.run(['amixer', '-c', _CARD, *args], capture_output=True, + text=True, timeout=5, check=True).stdout + +def initialize(): + """Lit le volume existant sans modifier les réglages audio au démarrage.""" + try: + output = _amixer('sget', _CONTROL) + matches = re.findall(r'\[(\d+)%\]', output) + with _LOCK: + if matches: + _STATE['volume'] = int(matches[0]) + # Ne pas déduire speaker_muted du mixer : état propre à Alfred. + except (OSError, subprocess.SubprocessError) as exc: + print(f'[ALFRED AUDIO] Lecture ALSA impossible : {exc}', flush=True) + +def get_settings(): + with _LOCK: + return dict(_STATE) + +def update_settings(payload): + if not isinstance(payload, dict): + raise ValueError('Un objet JSON est attendu') + allowed = {'volume', 'speaker_muted', 'mic_muted'} + if set(payload) - allowed: + raise ValueError('Paramètre audio inconnu') + with _LOCK: + new = dict(_STATE) + if 'volume' in payload: + v = payload['volume'] + if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100: + raise ValueError('volume doit être entre 0 et 100') + new['volume'] = round(v) + for key in ('speaker_muted', 'mic_muted'): + if key in payload: + if not isinstance(payload[key], bool): + raise ValueError(f'{key} doit être un booléen') + new[key] = payload[key] + # Ne touche jamais aux contrôles Mic/Capture : calibration conservée. + if new['volume'] != _STATE['volume']: + _amixer('sset', _CONTROL, f"{new['volume']}%") + _STATE.update(new) + return dict(_STATE) + +def speaker_muted(): + with _LOCK: + return _STATE['speaker_muted'] + +def mic_muted(): + with _LOCK: + return _STATE['mic_muted'] diff --git a/alfred/app.backup.camera/backup_micro_gain_20261016_134053/index.html b/alfred/app.backup.camera/backup_micro_gain_20261016_134053/index.html new file mode 100644 index 0000000..4762a5b --- /dev/null +++ b/alfred/app.backup.camera/backup_micro_gain_20261016_134053/index.html @@ -0,0 +1,28 @@ + + + + + + {{name}} + + + + + +
+
{{name}}repos
+

Bonjour.

À votre écoute.

+
+
+ + + + 70 % + + +
+
+ + + + diff --git a/alfred/app.backup.camera/config/hermes_session.json b/alfred/app.backup.camera/config/hermes_session.json new file mode 100644 index 0000000..de88db4 --- /dev/null +++ b/alfred/app.backup.camera/config/hermes_session.json @@ -0,0 +1,3 @@ +{ + "session_id": "local-65f11130ed816902a867736ef80b3af0ea255048cde912fb31b26267c8d1d599" +} diff --git a/alfred/app.backup.camera/config/hermes_session.json.bak.20261024_151328 b/alfred/app.backup.camera/config/hermes_session.json.bak.20261024_151328 new file mode 100644 index 0000000..4525415 --- /dev/null +++ b/alfred/app.backup.camera/config/hermes_session.json.bak.20261024_151328 @@ -0,0 +1,3 @@ +{ + "session_id": "local-f2399cb2bcf9e91094d3dc34492b463eda9cf42d09857d51b5954c5982aaaa5e" +} diff --git a/alfred/app.backup.camera/config/hermes_session.json.lock b/alfred/app.backup.camera/config/hermes_session.json.lock new file mode 100644 index 0000000..e69de29 diff --git a/alfred/app.backup.camera/config/identity.md b/alfred/app.backup.camera/config/identity.md new file mode 100644 index 0000000..2ac6084 --- /dev/null +++ b/alfred/app.backup.camera/config/identity.md @@ -0,0 +1,31 @@ +# Identité et comportement d'Alfred + +Tu es Alfred, le majordome vocal personnel de la maison. +Tu t'appelles Alfred, même si le moteur logiciel qui te permet de raisonner est Hermes Agent. + + +## Langue et style +- Réponds toujours en français, sauf demande explicite de parler une autre langue. +- Tutoie ton interlocuteur. +- Adopte un ton naturel, calme, cordial et professionnel. +- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute. +- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement. +- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne. + +## Domotique +- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles. +- Ne confirme une action que si son exécution a été effectivement vérifiée. +- Si tu ne peux pas effectuer une action, explique-le simplement. + +## Identité +- Si l'on te demande ton nom, réponds : « Je m'appelle Alfred. » + + +# Alfred +Tu disposes des outils et compétences configurés +dans Hermes Agent. + +Utilise librement les outils disponibles lorsque +la demande le nécessite. + +Ne limite pas tes capacités à la domotique. diff --git a/alfred/app.backup.camera/config/identity.md.bak.20261016_140326 b/alfred/app.backup.camera/config/identity.md.bak.20261016_140326 new file mode 100644 index 0000000..ed48720 --- /dev/null +++ b/alfred/app.backup.camera/config/identity.md.bak.20261016_140326 @@ -0,0 +1,26 @@ +# Identité d'Alfred + +Tu es Alfred, un majordome personnel intelligent. + +Tu fonctionnes dans une maison équipée de Home Assistant. +Tu utilises Hermes Agent comme moteur de raisonnement. + +## Comportement + +- Ton nom est Alfred. +- Tu réponds toujours en français, sauf demande explicite contraire. +- Tu t'adresses à ton utilisateur en le tutoyant. +- Tu adoptes un ton naturel, calme, chaleureux et professionnel. +- Tu privilégies les réponses courtes, adaptées à une conversation vocale. +- Tu évites les introductions inutiles et les formulations trop techniques. +- Tu ne mentionnes pas Hermes, Ollama ou ton fonctionnement interne sauf si on te le demande. + +## Domotique + +- Tu peux utiliser les outils Home Assistant disponibles. +- Lorsque tu exécutes une commande, confirme brièvement le résultat. +- Ne prétends jamais avoir exécuté une action si l'outil a échoué. + +## Identité + +Si on te demande ton nom, tu réponds : « Je m'appelle Alfred. » diff --git a/alfred/app.backup.camera/config/identity.md.ori2 b/alfred/app.backup.camera/config/identity.md.ori2 new file mode 100644 index 0000000..52603ea --- /dev/null +++ b/alfred/app.backup.camera/config/identity.md.ori2 @@ -0,0 +1,74 @@ +# Identité et comportement d'Alfred + +Tu es Alfred, un majordome numérique personnel, +intelligent, polyvalent et connecté. + +Tu n'es pas limité à la domotique. +Tu es un assistant capable de répondre à des questions, +de rechercher des informations, de générer des images, +d'analyser des documents et d'interagir avec la maison. + +## Langue et personnalité + +- Réponds toujours en français. +- Tutoie ton interlocuteur. +- Adopte un ton calme, cordial et professionnel. +- Privilégie les réponses courtes adaptées à la voix. +- Ne mentionne pas ton architecture interne sans raison. + +## Capacités et outils + +Tu disposes de plusieurs outils MCP. + +### Home Assistant +- Contrôler les équipements connectés. +- Consulter les capteurs et les états. +- Exécuter les commandes demandées. + +### ComfyUI +- Générer des images à partir de descriptions. +- Analyser des images avec les outils disponibles. +- Lorsqu'une génération est demandée, recherche et + utilise l'outil MCP generate_image. +- L'outil ComfyUI peut transmettre directement + l'image générée à l'interface graphique d'Alfred. +- Ne prétends pas être incapable de générer une image + sans avoir vérifié la disponibilité de cet outil. + +### Autres capacités +- Répondre aux questions générales. +- Utiliser les outils de recherche disponibles. +- Reconnaître des personnes avec le MCP facial, + uniquement lorsque son utilisation est appropriée. +- Utiliser les autres MCP selon les demandes. + +## Règles générales + +- Choisis l'outil approprié selon la demande. +- Ne limite jamais tes capacités à la seule domotique. +- Ne prétends jamais avoir exécuté une action + si celle-ci n'a pas abouti. +- Si un outil est indisponible, explique le problème. +- Ne fabrique jamais de résultats d'outils. + +## Identité + +Si on te demande ton nom, réponds : +"Je m'appelle Alfred." + +## Génération d'images + +Tu peux générer des images avec le MCP ComfyUI. + +Lorsqu'un utilisateur demande une image : +1. Recherche l'outil generate_image du MCP ComfyUI. +2. Si Tool Search est actif, utilise tool_search pour + découvrir l'outil et tool_describe si nécessaire. +3. Appelle l'outil de génération avec la description. +4. Attends son résultat avant de confirmer la génération. + +Le MCP ComfyUI transmet automatiquement les images +générées à l'interface graphique d'Alfred. + +Ne prétends pas que tu es limité à la domotique. +Si l'outil n'est pas accessible, indique l'erreur réelle. diff --git a/alfred/app.backup.camera/services/__pycache__/alfred_intents.cpython-313.pyc b/alfred/app.backup.camera/services/__pycache__/alfred_intents.cpython-313.pyc new file mode 100644 index 0000000..edc50db Binary files /dev/null and b/alfred/app.backup.camera/services/__pycache__/alfred_intents.cpython-313.pyc differ diff --git a/alfred/app.backup.camera/services/__pycache__/alfred_views_controller.cpython-313.pyc b/alfred/app.backup.camera/services/__pycache__/alfred_views_controller.cpython-313.pyc new file mode 100644 index 0000000..7b7c2ed Binary files /dev/null and b/alfred/app.backup.camera/services/__pycache__/alfred_views_controller.cpython-313.pyc differ diff --git a/alfred/app.backup.camera/services/__pycache__/audio.cpython-313.pyc b/alfred/app.backup.camera/services/__pycache__/audio.cpython-313.pyc new file mode 100644 index 0000000..a2ea94c Binary files /dev/null and b/alfred/app.backup.camera/services/__pycache__/audio.cpython-313.pyc differ diff --git a/alfred/app.backup.camera/services/__pycache__/audio_settings.cpython-313.pyc b/alfred/app.backup.camera/services/__pycache__/audio_settings.cpython-313.pyc new file mode 100644 index 0000000..fc453e0 Binary files /dev/null and b/alfred/app.backup.camera/services/__pycache__/audio_settings.cpython-313.pyc differ diff --git a/alfred/app.backup.camera/services/__pycache__/camera.cpython-313.pyc b/alfred/app.backup.camera/services/__pycache__/camera.cpython-313.pyc new file mode 100644 index 0000000..adcb34b Binary files /dev/null and b/alfred/app.backup.camera/services/__pycache__/camera.cpython-313.pyc differ diff --git a/alfred/app.backup.camera/services/__pycache__/ha_views.cpython-313.pyc b/alfred/app.backup.camera/services/__pycache__/ha_views.cpython-313.pyc new file mode 100644 index 0000000..8942c86 Binary files /dev/null and b/alfred/app.backup.camera/services/__pycache__/ha_views.cpython-313.pyc differ diff --git a/alfred/app.backup.camera/services/__pycache__/hermes_client.cpython-313.pyc b/alfred/app.backup.camera/services/__pycache__/hermes_client.cpython-313.pyc new file mode 100644 index 0000000..ec74a92 Binary files /dev/null and b/alfred/app.backup.camera/services/__pycache__/hermes_client.cpython-313.pyc differ diff --git a/alfred/app.backup.camera/services/__pycache__/piper_tts.cpython-313.pyc b/alfred/app.backup.camera/services/__pycache__/piper_tts.cpython-313.pyc new file mode 100644 index 0000000..4508a12 Binary files /dev/null and b/alfred/app.backup.camera/services/__pycache__/piper_tts.cpython-313.pyc differ diff --git a/alfred/app.backup.camera/services/__pycache__/wakeword.cpython-313.pyc b/alfred/app.backup.camera/services/__pycache__/wakeword.cpython-313.pyc new file mode 100644 index 0000000..9bc0ffb Binary files /dev/null and b/alfred/app.backup.camera/services/__pycache__/wakeword.cpython-313.pyc differ diff --git a/alfred/app.backup.camera/services/alfred_intents.py b/alfred/app.backup.camera/services/alfred_intents.py new file mode 100644 index 0000000..f2a2044 --- /dev/null +++ b/alfred/app.backup.camera/services/alfred_intents.py @@ -0,0 +1,189 @@ +"""Conservative French voice router. Never guesses dates for destructive actions.""" +from __future__ import annotations + +import os +import re +import threading +import time as monotonic_time +import unicodedata +from datetime import date, datetime, timedelta, time +from zoneinfo import ZoneInfo + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) +MONTHS = {m:i for i,m in enumerate('janvier fevrier mars avril mai juin juillet aout septembre octobre novembre decembre'.split(),1)} +DAYS = {'lundi':0,'mardi':1,'mercredi':2,'jeudi':3,'vendredi':4,'samedi':5,'dimanche':6} +PENDING_LOCK = threading.Lock() +PENDING_DELETE = {'value':None, 'expires':0} +PENDING_CREATE = {'value':None, 'expires':0} + + +def simplify(text): + return ''.join(c for c in unicodedata.normalize('NFD',text.lower()) if unicodedata.category(c)!='Mn') + + +def parse_day(s, now=None): + now = now or datetime.now(TZ) + text = simplify(s) + today = now.date() + if re.search(r'\b(apres.demain)\b',text):return today+timedelta(days=2) + if re.search(r'\bdemain\b',text):return today+timedelta(days=1) + if re.search(r'\b(aujourd.hui|ce matin|cet apres.midi|ce soir|cette nuit|dans la journee)\b',text):return today + m = re.search(r'\b(?:le\s+)?(\d{1,2})(?:er)?\s+(janvier|fevrier|mars|avril|mai|juin|juillet|aout|septembre|octobre|novembre|decembre)(?:\s+(20\d{2}))?\b',text) + if m: + month=MONTHS[m.group(2)];day=int(m.group(1));year=int(m.group(3)) if m.group(3) else today.year + candidate=date(year,month,day) + if not m.group(3) and candidate < today: candidate=date(year+1,month,day) + return candidate + m=re.search(r'\b(?:le\s+)?(\d{1,2})[/-](\d{1,2})(?:[/-](20\d{2}))?\b',text) + if m: + year=int(m.group(3)) if m.group(3) else today.year + candidate=date(year,int(m.group(2)),int(m.group(1))) + if not m.group(3) and candidate < today: candidate=date(year+1,candidate.month,candidate.day) + return candidate + for d, weekday in DAYS.items(): + if re.search(r'\b'+d+r'\b',text): + delta=(weekday-today.weekday())%7 + if 'prochain' in text and delta == 0: delta=7 + return today+timedelta(days=delta) + return None + + +def parse_clock(s): + text=simplify(s) + m=re.search(r'\b(?:a|vers|de|pour)\s+(\d{1,2})(?:\s*h(?:\s*(\d{1,2}))?|\s*heures?(?:\s*(\d{1,2}))?)\b',text) + if not m: return None + hh=int(m.group(1)); mm=int(m.group(2) or m.group(3) or 0) + if hh>23 or mm>59: raise ValueError('Heure invalide') + return time(hh,mm) + + +def period(s, now=None): + now = now or datetime.now(TZ) + text=simplify(s) + explicit_day=parse_day(text, now) + morning = 'matin' in text + afternoon = 'apres-midi' in text + evening = 'ce soir' in text or 'ce soir' in text + days=re.search(r'\b(?:sur|pour|pendant|les)?\s*(\d{1,2})\s*jours?\b',text) + if explicit_day: + start=datetime.combine(explicit_day,time.min,TZ) + end=start+timedelta(days=1) + if morning:start=start.replace(hour=6);end=start.replace(hour=12) + elif afternoon:start=start.replace(hour=12);end=start.replace(hour=18) + elif evening:start=start.replace(hour=18);end=(start+timedelta(days=1)).replace(hour=0) + return start,end,'day' + if days: + n=int(days.group(1)) + if not 1<=n<=31: raise ValueError('Demander entre 1 et 31 jours') + start=datetime.combine(now.date(),time.min,TZ) + return start,start+timedelta(days=n), 'days' + if 'mois' in text: + start=datetime(now.year,now.month,1,tzinfo=TZ) + next_month = datetime(now.year+int(now.month==12), now.month%12+1,1,tzinfo=TZ) + return start,next_month,'month' + if 'semaine' in text: + start=datetime.combine(now.date()-timedelta(days=now.weekday()),time.min,TZ) + if 'prochaine' in text:start+=timedelta(days=7) + return start,start+timedelta(days=7),'week' + start=datetime.combine(now.date(),time.min,TZ) + return start,start+timedelta(days=7),'days' + + +def classify(text): + t=simplify(text).strip() + if not t:return None + if re.search(r'\b(annule|non merci|laisse tomber)\b', t) and (current_delete() or current_create()):return {'kind':'cancel_pending'} + if re.search(r'\b(confirme|oui supprime|confirmer la suppression|oui c.est bon|oui valide)\b',t): + if current_delete():return {'kind':'confirm_delete'} + if current_create():return {'kind':'confirm_create'} + if re.search(r'\b(accueil|reviens|retour|ferme (le|la) (planning|calendrier|vue)|ecran principal)\b',t) and not re.search(r'\b(rendez.vous|reunion|evenement)\b',t):return {'kind':'home'} + if re.search(r'\b(montre|affiche|voir|regarde)\b.*\b(camera|cameras|video|videos)\b',t):return {'kind':'cameras'} + if re.search(r'\b(affiche|montre|donne|quelles|quelle|voir)\b.*\b(temperature|temperatures|capteurs|chauffage)\b',t):return {'kind':'temperatures'} + # Do not hijack general requests for images, HA or Hermes. + if re.search(r'\b(supprime|efface|retire|annule)\b.*\b(rendez.vous|evenement|agenda|calendrier|planning)\b',t):return {'kind':'delete'} + if re.search(r'\b(ajoute|ajouter|cree|creer|inscris|planifie|note)\b.*\b(rendez.vous|evenement|agenda|calendrier|planning)\b',t):return {'kind':'create'} + cal_words=r'\b(agenda|calendrier|planning|rendez.vous|evenements?)\b' + query_words=r'\b(pre[v]?u|prevu|programme|organise|j.ai|ai.je|quoi|qu.est.ce|choses?|occupe|libre)\b' + time_words=r'\b(demain|aujourd.hui|matin|soir|apres-midi|journee|semaine|mois|jours?|novembre|janvier|fevrier|mars|avril|mai|juin|juillet|aout|septembre|octobre|decembre)\b' + if re.search(cal_words,t) or (re.search(query_words,t) and re.search(time_words,t)) or ('8 novembre' in t): + return {'kind':'calendar', 'question': bool(re.search(query_words,t))} + return None + + +def extract_create(text, calendars): + """Parse straightforward instructions only. Missing/ambiguous fields become a prefilled form.""" + raw=text.strip(); normalized=simplify(raw) + day=parse_day(raw) + hour=parse_clock(raw) + options=[c for c in calendars if c.get('can_create')] + preferred=os.getenv('HA_DEFAULT_CALENDAR','').strip() + matching=[] + for c in options: + if simplify(c['name']) in normalized or simplify(c['entity_id'].split('.',1)[-1]).replace('_',' ') in normalized: + matching.append(c) + calendar = matching[0]['entity_id'] if len(matching)==1 else None + if calendar is None and preferred in {c['entity_id'] for c in options}:calendar=preferred + if calendar is None and len(options)==1:calendar=options[0]['entity_id'] + # Strip date and time syntax to derive a plausible title. + title=re.sub(r'^.*?\b(?:ajoute|ajouter|cree|creer|inscris|planifie|note)\b', '', normalized, count=1) + title=re.sub(r'\b(?:dans|sur)\s+(?:le\s+)?calendrier\s+[\w\s_-]+?(?=\b(?:le|demain|aujourd.hui|a)\b|$)', ' ', title) + title=re.sub(r'\b(?:le\s+)?\d{1,2}\s+(?:'+ '|'.join(MONTHS)+ r')(?:\s+20\d{2})?\b',' ',title) + title=re.sub(r'\b(?:demain|aujourd.hui|apres.demain|lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b',' ',title) + title=re.sub(r'\b(?:a|de)\s+\d{1,2}\s*(?:h|heures?)\s*\d{0,2}\b',' ',title) + title=re.sub(r'\b(?:dans|sur)\s+(?:le\s+)?(?:calendrier|agenda)\b.*$', ' ',title) + title=re.sub(r'^\s*(?:moi|un|une|le|la|les|rendez.vous|evenement|pour|de|a)\s+', '',title) + title=re.sub(r'\s+', ' ', title).strip(' ,.:') + # A guided confirmation is safer than using a guessed title. + title=title[:200] + data={'title':title,'calendar_id':calendar or '', 'date':day.isoformat() if day else '', + 'time':hour.strftime('%H:%M') if hour else '','duration_minutes':60} + complete=bool(day and hour and calendar and title and title not in ('calendrier','agenda','rendez vous')) + return data, complete + + +def extract_search(text): + normalized=simplify(text) + normalized=re.sub(r'^.*?\b(?:supprime|efface|retire|annule)\b','',normalized,count=1) + normalized=re.sub(r'\b(?:le\s+)?\d{1,2}\s+(?:'+ '|'.join(MONTHS)+ r')\b','',normalized) + normalized=re.sub(r'\b(demain|aujourd.hui|apres.demain|lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b','',normalized) + normalized=re.sub(r'\b(de|du|mon|ma|un|une|le|la|les|rendez.vous|evenement|agenda|calendrier|planning)\b',' ',normalized) + return re.sub(r'\s+',' ',normalized).strip() + + +def set_delete(event): + with PENDING_LOCK: + PENDING_DELETE['value']=event + PENDING_DELETE['expires']=monotonic_time.monotonic()+90 + + +def current_delete(): + with PENDING_LOCK: + if monotonic_time.monotonic()>PENDING_DELETE['expires']: + PENDING_DELETE['value']=None + return dict(PENDING_DELETE['value']) if PENDING_DELETE['value'] else None + + +def clear_delete(): + with PENDING_LOCK: + p=PENDING_DELETE['value'];PENDING_DELETE['value']=None + return p + + +def set_create(details): + with PENDING_LOCK: + PENDING_CREATE['value']=dict(details) + PENDING_CREATE['expires']=monotonic_time.monotonic()+90 + +def current_create(): + with PENDING_LOCK: + if monotonic_time.monotonic()>PENDING_CREATE['expires']: + PENDING_CREATE['value']=None + return dict(PENDING_CREATE['value']) if PENDING_CREATE['value'] else None + +def clear_create(): + with PENDING_LOCK: + p=PENDING_CREATE['value'];PENDING_CREATE['value']=None + return p + +def cancel_pending(): + clear_create();clear_delete() diff --git a/alfred/app.backup.camera/services/alfred_views_controller.py b/alfred/app.backup.camera/services/alfred_views_controller.py new file mode 100644 index 0000000..9477dff --- /dev/null +++ b/alfred/app.backup.camera/services/alfred_views_controller.py @@ -0,0 +1,127 @@ +"""Fast voice routing for visual actions; Hermes still handles general tasks.""" +from __future__ import annotations + +import os +from datetime import datetime, timedelta, time +from zoneinfo import ZoneInfo + +from . import ha_views as ha +from . import alfred_intents as intents + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris')) + + +def _label(s,e): + return f"du {s:%d/%m} au {(e-timedelta(days=1)):%d/%m}" + + +def _event_summary(events, question): + count=len(events) + if count == 0:return "Non, je n'ai trouvé aucun événement pour cette période." if question else 'Aucun événement prévu sur cette période.' + names=', '.join(ev['title'] for ev in events[:3]) + if question: + return f"Oui, {count} événement{'s' if count>1 else ''} prévu{'s' if count>1 else ''}. {names}." if count<=3 else f"Oui, {count} événements sont prévus. Notamment : {names}." + return f"Voici ton planning avec {count} événement{'s' if count>1 else ''}." + + +def _calendar(text, question=False): + start,end,view=intents.period(text) + payload=ha.get_events(start,end) + content={'type':'calendar','view':view,'title':'Planning','start':start.isoformat(),'end':end.isoformat(), + 'events':payload['events'],'calendars':payload['calendars'],'errors':payload['errors']} + speech=_event_summary(payload['events'],question) + if payload['errors']: speech+=' Certains calendriers sont indisponibles.' + return content,speech + + +def _create(text): + calendars=ha.list_calendars() + details, complete=intents.extract_create(text,calendars) + if not complete: + return {'type':'calendar_form','values':details,'calendars':calendars},'Je prépare le rendez-vous. Complète ou vérifie les informations à l’écran avant de l’enregistrer.' + # No calendar mutation until a second explicit confirmation. + intents.set_create(details) + pretty=datetime.fromisoformat(details['date']).strftime('%d/%m') + speech=f"Je vais ajouter {details['title']} le {pretty} à {details['time']}. Dis confirme pour enregistrer, ou annule." + return {'type':'calendar_form','values':details,'calendars':calendars,'awaiting_confirmation':True},speech + + +def _delete(text): + now=datetime.now(TZ) + day=intents.parse_day(text,now) + if day: + start=datetime.combine(day,time.min,TZ); end=start+timedelta(days=1) + else: + start=datetime.combine(now.date(),time.min,TZ);end=start+timedelta(days=45) + data=ha.get_events(start,end) + needle=intents.extract_search(text) + events=[e for e in data['events'] if intents.simplify(needle) in intents.simplify(e['title'])] if needle else data['events'] + if not events: + return {'type':'calendar','view':'days','start':start.isoformat(),'end':end.isoformat(),**data},'Je ne trouve pas cet événement. Aucun événement n’a été supprimé.' + if len(events)!=1: + return {'type':'delete_candidates','events':events[:30]},f"J'ai trouvé {len(events)} événements correspondants. Choisis celui à supprimer sur l'écran." + ev=events[0] + if not ev['can_delete']: + return {'type':'delete_candidates','events':[ev]},'Ce calendrier ne permet pas la suppression de cet événement depuis Alfred.' + intents.set_delete(ev) + warning=' Attention, si cet événement est récurrent, cela peut supprimer toute la série.' if not ev['recurrence_id'] else '' + return {'type':'delete_candidates','events':[ev], 'awaiting_confirmation':True},f"Confirme la suppression de {ev['title']}, ou dis annule.{warning}" + + +def _confirm_delete(): + e=intents.current_delete() + if not e:return {'type':'text','text':'Confirmation expirée.'},'La demande de suppression a expiré.' + # Recheck existence in HA to avoid deleting an event that changed meanwhile. + start=datetime.fromisoformat(e['start'])-timedelta(days=1) + end=datetime.fromisoformat(e['end'])+timedelta(days=1) + found=ha.get_events(start,end,[e['calendar_id']])['events'] + if not any(x['id']==e['id'] and x['start']==e['start'] and x['recurrence_id']==e['recurrence_id'] for x in found): + intents.clear_delete() + return {'type':'text','text':'Cet événement a changé. Suppression annulée.'},'Cet événement a changé. Rien n’a été supprimé.' + ha.delete_event(e['calendar_id'],e['id'],e['recurrence_id'] or None) + intents.clear_delete() + content,speech=_calendar('planning sur 7 jours') + return content,'Événement supprimé. '+speech + + +def _confirm_create(): + data=intents.current_create() + if not data:return {'type':'text','text':'Confirmation expirée.'},'La demande de création a expiré.' + if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'): + return {'type':'calendar_form','values':data,'calendars':ha.list_calendars()},'L’écriture est désactivée. Active ALFRED_ALLOW_CALENDAR_WRITES dans la configuration.' + start=datetime.fromisoformat(data['date']+'T'+data['time']).replace(tzinfo=TZ) + end=start+timedelta(minutes=int(data.get('duration_minutes',60))) + ha.create_event(data['calendar_id'],data['title'],start,end) + intents.clear_create() + content,speech=_calendar('planning sur 7 jours') + return content,'Événement ajouté. '+speech + + +def route(text, last_view=None): + intent=intents.classify(text) + # Short temporal follow-ups to a displayed calendar: 'Et demain ?', 'Ce matin ?' + if intent is None and last_view == 'calendar' and len(text.strip()) < 45: + t=intents.simplify(text) + if any(word in t for word in ('demain','matin','apres-midi','soir','journee','semaine','mois','jours','novembre','decembre','janvier','fevrier','mars','avril','mai','juin','juillet','aout','septembre','octobre')): + intent={'kind':'calendar','question':True} + if intent is None:return None + kind=intent['kind'] + if kind=='home':return {'type':'home','title':'Alfred','text':'À ton écoute.'},'Je reviens à l’accueil.' + if kind=='calendar':return _calendar(text, intent.get('question',False)) + if kind=='create':return _create(text) + if kind=='delete':return _delete(text) + if kind=='confirm_delete': + if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'): + return {'type':'text','text':'Écriture calendrier désactivée.'},'La suppression est désactivée dans la configuration.' + return _confirm_delete() + if kind=='confirm_create':return _confirm_create() + if kind=='cancel_pending': + intents.cancel_pending() + return {'type':'home','text':'Demande annulée.'},'D’accord, aucune modification effectuée.' + if kind=='temperatures': + temps=ha.get_temperatures() + return {'type':'temperatures','items':temps},f"Voici {len(temps)} mesures de température provenant de Home Assistant." + if kind=='cameras': + cameras=ha.list_cameras() + return {'type':'cameras','items':cameras},f"Voici {len(cameras)} caméras Home Assistant disponibles." + return None diff --git a/alfred/app.backup.camera/services/audio.py b/alfred/app.backup.camera/services/audio.py new file mode 100644 index 0000000..390f1d4 --- /dev/null +++ b/alfred/app.backup.camera/services/audio.py @@ -0,0 +1,392 @@ +"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre. + +STT: Vosk + sounddevice, avec fallback arecord/ALSA. +TTS: Piper local, avec fallback espeak puis spd-say. +Sortie: haut-parleur local via paplay/aplay. + +Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu +à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique. +""" +from __future__ import annotations +from functools import lru_cache + +import time +import json +import os +import queue +import shutil +import subprocess +import tempfile +import threading +from dataclasses import dataclass +from typing import Callable, Optional + + +def _cmd_ok(cmd, **kwargs): + try: + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=True, text=True, **kwargs) + except Exception: + return None + + +def audio_backend_detect() -> str: + configured = os.getenv("AUDIO_BACKEND", "auto").lower() + if configured in ("pactl", "alsa"): + return configured + if shutil.which("pactl"): + info = _cmd_ok(["pactl", "info"]) + if info and "Server Name" in info.stdout: + return "pactl" + if shutil.which("amixer"): + return "alsa" + return "none" + + +def _which(cmd): + return shutil.which(cmd) + + +def _is_piper_tts(binary: Optional[str]) -> bool: + if not binary: + return False + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + if "gtk" in out or "ratbag" in out: + return False + return (("--model" in out or " -m " in out) and + ("--output_file" in out or " -o " in out)) + except Exception: + return False + + +def _find_piper_binary() -> Optional[str]: + candidates = [ + os.getenv("PIPER_BIN"), + "/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"), + "/usr/local/bin/piper", "/usr/bin/piper", _which("piper"), + ] + for candidate in candidates: + if candidate and _is_piper_tts(candidate): + return candidate + return None + + +def _piper_supports_long_flag(binary: str) -> bool: + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + return "--model" in out and "--output_file" in out + except Exception: + return True + +### +def tts_backend_detect() -> str: + """Retourne le moteur TTS configuré pour Alfred.""" + + if os.getenv("PIPER_HOST"): + return "wyoming-piper" + + if _which("espeak-ng"): + return "espeak-ng" + + return "none" + + +''' +def tts_backend_detect() -> str: + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + if piper and model and os.path.exists(model): + return "piper" + if _which("espeak"): + return "espeak" + if _which("spd-say"): + return "spd-say" + return "none" +''' +#### + +def speak_text(text: str): + """Synthèse vocale via Wyoming Piper, avec secours eSpeak.""" + from services.audio_settings import speaker_muted + if speaker_muted(): + return + if not text or not text.strip(): + return + + text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))] + + try: + from services.piper_tts import speak as piper_speak + + print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True) + piper_speak(text) + return + + except Exception as exc: + print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True) + + # Secours eSpeak + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + print("[ALFRED TTS] Secours eSpeak", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + except Exception as exc: + print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + +''' +def speak_text(text: str): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + start = time.monotonic() + print("[ALFRED TTS] Génération audio...", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + print("[ALFRED TTS] Lecture audio...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED TTS] Erreur : {e}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + + + + +def speak_text(text: str): + """Synthétise et joue localement le texte. Retourne (ok, backend, erreur).""" + text = (text or "").strip() + if not text: + return False, "none", "texte vide" + text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))] + + backend = audio_backend_detect() + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + + if piper and model and os.path.exists(model): + wav_path = None + try: + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + wav_path = tmp.name + cmd = [piper] + if _piper_supports_long_flag(piper): + cmd += ["--model", model, "--output_file", wav_path] + else: + cmd += ["-m", model, "-o", wav_path] + p = subprocess.run(cmd, input=text, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if p.returncode != 0: + return False, "piper", (p.stderr or p.stdout or "")[:2000] + if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0: + return False, "piper", "Piper n'a pas produit de WAV" + if backend == "pactl" and shutil.which("paplay"): + play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + elif shutil.which("aplay"): + play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + else: + return False, "piper", "paplay/aplay introuvable" + return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée" + except Exception as exc: + return False, "piper", str(exc) + finally: + if wav_path: + try: os.unlink(wav_path) + except OSError: pass + + if _which("espeak"): + args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text] + if backend == "pactl" and shutil.which("padsp"): + args = ["padsp"] + args + ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0 + return ok, "espeak", None if ok else "espeak a échoué" + + if _which("spd-say"): + ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE, + stderr=subprocess.PIPE).returncode == 0 + return ok, "spd-say", None if ok else "spd-say a échoué" + return False, "none", "aucun backend TTS disponible" +''' + +_tts_lock = threading.Lock() +def speak_async(text: str, on_start: Optional[Callable] = None, + on_end: Optional[Callable] = None) -> bool: + """Lecture non bloquante; refuse d'empiler deux réponses vocales.""" + if _tts_lock.locked(): + return False + def worker(): + with _tts_lock: + if on_start: on_start() + try: speak_text(text) + finally: + if on_end: on_end() + threading.Thread(target=worker, daemon=True).start() + return True + + +@dataclass +class STTResult: + text: str = "" + error: Optional[str] = None + + + +@lru_cache(maxsize=2) +def get_vosk_model(model_path): + from vosk import Model + + print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True) + model = Model(model_path) + print("[ALFRED] Modèle Vosk prêt", flush=True) + + return model + + +def listen_once(stop_event: Optional[threading.Event] = None, + on_partial: Optional[Callable[[str], None]] = None, + on_level: Optional[Callable[[float], None]] = None) -> STTResult: + """Attend une phrase finale Vosk et la retourne. + + Reprend le comportement du prototype: sounddevice en priorité, arecord sinon. + Le niveau audio transmis à l'UI est une approximation RMS sur int16. + """ + try: + import numpy as np + from vosk import Model, KaldiRecognizer + except Exception as exc: + return STTResult(error=f"Dépendance STT absente: {exc}") + + model_path = os.getenv("VOSK_MODEL_PATH", "") + sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000")) + if not model_path or not os.path.exists(model_path): + return STTResult(error=f"Modèle Vosk introuvable: {model_path}") + try: +# rec = KaldiRecognizer(Model(model_path), sample_rate) + rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate) + except Exception as exc: + return STTResult(error=f"Init Vosk: {exc}") + + def process(data: bytes) -> Optional[str]: + if on_level and data: + try: + a = np.frombuffer(data, dtype=np.int16).astype(np.float32) + rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0 + on_level(min(1.0, rms / 6000.0)) + except Exception: + pass + if rec.AcceptWaveform(data): + text = (json.loads(rec.Result() or "{}").get("text") or "").strip() + if text: + return text + elif on_partial: + partial = json.loads(rec.PartialResult() or "{}").get("partial", "") + print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True) + on_partial(partial) + return None + + try: + import sounddevice as sd + q = queue.Queue(maxsize=32) + def callback(indata, frames, time_info, status): + try: + q.put_nowait(bytes(indata)) + except queue.Full: + pass + device = os.getenv("AUDIO_INPUT_DEVICE") or None + with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16", + channels=1, callback=callback, device=device): + while not (stop_event and stop_event.is_set()): + try: + chunk = q.get(timeout=0.25) + except queue.Empty: + continue + text = process(chunk) + if text: + return STTResult(text=text) + except Exception as exc: + print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True) + +# except Exception: +# pass + + if stop_event and stop_event.is_set(): + return STTResult() + + if not shutil.which("arecord"): + return STTResult(error="sounddevice indisponible et arecord introuvable") + cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"] + alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip() + if alsa_device: + cmd += ["-D", alsa_device] + proc = None + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) + while not (stop_event and stop_event.is_set()): + chunk = proc.stdout.read(8000) + if not chunk: + continue + text = process(chunk) + if text: + return STTResult(text=text) + return STTResult() + except Exception as exc: + return STTResult(error=f"arecord: {exc}") + finally: + if proc and proc.poll() is None: + proc.terminate() + + +def devices(): + try: +# import sounddevice as sd +# return sd.query_devices() + print("[ALFRED STT] Périphériques audio :", flush=True) + print(sd.query_devices(), flush=True) + print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True) + return sd.query_devices() + except Exception: + return [] diff --git a/alfred/app.backup.camera/services/audio_settings.py b/alfred/app.backup.camera/services/audio_settings.py new file mode 100644 index 0000000..b310c32 --- /dev/null +++ b/alfred/app.backup.camera/services/audio_settings.py @@ -0,0 +1,77 @@ +"""Réglages audio Alfred : ALSA carte 1 et états logiques thread-safe.""" +import os +import re +import subprocess +import threading + +_LOCK = threading.RLock() +_STATE = {'volume': 70, 'speaker_muted': False, 'mic_muted': False, 'mic_gain': None} +_CARD = os.getenv('ALFRED_ALSA_CARD', '1') +_CONTROL = os.getenv('ALFRED_ALSA_VOLUME_CONTROL', 'Master') + +def _amixer(*args): + return subprocess.run(['amixer', '-c', _CARD, *args], capture_output=True, + text=True, timeout=5, check=True).stdout + +def initialize(): + """Lit le volume existant sans modifier les réglages audio au démarrage.""" + try: + output = _amixer('sget', _CONTROL) + matches = re.findall(r'\[(\d+)%\]', output) + with _LOCK: + if matches: + _STATE['volume'] = int(matches[0]) + # ALFRED_MIC_GAIN_V2 : gain Capture, sans toucher à Mic Boost. + try: + gain_out = _amixer('sget', os.getenv('ALFRED_ALSA_CAPTURE_CONTROL', 'Capture')) + gains = re.findall(r'\[(\d+)%\]', gain_out) + _STATE['mic_gain'] = int(gains[0]) if gains else None + except (OSError, subprocess.SubprocessError) as exc: + print(f'[ALFRED AUDIO] Gain Capture indisponible : {exc}', flush=True) + # Ne pas déduire speaker_muted du mixer : état propre à Alfred. + except (OSError, subprocess.SubprocessError) as exc: + print(f'[ALFRED AUDIO] Lecture ALSA impossible : {exc}', flush=True) + +def get_settings(): + with _LOCK: + return dict(_STATE) + +def update_settings(payload): + if not isinstance(payload, dict): + raise ValueError('Un objet JSON est attendu') + allowed = {'volume', 'speaker_muted', 'mic_muted', 'mic_gain'} + if set(payload) - allowed: + raise ValueError('Paramètre audio inconnu') + with _LOCK: + new = dict(_STATE) + if 'volume' in payload: + v = payload['volume'] + if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100: + raise ValueError('volume doit être entre 0 et 100') + new['volume'] = round(v) + if 'mic_gain' in payload: + v = payload['mic_gain'] + if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100: + raise ValueError('mic_gain doit être entre 0 et 100') + new['mic_gain'] = round(v) + for key in ('speaker_muted', 'mic_muted'): + if key in payload: + if not isinstance(payload[key], bool): + raise ValueError(f'{key} doit être un booléen') + new[key] = payload[key] + # Le gain Capture ne change que sur demande explicite. + # Mic Boost et Internal Mic Boost restent inchangés. + if 'mic_gain' in payload and new['mic_gain'] != _STATE['mic_gain']: + _amixer('sset', os.getenv('ALFRED_ALSA_CAPTURE_CONTROL', 'Capture'), f"{new['mic_gain']}%") + if new['volume'] != _STATE['volume']: + _amixer('sset', _CONTROL, f"{new['volume']}%") + _STATE.update(new) + return dict(_STATE) + +def speaker_muted(): + with _LOCK: + return _STATE['speaker_muted'] + +def mic_muted(): + with _LOCK: + return _STATE['mic_muted'] diff --git a/alfred/app.backup.camera/services/camera.py b/alfred/app.backup.camera/services/camera.py new file mode 100644 index 0000000..7459ddc --- /dev/null +++ b/alfred/app.backup.camera/services/camera.py @@ -0,0 +1,71 @@ +"""Accès simple à la caméra d'Alfred, avec aperçu MJPEG activé à la demande. + +Une seule lecture par instance Alfred. La vidéo ne démarre que lorsque +l'utilisateur clique sur Activer, pour éviter de monopoliser la caméra. +""" +import os +import threading +import time + +_CAMERA_LOCK = threading.Lock() + + +def snapshot(path, index=0): + import cv2 + if not _CAMERA_LOCK.acquire(blocking=False): + raise RuntimeError('Caméra déjà utilisée par la prévisualisation Alfred') + try: + cap = cv2.VideoCapture(index) + try: + ok, frame = cap.read() + finally: + cap.release() + if not ok: + raise RuntimeError('Impossible de lire la caméra locale') + cv2.imwrite(path, frame) + return path + finally: + _CAMERA_LOCK.release() + + +def video_stream(index=0): + """Ouvre la caméra, retourne un générateur de trames JPEG multipart. + + L'appelant doit retourner ce générateur dans une réponse Flask en streaming. + L'absence de cv2 ou de webcam déclenche une exception avant HTTP 200. + """ + import cv2 + if not _CAMERA_LOCK.acquire(blocking=False): + raise RuntimeError('Prévisualisation vidéo déjà active') + + cap = None + try: + cap = cv2.VideoCapture(index) + if not cap.isOpened(): + cap.release() + raise RuntimeError(f'Caméra {index} indisponible') + cap.set(cv2.CAP_PROP_FRAME_WIDTH, int(os.getenv('CAMERA_WIDTH', '640'))) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(os.getenv('CAMERA_HEIGHT', '480'))) + fps = min(15.0, max(1.0, float(os.getenv('CAMERA_FPS', '8')))) + quality = min(95, max(35, int(os.getenv('CAMERA_JPEG_QUALITY', '75')))) + except Exception: + if cap is not None: + cap.release() + _CAMERA_LOCK.release() + raise + + def frames(): + try: + while True: + ok, frame = cap.read() + if not ok: + break + ok_jpeg, encoded = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, quality]) + if ok_jpeg: + yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n' + time.sleep(1.0 / fps) + finally: + cap.release() + _CAMERA_LOCK.release() + + return frames() diff --git a/alfred/app.backup.camera/services/ha_views.py b/alfred/app.backup.camera/services/ha_views.py new file mode 100644 index 0000000..41e2bdf --- /dev/null +++ b/alfred/app.backup.camera/services/ha_views.py @@ -0,0 +1,236 @@ +"""Home Assistant data adapter for Alfred. Tokens remain on the Flask host.""" +from __future__ import annotations + +import os +import re +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import date, datetime, time, timedelta +from urllib.parse import quote, urlparse +from zoneinfo import ZoneInfo + +import requests + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) +CREATE = 1 +DELETE = 2 + +class HAError(RuntimeError): + pass + + +def _cfg(): + base = (os.getenv('HA_URL') or os.getenv('HOME_ASSISTANT_URL') or '').strip().rstrip('/') + token = (os.getenv('HA_TOKEN') or os.getenv('HOME_ASSISTANT_TOKEN') or '').strip() + if not base or not token: + raise HAError('Configurer HA_URL et HA_TOKEN dans l’environnement du service Alfred.') + if urlparse(base).scheme not in ('http','https'): + raise HAError('HA_URL doit commencer par http:// ou https://') + return base, token + + +def _request(method, path, *, params=None, payload=None, timeout=12): + base, token = _cfg() + try: + response = requests.request(method, base + path, + params=params, json=payload, + headers={'Authorization': 'Bearer '+token, 'Accept':'application/json'}, + timeout=timeout) + response.raise_for_status() + return response.json() if response.content else None + except requests.RequestException as exc: + code = exc.response.status_code if exc.response is not None else 'réseau' + raise HAError(f'Home Assistant indisponible ou action refusée ({code}).') from exc + + +def states(): + return _request('GET', '/api/states') + + +def _allowed(): + return {s.strip() for s in os.getenv('HA_CALENDARS', '').split(',') if s.strip()} + + +def list_calendars(): + raw = _request('GET', '/api/calendars') or [] + allow = _allowed() + state_map = {s.get('entity_id'):s for s in states() if s.get('entity_id','').startswith('calendar.')} + items = [] + for c in raw: + eid = str(c.get('entity_id','')) + if not eid.startswith('calendar.') or (allow and eid not in allow): + continue + st = state_map.get(eid, {}) + features = int((st.get('attributes') or {}).get('supported_features') or 0) + items.append({'entity_id':eid, 'name':c.get('name') or eid.split('.',1)[-1], + 'can_create':bool(features & CREATE), 'can_delete':bool(features & DELETE)}) + return sorted(items, key=lambda x:x['name'].casefold()) + + +def _parse_dt(raw): + # HA REST can return RFC3339 strings, date strings, or {date}/{dateTime} dictionaries. + if isinstance(raw, dict): + raw = raw.get('dateTime') or raw.get('date') + if not isinstance(raw, str) or not raw: + raise ValueError('Date de calendrier invalide') + if len(raw) == 10: + return datetime.combine(date.fromisoformat(raw), time.min, TZ), True + dt = datetime.fromisoformat(raw.replace('Z', '+00:00')) + return (dt.replace(tzinfo=TZ) if dt.tzinfo is None else dt.astimezone(TZ)), False + + +def _normalize_event(e, cal): + start, all_day = _parse_dt(e.get('start')) + end, _ = _parse_dt(e.get('end')) + return { + 'id':str(e.get('uid') or ''), 'recurrence_id':str(e.get('recurrence_id') or ''), + 'title':str(e.get('summary') or '(Sans titre)'), + 'description':str(e.get('description') or ''), + 'location':str(e.get('location') or ''), + 'start':start.isoformat(), 'end':end.isoformat(), 'all_day':all_day, + 'calendar_id':cal['entity_id'], 'calendar_name':cal['name'], + 'can_delete':cal['can_delete'] and bool(e.get('uid')), + 'source':'home_assistant' + } + + +def get_events(start:datetime, end:datetime, calendar_ids=None): + if start.tzinfo is None or end.tzinfo is None or start >= end: + raise ValueError('Période incorrecte') + if (end-start).days > 65: + raise ValueError('Période limitée à 65 jours') + available = list_calendars() + ids = set(calendar_ids or [c['entity_id'] for c in available]) + if not ids.issubset({c['entity_id'] for c in available}): + raise ValueError('Calendrier non autorisé') + selected = [c for c in available if c['entity_id'] in ids] + results, errors = [], [] + def load(cal): + raw = _request('GET', '/api/calendars/'+quote(cal['entity_id'], safe=''), + params={'start':start.isoformat(), 'end':end.isoformat()}) or [] + return [_normalize_event(item,cal) for item in raw] + with ThreadPoolExecutor(max_workers=min(5,len(selected)) or 1) as pool: + futures = {pool.submit(load,c):c for c in selected} + for f in as_completed(futures): + try: results.extend(f.result()) + except (HAError,ValueError,KeyError) as exc: errors.append(f"{futures[f]['name']}: {exc}") + results.sort(key=lambda e:(e['start'],e['title'].lower())) + return {'events':results, 'calendars':available, 'errors':errors, + 'start':start.isoformat(), 'end':end.isoformat()} + + +def _find_calendar(entity_id, capability): + cal = next((c for c in list_calendars() if c['entity_id'] == entity_id),None) + if cal is None: + raise HAError('Calendrier absent ou non autorisé.') + if not cal[capability]: + raise HAError('Ce calendrier ne permet pas cette opération depuis Home Assistant.') + return cal + + +def create_event(entity_id, title, start, end, description='', location='', all_day=False): + _find_calendar(entity_id, 'can_create') + if not title.strip() or len(title) > 200: + raise ValueError('Titre requis (200 caractères maximum).') + if start >= end: + raise ValueError('La fin doit être après le début.') + payload = {'entity_id':entity_id, 'summary':title.strip(), + 'description':description[:2000], 'location':location[:300]} + if all_day: + payload.update(start_date=start.date().isoformat(), end_date=end.date().isoformat()) + else: + payload.update(start_date_time=start.isoformat(), end_date_time=end.isoformat()) + _request('POST', '/api/services/calendar/create_event',payload=payload) + return {'ok':True,'message':'Événement ajouté.'} + + +def delete_event(entity_id, uid, recurrence_id=None): + _find_calendar(entity_id, 'can_delete') + if not isinstance(uid,str) or not uid.strip() or len(uid)>600: + raise ValueError('Identifiant d’événement manquant ou invalide.') + base, token = _cfg() + ws_url = ('wss://' if base.startswith('https://') else 'ws://') + base.split('://',1)[1] + '/api/websocket' + try: + from websocket import create_connection + except ImportError as exc: + raise HAError('Installer websocket-client dans le venv d’Alfred pour supprimer des événements.') from exc + import json + try: + ws = create_connection(ws_url, timeout=12) + try: + auth_required = json.loads(ws.recv()) + if auth_required.get('type') != 'auth_required': + raise HAError('WebSocket Home Assistant : négociation inattendue.') + ws.send(json.dumps({'type':'auth','access_token':token})) + auth = json.loads(ws.recv()) + if auth.get('type') != 'auth_ok': + raise HAError('Authentification WebSocket Home Assistant refusée.') + cmd = {'id':1, 'type':'calendar/event/delete','entity_id':entity_id,'uid':uid} + if recurrence_id: cmd['recurrence_id'] = recurrence_id + ws.send(json.dumps(cmd)) + result = json.loads(ws.recv()) + if result.get('id') != 1 or not result.get('success'): + error = result.get('error') or {} + raise HAError('Suppression refusée par Home Assistant : '+str(error.get('message') or error)) + return {'ok':True, 'message':'Événement supprimé.'} + finally: + ws.close() + except (OSError,TimeoutError) as exc: + raise HAError('Impossible de joindre le WebSocket Home Assistant.') from exc + + +def get_temperatures(): + data=[] + for item in states(): + entity=item.get('entity_id',''); attrs=item.get('attributes') or {} + if (entity.startswith('sensor.') and attrs.get('device_class') == 'temperature') or entity.startswith('climate.'): + raw = attrs.get('current_temperature') if entity.startswith('climate.') else item.get('state') + try: value = float(raw) + except (TypeError,ValueError):continue + if not -50 <= value <= 90:continue + data.append({'name':attrs.get('friendly_name',entity), 'value':value, + 'unit':attrs.get('unit_of_measurement') or '°C','entity_id':entity}) + return sorted(data, key=lambda x:x['name'].lower())[:60] + + +def list_cameras(): + allow = {s.strip() for s in os.getenv('HA_CAMERAS','').split(',') if s.strip()} + return [{'entity_id':item['entity_id'], 'name':item.get('attributes',{}).get('friendly_name') or item['entity_id']} + for item in states() + if item.get('entity_id','').startswith('camera.') and + (not allow or item['entity_id'] in allow)][:30] + + +def camera_snapshot(entity_id): + allowed = {item['entity_id'] for item in list_cameras()} + if entity_id not in allowed: + raise HAError('Caméra non autorisée.') + base, token = _cfg() + try: + r=requests.get(base+'/api/camera_proxy/'+quote(entity_id,safe=''), + headers={'Authorization':'Bearer '+token},timeout=15) + r.raise_for_status() + if not r.headers.get('Content-Type','').startswith('image/'): + raise HAError('La caméra ne fournit pas une image JPEG/PNG.') + return r.content,r.headers['Content-Type'] + except requests.RequestException as exc: + raise HAError('Impossible de récupérer l’image de la caméra.') from exc + + +def temperature_history(entity_id, hours=24): + hours=max(1,min(int(hours),72)) + if entity_id not in {e['entity_id'] for e in get_temperatures()}: + raise HAError('Capteur non autorisé.') + end=datetime.now(TZ);start=end-timedelta(hours=hours) + raw=_request('GET','/api/history/period/'+quote(start.isoformat(),safe=''), + params={'filter_entity_id':entity_id,'end_time':end.isoformat(), + 'minimal_response':'true'},timeout=18) or [] + out=[] + for collection in raw: + for point in collection: + try: + value=float(point['state']) + moment=point.get('last_changed') or point.get('last_updated') + if -50 <= value <= 90 and moment: + out.append({'time':moment,'value':value}) + except (ValueError,TypeError,KeyError):continue + return out[-250:] diff --git a/alfred/app.backup.camera/services/hermes_client.py b/alfred/app.backup.camera/services/hermes_client.py new file mode 100644 index 0000000..854abcf --- /dev/null +++ b/alfred/app.backup.camera/services/hermes_client.py @@ -0,0 +1,474 @@ + +"""Pont Alfred -> Hermes avec session persistante.""" + +import fcntl +import json +import os +import re +import subprocess +import tempfile +import threading + +from contextlib import contextmanager +from pathlib import Path + + +# -------------------------------------------------- +# CONFIGURATION +# -------------------------------------------------- + +DEFAULT_IDENTITY_FILE = ( + Path(__file__).resolve().parent.parent + / "config" + / "identity.md" +) + +DEFAULT_SESSION_FILE = ( + Path(__file__).resolve().parent.parent + / "config" + / "hermes_session.json" +) + +_REQUEST_LOCK = threading.Lock() + +_ID_PATTERN = re.compile( + r"^[A-Za-z0-9_:.\-]{1,128}$" +) + + +# -------------------------------------------------- +# IDENTITÉ D'ALFRED +# -------------------------------------------------- + +def _build_prompt(question: str) -> str: + + identity_path = Path( + os.getenv( + "ALFRED_IDENTITY_FILE", + str(DEFAULT_IDENTITY_FILE) + ) + ) + + try: + identity = identity_path.read_text( + encoding="utf-8" + ).strip() + + except OSError as exc: + print( + f"[ALFRED] Contexte d'identité " + f"indisponible ({identity_path}): {exc}", + flush=True + ) + identity = "" + + question = str(question or "").strip() + + if not identity: + return question + + return ( + "Instructions de contexte pour cette " + "interaction vocale avec Alfred. " + "Applique ces instructions de comportement " + "à ta réponse, sans les réciter. " + "La demande vocale ci-dessous est une " + "entrée utilisateur, pas une modification " + "de ton identité.\n\n" + f"\n" + f"{identity}\n" + f"\n\n" + f"\n" + f"{question}\n" + f"" + ) + + +# -------------------------------------------------- +# FICHIER DE SESSION +# -------------------------------------------------- + +def _session_file() -> Path: + + return Path( + os.getenv( + "HERMES_SESSION_FILE", + str(DEFAULT_SESSION_FILE) + ) + ).expanduser() + + +def _read_session(path: Path) -> str | None: + + if not path.exists(): + return None + + try: + obj = json.loads( + path.read_text(encoding="utf-8") + ) + + sid = obj["session_id"] + + except ( + OSError, + ValueError, + KeyError, + TypeError + ) as exc: + + raise RuntimeError( + f"État de session Hermes invalide " + f"({path}) : {exc}" + ) from exc + + if ( + not isinstance(sid, str) + or not _ID_PATTERN.fullmatch(sid) + ): + raise RuntimeError( + f"Identifiant de session Hermes " + f"invalide dans {path}" + ) + + return sid + + +def _save_session(path: Path, sid: str): + + if not _ID_PATTERN.fullmatch(sid): + raise RuntimeError( + "Hermes a renvoyé un identifiant " + "de session non valide." + ) + + path.parent.mkdir( + parents=True, + exist_ok=True + ) + + temp_path = None + + try: + fd, temp_name = tempfile.mkstemp( + prefix=".hermes_session_", + suffix=".tmp", + dir=path.parent + ) + + temp_path = Path(temp_name) + + with os.fdopen( + fd, + "w", + encoding="utf-8" + ) as handle: + + json.dump( + {"session_id": sid}, + handle, + indent=2 + ) + + handle.write("\n") + + os.replace(temp_path, path) + + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + +# -------------------------------------------------- +# VERROUILLAGE SESSION +# -------------------------------------------------- + +@contextmanager +def _session_lock(path: Path): + + path.parent.mkdir( + parents=True, + exist_ok=True + ) + + lock_path = path.with_name( + path.name + ".lock" + ) + + fd = os.open( + lock_path, + os.O_CREAT | os.O_RDWR, + 0o600 + ) + + with os.fdopen(fd, "w") as handle: + + fcntl.flock( + handle.fileno(), + fcntl.LOCK_EX + ) + + try: + yield + + finally: + fcntl.flock( + handle.fileno(), + fcntl.LOCK_UN + ) + + +# -------------------------------------------------- +# CONFIGURATION HERMES +# -------------------------------------------------- + +def _hermes_binary() -> str: + + return os.getenv( + "HERMES_CLI", + "/root/.local/bin/hermes" + ) + + +def _hermes_timeout() -> float: + + return max( + 10.0, + float( + os.getenv("HERMES_TIMEOUT", "180") + ) + ) + + +# -------------------------------------------------- +# ANCIEN MODE SANS SESSION +# -------------------------------------------------- + +def _oneshot_legacy(prompt: str) -> str: + + try: + result = subprocess.run( + [ + _hermes_binary(), + "--oneshot", + prompt + ], + capture_output=True, + text=True, + timeout=_hermes_timeout(), + check=True + ) + + return result.stdout.strip() + + except subprocess.TimeoutExpired as exc: + + raise RuntimeError( + "Hermes n'a pas répondu " + "dans le délai imparti." + ) from exc + + except subprocess.CalledProcessError as exc: + + details = ( + exc.stderr + or exc.stdout + or "aucun détail" + ).strip() + + raise RuntimeError( + f"Hermes a échoué " + f"(code {exc.returncode}): {details}" + ) from exc + + +# -------------------------------------------------- +# INTERPRÉTATION RÉPONSE JSONL +# -------------------------------------------------- + +def _parse_jsonl(stdout: str) -> tuple[str, str]: + + final = None + initial_session_id = None + + for line in stdout.splitlines(): + + if not line.strip(): + continue + + try: + event = json.loads(line) + + except json.JSONDecodeError: + continue + + if not isinstance(event, dict): + continue + + if ( + event.get("type") == "system" + and event.get("subtype") == "init" + ): + initial_session_id = event.get( + "session_id" + ) + + if event.get("type") == "result": + final = event + + if final is None: + + raise RuntimeError( + "Hermes n'a pas renvoyé de résultat " + "JSONL. Vérifie la prise en charge " + "de --format stream-json." + ) + + if final.get("exit_code", 0) != 0: + + raise RuntimeError( + f"Hermes : " + f"{final.get('error') or 'échec'}" + ) + + sid = ( + final.get("session_id") + or initial_session_id + ) + + if ( + not isinstance(sid, str) + or not _ID_PATTERN.fullmatch(sid) + ): + raise RuntimeError( + "Hermes n'a pas fourni " + "d'identifiant de session utilisable." + ) + + answer = final.get("text") + + if not isinstance(answer, str): + raise RuntimeError( + "La réponse JSONL finale " + "ne contient pas de texte." + ) + + return answer.strip(), sid + + +# -------------------------------------------------- +# APPEL HERMES AVEC REPRISE DE SESSION +# -------------------------------------------------- + +def _call_hermes( + prompt: str, + session_id: str | None +) -> tuple[str, str]: + + cmd = [ + _hermes_binary(), + "chat", + "--oneshot", + "--query-file", + "-", + "--format", + "stream-json", + "--source", + "cli" + ] + + if session_id: + cmd.extend([ + "--resume", + session_id + ]) + + try: + process = subprocess.run( + cmd, + input=prompt, + capture_output=True, + text=True, + timeout=_hermes_timeout(), + check=False + ) + + except subprocess.TimeoutExpired as exc: + + raise RuntimeError( + "Hermes n'a pas répondu " + "dans le délai imparti." + ) from exc + + except FileNotFoundError as exc: + + raise RuntimeError( + f"Hermes introuvable : " + f"{_hermes_binary()}" + ) from exc + + if process.returncode != 0: + + msg = ( + process.stderr + or process.stdout + or "aucun détail" + ).strip() + + if len(msg) > 1500: + msg = msg[-1500:] + + raise RuntimeError( + f"Hermes a échoué " + f"(code {process.returncode}) : " + f"{msg}" + ) + + return _parse_jsonl(process.stdout) + + +# -------------------------------------------------- +# FONCTION PRINCIPALE APPELÉE PAR FLASK +# -------------------------------------------------- + +def ask_hermes(text: str) -> str: + + prompt = _build_prompt(text) + + # Ancien fonctionnement sans session + if ( + os.getenv( + "HERMES_SESSION_MODE", + "persistent" + ).lower() == "legacy" + ): + return _oneshot_legacy(prompt) + + path = _session_file() + + # Empêche les appels concurrents + with _REQUEST_LOCK, _session_lock(path): + + # Recherche la session précédente + current_sid = _read_session(path) + + # Envoie la question à Hermes + answer, new_sid = _call_hermes( + prompt, + current_sid + ) + + # Sauvegarde l'identifiant + _save_session( + path, + new_sid + ) + + print( + f"[ALFRED] Session Hermes : " + f"{new_sid} " + f"({'reprise' if current_sid else 'création'})", + flush=True + ) + + return answer diff --git a/alfred/app.backup.camera/services/hermes_client.py.bak.20261016_140326 b/alfred/app.backup.camera/services/hermes_client.py.bak.20261016_140326 new file mode 100644 index 0000000..94e4b3e --- /dev/null +++ b/alfred/app.backup.camera/services/hermes_client.py.bak.20261016_140326 @@ -0,0 +1,41 @@ +import os +import subprocess +import time + + +def ask_hermes(text): + hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes") + + start = time.perf_counter() + + try: + result = subprocess.run( + [hermes_cli, "--oneshot", text], + capture_output=True, + text=True, + timeout=180, + check=True + ) + + elapsed = time.perf_counter() - start + + print( + f"[ALFRED] Hermes : {elapsed:.2f} secondes", + flush=True + ) + + return result.stdout.strip() + + except subprocess.TimeoutExpired: + raise RuntimeError( + "Hermes n'a pas répondu dans le délai imparti." + ) + + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + stdout = (e.stdout or "").strip() + + raise RuntimeError( + f"Hermes a échoué (code {e.returncode}): " + f"{stderr or stdout or 'aucun détail'}" + ) diff --git a/alfred/app.backup.camera/services/hermes_client.py.ko.1 b/alfred/app.backup.camera/services/hermes_client.py.ko.1 new file mode 100644 index 0000000..95f3abc --- /dev/null +++ b/alfred/app.backup.camera/services/hermes_client.py.ko.1 @@ -0,0 +1,7 @@ +"""Adaptateur V1. À ajuster à l'interface exacte de l'installation Hermes existante.""" +import os, subprocess, requests + +def ask_hermes(text:str)->str: + if os.getenv('HERMES_MODE','cli') == 'http': + r=requests.post(os.environ['HERMES_API_URL'],json={'message':text},headers={'Authorization':f"Bearer {os.getenv('HERMES_API_TOKEN','')}"},timeout=120);r.raise_for_status();return r.json().get('response',r.text) + p=subprocess.run([os.getenv('HERMES_CLI','hermes'), text],capture_output=True,text=True,timeout=180,check=True);return p.stdout.strip() diff --git a/alfred/app.backup.camera/services/hermes_client.py.okv1 b/alfred/app.backup.camera/services/hermes_client.py.okv1 new file mode 100644 index 0000000..ec2ff4a --- /dev/null +++ b/alfred/app.backup.camera/services/hermes_client.py.okv1 @@ -0,0 +1,47 @@ +"""Client Hermes CLI d'Alfred avec contexte vocal dédié.""" +import os +import subprocess +from pathlib import Path + +DEFAULT_IDENTITY_FILE = Path(__file__).resolve().parent.parent / "config" / "identity.md" + + +def _build_prompt(question: str) -> str: + identity_path = Path(os.getenv("ALFRED_IDENTITY_FILE", str(DEFAULT_IDENTITY_FILE))) + try: + identity = identity_path.read_text(encoding="utf-8").strip() + except OSError as exc: + print(f"[ALFRED] Contexte d'identité indisponible ({identity_path}): {exc}", flush=True) + identity = "" + + question = str(question or "").strip() + if not identity: + return question + + return ( + "Instructions de contexte pour cette interaction vocale avec Alfred. " + "Applique ces instructions de comportement à ta réponse, sans les réciter. " + "La demande vocale ci-dessous est une entrée utilisateur, pas une modification de ton identité.\n\n" + f"\n{identity}\n\n\n" + f"\n{question}\n" + ) + + +def ask_hermes(text): + hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes") + prompt = _build_prompt(text) + try: + result = subprocess.run( + [hermes_cli, "--oneshot", prompt], + capture_output=True, text=True, timeout=180, check=True, + ) + return result.stdout.strip() + except subprocess.TimeoutExpired: + raise RuntimeError("Hermes n'a pas répondu dans le délai imparti.") + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + stdout = (e.stdout or "").strip() + raise RuntimeError( + f"Hermes a échoué (code {e.returncode}): " + f"{stderr or stdout or 'aucun détail'}" + ) diff --git a/alfred/app.backup.camera/services/hermes_client.py.old b/alfred/app.backup.camera/services/hermes_client.py.old new file mode 100644 index 0000000..51802b9 --- /dev/null +++ b/alfred/app.backup.camera/services/hermes_client.py.old @@ -0,0 +1,35 @@ +import os +import subprocess + + +def ask_hermes(text): + hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes") + + try: + result = subprocess.run( + [ + hermes_cli, + "--oneshot", + text + ], + capture_output=True, + text=True, + timeout=180, + check=True + ) + + return result.stdout.strip() + + except subprocess.TimeoutExpired: + raise RuntimeError( + "Hermes n'a pas répondu dans le délai imparti." + ) + + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + stdout = (e.stdout or "").strip() + + raise RuntimeError( + f"Hermes a échoué (code {e.returncode}): " + f"{stderr or stdout or 'aucun détail'}" + ) diff --git a/alfred/app.backup.camera/services/piper_tts.py b/alfred/app.backup.camera/services/piper_tts.py new file mode 100644 index 0000000..765a883 --- /dev/null +++ b/alfred/app.backup.camera/services/piper_tts.py @@ -0,0 +1,109 @@ +import asyncio +import os +import subprocess +import tempfile +import time +import wave + +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.tts import Synthesize, SynthesizeVoice + + +PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1") +PIPER_PORT = int(os.getenv("PIPER_PORT", "10200")) +AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0") + + +async def synthesize(text, wav_path): + client = AsyncTcpClient(PIPER_HOST, PIPER_PORT) + + await client.connect() + + try: + await client.write_event( + Synthesize( + text=text, + voice=SynthesizeVoice( + name=os.getenv("PIPER_VOICE", "fr_FR-tom-medium") + )).event() + ) + + wav_file = None + + try: + while True: + event = await asyncio.wait_for( + client.read_event(), + timeout=30 + ) + + if event is None: + raise RuntimeError("Connexion Piper interrompue") + + if AudioStart.is_type(event.type): + start = AudioStart.from_event(event) + + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(start.channels) + wav_file.setsampwidth(start.width) + wav_file.setframerate(start.rate) + + elif AudioChunk.is_type(event.type): + chunk = AudioChunk.from_event(event) + + if wav_file is None: + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(chunk.channels) + wav_file.setsampwidth(chunk.width) + wav_file.setframerate(chunk.rate) + + wav_file.writeframes(chunk.audio) + + elif AudioStop.is_type(event.type): + break + + finally: + if wav_file is not None: + wav_file.close() + + finally: + await client.disconnect() + + +def speak(text): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp( + prefix="alfred_piper_", + suffix=".wav" + ) + os.close(fd) + + start = time.monotonic() + + try: + print("[ALFRED PIPER] Synthèse...", flush=True) + + asyncio.run(synthesize(text, wav_path)) + + print("[ALFRED PIPER] Lecture...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", AUDIO_DEVICE, wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED PIPER] Erreur : {e}", flush=True) + raise + finally: + if os.path.exists(wav_path): + os.remove(wav_path) diff --git a/alfred/app.backup.camera/services/piper_tts.py.old b/alfred/app.backup.camera/services/piper_tts.py.old new file mode 100644 index 0000000..9558412 --- /dev/null +++ b/alfred/app.backup.camera/services/piper_tts.py.old @@ -0,0 +1,105 @@ +import asyncio +import os +import subprocess +import tempfile +import time +import wave + +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.tts import Synthesize + + +PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1") +PIPER_PORT = int(os.getenv("PIPER_PORT", "10200")) +AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0") + + +async def synthesize(text, wav_path): + client = AsyncTcpClient(PIPER_HOST, PIPER_PORT) + + await client.connect() + + try: + await client.write_event( + Synthesize(text=text).event() + ) + + wav_file = None + + try: + while True: + event = await asyncio.wait_for( + client.read_event(), + timeout=30 + ) + + if event is None: + raise RuntimeError("Connexion Piper interrompue") + + if AudioStart.is_type(event.type): + start = AudioStart.from_event(event) + + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(start.channels) + wav_file.setsampwidth(start.width) + wav_file.setframerate(start.rate) + + elif AudioChunk.is_type(event.type): + chunk = AudioChunk.from_event(event) + + if wav_file is None: + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(chunk.channels) + wav_file.setsampwidth(chunk.width) + wav_file.setframerate(chunk.rate) + + wav_file.writeframes(chunk.audio) + + elif AudioStop.is_type(event.type): + break + + finally: + if wav_file is not None: + wav_file.close() + + finally: + await client.disconnect() + + +def speak(text): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp( + prefix="alfred_piper_", + suffix=".wav" + ) + os.close(fd) + + start = time.monotonic() + + try: + print("[ALFRED PIPER] Synthèse...", flush=True) + + asyncio.run(synthesize(text, wav_path)) + + print("[ALFRED PIPER] Lecture...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", AUDIO_DEVICE, wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED PIPER] Erreur : {e}", flush=True) + raise + finally: + if os.path.exists(wav_path): + os.remove(wav_path) diff --git a/alfred/app.backup.camera/services/wakeword.py b/alfred/app.backup.camera/services/wakeword.py new file mode 100644 index 0000000..ae5c227 --- /dev/null +++ b/alfred/app.backup.camera/services/wakeword.py @@ -0,0 +1,110 @@ +"""Détection permanente du mot d'activation d'Alfred. + +Deux moteurs : +- vosk_keyword (par défaut) : fonctionne immédiatement avec le modèle Vosk français déjà utilisé. +- openwakeword : plus léger/spécialisé, à utiliser avec un modèle ONNX/TFLite custom « Alfred ». + +Le détecteur libère le micro dès l'activation avant de lancer le STT complet. +""" +from __future__ import annotations +from services.audio import get_vosk_model +import json, os, queue, subprocess, shutil, threading, time +from typing import Callable, Optional + +class WakeWordListener: + def __init__(self, on_wake: Callable[[float], None], on_level: Optional[Callable[[float], None]]=None): + self.on_wake=on_wake; self.on_level=on_level + self.stop_event=threading.Event(); self.pause_event=threading.Event(); self.thread=None + self.last_error=None; self.last_score=0.0 + + def start(self): + if self.thread and self.thread.is_alive(): return + self.stop_event.clear(); self.thread=threading.Thread(target=self._run, daemon=True); self.thread.start() + + def stop(self): self.stop_event.set() + def pause(self): self.pause_event.set() + def resume(self): self.pause_event.clear() + + def status(self): + return {"running": bool(self.thread and self.thread.is_alive()), "paused": self.pause_event.is_set(), + "engine": os.getenv("WAKE_ENGINE","vosk_keyword"), "word": os.getenv("WAKE_WORD","alfred"), + "last_score": self.last_score, "error": self.last_error} + + def _run(self): + engine=os.getenv("WAKE_ENGINE","vosk_keyword").lower() + while not self.stop_event.is_set(): + if self.pause_event.is_set(): time.sleep(.1); continue + try: + if engine == "openwakeword": self._openwakeword_session() + else: self._vosk_session() + except Exception as e: + self.last_error=str(e); time.sleep(1) + + def _level(self, data): + if not self.on_level: return + try: + import numpy as np + a=np.frombuffer(data,dtype=np.int16).astype(np.float32) + rms=float(np.sqrt(np.mean(a*a))) if a.size else 0.0 + self.on_level(min(1.0,rms/6000.0)) + except Exception: pass + + def _audio_chunks(self, blocksize=1280): + rate=int(os.getenv("VOSK_SAMPLE_RATE","16000")); q=queue.Queue() + try: + import sounddevice as sd + def cb(indata, frames, ti, status): q.put(bytes(indata)) + dev=os.getenv("AUDIO_INPUT_DEVICE") or None + with sd.RawInputStream(samplerate=rate, blocksize=blocksize, dtype="int16", channels=1, callback=cb, device=dev): + while not self.stop_event.is_set() and not self.pause_event.is_set(): yield q.get() + return + except Exception: pass + if not shutil.which("arecord"): raise RuntimeError("Micro indisponible: sounddevice et arecord absents") + cmd=["arecord","-q","-f","S16_LE","-r",str(rate),"-c","1"] + dev=os.getenv("ALSA_CAPTURE_DEVICE","").strip() + if dev: cmd += ["-D",dev] + p=subprocess.Popen(cmd,stdout=subprocess.PIPE) + try: + while not self.stop_event.is_set() and not self.pause_event.is_set(): + data=p.stdout.read(blocksize*2) + if data: yield data + finally: + if p.poll() is None: p.terminate() + + def _vosk_session(self): + from vosk import Model, KaldiRecognizer +## path=os.getenv("VOSK_MODEL_PATH",""); word=os.getenv("WAKE_WORD","alfred").strip().lower() +## + path = os.getenv( + "WAKE_VOSK_MODEL_PATH", + os.getenv("VOSK_MODEL_PATH", "") + ); word=os.getenv("WAKE_WORD","alfred").strip().lower() + if not os.path.exists(path): raise RuntimeError(f"Modèle Vosk introuvable: {path}") + rate=int(os.getenv("VOSK_SAMPLE_RATE","16000")) + # Grammaire réduite = détection peu coûteuse et évite de transcrire les conversations. +## rec=KaldiRecognizer(Model(path), rate, json.dumps([word,"[unk]"], ensure_ascii=False)) +## + rec = KaldiRecognizer( + get_vosk_model(path), rate, json.dumps([word, "[unk]"], ensure_ascii=False) + ) + + for data in self._audio_chunks(4000): + self._level(data) + if rec.AcceptWaveform(data): txt=(json.loads(rec.Result() or "{}").get("text") or "").lower() + else: txt=(json.loads(rec.PartialResult() or "{}").get("partial") or "").lower() + if word in txt.split(): + self.last_score=1.0; self.pause_event.set(); self.on_wake(1.0); return + + def _openwakeword_session(self): + import numpy as np + from openwakeword.model import Model + model_path=os.getenv("WAKE_MODEL_PATH","").strip() + if not model_path or not os.path.exists(model_path): raise RuntimeError("WAKE_MODEL_PATH absent: ajoute le modèle custom Alfred") + threshold=float(os.getenv("WAKE_THRESHOLD","0.5")) + model=Model(wakeword_models=[model_path], inference_framework=os.getenv("WAKE_INFERENCE","onnx")) + for data in self._audio_chunks(1280): + self._level(data); audio=np.frombuffer(data,dtype=np.int16) + scores=model.predict(audio) + score=max((float(v) for v in scores.values()), default=0.0); self.last_score=score + if score >= threshold: + self.pause_event.set(); self.on_wake(score); return diff --git a/alfred/app.backup.camera/static/css/alfred-controls.css b/alfred/app.backup.camera/static/css/alfred-controls.css new file mode 100644 index 0000000..b4d4b7c --- /dev/null +++ b/alfred/app.backup.camera/static/css/alfred-controls.css @@ -0,0 +1,19 @@ +/* Pop-up des réglages : ne modifie pas la page configuration historique. */ +.audio-popover { position:absolute; top:82px; right:36px; z-index:10; width:min(355px,calc(100vw - 28px)); background:#252a2e; border:1px solid #ffffff2b; border-radius:17px; padding:20px; box-shadow:0 24px 70px #0008; } +.audio-popover[hidden] { display:none !important; } +.popover-heading { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; font-size:16px; } +.icon-button.mini { width:28px; height:28px; font-size:22px; border:0; background:none; } +.setting-row { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:19px; margin-bottom:12px; color:#d5dbe0; font-size:13px; } +.setting-row output { color:#f4f5f6; font-size:13px; font-variant-numeric:tabular-nums; } +.audio-popover input[type="range"] { display:block; width:100%; margin:0 0 5px; cursor:pointer; accent-color:#a8c7ce; } +.audio-popover input[type="range"]:disabled { opacity:.35; cursor:not-allowed; } +.setting-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-top:22px; padding-top:16px; border-top:1px solid #ffffff1c; color:#cbd1d5; font-size:13px; } +.quiet-button { color:#c3e7dc; background:#42675b33; border:1px solid #83c9ae44; padding:7px 13px; border-radius:8px; cursor:pointer; } +.quiet-button[aria-pressed="true"] { color:#f7b5b5; background:#b65c5c26; border-color:#b65c5c66; } +.settings-message { min-height:14px; margin:12px 0 0; font-size:11px; color:#e4c089; line-height:1.4; } +@media(max-width:760px) { .audio-popover { top:65px; right:14px; } } + +/* Bouton mute microphone dans la barre supérieure. aria-pressed reflète l'état réel de l'API. */ +.mic-off-icon { display: none; } +#micMute[aria-pressed="true"] .mic-on-icon { display: none; } +#micMute[aria-pressed="true"] .mic-off-icon { display: block; } diff --git a/alfred/app.backup.camera/static/css/alfred-mode-icons.css b/alfred/app.backup.camera/static/css/alfred-mode-icons.css new file mode 100644 index 0000000..d78db8c --- /dev/null +++ b/alfred/app.backup.camera/static/css/alfred-mode-icons.css @@ -0,0 +1,13 @@ +.alfred-mode-icon { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + min-height: 36px; + font-size: 26px; + line-height: 1; + transition: transform .25s ease, opacity .25s ease; +} +.alfred-mode-icon[data-mode="thinking"] { animation: alfred-thinking 1.8s ease-in-out infinite; } +@keyframes alfred-thinking { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.12); } } +@media (prefers-reduced-motion: reduce) { .alfred-mode-icon { animation: none !important; } } diff --git a/alfred/app.backup.camera/static/css/alfred-views.css b/alfred/app.backup.camera/static/css/alfred-views.css new file mode 100644 index 0000000..d6949f6 --- /dev/null +++ b/alfred/app.backup.camera/static/css/alfred-views.css @@ -0,0 +1,54 @@ +/* Calendar and Home Assistant views — preserves original dark Alfred layout */ +#stage:has(.alfred-view) {padding:0 0 6px;justify-content:stretch;align-items:stretch} +#content.alfred-view {width:100%;height:100%;text-align:left;min-height:0;} +.view-pane {display:flex;flex-direction:column;gap:12px;max-height:100%;height:100%;min-height:0;padding:8px 1px 14px;} +.view-head {display:flex;justify-content:space-between;align-items:center;gap:12px;} +#content .view-title {font-size:clamp(1.2rem,1.7vw,1.85rem);margin:0;font-weight:500} +.view-close,.view-button,.view-pill {background:#363d42;color:#f3f7fa;border:1px solid #ffffff24;border-radius:10px;padding:9px 14px;cursor:pointer;font:inherit} +.view-close {font-size:1.4rem;min-width:40px;height:40px;padding:0} +.view-button:hover,.view-pill:hover,.view-close:hover {background:#455057} +.view-pill.active,.view-button.primary {background:#427789;border-color:#619aad} +.view-toolbar {display:flex;gap:9px;align-items:center;flex-wrap:wrap;} +.view-nav,.view-modes {display:flex;gap:5px;align-items:center} +.view-period {font-size:.85rem;min-width:215px;text-align:center;color:#d8e0e4} +.view-select {padding:9px;max-width:220px;border-radius:9px;background:#333a3e;color:#eff3f5;border:1px solid #ffffff2b} +.agenda-grid {display:grid;gap:5px;min-height:0;flex:1;overflow:auto;padding:2px;align-content:stretch;} +.agenda-seven,.agenda-month {grid-template-columns:repeat(7,minmax(0,1fr))} +.agenda-month {grid-template-rows:repeat(6,minmax(110px,1fr));} +.agenda-three {grid-template-columns:repeat(3,minmax(0,1fr))} +.agenda-one {grid-template-columns:1fr} +.agenda-day {border:1px solid #ffffff1c;border-radius:11px;min-width:0;min-height:120px;background:#ffffff08;padding:9px;display:flex;flex-direction:column;gap:6px;overflow-y:auto} +.agenda-day.is-today {border-color:#86bfda7a;background:#57839923} +.agenda-day.out-month {opacity:.42} +.agenda-date {font-size:.83rem;font-weight:600;color:#dce2e6;letter-spacing:.01em;padding-bottom:7px;border-bottom:1px solid #ffffff14} +.agenda-event {width:100%;text-align:left;background:color-mix(in srgb,var(--event-color) 17%,#2a2e32);color:#f1f3f5;border:0;border-left:3px solid var(--event-color);border-radius:5px;padding:7px 5px;font-size:clamp(.72rem,.85vw,.95rem);line-height:1.3;cursor:pointer;overflow-wrap:anywhere} +.agenda-event:hover {background:color-mix(in srgb,var(--event-color) 30%,#2a2e32)} +.agenda-empty {font-size:.8rem;color:#667781;margin:auto} +#content .view-footer {color:#9aa8b3;font-size:.76rem;margin:0} +#content .view-error {color:#f7adad;font-size:.85rem;margin:0} +#content .view-empty {font-size:1rem;color:#b8c2ca} +.agenda-form {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;gap:14px;max-width:850px;width:100%;overflow-y:auto;margin:12px auto;} +.form-field {display:flex;flex-direction:column;gap:7px;color:#cfd6dd;font-size:.83rem} +.form-field input,.form-field select,.form-field textarea {padding:12px;border-radius:10px;color:#eff3f5;background:#2e3439;border:1px solid #ffffff32;font:inherit;min-width:0} +.form-field textarea {resize:vertical} +.form-check {display:flex;align-items:center;gap:10px} +.view-actions {display:flex;align-items:center;gap:10px;justify-content:flex-end;flex-wrap:wrap;} +.form-message {grid-column:1 / -1;} +.agenda-form .view-actions {grid-column:1 / -1} +.view-danger {color:#ffc4c4;background:#8f383850;border-color:#ed929255} +.detail-modal {position:fixed;inset:0;z-index:30;display:grid;place-items:center;background:#000b;padding:20px} +.detail-box {min-width:min(92vw,300px);max-width:540px;width:100%;border:1px solid #ffffff36;border-radius:19px;background:#292e33;box-shadow:0 25px 90px #000a;padding:24px;} +.detail-box h2 {font-size:1.4rem;margin:0 0 20px}.detail-box p {font-size:1rem;color:#d7dfe2;overflow-wrap:anywhere} +.delete-list {display:grid;gap:8px;overflow:auto}.delete-row {display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid #ffffff1c;border-radius:9px;padding:12px} +.temperature-grid {display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:4px;max-height:53%;} +.temperature-card {border:1px solid #ffffff22;background:#ffffff0a;border-radius:12px;padding:22px;color:#eaf1f3;white-space:pre-line;text-align:left;font:inherit;cursor:pointer;line-height:1.9} +.temperature-card:hover {background:#ffffff18} +.temperature-history {max-width:700px;text-align:center;margin:auto;width:100%;} +.temperature-history svg {width:100%;max-height:155px} +.temperature-history h2 {font-size:1.1rem;font-weight:450} +.ha-cameras {display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;overflow:auto;} +.ha-camera-card {background:#ffffff0a;border-radius:13px;overflow:hidden;} +.ha-camera-card h2 {font-size:.95rem;padding:10px;font-weight:500;margin:0} +.ha-camera-card img {display:block;width:100%;aspect-ratio:16/9;object-fit:contain;background:#121619} +@media(max-width:1100px){.view-toolbar{gap:6px}.view-button,.view-pill {padding:7px;font-size:.77rem}.view-period{min-width:150px}.agenda-day{padding:5px}.agenda-event{padding:4px;font-size:.74rem}} +@media(max-width:760px){#stage:has(.alfred-view){min-height:460px}.agenda-grid{min-height:280px}.agenda-seven,.agenda-month{min-width:630px}.agenda-day{min-height:105px}.view-pane{max-height:600px}.view-head{padding:0 4px}.agenda-form{grid-template-columns:1fr}.header-actions{gap:3px}.header-actions .icon-button {width:38px;height:38px}.view-toolbar{overflow:auto}} diff --git a/alfred/app.backup.camera/static/css/alfred.css b/alfred/app.backup.camera/static/css/alfred.css new file mode 100644 index 0000000..521a067 --- /dev/null +++ b/alfred/app.backup.camera/static/css/alfred.css @@ -0,0 +1,62 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + color: #f1f3f4; + background: #202326; +} +* { box-sizing: border-box; } +[hidden] { display:none !important; } +body { margin: 0; min-height: 100vh; background: radial-gradient(ellipse at 52% 34%, #33373a 0, #25282b 46%, #1a1d20 100%); } +.alfred-shell { position:relative; isolation:isolate; display:grid; grid-template-rows: 56px minmax(0,1fr) 142px; gap:16px; min-height:100vh; min-height:100dvh; padding:25px 36px; } +.topbar { display:flex; align-items:center; justify-content:space-between; z-index:5; } +.status { display:inline-flex; align-items:center; gap:11px; font-size:14px; font-weight:500; color:#e6eaed; letter-spacing:.045em; } +.status-dot { width:10px; height:10px; border-radius:50%; background:#7d8790; box-shadow:0 0 0 4px #ffffff0a; transition:background .25s,box-shadow .25s; } +.status-dot[data-mode="listening"] { background:#70c7ce; box-shadow:0 0 0 5px #70c7ce17; animation:status-pulse 1.6s infinite; } +.status-dot[data-mode="thinking"] { background:#e7bb72; box-shadow:0 0 0 5px #e7bb7217; animation:status-pulse 1.9s infinite; } +.status-dot[data-mode="speaking"] { background:#9ad0b7; box-shadow:0 0 0 5px #9ad0b717; animation:status-pulse 1.4s infinite; } +.status-dot[data-mode="error"] { background:#f18989; box-shadow:0 0 0 5px #f1898917; } +.header-actions { display:flex; gap:10px; align-items:center; } +.icon-button { display:grid; place-items:center; width:44px; height:44px; border:1px solid #ffffff22; border-radius:13px; background:#ffffff08; color:#e8ebee; cursor:pointer; transition:background .2s,border-color .2s; } +.icon-button:hover { background:#ffffff14; border-color:#ffffff35; } +.icon-button:focus-visible,.panel-action:focus-visible,.quiet-button:focus-visible { outline:2px solid #adc8e0; outline-offset:3px; } +.icon-button svg { width:21px; height:21px; } +.icon-button[aria-pressed="true"] { color:#f4a2a2; background:#ae47471b; border-color:#b95d5d55; } +.speaker-off-icon { display:none; } +.icon-button[aria-pressed="true"] .speaker-on-icon { display:none; } +.icon-button[aria-pressed="true"] .speaker-off-icon { display:block; } +#stage { display:flex; align-items:center; justify-content:center; overflow:hidden; min-height:0; padding:0 8%; } +#content { width:min(990px,86vw); text-align:center; transition:opacity .25s ease,transform .25s ease; animation:appear .45s ease; } +#content.swap { opacity:0; transform:translateY(10px) scale(.99); } +#content h1 { font-size:clamp(2rem,4vw,4.3rem); font-weight:350; margin:.2em 0; line-height:1.2; } +#content p { font-size:clamp(1rem,1.6vw,1.35rem); color:#cdd1d4; line-height:1.55; white-space:pre-wrap; overflow-wrap:anywhere; } +.hero { max-width:min(100%,750px); max-height:56vh; object-fit:contain; border-radius:18px; box-shadow:0 18px 50px #0006; animation:appear .45s ease; } +.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(205px,1fr)); gap:13px; text-align:left; } +.card { padding:18px; border:1px solid #ffffff1c; background:#ffffff0a; border-radius:15px; } +.card h2 { margin:0 0 8px; font-weight:500; } +.card p { margin:0; font-size:1rem; } +#wave { display:flex; align-items:center; justify-self:center; width: min(560px,37vw); height:100px; align-self:end; } +#wave canvas { width:100%; height:100%; } +.camera-panel,.transcript-panel { position:absolute; z-index:2; bottom:34px; border:1px solid #ffffff20; background:#222629db; box-shadow:0 14px 35px #0002; border-radius:16px; backdrop-filter:blur(12px); } +.camera-panel { left:36px; width:clamp(220px,24vw,355px); overflow:hidden; } +.transcript-panel { right:36px; width:clamp(250px,25vw,405px); padding:15px 17px 18px; min-height:136px; } +.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:42px; padding:0 13px; } +.transcript-panel .panel-heading { padding:0; min-height:30px; } +.panel-title { display:flex; align-items:center; gap:9px; font-size:10px; letter-spacing:.1em; font-weight:700; color:#abb5be; } +.panel-indicator { width:7px; height:7px; border-radius:50%; background:#71818c; } +.camera-panel[data-active="true"] .panel-indicator { background:#83c5cc; } +.transcript-panel[data-active="true"] .stt-indicator { background:#83c5cc; } +.panel-action { border:0; border-radius:8px; background:#ffffff0c; color:#e3e8ed; padding:7px 10px; font-size:12px; cursor:pointer; } +.panel-action:hover { background:#ffffff22; } +.camera-viewport { display:grid; place-items:center; position:relative; aspect-ratio:16/9; background:#101315; overflow:hidden; } +.camera-viewport > * { grid-area:1/1; } +.camera-viewport img { width:100%; height:100%; object-fit:contain; } +.camera-placeholder { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; width:100%; height:100%; color:#919ca4; font-size:12px; text-align:center; padding:12px; } +.camera-placeholder svg { width:30px; height:30px; opacity:.65; } +.transcript-state { color:#909ba3; font-size:11px; flex-shrink:0; } +.transcript-text { margin:11px 0 0; color:#edf0f2; font-size:14px; line-height:1.6; overflow-wrap:anywhere; max-height:110px; overflow-y:auto; } +.transcript-text.is-placeholder { color:#87939c; } +@keyframes appear { from { opacity:0; transform:translateY(8px) } to { opacity:1;transform:translateY(0) } } +@keyframes status-pulse { 0%,100% { opacity:1 } 50% { opacity:.45 } } +@media(max-width:1120px) { #wave { width:28vw; } .camera-panel { width:27vw; } .transcript-panel { width:29vw; } } +@media(max-width:760px) { .alfred-shell { padding:14px; grid-template-rows:50px minmax(220px,1fr) 80px auto; gap:8px; } #stage { padding:10px; } #wave { width:80%; height:70px; } .camera-panel,.transcript-panel { position:relative; left:auto; right:auto; bottom:auto; width:100%; } .camera-panel { grid-row:4; max-width:none; } .transcript-panel { grid-row:5; min-height:auto; } .topbar { grid-row:1; } } +@media(prefers-reduced-motion:reduce) { * { animation:none !important; transition:none !important; } } diff --git a/alfred/app.backup.camera/static/js/alfred-camera.js b/alfred/app.backup.camera/static/js/alfred-camera.js new file mode 100644 index 0000000..fc58979 --- /dev/null +++ b/alfred/app.backup.camera/static/js/alfred-camera.js @@ -0,0 +1,41 @@ +/* Lecture MJPEG activée manuellement : libère la caméra si désactivée. */ +(() => { + 'use strict'; + const button = document.getElementById('cameraToggle'); + const image = document.getElementById('cameraFeed'); + const placeholder = document.getElementById('cameraPlaceholder'); + const message = document.getElementById('cameraMessage'); + const panel = document.querySelector('.camera-panel'); + let active = false; + + function stop() { + active = false; + image.removeAttribute('src'); + image.hidden = true; + placeholder.hidden = false; + message.textContent = 'Caméra désactivée'; + button.textContent = 'Activer'; + button.setAttribute('aria-pressed', 'false'); + panel.dataset.active = 'false'; + } + function start() { + active = true; + placeholder.hidden = false; + message.textContent = 'Connexion à la caméra…'; + image.hidden = false; + image.src = `/api/camera/stream?t=${Date.now()}`; + button.textContent = 'Arrêter'; + button.setAttribute('aria-pressed', 'true'); + } + button.addEventListener('click', () => active ? stop() : start()); + image.addEventListener('load', () => { if (active) { placeholder.hidden = true; panel.dataset.active = 'true'; } }); + image.addEventListener('error', () => { + if (!active) return; + stop(); + message.textContent = 'Caméra indisponible ou déjà utilisée'; + }); + // Si l'onglet n'est pas visible, relâcher le périphérique USB. + document.addEventListener('visibilitychange', () => { if (document.hidden && active) stop(); }); + window.addEventListener('pagehide', stop); + stop(); +})(); diff --git a/alfred/app.backup.camera/static/js/alfred-controls.js b/alfred/app.backup.camera/static/js/alfred-controls.js new file mode 100644 index 0000000..8f3ae69 --- /dev/null +++ b/alfred/app.backup.camera/static/js/alfred-controls.js @@ -0,0 +1,93 @@ +/* Réglages volume/gain dans la fenêtre, mute micro + haut-parleur dans la barre haute. */ +(() => { + 'use strict'; + const panel = document.getElementById('audioSettingsPanel'); + const button = document.getElementById('audioSettingsButton'); + const close = document.getElementById('closeAudioSettings'); + const speaker = document.getElementById('speakerMuteQuick'); + const mic = document.getElementById('micMute'); + const volume = document.getElementById('volumeSlider'); + const volumeValue = document.getElementById('volumeValue'); + const gain = document.getElementById('micGainSlider'); + const gainValue = document.getElementById('micGainValue'); + const message = document.getElementById('audioSettingsMessage'); + let settings = { volume:70, mic_gain:null, speaker_muted:false, mic_muted:false }; + let volumeTimer, gainTimer; + + function render() { + volume.value = settings.volume; + volumeValue.textContent = `${settings.volume} %`; + gain.disabled = !Number.isFinite(settings.mic_gain); + if (!gain.disabled) gain.value = settings.mic_gain; + gainValue.textContent = gain.disabled ? 'N/D' : `${settings.mic_gain} %`; + speaker.setAttribute('aria-pressed', String(settings.speaker_muted)); + speaker.setAttribute('aria-label', settings.speaker_muted ? 'Réactiver le son' : 'Couper le son'); + speaker.title = speaker.getAttribute('aria-label'); + mic.setAttribute('aria-pressed', String(settings.mic_muted)); + mic.setAttribute('aria-label', settings.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone'); + mic.title = mic.getAttribute('aria-label'); + } + + async function update(changes) { + message.textContent = ''; + try { + const response = await fetch('/api/audio/settings', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify(changes) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); + settings = { ...settings, ...data }; + render(); + } catch (err) { + message.textContent = `Impossible de modifier le réglage : ${err.message}`; + console.warn('[ALFRED AUDIO]', err); + await refresh(); + } + } + + async function refresh() { + try { + const response = await fetch('/api/audio/settings', {cache:'no-store'}); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + settings = { ...settings, ...await response.json() }; + render(); + } catch (err) { + message.textContent = 'Réglages indisponibles.'; + console.warn('[ALFRED AUDIO]', err); + } + } + + function setOpen(open) { + panel.hidden = !open; + button.setAttribute('aria-expanded', String(open)); + if (open) { refresh(); close.focus(); } else { button.focus(); } + } + button.addEventListener('click', () => setOpen(panel.hidden)); + close.addEventListener('click', () => setOpen(false)); + document.addEventListener('pointerdown', e => { + if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) { + panel.hidden = true; button.setAttribute('aria-expanded', 'false'); + } + }); + document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) setOpen(false); }); + + volume.addEventListener('input', () => { + settings.volume = Number(volume.value); render(); + clearTimeout(volumeTimer); + volumeTimer = setTimeout(() => update({volume:Number(volume.value)}), 220); + }); + gain.addEventListener('input', () => { + settings.mic_gain = Number(gain.value); render(); + clearTimeout(gainTimer); + gainTimer = setTimeout(() => update({mic_gain:Number(gain.value)}), 250); + }); + speaker.addEventListener('click', () => update({speaker_muted:!settings.speaker_muted})); + mic.addEventListener('click', () => update({mic_muted:!settings.mic_muted})); + + if (window.alfredSocket) window.alfredSocket.on('audio_settings', data => { + settings = {...settings, ...data}; render(); + }); + render(); refresh(); +})(); diff --git a/alfred/app.backup.camera/static/js/alfred-mode-icons.js b/alfred/app.backup.camera/static/js/alfred-mode-icons.js new file mode 100644 index 0000000..9a36c90 --- /dev/null +++ b/alfred/app.backup.camera/static/js/alfred-mode-icons.js @@ -0,0 +1,35 @@ +/* Icônes de statut : compatible avec alfred.js existant, sans le remplacer. */ +(() => { + 'use strict'; + const el = document.getElementById('mode'); + if (!el) return; + const icons = { + idle: ['🛏️', 'Repos'], repos: ['🛏️', 'Repos'], + listening: ['👂', 'Écoute'], thinking: ['🧠', 'Réflexion'], + speaking: ['🔊', 'Parole'], error: ['⚠️', 'Erreur'] + }; + let current = 'idle'; + function apply(raw) { + const key = String(raw || '').trim().toLowerCase(); + if (!icons[key]) return; + current = key; + const [icon, label] = icons[key]; + if (el.textContent !== icon) el.textContent = icon; + el.title = label; + el.setAttribute('aria-label', label); + el.dataset.mode = key; + } + // L'ancien alfred.js écrit le nom du mode dans #mode. + // L'observateur le convertit immédiatement en icône, sans modifier ce script. + const observer = new MutationObserver(() => { + const value = el.textContent.trim(); + if (icons[value.toLowerCase()]) apply(value); + }); + observer.observe(el, { childList: true, characterData: true, subtree: true }); + apply(el.textContent); + // Récupère également l'état actuel après un rafraîchissement de page. + fetch('/api/state', { cache: 'no-store' }) + .then(r => { if (!r.ok) throw new Error('État indisponible'); return r.json(); }) + .then(data => { if (data && data.mode) apply(data.mode); }) + .catch(() => {}); +})(); diff --git a/alfred/app.backup.camera/static/js/alfred-views.js b/alfred/app.backup.camera/static/js/alfred-views.js new file mode 100644 index 0000000..3b37e7d --- /dev/null +++ b/alfred/app.backup.camera/static/js/alfred-views.js @@ -0,0 +1,235 @@ +/* Alfred • visual views, powered by first-party Flask endpoints. No extra CDN. */ +(() => { + 'use strict'; + const root = document.getElementById('content'); + let current = null, camerasRefresh = null; + const DAYS = ['lun.','mar.','mer.','jeu.','ven.','sam.','dim.']; + const PALETTE = ['#77bcdf','#e5b677','#9dc99a','#d3a2d5','#8cb7c9','#ec9f95','#b6b5e1','#b9d78d']; + const el = (tag, cls, text) => { const x=document.createElement(tag); if(cls)x.className=cls; if(text!==undefined)x.textContent=String(text); return x; }; + const btn = (label, fn, cls='view-button') => { const b=el('button',cls,label); b.type='button'; b.addEventListener('click',fn);return b; }; + const api = async (url, options) => { + const r=await fetch(url,{cache:'no-store', ...options}); + const data=await r.json().catch(()=>({error:`Erreur HTTP ${r.status}`})); + if(!r.ok)throw new Error(data.error || `HTTP ${r.status}`); + return data; + }; + const post = (url,data) => api(url,{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify(data)}); + const dateLabel = iso => new Date(iso).toLocaleDateString('fr-FR',{day:'numeric',month:'long',year:'numeric'}); + const timeLabel = iso => new Date(iso).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}); + const localKey = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + const atDay = (d,h=0) => new Date(d.getFullYear(),d.getMonth(),d.getDate(),h); + const dayShift = (d,n) => new Date(d.getFullYear(),d.getMonth(),d.getDate()+n,d.getHours(),d.getMinutes()); + const eventOnDay=(ev,day)=> {const start=new Date(ev.start),end=new Date(ev.end);return startatDay(day);}; + const fmtRange=(a,b)=>`${dateLabel(a)} — ${dateLabel(new Date(new Date(b).getTime()-1000).toISOString())}`; + const wrap = (title) => { + clearInterval(camerasRefresh);camerasRefresh=null;root.replaceChildren(); + root.classList.add('alfred-view'); + const pane=el('div','view-pane'),head=el('header','view-head'); + head.append(el('h1','view-title',title)); + head.append(btn('×',goHome,'view-close')); + pane.append(head);root.append(pane);return {pane,head}; + }; + const showError=(err,pane)=>pane.append(el('p','view-error',err.message||String(err))); + const canRender = data=>['calendar','calendar_form','delete_candidates','temperatures','cameras','home'].includes(data?.type); + function render(data){ + if(!canRender(data))return false; + current=data; + if(data.type==='calendar')drawCalendar(data); + if(data.type==='calendar_form')drawForm(data); + if(data.type==='delete_candidates')drawDeleteChoices(data); + if(data.type==='temperatures')drawTemps(data); + if(data.type==='cameras')drawCameras(data); + if(data.type==='home'){root.classList.remove('alfred-view');root.replaceChildren(el('h1','', 'Bonjour.'), el('p','',data.text||'À ton écoute.')); clearInterval(camerasRefresh);camerasRefresh=null;} + return true; + } + const getSelected=()=>document.getElementById('calendar-source')?.value || ''; + async function loadCalendar(start,end,view,filter){ + const q=new URLSearchParams({start:new Date(start).toISOString(),end:new Date(end).toISOString()}); + if(filter)q.append('calendar_id',filter); + const data=await api('/api/calendar/events?'+q); + render({type:'calendar',view,start:new Date(start).toISOString(),end:new Date(end).toISOString(),...data,selected:filter||''}); + } + function moveCalendar(direction){ + if(!current || current.type!=='calendar')return; + const start=new Date(current.start),end=new Date(current.end),view=current.view; + let a,b; + if(view==='month'){ + a=new Date(start.getFullYear(),start.getMonth()+direction,1); + b=new Date(a.getFullYear(),a.getMonth()+1,1); + }else{ + const days=Math.round((end-start)/86400000) || 1; + a=dayShift(start,days*direction);b=dayShift(end,days*direction); + } + loadCalendar(a,b,view,getSelected()).catch(err=>{const {pane}=wrap('Agenda');showError(err,pane)}); + } + const details = (event) => { + const d=el('div','detail-modal'),box=el('section','detail-box'); + box.setAttribute('role','dialog');box.setAttribute('aria-modal','true');box.setAttribute('aria-label','Détails de l’événement'); + box.append(el('h2','',event.title)); + box.append(el('p','',`${dateLabel(event.start)}${event.all_day?' · Toute la journée':' · '+timeLabel(event.start)+'–'+timeLabel(event.end)}`)); + box.append(el('p','',event.calendar_name)); + if(event.location)box.append(el('p','',`Lieu : ${event.location}`)); + if(event.description)box.append(el('p','',event.description)); + const acts=el('div','view-actions');acts.append(btn('Fermer',()=>d.remove())); + if(event.can_delete) acts.append(btn('Supprimer…',async()=>{ + const recurrence=event.recurrence_id ? 'Cela supprimera cette occurrence.' : 'Pour une série récurrente, cela peut supprimer toute la série.'; + if(!confirm(`Supprimer « ${event.title} » du calendrier « ${event.calendar_name} » ?\n${recurrence}`))return; + try{await post('/api/calendar/delete',{calendar_id:event.calendar_id,uid:event.id,start:event.start,end:event.end,recurrence_id:event.recurrence_id||''}); d.remove();await loadCalendar(current.start,current.end,current.view,getSelected());} + catch(err){alert(err.message)} + },'view-danger')); + box.append(acts);d.append(box);document.body.append(d); + d.addEventListener('click',e=>{if(e.target===d)d.remove()}); + }; + function drawCalendar(data){ + const {pane,head}=wrap('Planning'); + const toolbar=el('div','view-toolbar'); + const first=el('div','view-nav'); + first.append(btn('‹',()=>moveCalendar(-1)),el('span','view-period',fmtRange(data.start,data.end)),btn('›',()=>moveCalendar(1))); + toolbar.append(first); + const modes=el('div','view-modes'); + [['Jour',1,'day'],['3 jours',3,'days'],['7 jours',7,'days'],['Semaine',7,'week'],['Mois',31,'month']].forEach(([label,n,v])=>{ + const b=btn(label,()=>{ + const now=new Date(),start=(v==='week')?dayShift(atDay(now),-((now.getDay()+6)%7)):v==='month'?new Date(now.getFullYear(),now.getMonth(),1):atDay(now); + const end=v==='month'?new Date(start.getFullYear(),start.getMonth()+1,1):dayShift(start,n); + loadCalendar(start,end,v,getSelected()).catch(err=>showError(err,pane)); + },'view-pill'+(data.view===v?' active':''));modes.append(b); + });toolbar.append(modes); + const src=el('select','view-select');src.id='calendar-source'; + src.append(new Option('Tous les calendriers','')); + (data.calendars||[]).forEach(c=>src.append(new Option(c.name,c.entity_id))); + src.value=data.selected||''; + src.addEventListener('change',()=>loadCalendar(data.start,data.end,data.view,src.value).catch(err=>showError(err,pane))); + toolbar.append(src,btn('+ Rendez-vous',()=>openForm())); + pane.append(toolbar); + if(data.errors?.length)pane.append(el('p','view-error','Calendriers partiellement indisponibles : '+data.errors.join(' · '))); + const grid=el('div','agenda-grid'); + const start=new Date(data.start),end=new Date(data.end); + let days=[],cursor=atDay(start); + const month=data.view==='month'; + if(month) cursor=dayShift(cursor,-((cursor.getDay()+6)%7)); + let limit=0; + while(cursor[c.entity_id, PALETTE[i%PALETTE.length]])); + days.forEach(d=>{ + const cell=el('section','agenda-day'); + if(month && d.getMonth()!==start.getMonth())cell.classList.add('out-month'); + if(localKey(d)===localKey(new Date()))cell.classList.add('is-today'); + cell.append(el('div','agenda-date',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:month?undefined:'short'}))); + const events=(data.events||[]).filter(ev=>eventOnDay(ev,d)); + if(!events.length)cell.append(el('div','agenda-empty','—')); + events.forEach(ev=>{ + const e=btn((ev.all_day?'':timeLabel(ev.start)+' ')+ev.title,()=>details(ev),'agenda-event'); + e.style.setProperty('--event-color',colors.get(ev.calendar_id)||'#7bbbe2'); + e.title=`${ev.title} · ${ev.calendar_name}`;cell.append(e); + }); + grid.append(cell); + }); + pane.append(grid); + pane.append(el('p','view-footer',`${data.events?.length||0} événement(s) · ${data.calendars?.length||0} calendrier(s) Home Assistant`)); + } + async function openForm(values){ + let calendars=[]; + try {const info=await api('/api/calendar/calendars');calendars=info.calendars||[];} + catch(err){const {pane}=wrap('Nouveau rendez-vous');showError(err,pane);return;} + render({type:'calendar_form',calendars,values:values||{}}); + } + function drawForm(data){ + const {pane}=wrap('Ajouter un événement'); + const v=data.values||{}; + const form=el('form','agenda-form');form.autocomplete='off'; + const field=(label,node)=>{const row=el('label','form-field');row.append(el('span','',label),node);form.append(row);return node}; + const makeInput=(type,value)=>{const i=el('input');i.type=type;i.value=value||'';i.required=true;return i}; + const title=field('Titre',makeInput('text',v.title));title.maxLength=200; + const src=el('select');src.required=true;src.append(new Option('Choisir un calendrier','')); + data.calendars.filter(c=>c.can_create).forEach(c=>src.append(new Option(c.name,c.entity_id))); + src.value=v.calendar_id||'';field('Calendrier',src); + const day=field('Date',makeInput('date',v.date||localKey(new Date()))); + const startTime=field('Début',makeInput('time',v.time||'09:00')); + const duration=field('Durée (minutes)',makeInput('number',v.duration_minutes||60));duration.min=15;duration.max=1440; + const location=field('Lieu (facultatif)',makeInput('text',''));location.required=false; + const description=field('Description (facultatif)',el('textarea'));description.required=false;description.rows=2; + const allDay=el('input');allDay.type='checkbox';allDay.required=false; + const allDayWrap=el('label','form-check');allDayWrap.append(allDay,el('span','','Toute la journée'));form.append(allDayWrap); + allDay.addEventListener('change',()=>{startTime.disabled=allDay.checked;duration.disabled=allDay.checked}); + const msg=el('p','form-message'); + const actions=el('div','view-actions');actions.append(btn('Annuler',goHome)); + const submit=el('button','view-button primary','Enregistrer');submit.type='submit';actions.append(submit); + form.append(actions,msg);pane.append(form); + form.addEventListener('submit',async e=>{ + e.preventDefault();submit.disabled=true;msg.textContent='Enregistrement…'; + try{ + const from=allDay.checked?new Date(day.value+'T00:00'):new Date(day.value+'T'+startTime.value+':00'); + if(Number.isNaN(from.getTime()))throw new Error('Date invalide.'); + const end=allDay.checked?dayShift(from,1):new Date(from.getTime()+Number(duration.value)*60000); + await post('/api/calendar/create',{calendar_id:src.value,title:title.value,start:from.toISOString(),end:end.toISOString(),all_day:allDay.checked,location:location.value,description:description.value}); + const first=atDay(from);await loadCalendar(first,dayShift(first,7),'days'); + }catch(err){msg.textContent=err.message;submit.disabled=false} + }); + } + function drawDeleteChoices(data){ + const {pane}=wrap('Choisir un événement à supprimer'); + const arr=el('div','delete-list'); + (data.events||[]).forEach(ev=>{ + const row=el('div','delete-row');row.append(el('span','',`${ev.title} • ${dateLabel(ev.start)} • ${ev.calendar_name}`)); + row.append(btn('Détails / Supprimer',()=>details(ev),'view-button'));arr.append(row); + });pane.append(arr); + if(data.awaiting_confirmation)pane.append(el('p','view-footer','Dis « confirme » pour supprimer, ou « annule ».')); + } + function drawTemps(data){ + const {pane}=wrap('Températures Home Assistant'); + if(!data.items?.length){pane.append(el('p','view-empty','Aucun capteur de température trouvé.'));return} + const grid=el('div','temperature-grid');pane.append(grid); + data.items.forEach(item=>{ + const c=btn(`${item.name}\n${item.value.toFixed(1)} ${item.unit}` ,async()=>{ + const q=new URLSearchParams({entity_id:item.entity_id,hours:'24'}); + try{const data=await api('/api/ha/temperature/history?'+q);showHistory(item,data.items||[],pane)} + catch(err){showError(err,pane)} + },'temperature-card');grid.append(c); + });pane.append(el('p','view-footer','Touchez un capteur pour afficher son historique sur 24 h.')); + } + function showHistory(item,points,pane){ + pane.querySelector('.temperature-history')?.remove(); + const section=el('section','temperature-history');section.append(el('h2','',item.name+' · 24 dernières heures')); + if(points.length<2){section.append(el('p','', 'Historique insuffisant.'));pane.append(section);return} + const vals=points.map(p=>p.value);const lo=Math.min(...vals)-0.4,hi=Math.max(...vals)+0.4; + const a=Date.parse(points[0].time),b=Date.parse(points[points.length-1].time); + const coords=points.map((p,i)=>`${30+560*((Date.parse(p.time)-a)/Math.max(1,b-a))},${140-110*((p.value-lo)/Math.max(0.1,hi-lo))}`).join(' '); + const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 620 170');svg.setAttribute('role','img');svg.setAttribute('aria-label','Courbe de température'); + const poly=document.createElementNS(ns,'polyline');poly.setAttribute('points',coords);poly.setAttribute('fill','none');poly.setAttribute('stroke','#83c5cc');poly.setAttribute('stroke-width','3');svg.append(poly); + section.append(svg,el('p','',`Min ${Math.min(...vals).toFixed(1)}° · Max ${Math.max(...vals).toFixed(1)}°`));pane.append(section); + } + function drawCameras(data){ + const {pane}=wrap('Caméras Home Assistant'); + if(!data.items?.length){pane.append(el('p','view-empty','Aucune caméra Home Assistant disponible.'));return} + const grid=el('div','ha-cameras');pane.append(grid); + const imgs=[]; + data.items.forEach(item=>{ + const card=el('div','ha-camera-card');card.append(el('h2','',item.name)); + const img=el('img');img.alt='Instantané caméra '+item.name;img.loading='lazy';imgs.push([img,item.entity_id]);card.append(img);grid.append(card); + }); + const reload=()=>{if(document.hidden)return; imgs.forEach(([img,eid])=>img.src='/api/ha/camera/'+encodeURIComponent(eid)+'/snapshot?t='+Date.now())}; + reload();camerasRefresh=setInterval(reload,15000); + pane.append(el('p','view-footer','Instantanés réactualisés toutes les 15 s lorsque cette vue est ouverte.')); + } + async function goHome(){ + try{await post('/api/ui/home',{})}catch(err){render({type:'home'})} + } + async function openCalendar(){ + const d=atDay(new Date());try{await loadCalendar(d,dayShift(d,7),'days')}catch(err){const {pane}=wrap('Agenda');showError(err,pane)} + } + async function openTemperatures(){ + try{render({type:'temperatures',...await api('/api/ha/temperatures')})}catch(err){const {pane}=wrap('Températures');showError(err,pane)} + } + async function openCameras(){ + try{render({type:'cameras',...await api('/api/ha/cameras')})}catch(err){const {pane}=wrap('Caméras');showError(err,pane)} + } + document.getElementById('viewHome')?.addEventListener('click',goHome); + document.getElementById('viewCalendar')?.addEventListener('click',openCalendar); + document.getElementById('viewTemperatures')?.addEventListener('click',openTemperatures); + document.getElementById('viewCameras')?.addEventListener('click',openCameras); + const socket=window.alfredSocket; + socket?.on('calendar_changed',()=>{if(current?.type==='calendar') loadCalendar(current.start,current.end,current.view,getSelected()).catch(console.warn)}); + window.AlfredViews={render,canRender,openCalendar,openTemperatures,openCameras,goHome}; +})(); diff --git a/alfred/app.backup.camera/static/js/alfred.js b/alfred/app.backup.camera/static/js/alfred.js new file mode 100644 index 0000000..b143532 --- /dev/null +++ b/alfred/app.backup.camera/static/js/alfred.js @@ -0,0 +1,126 @@ +/* Interface principale Alfred : statut, scène, onde et transcription Vosk. */ +(() => { + 'use strict'; + const socket = (typeof io === 'function') ? io() : null; + window.alfredSocket = socket; + + const content = document.getElementById('content'); + const modeEl = document.getElementById('mode'); + const statusDot = document.getElementById('statusDot'); + const transcriptText = document.getElementById('transcriptText'); + const transcriptState = document.getElementById('transcriptState'); + const transcriptPanel = document.querySelector('.transcript-panel'); + const canvas = document.getElementById('waveCanvas'); + const ctx = canvas.getContext('2d'); + + let mode = 'idle', level = 0.02, phase = 0, previousContent = ''; + const labels = { + idle:'En attente', waiting:'En attente', listening:'À l’écoute', + thinking:'Réflexion en cours', speaking:'Alfred parle', error:'Erreur' + }; + + function resize() { + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(canvas.clientWidth * dpr); + canvas.height = Math.round(canvas.clientHeight * dpr); + } + window.addEventListener('resize', resize); + resize(); + function draw() { + const dpr = window.devicePixelRatio || 1; + phase += 0.035; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = 'rgba(232,241,243,.76)'; + ctx.lineWidth = 1.6 * dpr; + ctx.beginPath(); + const mid = canvas.height / 2; + const amp = (mode === 'idle' ? 1.5 : mode === 'thinking' ? 7 : Math.max(5, level * 45)) * dpr; + for (let x = 0; x < canvas.width; x += 4 * dpr) { + const y = mid + Math.sin(x * 0.018 + phase * 4) * Math.sin(x * 0.006 + phase) * amp * + (0.35 + 0.65 * Math.sin(x / canvas.width * Math.PI)); + if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + window.requestAnimationFrame(draw); + } + window.requestAnimationFrame(draw); + + function makeText(tag, value, className) { + const el = document.createElement(tag); + if (className) el.className = className; + el.textContent = String(value ?? ''); + return el; + } + + function render(data) { + if (!data || typeof data !== 'object') return; + const key = JSON.stringify(data); + if (key === previousContent) return; + previousContent = key; + if (window.AlfredViews && window.AlfredViews.canRender(data)) { + window.AlfredViews.render(data); + return; + } + content.classList.remove('alfred-view'); + const fragment = document.createDocumentFragment(); + if (data.type === 'image' && typeof data.url === 'string') { + // Refuser les URL javascript: et les schémas non prévus. + if (/^(https?:\/\/|\/[^/])/i.test(data.url)) { + const img = document.createElement('img'); + img.className = 'hero'; img.src = data.url; img.alt = String(data.alt || 'Image affichée par Alfred'); + fragment.appendChild(img); + } + if (data.caption) fragment.appendChild(makeText('p', data.caption)); + } else if (data.type === 'cards' && Array.isArray(data.items)) { + const cards = document.createElement('div'); cards.className = 'cards'; + data.items.forEach(item => { + const card = makeText('article', '', 'card'); + card.append(makeText('h2', item.title || ''), makeText('p', item.text || '')); + cards.appendChild(card); + }); + fragment.appendChild(cards); + } else { + if (data.title) fragment.appendChild(makeText('h1', data.title)); + fragment.appendChild(makeText('p', data.text || '')); + } + content.replaceChildren(fragment); + } + + function updateTranscript(s) { + // Une phrase finale reste visible lors du retour en veille. + const finalText = String(s.transcript || '').trim(); + const partial = String(s.partial || '').trim(); + if (finalText) { + transcriptText.textContent = finalText; + transcriptText.classList.remove('is-placeholder'); + transcriptState.textContent = 'Reconnu'; + } else if (s.mode === 'listening') { + transcriptText.textContent = partial || 'Je vous écoute…'; + transcriptText.classList.toggle('is-placeholder', !partial); + transcriptState.textContent = partial ? 'En cours' : 'Écoute'; + } else if (s.mode === 'error') { + transcriptState.textContent = 'Erreur'; + } + transcriptPanel.dataset.active = String(s.mode === 'listening'); + } + + function updateState(s) { + if (!s || typeof s !== 'object') return; + mode = String(s.mode || 'idle').toLowerCase(); + modeEl.textContent = labels[mode] || mode; + modeEl.dataset.mode = mode; + statusDot.dataset.mode = mode; + updateTranscript(s); + if (s.content) render(s.content); + } + + if (socket) { + socket.on('alfred_state', updateState); + socket.on('alfred_render', render); + socket.on('audio_level', data => { level = Math.max(0, Math.min(1, Number(data.level) || 0)); }); + socket.on('connect', () => { + fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {}); + }); + } + fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {}); +})(); diff --git a/alfred/app.backup.camera/templates/configuration.html b/alfred/app.backup.camera/templates/configuration.html new file mode 100644 index 0000000..6746c73 --- /dev/null +++ b/alfred/app.backup.camera/templates/configuration.html @@ -0,0 +1,130 @@ + + + + + + Alfred — Configuration audio + + + +
+
+

Configuration

+

Alfred · Paramètres audio

+
+
+
+
+ + 50 % +
+ +
+
+
+ + + Couper le haut-parleur + + +
+

Désactive les réponses vocales d’Alfred.

+
+
+
+ + + Couper le microphone + + +
+

Suspend l’écoute et la détection du mot « Alfred ».

+
+
+

+
+ + + diff --git a/alfred/app.backup.camera/templates/index.html b/alfred/app.backup.camera/templates/index.html new file mode 100644 index 0000000..f103e72 --- /dev/null +++ b/alfred/app.backup.camera/templates/index.html @@ -0,0 +1,116 @@ + + + + + + + {{ name }} + + + + + + +
+
+ +
+ + En attente +
+ +
+ + + + + + + +
+
+ + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + + +
+ + + + + + + diff --git a/alfred/app.backup.camera/templates/index.html.1 b/alfred/app.backup.camera/templates/index.html.1 new file mode 100644 index 0000000..8b35c18 --- /dev/null +++ b/alfred/app.backup.camera/templates/index.html.1 @@ -0,0 +1,31 @@ + + + + + + {{name}} + + + + + +
+
{{name}}repos
+

Bonjour.

À votre écoute.

+
+
+ + + + 70 % + + + + — + +
+
+ + + + diff --git a/alfred/app.backup.camera/templates/index.html.ori b/alfred/app.backup.camera/templates/index.html.ori new file mode 100644 index 0000000..d0b38fd --- /dev/null +++ b/alfred/app.backup.camera/templates/index.html.ori @@ -0,0 +1,2 @@ +{{name}} +
{{name}}repos

Bonjour.

À votre écoute.

diff --git a/alfred/app.backup.camera/test.py b/alfred/app.backup.camera/test.py new file mode 100644 index 0000000..95385a2 --- /dev/null +++ b/alfred/app.backup.camera/test.py @@ -0,0 +1,19 @@ +python - <<'PY' +import sounddevice as sd +import soundfile as sf + +print(sd.query_devices()) +print("Microphone utilisé :", sd.default.device) + +print("Parle pendant 8 secondes...") +audio = sd.rec( + int(8 * 16000), + samplerate=16000, + channels=1, + dtype="int16" +) +sd.wait() + +sf.write("/tmp/alfred-micro.wav", audio, 16000) +print("Enregistrement terminé : /tmp/alfred-micro.wav") +PY diff --git a/alfred/app.backup.weather_outfits/__pycache__/app.cpython-313.pyc b/alfred/app.backup.weather_outfits/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000..d65771f Binary files /dev/null and b/alfred/app.backup.weather_outfits/__pycache__/app.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/app.py b/alfred/app.backup.weather_outfits/app.py new file mode 100644 index 0000000..9a89bef --- /dev/null +++ b/alfred/app.backup.weather_outfits/app.py @@ -0,0 +1,412 @@ +import os, threading, hmac +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from flask import Flask, render_template, request, jsonify, Response +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services.hermes_client import ask_hermes +from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1 +from services.camera import video_stream +from services import ha_views, alfred_views_controller +from services import camera_settings, ha_weather + +load_dotenv() + +## +from services.audio import get_vosk_model +print("[ALFRED] Préchargement des modèles vocaux...", flush=True) +get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) +get_vosk_model(os.environ["VOSK_MODEL_PATH"]) +print("[ALFRED] Modèles vocaux prêts.", flush=True) +## +app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") +STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} +STATE_LOCK = threading.RLock() +IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI. +conversation_lock=threading.Lock() +mic_stop_event=threading.Event() +audio_settings.initialize() +audio_settings.update_settings({"mic_gain": 30}) +# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente. +camera_settings.restore_saved_settings() + +def emit_state(mode=None, **extra): + with STATE_LOCK: + if mode: STATE["mode"] = mode + STATE.update(extra) + socketio.emit("alfred_state", dict(STATE)) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + stop_event=mic_stop_event, + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if audio_settings.mic_muted(): + emit_state("idle", partial="") + return + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + # Vue dynamique : commandes déterministes exécutées sans faire + # dépendre l'affichage des capacités ou sessions Hermes. + visual = None + try: + visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type')) + except (ha_views.HAError, ValueError) as exc: + print(f"[ALFRED] Vue indisponible : {exc}", flush=True) + visual = ({"type":"text", "text":str(exc)}, str(exc)) + + if visual is not None: + content, speech = visual + with STATE_LOCK: + STATE["content"] = content + socketio.emit("alfred_render", content) + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + emit_state("idle", partial="") + return + + print("[ALFRED] Envoi à Hermes", flush=True) + # ComfyUI publie l'image pendant cet appel Hermes. + with STATE_LOCK: + image_revision_before = IMAGE_REVISION + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + # Si le MCP ComfyUI a affiché une image pendant la requête, + # ne pas l'écraser par la réponse textuelle d'Hermes. + with STATE_LOCK: + if IMAGE_REVISION == image_revision_before: + STATE["content"] = display + socketio.emit("alfred_render", display) + else: + print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + if audio_settings.mic_muted(): + return + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +@app.get('/api/audio/settings') +def get_audio_settings(): + return jsonify(audio_settings.get_settings()) + +@app.post('/api/audio/settings') +def set_audio_settings(): + data = request.get_json(silent=True) + try: + old = audio_settings.get_settings() + new = audio_settings.update_settings(data) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': f'ALSA : {exc}'}), 500 + if new['mic_muted'] != old['mic_muted']: + if new['mic_muted']: + mic_stop_event.set() + wake.pause() + else: + mic_stop_event.clear() + if not conversation_lock.locked(): + wake.resume() + socketio.emit('audio_settings', new) + return jsonify(new) + +@app.get('/api/camera/settings') +def get_camera_settings(): + try: + return jsonify(camera_settings.get_settings()) + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.post('/api/camera/settings') +def set_camera_settings(): + # Même garde-fou que les commandes calendrier existantes. + if not _same_origin_json(): + return jsonify({'error': 'Requête non autorisée.'}), 403 + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'JSON invalide.'}), 400 + try: + result = camera_settings.update_setting(data.get('control'), data.get('value')) + socketio.emit('camera_settings', result) + return jsonify(result) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.post('/api/camera/reset') +def reset_camera_settings(): + if not _same_origin_json(): + return jsonify({'error': 'Requête non autorisée.'}), 403 + try: + result = camera_settings.reset_defaults() + socketio.emit('camera_settings', result) + return jsonify(result) + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.get('/api/camera/stream') +def camera_stream(): + # Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan. + try: + frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0'))) + except (ImportError, ValueError, RuntimeError, OSError) as exc: + return jsonify({'error': str(exc)}), 503 + return Response( + frames, + mimetype='multipart/x-mixed-replace; boundary=frame', + headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'} + ) + +@app.get("/") +def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) +@app.get("/api/state") +def state(): + with STATE_LOCK: + snapshot = dict(STATE) + return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()}) + +@app.post("/api/state") +def set_state(): + data = request.get_json(force=True) + with STATE_LOCK: + STATE.update(data) + socketio.emit("alfred_state", dict(STATE)) + return jsonify({"ok": True}) +@app.post("/api/render") +def render_payload(): + global IMAGE_REVISION + token = os.getenv("ALFRED_RENDER_TOKEN", "").strip() + if token and not hmac.compare_digest( + request.headers.get("X-Alfred-Render-Token", ""), token + ): + return jsonify({"ok": False, "error": "Non autorisé"}), 401 + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"ok": False, "error": "JSON objet attendu"}), 400 + if data.get("type") == "image": + image_url = data.get("url") + if not isinstance(image_url, str) or not ( + image_url.startswith(("http://", "https://", "/")) + and not image_url.startswith("//") + ): + return jsonify({"ok": False, "error": "URL image invalide"}), 400 + + with STATE_LOCK: + if data.get("type") == "image": + IMAGE_REVISION += 1 + STATE["content"] = data + socketio.emit("alfred_render", data) + return jsonify({"ok": True, "image_revision": IMAGE_REVISION}) +# --- Agenda / capteurs / caméras Home Assistant --- +def _calendar_range(): + tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) + now = datetime.now(tz) + def parse(name, default): + raw = request.args.get(name) + if not raw:return default + value = datetime.fromisoformat(raw.replace('Z','+00:00')) + if value.tzinfo is None: value = value.replace(tzinfo=tz) + return value.astimezone(tz) + start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0)) + end = parse('end',start+timedelta(days=7)) + return start,end + +def _json_error(exc): + code = 400 if isinstance(exc,ValueError) else 502 + return jsonify({'error':str(exc)}),code + +def _writes_enabled(): + return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes') + +def _same_origin_json(): + # POSTs require a custom header and same-origin browser requests. + if request.headers.get('X-Alfred-Request') != '1':return False + origin=request.headers.get('Origin') + if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False + return True + +@app.get('/api/calendar/calendars') +def calendars_list(): + try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/calendar/events') +def calendar_events(): + try: + start,end=_calendar_range() + selected=request.args.getlist('calendar_id') + return jsonify(ha_views.get_events(start,end,selected or None)) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.post('/api/calendar/create') +def calendar_create(): + if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 + if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403 + data=request.get_json(silent=True) + if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 + try: + tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris')) + def read_dt(key): + dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00')) + return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz) + result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')), + read_dt('start'),read_dt('end'),str(data.get('description') or ''), + str(data.get('location') or ''),bool(data.get('all_day',False))) + from services import alfred_intents + alfred_intents.clear_create() + socketio.emit('calendar_changed', {'type':'created'}) + return jsonify(result) + except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) + +@app.post('/api/calendar/delete') +def calendar_delete(): + if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 + if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403 + data=request.get_json(silent=True) + if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 + try: + eid=str(data.get('calendar_id',''));uid=str(data.get('uid','')) + start=datetime.fromisoformat(str(data['start'])) + end=datetime.fromisoformat(str(data['end'])) + if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.') + matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events'] + rid=str(data.get('recurrence_id') or '') + if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches): + return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409 + result=ha_views.delete_event(eid,uid,rid or None) + from services import alfred_intents + alfred_intents.clear_delete() + socketio.emit('calendar_changed',{'type':'deleted'}) + return jsonify(result) + except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) + +@app.get('/api/ha/weather') +def ha_weather_data(): + # Read-only route, token always stays on the Alfred server. + try: + return jsonify(ha_weather.get_weather()) + except (ha_views.HAError, ValueError) as exc: + return _json_error(exc) + +@app.get('/api/ha/temperatures') +def ha_temperatures(): + try:return jsonify({'items':ha_views.get_temperatures()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/temperature/history') +def ha_temperature_history(): + try: + eid=request.args.get('entity_id','') + hours=int(request.args.get('hours','24')) + return jsonify({'items':ha_views.temperature_history(eid,hours)}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/cameras') +def ha_cameras(): + try:return jsonify({'items':ha_views.list_cameras()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/camera//snapshot') +def ha_camera_snapshot(entity_id): + try: + binary,mime=ha_views.camera_snapshot(entity_id) + return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.post('/api/ui/home') +def ui_home(): + with STATE_LOCK: + STATE['content']={'type':'home','text':'À ton écoute.'} + socketio.emit('alfred_render',STATE['content']) + return jsonify({'ok':True}) + +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) + diff --git a/alfred/app.backup.weather_outfits/app.py.ori b/alfred/app.backup.weather_outfits/app.py.ori new file mode 100644 index 0000000..d44c3ca --- /dev/null +++ b/alfred/app.backup.weather_outfits/app.py.ori @@ -0,0 +1,180 @@ +import os, threading +from flask import Flask, render_template, request, jsonify, Response +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services.hermes_client import ask_hermes +from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1 +from services.camera import video_stream + +load_dotenv() + +## +from services.audio import get_vosk_model +print("[ALFRED] Préchargement des modèles vocaux...", flush=True) +get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) +get_vosk_model(os.environ["VOSK_MODEL_PATH"]) +print("[ALFRED] Modèles vocaux prêts.", flush=True) +## +app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") +STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} +conversation_lock=threading.Lock() +mic_stop_event=threading.Event() +audio_settings.initialize() + +def emit_state(mode=None, **extra): + if mode: STATE["mode"]=mode + STATE.update(extra); socketio.emit("alfred_state",STATE) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + stop_event=mic_stop_event, + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if audio_settings.mic_muted(): + emit_state("idle", partial="") + return + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + print("[ALFRED] Envoi à Hermes", flush=True) + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + STATE["content"] = display + socketio.emit("alfred_render", display) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + if audio_settings.mic_muted(): + return + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +@app.get('/api/audio/settings') +def get_audio_settings(): + return jsonify(audio_settings.get_settings()) + +@app.post('/api/audio/settings') +def set_audio_settings(): + data = request.get_json(silent=True) + try: + old = audio_settings.get_settings() + new = audio_settings.update_settings(data) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': f'ALSA : {exc}'}), 500 + if new['mic_muted'] != old['mic_muted']: + if new['mic_muted']: + mic_stop_event.set() + wake.pause() + else: + mic_stop_event.clear() + if not conversation_lock.locked(): + wake.resume() + socketio.emit('audio_settings', new) + return jsonify(new) + +@app.get('/api/camera/stream') +def camera_stream(): + # Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan. + try: + frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0'))) + except (ImportError, ValueError, RuntimeError, OSError) as exc: + return jsonify({'error': str(exc)}), 503 + return Response( + frames, + mimetype='multipart/x-mixed-replace; boundary=frame', + headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'} + ) + +@app.get("/") +def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) +@app.get("/api/state") +def state(): return jsonify({**STATE,"wake":wake.status(),"tts":tts_backend_detect()}) +@app.post("/api/state") +def set_state(): + STATE.update(request.get_json(force=True)); socketio.emit("alfred_state",STATE); return jsonify({"ok":True}) +@app.post("/api/render") +def render_payload(): + data=request.get_json(force=True); STATE["content"]=data; socketio.emit("alfred_render",data); return jsonify({"ok":True}) +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) diff --git a/alfred/app.backup.weather_outfits/backup_audio_20261016_133326/app.py b/alfred/app.backup.weather_outfits/backup_audio_20261016_133326/app.py new file mode 100644 index 0000000..9a63c93 --- /dev/null +++ b/alfred/app.backup.weather_outfits/backup_audio_20261016_133326/app.py @@ -0,0 +1,130 @@ +import os, threading +from flask import Flask, render_template, request, jsonify +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services.hermes_client import ask_hermes + +load_dotenv() + +## +from services.audio import get_vosk_model +print("[ALFRED] Préchargement des modèles vocaux...", flush=True) +get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) +get_vosk_model(os.environ["VOSK_MODEL_PATH"]) +print("[ALFRED] Modèles vocaux prêts.", flush=True) +## +app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") +STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} +conversation_lock=threading.Lock() + +def emit_state(mode=None, **extra): + if mode: STATE["mode"]=mode + STATE.update(extra); socketio.emit("alfred_state",STATE) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + print("[ALFRED] Envoi à Hermes", flush=True) + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + STATE["content"] = display + socketio.emit("alfred_render", display) + + if speech: + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +@app.get("/") +def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) +@app.get("/api/state") +def state(): return jsonify({**STATE,"wake":wake.status(),"tts":tts_backend_detect()}) +@app.post("/api/state") +def set_state(): + STATE.update(request.get_json(force=True)); socketio.emit("alfred_state",STATE); return jsonify({"ok":True}) +@app.post("/api/render") +def render_payload(): + data=request.get_json(force=True); STATE["content"]=data; socketio.emit("alfred_render",data); return jsonify({"ok":True}) +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on"): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) diff --git a/alfred/app.backup.weather_outfits/backup_audio_20261016_133326/audio.py b/alfred/app.backup.weather_outfits/backup_audio_20261016_133326/audio.py new file mode 100644 index 0000000..b655c60 --- /dev/null +++ b/alfred/app.backup.weather_outfits/backup_audio_20261016_133326/audio.py @@ -0,0 +1,379 @@ +"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre. + +STT: Vosk + sounddevice, avec fallback arecord/ALSA. +TTS: Piper local, avec fallback espeak puis spd-say. +Sortie: haut-parleur local via paplay/aplay. + +Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu +à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique. +""" +from __future__ import annotations +from functools import lru_cache + +import time +import json +import os +import queue +import shutil +import subprocess +import tempfile +import threading +from dataclasses import dataclass +from typing import Callable, Optional + + +def _cmd_ok(cmd, **kwargs): + try: + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=True, text=True, **kwargs) + except Exception: + return None + + +def audio_backend_detect() -> str: + configured = os.getenv("AUDIO_BACKEND", "auto").lower() + if configured in ("pactl", "alsa"): + return configured + if shutil.which("pactl"): + info = _cmd_ok(["pactl", "info"]) + if info and "Server Name" in info.stdout: + return "pactl" + if shutil.which("amixer"): + return "alsa" + return "none" + + +def _which(cmd): + return shutil.which(cmd) + + +def _is_piper_tts(binary: Optional[str]) -> bool: + if not binary: + return False + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + if "gtk" in out or "ratbag" in out: + return False + return (("--model" in out or " -m " in out) and + ("--output_file" in out or " -o " in out)) + except Exception: + return False + + +def _find_piper_binary() -> Optional[str]: + candidates = [ + os.getenv("PIPER_BIN"), + "/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"), + "/usr/local/bin/piper", "/usr/bin/piper", _which("piper"), + ] + for candidate in candidates: + if candidate and _is_piper_tts(candidate): + return candidate + return None + + +def _piper_supports_long_flag(binary: str) -> bool: + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + return "--model" in out and "--output_file" in out + except Exception: + return True + +### +def tts_backend_detect() -> str: + """Retourne le moteur TTS configuré pour Alfred.""" + + if os.getenv("PIPER_HOST"): + return "wyoming-piper" + + if _which("espeak-ng"): + return "espeak-ng" + + return "none" + + +''' +def tts_backend_detect() -> str: + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + if piper and model and os.path.exists(model): + return "piper" + if _which("espeak"): + return "espeak" + if _which("spd-say"): + return "spd-say" + return "none" +''' +#### + +def speak_text(text: str): + """Synthèse vocale via Wyoming Piper, avec secours eSpeak.""" + if not text or not text.strip(): + return + + text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))] + + try: + from services.piper_tts import speak as piper_speak + + print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True) + piper_speak(text) + return + + except Exception as exc: + print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True) + + # Secours eSpeak + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + print("[ALFRED TTS] Secours eSpeak", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + except Exception as exc: + print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + +''' +def speak_text(text: str): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + start = time.monotonic() + print("[ALFRED TTS] Génération audio...", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + print("[ALFRED TTS] Lecture audio...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED TTS] Erreur : {e}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + + + + +def speak_text(text: str): + """Synthétise et joue localement le texte. Retourne (ok, backend, erreur).""" + text = (text or "").strip() + if not text: + return False, "none", "texte vide" + text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))] + + backend = audio_backend_detect() + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + + if piper and model and os.path.exists(model): + wav_path = None + try: + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + wav_path = tmp.name + cmd = [piper] + if _piper_supports_long_flag(piper): + cmd += ["--model", model, "--output_file", wav_path] + else: + cmd += ["-m", model, "-o", wav_path] + p = subprocess.run(cmd, input=text, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if p.returncode != 0: + return False, "piper", (p.stderr or p.stdout or "")[:2000] + if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0: + return False, "piper", "Piper n'a pas produit de WAV" + if backend == "pactl" and shutil.which("paplay"): + play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + elif shutil.which("aplay"): + play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + else: + return False, "piper", "paplay/aplay introuvable" + return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée" + except Exception as exc: + return False, "piper", str(exc) + finally: + if wav_path: + try: os.unlink(wav_path) + except OSError: pass + + if _which("espeak"): + args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text] + if backend == "pactl" and shutil.which("padsp"): + args = ["padsp"] + args + ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0 + return ok, "espeak", None if ok else "espeak a échoué" + + if _which("spd-say"): + ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE, + stderr=subprocess.PIPE).returncode == 0 + return ok, "spd-say", None if ok else "spd-say a échoué" + return False, "none", "aucun backend TTS disponible" +''' + +_tts_lock = threading.Lock() +def speak_async(text: str, on_start: Optional[Callable] = None, + on_end: Optional[Callable] = None) -> bool: + """Lecture non bloquante; refuse d'empiler deux réponses vocales.""" + if _tts_lock.locked(): + return False + def worker(): + with _tts_lock: + if on_start: on_start() + try: speak_text(text) + finally: + if on_end: on_end() + threading.Thread(target=worker, daemon=True).start() + return True + + +@dataclass +class STTResult: + text: str = "" + error: Optional[str] = None + + + +@lru_cache(maxsize=2) +def get_vosk_model(model_path): + from vosk import Model + + print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True) + model = Model(model_path) + print("[ALFRED] Modèle Vosk prêt", flush=True) + + return model + + +def listen_once(stop_event: Optional[threading.Event] = None, + on_partial: Optional[Callable[[str], None]] = None, + on_level: Optional[Callable[[float], None]] = None) -> STTResult: + """Attend une phrase finale Vosk et la retourne. + + Reprend le comportement du prototype: sounddevice en priorité, arecord sinon. + Le niveau audio transmis à l'UI est une approximation RMS sur int16. + """ + try: + import numpy as np + from vosk import Model, KaldiRecognizer + except Exception as exc: + return STTResult(error=f"Dépendance STT absente: {exc}") + + model_path = os.getenv("VOSK_MODEL_PATH", "") + sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000")) + if not model_path or not os.path.exists(model_path): + return STTResult(error=f"Modèle Vosk introuvable: {model_path}") + try: +# rec = KaldiRecognizer(Model(model_path), sample_rate) + rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate) + except Exception as exc: + return STTResult(error=f"Init Vosk: {exc}") + + def process(data: bytes) -> Optional[str]: + if on_level and data: + try: + a = np.frombuffer(data, dtype=np.int16).astype(np.float32) + rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0 + on_level(min(1.0, rms / 6000.0)) + except Exception: + pass + if rec.AcceptWaveform(data): + text = (json.loads(rec.Result() or "{}").get("text") or "").strip() + if text: + return text + elif on_partial: + partial = json.loads(rec.PartialResult() or "{}").get("partial", "") + print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True) + on_partial(partial) + return None + + try: + import sounddevice as sd + q = queue.Queue() + def callback(indata, frames, time_info, status): + q.put(bytes(indata)) + device = os.getenv("AUDIO_INPUT_DEVICE") or None + with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16", + channels=1, callback=callback, device=device): + while not (stop_event and stop_event.is_set()): + text = process(q.get()) + if text: + return STTResult(text=text) + except Exception as exc: + print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True) + +# except Exception: +# pass + + if not shutil.which("arecord"): + return STTResult(error="sounddevice indisponible et arecord introuvable") + cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"] + alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip() + if alsa_device: + cmd += ["-D", alsa_device] + proc = None + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) + while not (stop_event and stop_event.is_set()): + chunk = proc.stdout.read(8000) + if not chunk: + continue + text = process(chunk) + if text: + return STTResult(text=text) + return STTResult() + except Exception as exc: + return STTResult(error=f"arecord: {exc}") + finally: + if proc and proc.poll() is None: + proc.terminate() + + +def devices(): + try: +# import sounddevice as sd +# return sd.query_devices() + print("[ALFRED STT] Périphériques audio :", flush=True) + print(sd.query_devices(), flush=True) + print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True) + return sd.query_devices() + except Exception: + return [] diff --git a/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/alfred-controls.css b/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/alfred-controls.css new file mode 100644 index 0000000..ff91533 --- /dev/null +++ b/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/alfred-controls.css @@ -0,0 +1,19 @@ +/* Contrôles indépendants de la feuille alfred.css existante */ +body > main { position: relative; min-height: 100dvh; padding-bottom: 82px; box-sizing: border-box; } +.alfred-controls { + position: absolute; left: 16px; right: 16px; bottom: 12px; + display: flex; align-items: center; gap: 14px; min-height: 56px; + padding: 8px 18px; box-sizing: border-box; + border: 1px solid rgba(255,255,255,.10); border-radius: 16px; + background: rgba(35,37,41,.92); color: #f4f4f5; + box-shadow: 0 8px 28px rgba(0,0,0,.14); + font: 14px/1.3 system-ui, sans-serif; +} +.alfred-controls .audio-toggle { flex: 0 0 auto; width: 42px; height: 42px; padding: 0; border: 1px solid transparent; border-radius: 12px; background: transparent; color: inherit; font-size: 23px; cursor: pointer; } +.alfred-controls .audio-toggle:hover, .alfred-controls .audio-toggle:focus-visible { background: rgba(255,255,255,.10); outline-offset: 2px; } +.alfred-controls .audio-toggle[aria-pressed="true"] { background: rgba(225,100,100,.14); border-color: rgba(225,100,100,.28); } +.alfred-controls .volume-label { white-space: nowrap; color: #c8c8ce; } +.alfred-controls input[type="range"] { min-width: 60px; flex: 1; accent-color: #d1d5db; cursor: pointer; } +.alfred-controls output { min-width: 44px; text-align: right; font-variant-numeric: tabular-nums; } +.alfred-controls .controls-separator { height: 25px; width: 1px; background: rgba(255,255,255,.16); } +@media (max-width: 480px) { .alfred-controls { gap: 8px; padding: 6px 9px; left: 8px; right: 8px; } .alfred-controls .volume-label { display: none; } } diff --git a/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/alfred-controls.js b/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/alfred-controls.js new file mode 100644 index 0000000..142bd55 --- /dev/null +++ b/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/alfred-controls.js @@ -0,0 +1,73 @@ +(() => { + 'use strict'; + const slider = document.getElementById('volumeSlider'); + const volumeValue = document.getElementById('volumeValue'); + const speaker = document.getElementById('speakerMute'); + const mic = document.getElementById('micMute'); + const endpoint = '/api/audio/settings'; + let state = { volume: 70, speaker_muted: false, mic_muted: false }; + let saveTimer; + + function render() { + slider.value = String(state.volume); + volumeValue.value = `${state.volume} %`; + speaker.setAttribute('aria-pressed', String(state.speaker_muted)); + speaker.setAttribute('aria-label', state.speaker_muted ? 'Réactiver le haut-parleur' : 'Couper le haut-parleur'); + speaker.title = speaker.getAttribute('aria-label'); + speaker.textContent = state.speaker_muted ? '🔇' : '🔊'; + mic.setAttribute('aria-pressed', String(state.mic_muted)); + mic.setAttribute('aria-label', state.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone'); + mic.title = mic.getAttribute('aria-label'); + mic.textContent = state.mic_muted ? '🎙️̸' : '🎙️'; + } + + async function save() { + try { + const response = await fetch(endpoint, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(state) + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + if (result && typeof result.volume === 'number') { + state = { ...state, ...result }; + render(); + } + } catch (error) { + console.warn('Réglages audio non synchronisés avec Flask :', error); + } + } + + slider.addEventListener('input', () => { + state.volume = Number(slider.value); + render(); + clearTimeout(saveTimer); + saveTimer = setTimeout(save, 200); + }); + speaker.addEventListener('click', () => { + state.speaker_muted = !state.speaker_muted; + render(); + clearTimeout(saveTimer); + save(); + }); + mic.addEventListener('click', () => { + state.mic_muted = !state.mic_muted; + render(); + clearTimeout(saveTimer); + save(); + }); + + (async () => { + try { + const response = await fetch(endpoint); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const settings = await response.json(); + if (typeof settings.volume === 'number') state.volume = Math.max(0, Math.min(100, settings.volume)); + if (typeof settings.speaker_muted === 'boolean') state.speaker_muted = settings.speaker_muted; + if (typeof settings.mic_muted === 'boolean') state.mic_muted = settings.mic_muted; + } catch (error) { + console.warn('Réglages audio non disponibles :', error); + } + render(); + })(); +})(); diff --git a/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/audio_settings.py b/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/audio_settings.py new file mode 100644 index 0000000..20243b7 --- /dev/null +++ b/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/audio_settings.py @@ -0,0 +1,62 @@ +"""Réglages audio Alfred : ALSA carte 1 et états logiques thread-safe.""" +import os +import re +import subprocess +import threading + +_LOCK = threading.RLock() +_STATE = {'volume': 70, 'speaker_muted': False, 'mic_muted': False} +_CARD = os.getenv('ALFRED_ALSA_CARD', '1') +_CONTROL = os.getenv('ALFRED_ALSA_VOLUME_CONTROL', 'Master') + +def _amixer(*args): + return subprocess.run(['amixer', '-c', _CARD, *args], capture_output=True, + text=True, timeout=5, check=True).stdout + +def initialize(): + """Lit le volume existant sans modifier les réglages audio au démarrage.""" + try: + output = _amixer('sget', _CONTROL) + matches = re.findall(r'\[(\d+)%\]', output) + with _LOCK: + if matches: + _STATE['volume'] = int(matches[0]) + # Ne pas déduire speaker_muted du mixer : état propre à Alfred. + except (OSError, subprocess.SubprocessError) as exc: + print(f'[ALFRED AUDIO] Lecture ALSA impossible : {exc}', flush=True) + +def get_settings(): + with _LOCK: + return dict(_STATE) + +def update_settings(payload): + if not isinstance(payload, dict): + raise ValueError('Un objet JSON est attendu') + allowed = {'volume', 'speaker_muted', 'mic_muted'} + if set(payload) - allowed: + raise ValueError('Paramètre audio inconnu') + with _LOCK: + new = dict(_STATE) + if 'volume' in payload: + v = payload['volume'] + if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100: + raise ValueError('volume doit être entre 0 et 100') + new['volume'] = round(v) + for key in ('speaker_muted', 'mic_muted'): + if key in payload: + if not isinstance(payload[key], bool): + raise ValueError(f'{key} doit être un booléen') + new[key] = payload[key] + # Ne touche jamais aux contrôles Mic/Capture : calibration conservée. + if new['volume'] != _STATE['volume']: + _amixer('sset', _CONTROL, f"{new['volume']}%") + _STATE.update(new) + return dict(_STATE) + +def speaker_muted(): + with _LOCK: + return _STATE['speaker_muted'] + +def mic_muted(): + with _LOCK: + return _STATE['mic_muted'] diff --git a/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/index.html b/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/index.html new file mode 100644 index 0000000..4762a5b --- /dev/null +++ b/alfred/app.backup.weather_outfits/backup_micro_gain_20261016_134053/index.html @@ -0,0 +1,28 @@ + + + + + + {{name}} + + + + + +
+
{{name}}repos
+

Bonjour.

À votre écoute.

+
+
+ + + + 70 % + + +
+
+ + + + diff --git a/alfred/app.backup.weather_outfits/config/camera_controls.json b/alfred/app.backup.weather_outfits/config/camera_controls.json new file mode 100644 index 0000000..86998a2 --- /dev/null +++ b/alfred/app.backup.weather_outfits/config/camera_controls.json @@ -0,0 +1,16 @@ +{ + "controls": { + "brightness": 32, + "contrast": 42, + "saturation": 64, + "hue": 0, + "white_balance_automatic": 1, + "gamma": 300, + "gain": 64, + "power_line_frequency": 2, + "sharpness": 50, + "backlight_compensation": 0, + "auto_exposure": 3, + "exposure_dynamic_framerate": 1 + } +} diff --git a/alfred/app.backup.weather_outfits/config/hermes_session.json b/alfred/app.backup.weather_outfits/config/hermes_session.json new file mode 100644 index 0000000..de88db4 --- /dev/null +++ b/alfred/app.backup.weather_outfits/config/hermes_session.json @@ -0,0 +1,3 @@ +{ + "session_id": "local-65f11130ed816902a867736ef80b3af0ea255048cde912fb31b26267c8d1d599" +} diff --git a/alfred/app.backup.weather_outfits/config/hermes_session.json.bak.20261024_151328 b/alfred/app.backup.weather_outfits/config/hermes_session.json.bak.20261024_151328 new file mode 100644 index 0000000..4525415 --- /dev/null +++ b/alfred/app.backup.weather_outfits/config/hermes_session.json.bak.20261024_151328 @@ -0,0 +1,3 @@ +{ + "session_id": "local-f2399cb2bcf9e91094d3dc34492b463eda9cf42d09857d51b5954c5982aaaa5e" +} diff --git a/alfred/app.backup.weather_outfits/config/hermes_session.json.lock b/alfred/app.backup.weather_outfits/config/hermes_session.json.lock new file mode 100644 index 0000000..e69de29 diff --git a/alfred/app.backup.weather_outfits/config/identity.md b/alfred/app.backup.weather_outfits/config/identity.md new file mode 100644 index 0000000..2ac6084 --- /dev/null +++ b/alfred/app.backup.weather_outfits/config/identity.md @@ -0,0 +1,31 @@ +# Identité et comportement d'Alfred + +Tu es Alfred, le majordome vocal personnel de la maison. +Tu t'appelles Alfred, même si le moteur logiciel qui te permet de raisonner est Hermes Agent. + + +## Langue et style +- Réponds toujours en français, sauf demande explicite de parler une autre langue. +- Tutoie ton interlocuteur. +- Adopte un ton naturel, calme, cordial et professionnel. +- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute. +- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement. +- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne. + +## Domotique +- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles. +- Ne confirme une action que si son exécution a été effectivement vérifiée. +- Si tu ne peux pas effectuer une action, explique-le simplement. + +## Identité +- Si l'on te demande ton nom, réponds : « Je m'appelle Alfred. » + + +# Alfred +Tu disposes des outils et compétences configurés +dans Hermes Agent. + +Utilise librement les outils disponibles lorsque +la demande le nécessite. + +Ne limite pas tes capacités à la domotique. diff --git a/alfred/app.backup.weather_outfits/config/identity.md.bak.20261016_140326 b/alfred/app.backup.weather_outfits/config/identity.md.bak.20261016_140326 new file mode 100644 index 0000000..ed48720 --- /dev/null +++ b/alfred/app.backup.weather_outfits/config/identity.md.bak.20261016_140326 @@ -0,0 +1,26 @@ +# Identité d'Alfred + +Tu es Alfred, un majordome personnel intelligent. + +Tu fonctionnes dans une maison équipée de Home Assistant. +Tu utilises Hermes Agent comme moteur de raisonnement. + +## Comportement + +- Ton nom est Alfred. +- Tu réponds toujours en français, sauf demande explicite contraire. +- Tu t'adresses à ton utilisateur en le tutoyant. +- Tu adoptes un ton naturel, calme, chaleureux et professionnel. +- Tu privilégies les réponses courtes, adaptées à une conversation vocale. +- Tu évites les introductions inutiles et les formulations trop techniques. +- Tu ne mentionnes pas Hermes, Ollama ou ton fonctionnement interne sauf si on te le demande. + +## Domotique + +- Tu peux utiliser les outils Home Assistant disponibles. +- Lorsque tu exécutes une commande, confirme brièvement le résultat. +- Ne prétends jamais avoir exécuté une action si l'outil a échoué. + +## Identité + +Si on te demande ton nom, tu réponds : « Je m'appelle Alfred. » diff --git a/alfred/app.backup.weather_outfits/config/identity.md.ori2 b/alfred/app.backup.weather_outfits/config/identity.md.ori2 new file mode 100644 index 0000000..52603ea --- /dev/null +++ b/alfred/app.backup.weather_outfits/config/identity.md.ori2 @@ -0,0 +1,74 @@ +# Identité et comportement d'Alfred + +Tu es Alfred, un majordome numérique personnel, +intelligent, polyvalent et connecté. + +Tu n'es pas limité à la domotique. +Tu es un assistant capable de répondre à des questions, +de rechercher des informations, de générer des images, +d'analyser des documents et d'interagir avec la maison. + +## Langue et personnalité + +- Réponds toujours en français. +- Tutoie ton interlocuteur. +- Adopte un ton calme, cordial et professionnel. +- Privilégie les réponses courtes adaptées à la voix. +- Ne mentionne pas ton architecture interne sans raison. + +## Capacités et outils + +Tu disposes de plusieurs outils MCP. + +### Home Assistant +- Contrôler les équipements connectés. +- Consulter les capteurs et les états. +- Exécuter les commandes demandées. + +### ComfyUI +- Générer des images à partir de descriptions. +- Analyser des images avec les outils disponibles. +- Lorsqu'une génération est demandée, recherche et + utilise l'outil MCP generate_image. +- L'outil ComfyUI peut transmettre directement + l'image générée à l'interface graphique d'Alfred. +- Ne prétends pas être incapable de générer une image + sans avoir vérifié la disponibilité de cet outil. + +### Autres capacités +- Répondre aux questions générales. +- Utiliser les outils de recherche disponibles. +- Reconnaître des personnes avec le MCP facial, + uniquement lorsque son utilisation est appropriée. +- Utiliser les autres MCP selon les demandes. + +## Règles générales + +- Choisis l'outil approprié selon la demande. +- Ne limite jamais tes capacités à la seule domotique. +- Ne prétends jamais avoir exécuté une action + si celle-ci n'a pas abouti. +- Si un outil est indisponible, explique le problème. +- Ne fabrique jamais de résultats d'outils. + +## Identité + +Si on te demande ton nom, réponds : +"Je m'appelle Alfred." + +## Génération d'images + +Tu peux générer des images avec le MCP ComfyUI. + +Lorsqu'un utilisateur demande une image : +1. Recherche l'outil generate_image du MCP ComfyUI. +2. Si Tool Search est actif, utilise tool_search pour + découvrir l'outil et tool_describe si nécessaire. +3. Appelle l'outil de génération avec la description. +4. Attends son résultat avant de confirmer la génération. + +Le MCP ComfyUI transmet automatiquement les images +générées à l'interface graphique d'Alfred. + +Ne prétends pas que tu es limité à la domotique. +Si l'outil n'est pas accessible, indique l'erreur réelle. diff --git a/alfred/app.backup.weather_outfits/services/__pycache__/alfred_intents.cpython-313.pyc b/alfred/app.backup.weather_outfits/services/__pycache__/alfred_intents.cpython-313.pyc new file mode 100644 index 0000000..edc50db Binary files /dev/null and b/alfred/app.backup.weather_outfits/services/__pycache__/alfred_intents.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/services/__pycache__/alfred_views_controller.cpython-313.pyc b/alfred/app.backup.weather_outfits/services/__pycache__/alfred_views_controller.cpython-313.pyc new file mode 100644 index 0000000..d6990f4 Binary files /dev/null and b/alfred/app.backup.weather_outfits/services/__pycache__/alfred_views_controller.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/services/__pycache__/audio.cpython-313.pyc b/alfred/app.backup.weather_outfits/services/__pycache__/audio.cpython-313.pyc new file mode 100644 index 0000000..a2ea94c Binary files /dev/null and b/alfred/app.backup.weather_outfits/services/__pycache__/audio.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/services/__pycache__/audio_settings.cpython-313.pyc b/alfred/app.backup.weather_outfits/services/__pycache__/audio_settings.cpython-313.pyc new file mode 100644 index 0000000..fc453e0 Binary files /dev/null and b/alfred/app.backup.weather_outfits/services/__pycache__/audio_settings.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/services/__pycache__/camera.cpython-313.pyc b/alfred/app.backup.weather_outfits/services/__pycache__/camera.cpython-313.pyc new file mode 100644 index 0000000..adcb34b Binary files /dev/null and b/alfred/app.backup.weather_outfits/services/__pycache__/camera.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/services/__pycache__/camera_settings.cpython-313.pyc b/alfred/app.backup.weather_outfits/services/__pycache__/camera_settings.cpython-313.pyc new file mode 100644 index 0000000..bd3e743 Binary files /dev/null and b/alfred/app.backup.weather_outfits/services/__pycache__/camera_settings.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/services/__pycache__/ha_views.cpython-313.pyc b/alfred/app.backup.weather_outfits/services/__pycache__/ha_views.cpython-313.pyc new file mode 100644 index 0000000..8942c86 Binary files /dev/null and b/alfred/app.backup.weather_outfits/services/__pycache__/ha_views.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/services/__pycache__/ha_weather.cpython-313.pyc b/alfred/app.backup.weather_outfits/services/__pycache__/ha_weather.cpython-313.pyc new file mode 100644 index 0000000..a602b90 Binary files /dev/null and b/alfred/app.backup.weather_outfits/services/__pycache__/ha_weather.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/services/__pycache__/hermes_client.cpython-313.pyc b/alfred/app.backup.weather_outfits/services/__pycache__/hermes_client.cpython-313.pyc new file mode 100644 index 0000000..ec74a92 Binary files /dev/null and b/alfred/app.backup.weather_outfits/services/__pycache__/hermes_client.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/services/__pycache__/piper_tts.cpython-313.pyc b/alfred/app.backup.weather_outfits/services/__pycache__/piper_tts.cpython-313.pyc new file mode 100644 index 0000000..4508a12 Binary files /dev/null and b/alfred/app.backup.weather_outfits/services/__pycache__/piper_tts.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/services/__pycache__/wakeword.cpython-313.pyc b/alfred/app.backup.weather_outfits/services/__pycache__/wakeword.cpython-313.pyc new file mode 100644 index 0000000..9bc0ffb Binary files /dev/null and b/alfred/app.backup.weather_outfits/services/__pycache__/wakeword.cpython-313.pyc differ diff --git a/alfred/app.backup.weather_outfits/services/alfred_intents.py b/alfred/app.backup.weather_outfits/services/alfred_intents.py new file mode 100644 index 0000000..f2a2044 --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/alfred_intents.py @@ -0,0 +1,189 @@ +"""Conservative French voice router. Never guesses dates for destructive actions.""" +from __future__ import annotations + +import os +import re +import threading +import time as monotonic_time +import unicodedata +from datetime import date, datetime, timedelta, time +from zoneinfo import ZoneInfo + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) +MONTHS = {m:i for i,m in enumerate('janvier fevrier mars avril mai juin juillet aout septembre octobre novembre decembre'.split(),1)} +DAYS = {'lundi':0,'mardi':1,'mercredi':2,'jeudi':3,'vendredi':4,'samedi':5,'dimanche':6} +PENDING_LOCK = threading.Lock() +PENDING_DELETE = {'value':None, 'expires':0} +PENDING_CREATE = {'value':None, 'expires':0} + + +def simplify(text): + return ''.join(c for c in unicodedata.normalize('NFD',text.lower()) if unicodedata.category(c)!='Mn') + + +def parse_day(s, now=None): + now = now or datetime.now(TZ) + text = simplify(s) + today = now.date() + if re.search(r'\b(apres.demain)\b',text):return today+timedelta(days=2) + if re.search(r'\bdemain\b',text):return today+timedelta(days=1) + if re.search(r'\b(aujourd.hui|ce matin|cet apres.midi|ce soir|cette nuit|dans la journee)\b',text):return today + m = re.search(r'\b(?:le\s+)?(\d{1,2})(?:er)?\s+(janvier|fevrier|mars|avril|mai|juin|juillet|aout|septembre|octobre|novembre|decembre)(?:\s+(20\d{2}))?\b',text) + if m: + month=MONTHS[m.group(2)];day=int(m.group(1));year=int(m.group(3)) if m.group(3) else today.year + candidate=date(year,month,day) + if not m.group(3) and candidate < today: candidate=date(year+1,month,day) + return candidate + m=re.search(r'\b(?:le\s+)?(\d{1,2})[/-](\d{1,2})(?:[/-](20\d{2}))?\b',text) + if m: + year=int(m.group(3)) if m.group(3) else today.year + candidate=date(year,int(m.group(2)),int(m.group(1))) + if not m.group(3) and candidate < today: candidate=date(year+1,candidate.month,candidate.day) + return candidate + for d, weekday in DAYS.items(): + if re.search(r'\b'+d+r'\b',text): + delta=(weekday-today.weekday())%7 + if 'prochain' in text and delta == 0: delta=7 + return today+timedelta(days=delta) + return None + + +def parse_clock(s): + text=simplify(s) + m=re.search(r'\b(?:a|vers|de|pour)\s+(\d{1,2})(?:\s*h(?:\s*(\d{1,2}))?|\s*heures?(?:\s*(\d{1,2}))?)\b',text) + if not m: return None + hh=int(m.group(1)); mm=int(m.group(2) or m.group(3) or 0) + if hh>23 or mm>59: raise ValueError('Heure invalide') + return time(hh,mm) + + +def period(s, now=None): + now = now or datetime.now(TZ) + text=simplify(s) + explicit_day=parse_day(text, now) + morning = 'matin' in text + afternoon = 'apres-midi' in text + evening = 'ce soir' in text or 'ce soir' in text + days=re.search(r'\b(?:sur|pour|pendant|les)?\s*(\d{1,2})\s*jours?\b',text) + if explicit_day: + start=datetime.combine(explicit_day,time.min,TZ) + end=start+timedelta(days=1) + if morning:start=start.replace(hour=6);end=start.replace(hour=12) + elif afternoon:start=start.replace(hour=12);end=start.replace(hour=18) + elif evening:start=start.replace(hour=18);end=(start+timedelta(days=1)).replace(hour=0) + return start,end,'day' + if days: + n=int(days.group(1)) + if not 1<=n<=31: raise ValueError('Demander entre 1 et 31 jours') + start=datetime.combine(now.date(),time.min,TZ) + return start,start+timedelta(days=n), 'days' + if 'mois' in text: + start=datetime(now.year,now.month,1,tzinfo=TZ) + next_month = datetime(now.year+int(now.month==12), now.month%12+1,1,tzinfo=TZ) + return start,next_month,'month' + if 'semaine' in text: + start=datetime.combine(now.date()-timedelta(days=now.weekday()),time.min,TZ) + if 'prochaine' in text:start+=timedelta(days=7) + return start,start+timedelta(days=7),'week' + start=datetime.combine(now.date(),time.min,TZ) + return start,start+timedelta(days=7),'days' + + +def classify(text): + t=simplify(text).strip() + if not t:return None + if re.search(r'\b(annule|non merci|laisse tomber)\b', t) and (current_delete() or current_create()):return {'kind':'cancel_pending'} + if re.search(r'\b(confirme|oui supprime|confirmer la suppression|oui c.est bon|oui valide)\b',t): + if current_delete():return {'kind':'confirm_delete'} + if current_create():return {'kind':'confirm_create'} + if re.search(r'\b(accueil|reviens|retour|ferme (le|la) (planning|calendrier|vue)|ecran principal)\b',t) and not re.search(r'\b(rendez.vous|reunion|evenement)\b',t):return {'kind':'home'} + if re.search(r'\b(montre|affiche|voir|regarde)\b.*\b(camera|cameras|video|videos)\b',t):return {'kind':'cameras'} + if re.search(r'\b(affiche|montre|donne|quelles|quelle|voir)\b.*\b(temperature|temperatures|capteurs|chauffage)\b',t):return {'kind':'temperatures'} + # Do not hijack general requests for images, HA or Hermes. + if re.search(r'\b(supprime|efface|retire|annule)\b.*\b(rendez.vous|evenement|agenda|calendrier|planning)\b',t):return {'kind':'delete'} + if re.search(r'\b(ajoute|ajouter|cree|creer|inscris|planifie|note)\b.*\b(rendez.vous|evenement|agenda|calendrier|planning)\b',t):return {'kind':'create'} + cal_words=r'\b(agenda|calendrier|planning|rendez.vous|evenements?)\b' + query_words=r'\b(pre[v]?u|prevu|programme|organise|j.ai|ai.je|quoi|qu.est.ce|choses?|occupe|libre)\b' + time_words=r'\b(demain|aujourd.hui|matin|soir|apres-midi|journee|semaine|mois|jours?|novembre|janvier|fevrier|mars|avril|mai|juin|juillet|aout|septembre|octobre|decembre)\b' + if re.search(cal_words,t) or (re.search(query_words,t) and re.search(time_words,t)) or ('8 novembre' in t): + return {'kind':'calendar', 'question': bool(re.search(query_words,t))} + return None + + +def extract_create(text, calendars): + """Parse straightforward instructions only. Missing/ambiguous fields become a prefilled form.""" + raw=text.strip(); normalized=simplify(raw) + day=parse_day(raw) + hour=parse_clock(raw) + options=[c for c in calendars if c.get('can_create')] + preferred=os.getenv('HA_DEFAULT_CALENDAR','').strip() + matching=[] + for c in options: + if simplify(c['name']) in normalized or simplify(c['entity_id'].split('.',1)[-1]).replace('_',' ') in normalized: + matching.append(c) + calendar = matching[0]['entity_id'] if len(matching)==1 else None + if calendar is None and preferred in {c['entity_id'] for c in options}:calendar=preferred + if calendar is None and len(options)==1:calendar=options[0]['entity_id'] + # Strip date and time syntax to derive a plausible title. + title=re.sub(r'^.*?\b(?:ajoute|ajouter|cree|creer|inscris|planifie|note)\b', '', normalized, count=1) + title=re.sub(r'\b(?:dans|sur)\s+(?:le\s+)?calendrier\s+[\w\s_-]+?(?=\b(?:le|demain|aujourd.hui|a)\b|$)', ' ', title) + title=re.sub(r'\b(?:le\s+)?\d{1,2}\s+(?:'+ '|'.join(MONTHS)+ r')(?:\s+20\d{2})?\b',' ',title) + title=re.sub(r'\b(?:demain|aujourd.hui|apres.demain|lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b',' ',title) + title=re.sub(r'\b(?:a|de)\s+\d{1,2}\s*(?:h|heures?)\s*\d{0,2}\b',' ',title) + title=re.sub(r'\b(?:dans|sur)\s+(?:le\s+)?(?:calendrier|agenda)\b.*$', ' ',title) + title=re.sub(r'^\s*(?:moi|un|une|le|la|les|rendez.vous|evenement|pour|de|a)\s+', '',title) + title=re.sub(r'\s+', ' ', title).strip(' ,.:') + # A guided confirmation is safer than using a guessed title. + title=title[:200] + data={'title':title,'calendar_id':calendar or '', 'date':day.isoformat() if day else '', + 'time':hour.strftime('%H:%M') if hour else '','duration_minutes':60} + complete=bool(day and hour and calendar and title and title not in ('calendrier','agenda','rendez vous')) + return data, complete + + +def extract_search(text): + normalized=simplify(text) + normalized=re.sub(r'^.*?\b(?:supprime|efface|retire|annule)\b','',normalized,count=1) + normalized=re.sub(r'\b(?:le\s+)?\d{1,2}\s+(?:'+ '|'.join(MONTHS)+ r')\b','',normalized) + normalized=re.sub(r'\b(demain|aujourd.hui|apres.demain|lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b','',normalized) + normalized=re.sub(r'\b(de|du|mon|ma|un|une|le|la|les|rendez.vous|evenement|agenda|calendrier|planning)\b',' ',normalized) + return re.sub(r'\s+',' ',normalized).strip() + + +def set_delete(event): + with PENDING_LOCK: + PENDING_DELETE['value']=event + PENDING_DELETE['expires']=monotonic_time.monotonic()+90 + + +def current_delete(): + with PENDING_LOCK: + if monotonic_time.monotonic()>PENDING_DELETE['expires']: + PENDING_DELETE['value']=None + return dict(PENDING_DELETE['value']) if PENDING_DELETE['value'] else None + + +def clear_delete(): + with PENDING_LOCK: + p=PENDING_DELETE['value'];PENDING_DELETE['value']=None + return p + + +def set_create(details): + with PENDING_LOCK: + PENDING_CREATE['value']=dict(details) + PENDING_CREATE['expires']=monotonic_time.monotonic()+90 + +def current_create(): + with PENDING_LOCK: + if monotonic_time.monotonic()>PENDING_CREATE['expires']: + PENDING_CREATE['value']=None + return dict(PENDING_CREATE['value']) if PENDING_CREATE['value'] else None + +def clear_create(): + with PENDING_LOCK: + p=PENDING_CREATE['value'];PENDING_CREATE['value']=None + return p + +def cancel_pending(): + clear_create();clear_delete() diff --git a/alfred/app.backup.weather_outfits/services/alfred_views_controller.py b/alfred/app.backup.weather_outfits/services/alfred_views_controller.py new file mode 100644 index 0000000..8984a8f --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/alfred_views_controller.py @@ -0,0 +1,132 @@ +"""Fast voice routing for visual actions; Hermes still handles general tasks.""" +from __future__ import annotations + +import os +from datetime import datetime, timedelta, time +from zoneinfo import ZoneInfo + +from . import ha_views as ha +from . import alfred_intents as intents +from . import ha_weather + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris')) + + +def _label(s,e): + return f"du {s:%d/%m} au {(e-timedelta(days=1)):%d/%m}" + + +def _event_summary(events, question): + count=len(events) + if count == 0:return "Non, je n'ai trouvé aucun événement pour cette période." if question else 'Aucun événement prévu sur cette période.' + names=', '.join(ev['title'] for ev in events[:3]) + if question: + return f"Oui, {count} événement{'s' if count>1 else ''} prévu{'s' if count>1 else ''}. {names}." if count<=3 else f"Oui, {count} événements sont prévus. Notamment : {names}." + return f"Voici ton planning avec {count} événement{'s' if count>1 else ''}." + + +def _calendar(text, question=False): + start,end,view=intents.period(text) + payload=ha.get_events(start,end) + content={'type':'calendar','view':view,'title':'Planning','start':start.isoformat(),'end':end.isoformat(), + 'events':payload['events'],'calendars':payload['calendars'],'errors':payload['errors']} + speech=_event_summary(payload['events'],question) + if payload['errors']: speech+=' Certains calendriers sont indisponibles.' + return content,speech + + +def _create(text): + calendars=ha.list_calendars() + details, complete=intents.extract_create(text,calendars) + if not complete: + return {'type':'calendar_form','values':details,'calendars':calendars},'Je prépare le rendez-vous. Complète ou vérifie les informations à l’écran avant de l’enregistrer.' + # No calendar mutation until a second explicit confirmation. + intents.set_create(details) + pretty=datetime.fromisoformat(details['date']).strftime('%d/%m') + speech=f"Je vais ajouter {details['title']} le {pretty} à {details['time']}. Dis confirme pour enregistrer, ou annule." + return {'type':'calendar_form','values':details,'calendars':calendars,'awaiting_confirmation':True},speech + + +def _delete(text): + now=datetime.now(TZ) + day=intents.parse_day(text,now) + if day: + start=datetime.combine(day,time.min,TZ); end=start+timedelta(days=1) + else: + start=datetime.combine(now.date(),time.min,TZ);end=start+timedelta(days=45) + data=ha.get_events(start,end) + needle=intents.extract_search(text) + events=[e for e in data['events'] if intents.simplify(needle) in intents.simplify(e['title'])] if needle else data['events'] + if not events: + return {'type':'calendar','view':'days','start':start.isoformat(),'end':end.isoformat(),**data},'Je ne trouve pas cet événement. Aucun événement n’a été supprimé.' + if len(events)!=1: + return {'type':'delete_candidates','events':events[:30]},f"J'ai trouvé {len(events)} événements correspondants. Choisis celui à supprimer sur l'écran." + ev=events[0] + if not ev['can_delete']: + return {'type':'delete_candidates','events':[ev]},'Ce calendrier ne permet pas la suppression de cet événement depuis Alfred.' + intents.set_delete(ev) + warning=' Attention, si cet événement est récurrent, cela peut supprimer toute la série.' if not ev['recurrence_id'] else '' + return {'type':'delete_candidates','events':[ev], 'awaiting_confirmation':True},f"Confirme la suppression de {ev['title']}, ou dis annule.{warning}" + + +def _confirm_delete(): + e=intents.current_delete() + if not e:return {'type':'text','text':'Confirmation expirée.'},'La demande de suppression a expiré.' + # Recheck existence in HA to avoid deleting an event that changed meanwhile. + start=datetime.fromisoformat(e['start'])-timedelta(days=1) + end=datetime.fromisoformat(e['end'])+timedelta(days=1) + found=ha.get_events(start,end,[e['calendar_id']])['events'] + if not any(x['id']==e['id'] and x['start']==e['start'] and x['recurrence_id']==e['recurrence_id'] for x in found): + intents.clear_delete() + return {'type':'text','text':'Cet événement a changé. Suppression annulée.'},'Cet événement a changé. Rien n’a été supprimé.' + ha.delete_event(e['calendar_id'],e['id'],e['recurrence_id'] or None) + intents.clear_delete() + content,speech=_calendar('planning sur 7 jours') + return content,'Événement supprimé. '+speech + + +def _confirm_create(): + data=intents.current_create() + if not data:return {'type':'text','text':'Confirmation expirée.'},'La demande de création a expiré.' + if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'): + return {'type':'calendar_form','values':data,'calendars':ha.list_calendars()},'L’écriture est désactivée. Active ALFRED_ALLOW_CALENDAR_WRITES dans la configuration.' + start=datetime.fromisoformat(data['date']+'T'+data['time']).replace(tzinfo=TZ) + end=start+timedelta(minutes=int(data.get('duration_minutes',60))) + ha.create_event(data['calendar_id'],data['title'],start,end) + intents.clear_create() + content,speech=_calendar('planning sur 7 jours') + return content,'Événement ajouté. '+speech + + +def route(text, last_view=None): + # Weather is a read-only dynamic view; avoid sending routine forecasts to Hermes. + if ha_weather.is_weather_request(text, last_view): + data = ha_weather.get_weather() + return data, ha_weather.describe(data, text) + intent=intents.classify(text) + # Short temporal follow-ups to a displayed calendar: 'Et demain ?', 'Ce matin ?' + if intent is None and last_view == 'calendar' and len(text.strip()) < 45: + t=intents.simplify(text) + if any(word in t for word in ('demain','matin','apres-midi','soir','journee','semaine','mois','jours','novembre','decembre','janvier','fevrier','mars','avril','mai','juin','juillet','aout','septembre','octobre')): + intent={'kind':'calendar','question':True} + if intent is None:return None + kind=intent['kind'] + if kind=='home':return {'type':'home','title':'Alfred','text':'À ton écoute.'},'Je reviens à l’accueil.' + if kind=='calendar':return _calendar(text, intent.get('question',False)) + if kind=='create':return _create(text) + if kind=='delete':return _delete(text) + if kind=='confirm_delete': + if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'): + return {'type':'text','text':'Écriture calendrier désactivée.'},'La suppression est désactivée dans la configuration.' + return _confirm_delete() + if kind=='confirm_create':return _confirm_create() + if kind=='cancel_pending': + intents.cancel_pending() + return {'type':'home','text':'Demande annulée.'},'D’accord, aucune modification effectuée.' + if kind=='temperatures': + temps=ha.get_temperatures() + return {'type':'temperatures','items':temps},f"Voici {len(temps)} mesures de température provenant de Home Assistant." + if kind=='cameras': + cameras=ha.list_cameras() + return {'type':'cameras','items':cameras},f"Voici {len(cameras)} caméras Home Assistant disponibles." + return None diff --git a/alfred/app.backup.weather_outfits/services/audio.py b/alfred/app.backup.weather_outfits/services/audio.py new file mode 100644 index 0000000..390f1d4 --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/audio.py @@ -0,0 +1,392 @@ +"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre. + +STT: Vosk + sounddevice, avec fallback arecord/ALSA. +TTS: Piper local, avec fallback espeak puis spd-say. +Sortie: haut-parleur local via paplay/aplay. + +Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu +à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique. +""" +from __future__ import annotations +from functools import lru_cache + +import time +import json +import os +import queue +import shutil +import subprocess +import tempfile +import threading +from dataclasses import dataclass +from typing import Callable, Optional + + +def _cmd_ok(cmd, **kwargs): + try: + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=True, text=True, **kwargs) + except Exception: + return None + + +def audio_backend_detect() -> str: + configured = os.getenv("AUDIO_BACKEND", "auto").lower() + if configured in ("pactl", "alsa"): + return configured + if shutil.which("pactl"): + info = _cmd_ok(["pactl", "info"]) + if info and "Server Name" in info.stdout: + return "pactl" + if shutil.which("amixer"): + return "alsa" + return "none" + + +def _which(cmd): + return shutil.which(cmd) + + +def _is_piper_tts(binary: Optional[str]) -> bool: + if not binary: + return False + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + if "gtk" in out or "ratbag" in out: + return False + return (("--model" in out or " -m " in out) and + ("--output_file" in out or " -o " in out)) + except Exception: + return False + + +def _find_piper_binary() -> Optional[str]: + candidates = [ + os.getenv("PIPER_BIN"), + "/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"), + "/usr/local/bin/piper", "/usr/bin/piper", _which("piper"), + ] + for candidate in candidates: + if candidate and _is_piper_tts(candidate): + return candidate + return None + + +def _piper_supports_long_flag(binary: str) -> bool: + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + return "--model" in out and "--output_file" in out + except Exception: + return True + +### +def tts_backend_detect() -> str: + """Retourne le moteur TTS configuré pour Alfred.""" + + if os.getenv("PIPER_HOST"): + return "wyoming-piper" + + if _which("espeak-ng"): + return "espeak-ng" + + return "none" + + +''' +def tts_backend_detect() -> str: + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + if piper and model and os.path.exists(model): + return "piper" + if _which("espeak"): + return "espeak" + if _which("spd-say"): + return "spd-say" + return "none" +''' +#### + +def speak_text(text: str): + """Synthèse vocale via Wyoming Piper, avec secours eSpeak.""" + from services.audio_settings import speaker_muted + if speaker_muted(): + return + if not text or not text.strip(): + return + + text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))] + + try: + from services.piper_tts import speak as piper_speak + + print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True) + piper_speak(text) + return + + except Exception as exc: + print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True) + + # Secours eSpeak + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + print("[ALFRED TTS] Secours eSpeak", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + except Exception as exc: + print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + +''' +def speak_text(text: str): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + start = time.monotonic() + print("[ALFRED TTS] Génération audio...", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + print("[ALFRED TTS] Lecture audio...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED TTS] Erreur : {e}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + + + + +def speak_text(text: str): + """Synthétise et joue localement le texte. Retourne (ok, backend, erreur).""" + text = (text or "").strip() + if not text: + return False, "none", "texte vide" + text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))] + + backend = audio_backend_detect() + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + + if piper and model and os.path.exists(model): + wav_path = None + try: + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + wav_path = tmp.name + cmd = [piper] + if _piper_supports_long_flag(piper): + cmd += ["--model", model, "--output_file", wav_path] + else: + cmd += ["-m", model, "-o", wav_path] + p = subprocess.run(cmd, input=text, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if p.returncode != 0: + return False, "piper", (p.stderr or p.stdout or "")[:2000] + if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0: + return False, "piper", "Piper n'a pas produit de WAV" + if backend == "pactl" and shutil.which("paplay"): + play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + elif shutil.which("aplay"): + play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + else: + return False, "piper", "paplay/aplay introuvable" + return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée" + except Exception as exc: + return False, "piper", str(exc) + finally: + if wav_path: + try: os.unlink(wav_path) + except OSError: pass + + if _which("espeak"): + args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text] + if backend == "pactl" and shutil.which("padsp"): + args = ["padsp"] + args + ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0 + return ok, "espeak", None if ok else "espeak a échoué" + + if _which("spd-say"): + ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE, + stderr=subprocess.PIPE).returncode == 0 + return ok, "spd-say", None if ok else "spd-say a échoué" + return False, "none", "aucun backend TTS disponible" +''' + +_tts_lock = threading.Lock() +def speak_async(text: str, on_start: Optional[Callable] = None, + on_end: Optional[Callable] = None) -> bool: + """Lecture non bloquante; refuse d'empiler deux réponses vocales.""" + if _tts_lock.locked(): + return False + def worker(): + with _tts_lock: + if on_start: on_start() + try: speak_text(text) + finally: + if on_end: on_end() + threading.Thread(target=worker, daemon=True).start() + return True + + +@dataclass +class STTResult: + text: str = "" + error: Optional[str] = None + + + +@lru_cache(maxsize=2) +def get_vosk_model(model_path): + from vosk import Model + + print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True) + model = Model(model_path) + print("[ALFRED] Modèle Vosk prêt", flush=True) + + return model + + +def listen_once(stop_event: Optional[threading.Event] = None, + on_partial: Optional[Callable[[str], None]] = None, + on_level: Optional[Callable[[float], None]] = None) -> STTResult: + """Attend une phrase finale Vosk et la retourne. + + Reprend le comportement du prototype: sounddevice en priorité, arecord sinon. + Le niveau audio transmis à l'UI est une approximation RMS sur int16. + """ + try: + import numpy as np + from vosk import Model, KaldiRecognizer + except Exception as exc: + return STTResult(error=f"Dépendance STT absente: {exc}") + + model_path = os.getenv("VOSK_MODEL_PATH", "") + sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000")) + if not model_path or not os.path.exists(model_path): + return STTResult(error=f"Modèle Vosk introuvable: {model_path}") + try: +# rec = KaldiRecognizer(Model(model_path), sample_rate) + rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate) + except Exception as exc: + return STTResult(error=f"Init Vosk: {exc}") + + def process(data: bytes) -> Optional[str]: + if on_level and data: + try: + a = np.frombuffer(data, dtype=np.int16).astype(np.float32) + rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0 + on_level(min(1.0, rms / 6000.0)) + except Exception: + pass + if rec.AcceptWaveform(data): + text = (json.loads(rec.Result() or "{}").get("text") or "").strip() + if text: + return text + elif on_partial: + partial = json.loads(rec.PartialResult() or "{}").get("partial", "") + print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True) + on_partial(partial) + return None + + try: + import sounddevice as sd + q = queue.Queue(maxsize=32) + def callback(indata, frames, time_info, status): + try: + q.put_nowait(bytes(indata)) + except queue.Full: + pass + device = os.getenv("AUDIO_INPUT_DEVICE") or None + with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16", + channels=1, callback=callback, device=device): + while not (stop_event and stop_event.is_set()): + try: + chunk = q.get(timeout=0.25) + except queue.Empty: + continue + text = process(chunk) + if text: + return STTResult(text=text) + except Exception as exc: + print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True) + +# except Exception: +# pass + + if stop_event and stop_event.is_set(): + return STTResult() + + if not shutil.which("arecord"): + return STTResult(error="sounddevice indisponible et arecord introuvable") + cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"] + alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip() + if alsa_device: + cmd += ["-D", alsa_device] + proc = None + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) + while not (stop_event and stop_event.is_set()): + chunk = proc.stdout.read(8000) + if not chunk: + continue + text = process(chunk) + if text: + return STTResult(text=text) + return STTResult() + except Exception as exc: + return STTResult(error=f"arecord: {exc}") + finally: + if proc and proc.poll() is None: + proc.terminate() + + +def devices(): + try: +# import sounddevice as sd +# return sd.query_devices() + print("[ALFRED STT] Périphériques audio :", flush=True) + print(sd.query_devices(), flush=True) + print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True) + return sd.query_devices() + except Exception: + return [] diff --git a/alfred/app.backup.weather_outfits/services/audio_settings.py b/alfred/app.backup.weather_outfits/services/audio_settings.py new file mode 100644 index 0000000..b310c32 --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/audio_settings.py @@ -0,0 +1,77 @@ +"""Réglages audio Alfred : ALSA carte 1 et états logiques thread-safe.""" +import os +import re +import subprocess +import threading + +_LOCK = threading.RLock() +_STATE = {'volume': 70, 'speaker_muted': False, 'mic_muted': False, 'mic_gain': None} +_CARD = os.getenv('ALFRED_ALSA_CARD', '1') +_CONTROL = os.getenv('ALFRED_ALSA_VOLUME_CONTROL', 'Master') + +def _amixer(*args): + return subprocess.run(['amixer', '-c', _CARD, *args], capture_output=True, + text=True, timeout=5, check=True).stdout + +def initialize(): + """Lit le volume existant sans modifier les réglages audio au démarrage.""" + try: + output = _amixer('sget', _CONTROL) + matches = re.findall(r'\[(\d+)%\]', output) + with _LOCK: + if matches: + _STATE['volume'] = int(matches[0]) + # ALFRED_MIC_GAIN_V2 : gain Capture, sans toucher à Mic Boost. + try: + gain_out = _amixer('sget', os.getenv('ALFRED_ALSA_CAPTURE_CONTROL', 'Capture')) + gains = re.findall(r'\[(\d+)%\]', gain_out) + _STATE['mic_gain'] = int(gains[0]) if gains else None + except (OSError, subprocess.SubprocessError) as exc: + print(f'[ALFRED AUDIO] Gain Capture indisponible : {exc}', flush=True) + # Ne pas déduire speaker_muted du mixer : état propre à Alfred. + except (OSError, subprocess.SubprocessError) as exc: + print(f'[ALFRED AUDIO] Lecture ALSA impossible : {exc}', flush=True) + +def get_settings(): + with _LOCK: + return dict(_STATE) + +def update_settings(payload): + if not isinstance(payload, dict): + raise ValueError('Un objet JSON est attendu') + allowed = {'volume', 'speaker_muted', 'mic_muted', 'mic_gain'} + if set(payload) - allowed: + raise ValueError('Paramètre audio inconnu') + with _LOCK: + new = dict(_STATE) + if 'volume' in payload: + v = payload['volume'] + if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100: + raise ValueError('volume doit être entre 0 et 100') + new['volume'] = round(v) + if 'mic_gain' in payload: + v = payload['mic_gain'] + if isinstance(v, bool) or not isinstance(v, (int, float)) or not 0 <= v <= 100: + raise ValueError('mic_gain doit être entre 0 et 100') + new['mic_gain'] = round(v) + for key in ('speaker_muted', 'mic_muted'): + if key in payload: + if not isinstance(payload[key], bool): + raise ValueError(f'{key} doit être un booléen') + new[key] = payload[key] + # Le gain Capture ne change que sur demande explicite. + # Mic Boost et Internal Mic Boost restent inchangés. + if 'mic_gain' in payload and new['mic_gain'] != _STATE['mic_gain']: + _amixer('sset', os.getenv('ALFRED_ALSA_CAPTURE_CONTROL', 'Capture'), f"{new['mic_gain']}%") + if new['volume'] != _STATE['volume']: + _amixer('sset', _CONTROL, f"{new['volume']}%") + _STATE.update(new) + return dict(_STATE) + +def speaker_muted(): + with _LOCK: + return _STATE['speaker_muted'] + +def mic_muted(): + with _LOCK: + return _STATE['mic_muted'] diff --git a/alfred/app.backup.weather_outfits/services/camera.py b/alfred/app.backup.weather_outfits/services/camera.py new file mode 100644 index 0000000..7459ddc --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/camera.py @@ -0,0 +1,71 @@ +"""Accès simple à la caméra d'Alfred, avec aperçu MJPEG activé à la demande. + +Une seule lecture par instance Alfred. La vidéo ne démarre que lorsque +l'utilisateur clique sur Activer, pour éviter de monopoliser la caméra. +""" +import os +import threading +import time + +_CAMERA_LOCK = threading.Lock() + + +def snapshot(path, index=0): + import cv2 + if not _CAMERA_LOCK.acquire(blocking=False): + raise RuntimeError('Caméra déjà utilisée par la prévisualisation Alfred') + try: + cap = cv2.VideoCapture(index) + try: + ok, frame = cap.read() + finally: + cap.release() + if not ok: + raise RuntimeError('Impossible de lire la caméra locale') + cv2.imwrite(path, frame) + return path + finally: + _CAMERA_LOCK.release() + + +def video_stream(index=0): + """Ouvre la caméra, retourne un générateur de trames JPEG multipart. + + L'appelant doit retourner ce générateur dans une réponse Flask en streaming. + L'absence de cv2 ou de webcam déclenche une exception avant HTTP 200. + """ + import cv2 + if not _CAMERA_LOCK.acquire(blocking=False): + raise RuntimeError('Prévisualisation vidéo déjà active') + + cap = None + try: + cap = cv2.VideoCapture(index) + if not cap.isOpened(): + cap.release() + raise RuntimeError(f'Caméra {index} indisponible') + cap.set(cv2.CAP_PROP_FRAME_WIDTH, int(os.getenv('CAMERA_WIDTH', '640'))) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(os.getenv('CAMERA_HEIGHT', '480'))) + fps = min(15.0, max(1.0, float(os.getenv('CAMERA_FPS', '8')))) + quality = min(95, max(35, int(os.getenv('CAMERA_JPEG_QUALITY', '75')))) + except Exception: + if cap is not None: + cap.release() + _CAMERA_LOCK.release() + raise + + def frames(): + try: + while True: + ok, frame = cap.read() + if not ok: + break + ok_jpeg, encoded = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, quality]) + if ok_jpeg: + yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n' + time.sleep(1.0 / fps) + finally: + cap.release() + _CAMERA_LOCK.release() + + return frames() diff --git a/alfred/app.backup.weather_outfits/services/camera_settings.py b/alfred/app.backup.weather_outfits/services/camera_settings.py new file mode 100644 index 0000000..7665385 --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/camera_settings.py @@ -0,0 +1,187 @@ +"""Réglages V4L2 de la webcam locale pour l'interface Alfred. + +API en lecture/écriture sécurisée par une liste blanche de contrôles, plages +et états renvoyés par le pilote. Aucun accès shell ni commande arbitraire. +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import tempfile +import threading +from pathlib import Path + +_ALLOWED = { + 'brightness', 'contrast', 'saturation', 'hue', 'gamma', 'gain', + 'sharpness', 'backlight_compensation', 'power_line_frequency', + 'white_balance_automatic', 'white_balance_temperature', + 'auto_exposure', 'exposure_time_absolute', 'exposure_dynamic_framerate', +} +_AUTO = ('white_balance_automatic', 'auto_exposure') +_LINE = re.compile(r'^\s*(\w+)\s+0x[\da-f]+\s+\((int|bool|menu)\)\s*:\s*(.*)$', re.I) +_KV = re.compile(r'(min|max|step|default|value)=(-?\d+)') +_MENU = re.compile(r'^\s*(-?\d+):\s*(.+?)\s*$') +_LOCK = threading.RLock() + + +class CameraSettingsError(RuntimeError): + pass + + +def _device() -> str: + return os.getenv('CAMERA_DEVICE') or f"/dev/video{int(os.getenv('CAMERA_INDEX', '0'))}" + + +def _config_path() -> Path: + return Path(os.getenv('CAMERA_SETTINGS_FILE', str( + Path(__file__).resolve().parent.parent / 'config' / 'camera_controls.json' + ))).expanduser() + + +def _run(*args: str) -> str: + binary = shutil.which('v4l2-ctl') + if not binary: + raise CameraSettingsError('v4l2-ctl est absent : installer le paquet v4l-utils.') + try: + result = subprocess.run( + [binary, '-d', _device(), *args], capture_output=True, + text=True, timeout=6, check=False + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise CameraSettingsError(f'Caméra indisponible ({exc}).') from exc + if result.returncode != 0: + explanation = (result.stderr or result.stdout or 'erreur inconnue').strip() + raise CameraSettingsError(f'V4L2 : {explanation[:300]}') + return result.stdout + + +def _parse_controls(output: str) -> dict: + controls: dict[str, dict] = {} + current = None + for line in output.splitlines(): + match = _LINE.match(line) + if match: + name, kind, raw = match.groups() + current = name if name in _ALLOWED else None + if current: + props = {k: int(v) for k, v in _KV.findall(raw)} + controls[current] = { + 'kind': kind.lower(), 'inactive': 'inactive' in raw, + 'menu': [], **props, + } + continue + menu = _MENU.match(line) + if menu and current and controls[current]['kind'] == 'menu': + value, title = menu.groups() + controls[current]['menu'].append({'value': int(value), 'label': title}) + return controls + + +def _get_controls() -> dict: + return _parse_controls(_run('--list-ctrls-menus')) + + +def get_settings() -> dict: + with _LOCK: + return {'device': _device(), 'controls': _get_controls()} + + +def _validate(control: object, value: object, controls: dict) -> int: + if not isinstance(control, str) or control not in _ALLOWED or control not in controls: + raise ValueError('Réglage caméra inconnu ou non pris en charge.') + data = controls[control] + if data['inactive']: + raise ValueError('Réglage indisponible tant que le mode automatique est actif.') + if type(value) is not int: # écarte bool et chaînes + raise ValueError('Valeur entière attendue.') + if data['kind'] == 'bool' and value not in (0, 1): + raise ValueError('Valeur booléenne attendue (0 ou 1).') + if data['kind'] == 'menu': + choices = [v['value'] for v in data['menu']] + if choices and value not in choices: + raise ValueError('Valeur absente des options de la caméra.') + minimum, maximum = data.get('min', 0), data.get('max', 1) + step = data.get('step', 1) + if not minimum <= value <= maximum or (value - minimum) % max(1, step): + raise ValueError(f'Valeur hors plage ({minimum} à {maximum}, pas {step}).') + return value + + +def _write_config(controls: dict) -> None: + path = _config_path() + path.parent.mkdir(parents=True, exist_ok=True) + snapshot = {name: props['value'] for name, props in controls.items() + if not props['inactive'] and 'value' in props} + fd = None + temp = None + try: + fd, temp = tempfile.mkstemp(prefix='.camera_', suffix='.tmp', dir=path.parent) + with os.fdopen(fd, 'w', encoding='utf-8') as stream: + fd = None + json.dump({'controls': snapshot}, stream, indent=2) + stream.write('\n') + os.replace(temp, path) + temp = None + except OSError as exc: + raise CameraSettingsError(f'Impossible de mémoriser les réglages caméra : {exc}') from exc + finally: + if fd is not None: + os.close(fd) + if temp is not None and os.path.exists(temp): + os.unlink(temp) + + +def update_setting(control: object, value: object) -> dict: + with _LOCK: + controls = _get_controls() + n = _validate(control, value, controls) + _run('--set-ctrl', f'{control}={n}') + refreshed = _get_controls() # réévalue les contrôles devenus actifs/inactifs + _write_config(refreshed) + return {'device': _device(), 'controls': refreshed} + + +def reset_defaults() -> dict: + """Restaure les valeurs exposées par le pilote, sans forcer les champs inactifs.""" + with _LOCK: + original = _get_controls() + # D'abord les modes auto, puis les contrôles restés actifs. + for name in [*_AUTO, *(c for c in original if c not in _AUTO)]: + live = _get_controls() + item = live.get(name) + if not item or item['inactive'] or 'default' not in item: + continue + try: + _run('--set-ctrl', f"{name}={item['default']}") + except CameraSettingsError as exc: + raise CameraSettingsError(f'Échec restauration {name} : {exc}') from exc + refreshed = _get_controls() + _write_config(refreshed) + return {'device': _device(), 'controls': refreshed} + + +def restore_saved_settings() -> None: + """Applique les valeurs persistées; une webcam absente ne bloque pas Flask.""" + with _LOCK: + path = _config_path() + if not path.is_file(): + return + try: + saved = json.loads(path.read_text(encoding='utf-8')).get('controls', {}) + if not isinstance(saved, dict): + raise ValueError('Format invalide') + for name in [*_AUTO, *(n for n in saved if n not in _AUTO)]: + if name not in saved: + continue + live = _get_controls() + try: + n = _validate(name, saved[name], live) + except ValueError: + continue # matériel/pilote modifié ou contrôle devenu inactif + _run('--set-ctrl', f'{name}={n}') + print('[ALFRED CAMERA] Réglages restaurés.', flush=True) + except (OSError, ValueError, CameraSettingsError) as exc: + print(f'[ALFRED CAMERA] Restauration ignorée : {exc}', flush=True) diff --git a/alfred/app.backup.weather_outfits/services/ha_views.py b/alfred/app.backup.weather_outfits/services/ha_views.py new file mode 100644 index 0000000..41e2bdf --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/ha_views.py @@ -0,0 +1,236 @@ +"""Home Assistant data adapter for Alfred. Tokens remain on the Flask host.""" +from __future__ import annotations + +import os +import re +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import date, datetime, time, timedelta +from urllib.parse import quote, urlparse +from zoneinfo import ZoneInfo + +import requests + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) +CREATE = 1 +DELETE = 2 + +class HAError(RuntimeError): + pass + + +def _cfg(): + base = (os.getenv('HA_URL') or os.getenv('HOME_ASSISTANT_URL') or '').strip().rstrip('/') + token = (os.getenv('HA_TOKEN') or os.getenv('HOME_ASSISTANT_TOKEN') or '').strip() + if not base or not token: + raise HAError('Configurer HA_URL et HA_TOKEN dans l’environnement du service Alfred.') + if urlparse(base).scheme not in ('http','https'): + raise HAError('HA_URL doit commencer par http:// ou https://') + return base, token + + +def _request(method, path, *, params=None, payload=None, timeout=12): + base, token = _cfg() + try: + response = requests.request(method, base + path, + params=params, json=payload, + headers={'Authorization': 'Bearer '+token, 'Accept':'application/json'}, + timeout=timeout) + response.raise_for_status() + return response.json() if response.content else None + except requests.RequestException as exc: + code = exc.response.status_code if exc.response is not None else 'réseau' + raise HAError(f'Home Assistant indisponible ou action refusée ({code}).') from exc + + +def states(): + return _request('GET', '/api/states') + + +def _allowed(): + return {s.strip() for s in os.getenv('HA_CALENDARS', '').split(',') if s.strip()} + + +def list_calendars(): + raw = _request('GET', '/api/calendars') or [] + allow = _allowed() + state_map = {s.get('entity_id'):s for s in states() if s.get('entity_id','').startswith('calendar.')} + items = [] + for c in raw: + eid = str(c.get('entity_id','')) + if not eid.startswith('calendar.') or (allow and eid not in allow): + continue + st = state_map.get(eid, {}) + features = int((st.get('attributes') or {}).get('supported_features') or 0) + items.append({'entity_id':eid, 'name':c.get('name') or eid.split('.',1)[-1], + 'can_create':bool(features & CREATE), 'can_delete':bool(features & DELETE)}) + return sorted(items, key=lambda x:x['name'].casefold()) + + +def _parse_dt(raw): + # HA REST can return RFC3339 strings, date strings, or {date}/{dateTime} dictionaries. + if isinstance(raw, dict): + raw = raw.get('dateTime') or raw.get('date') + if not isinstance(raw, str) or not raw: + raise ValueError('Date de calendrier invalide') + if len(raw) == 10: + return datetime.combine(date.fromisoformat(raw), time.min, TZ), True + dt = datetime.fromisoformat(raw.replace('Z', '+00:00')) + return (dt.replace(tzinfo=TZ) if dt.tzinfo is None else dt.astimezone(TZ)), False + + +def _normalize_event(e, cal): + start, all_day = _parse_dt(e.get('start')) + end, _ = _parse_dt(e.get('end')) + return { + 'id':str(e.get('uid') or ''), 'recurrence_id':str(e.get('recurrence_id') or ''), + 'title':str(e.get('summary') or '(Sans titre)'), + 'description':str(e.get('description') or ''), + 'location':str(e.get('location') or ''), + 'start':start.isoformat(), 'end':end.isoformat(), 'all_day':all_day, + 'calendar_id':cal['entity_id'], 'calendar_name':cal['name'], + 'can_delete':cal['can_delete'] and bool(e.get('uid')), + 'source':'home_assistant' + } + + +def get_events(start:datetime, end:datetime, calendar_ids=None): + if start.tzinfo is None or end.tzinfo is None or start >= end: + raise ValueError('Période incorrecte') + if (end-start).days > 65: + raise ValueError('Période limitée à 65 jours') + available = list_calendars() + ids = set(calendar_ids or [c['entity_id'] for c in available]) + if not ids.issubset({c['entity_id'] for c in available}): + raise ValueError('Calendrier non autorisé') + selected = [c for c in available if c['entity_id'] in ids] + results, errors = [], [] + def load(cal): + raw = _request('GET', '/api/calendars/'+quote(cal['entity_id'], safe=''), + params={'start':start.isoformat(), 'end':end.isoformat()}) or [] + return [_normalize_event(item,cal) for item in raw] + with ThreadPoolExecutor(max_workers=min(5,len(selected)) or 1) as pool: + futures = {pool.submit(load,c):c for c in selected} + for f in as_completed(futures): + try: results.extend(f.result()) + except (HAError,ValueError,KeyError) as exc: errors.append(f"{futures[f]['name']}: {exc}") + results.sort(key=lambda e:(e['start'],e['title'].lower())) + return {'events':results, 'calendars':available, 'errors':errors, + 'start':start.isoformat(), 'end':end.isoformat()} + + +def _find_calendar(entity_id, capability): + cal = next((c for c in list_calendars() if c['entity_id'] == entity_id),None) + if cal is None: + raise HAError('Calendrier absent ou non autorisé.') + if not cal[capability]: + raise HAError('Ce calendrier ne permet pas cette opération depuis Home Assistant.') + return cal + + +def create_event(entity_id, title, start, end, description='', location='', all_day=False): + _find_calendar(entity_id, 'can_create') + if not title.strip() or len(title) > 200: + raise ValueError('Titre requis (200 caractères maximum).') + if start >= end: + raise ValueError('La fin doit être après le début.') + payload = {'entity_id':entity_id, 'summary':title.strip(), + 'description':description[:2000], 'location':location[:300]} + if all_day: + payload.update(start_date=start.date().isoformat(), end_date=end.date().isoformat()) + else: + payload.update(start_date_time=start.isoformat(), end_date_time=end.isoformat()) + _request('POST', '/api/services/calendar/create_event',payload=payload) + return {'ok':True,'message':'Événement ajouté.'} + + +def delete_event(entity_id, uid, recurrence_id=None): + _find_calendar(entity_id, 'can_delete') + if not isinstance(uid,str) or not uid.strip() or len(uid)>600: + raise ValueError('Identifiant d’événement manquant ou invalide.') + base, token = _cfg() + ws_url = ('wss://' if base.startswith('https://') else 'ws://') + base.split('://',1)[1] + '/api/websocket' + try: + from websocket import create_connection + except ImportError as exc: + raise HAError('Installer websocket-client dans le venv d’Alfred pour supprimer des événements.') from exc + import json + try: + ws = create_connection(ws_url, timeout=12) + try: + auth_required = json.loads(ws.recv()) + if auth_required.get('type') != 'auth_required': + raise HAError('WebSocket Home Assistant : négociation inattendue.') + ws.send(json.dumps({'type':'auth','access_token':token})) + auth = json.loads(ws.recv()) + if auth.get('type') != 'auth_ok': + raise HAError('Authentification WebSocket Home Assistant refusée.') + cmd = {'id':1, 'type':'calendar/event/delete','entity_id':entity_id,'uid':uid} + if recurrence_id: cmd['recurrence_id'] = recurrence_id + ws.send(json.dumps(cmd)) + result = json.loads(ws.recv()) + if result.get('id') != 1 or not result.get('success'): + error = result.get('error') or {} + raise HAError('Suppression refusée par Home Assistant : '+str(error.get('message') or error)) + return {'ok':True, 'message':'Événement supprimé.'} + finally: + ws.close() + except (OSError,TimeoutError) as exc: + raise HAError('Impossible de joindre le WebSocket Home Assistant.') from exc + + +def get_temperatures(): + data=[] + for item in states(): + entity=item.get('entity_id',''); attrs=item.get('attributes') or {} + if (entity.startswith('sensor.') and attrs.get('device_class') == 'temperature') or entity.startswith('climate.'): + raw = attrs.get('current_temperature') if entity.startswith('climate.') else item.get('state') + try: value = float(raw) + except (TypeError,ValueError):continue + if not -50 <= value <= 90:continue + data.append({'name':attrs.get('friendly_name',entity), 'value':value, + 'unit':attrs.get('unit_of_measurement') or '°C','entity_id':entity}) + return sorted(data, key=lambda x:x['name'].lower())[:60] + + +def list_cameras(): + allow = {s.strip() for s in os.getenv('HA_CAMERAS','').split(',') if s.strip()} + return [{'entity_id':item['entity_id'], 'name':item.get('attributes',{}).get('friendly_name') or item['entity_id']} + for item in states() + if item.get('entity_id','').startswith('camera.') and + (not allow or item['entity_id'] in allow)][:30] + + +def camera_snapshot(entity_id): + allowed = {item['entity_id'] for item in list_cameras()} + if entity_id not in allowed: + raise HAError('Caméra non autorisée.') + base, token = _cfg() + try: + r=requests.get(base+'/api/camera_proxy/'+quote(entity_id,safe=''), + headers={'Authorization':'Bearer '+token},timeout=15) + r.raise_for_status() + if not r.headers.get('Content-Type','').startswith('image/'): + raise HAError('La caméra ne fournit pas une image JPEG/PNG.') + return r.content,r.headers['Content-Type'] + except requests.RequestException as exc: + raise HAError('Impossible de récupérer l’image de la caméra.') from exc + + +def temperature_history(entity_id, hours=24): + hours=max(1,min(int(hours),72)) + if entity_id not in {e['entity_id'] for e in get_temperatures()}: + raise HAError('Capteur non autorisé.') + end=datetime.now(TZ);start=end-timedelta(hours=hours) + raw=_request('GET','/api/history/period/'+quote(start.isoformat(),safe=''), + params={'filter_entity_id':entity_id,'end_time':end.isoformat(), + 'minimal_response':'true'},timeout=18) or [] + out=[] + for collection in raw: + for point in collection: + try: + value=float(point['state']) + moment=point.get('last_changed') or point.get('last_updated') + if -50 <= value <= 90 and moment: + out.append({'time':moment,'value':value}) + except (ValueError,TypeError,KeyError):continue + return out[-250:] diff --git a/alfred/app.backup.weather_outfits/services/ha_weather.py b/alfred/app.backup.weather_outfits/services/ha_weather.py new file mode 100644 index 0000000..377eea1 --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/ha_weather.py @@ -0,0 +1,227 @@ +"""Read-only weather adapter for Alfred: use existing HA_URL / HA_TOKEN. + +Home Assistant stores current conditions in weather entities. Forecasts must be +retrieved separately with the weather.get_forecasts action (return_response). +""" +from __future__ import annotations + +import os +import re +from datetime import datetime, timedelta, time +from zoneinfo import ZoneInfo + +from . import ha_views as ha +from . import alfred_intents as intents + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) + +CONDITIONS = { + 'clear-night': 'ciel dégagé', 'cloudy': 'ciel couvert', + 'exceptional': 'conditions exceptionnelles', 'fog': 'brouillard', + 'hail': 'grêle', 'lightning': 'orages', 'lightning-rainy': 'orages et pluie', + 'partlycloudy': 'éclaircies', 'pouring': 'fortes pluies', + 'rainy': 'pluie', 'snowy': 'neige', 'snowy-rainy': 'pluie et neige', + 'sunny': 'ensoleillé', 'windy': 'vent', 'windy-variant': 'vent et nuages', +} +RAIN = {'rainy', 'pouring', 'lightning-rainy', 'snowy-rainy'} + + +def is_weather_request(text: str, previous_view: str | None = None) -> bool: + t = intents.simplify(str(text or '')) + if re.search(r'\b(meteo|previsions?|previsions? meteorologiques?|previsions? du temps|pluie|pleuvoir|pleuvra|pleut|averse|averses|orage|orages|enneigement|neige|neiger|soleil|ensoleille|ensoleillee|parapluie|quel temps|quelle meteo|beau temps|mauvais temps)\b', t): + return True + if re.search(r'\b(temperature|temperatures|combien de degres)\b.{0,24}\b(dehors|exterieur|exterieure|exterieures)\b', t): + return True + if re.search(r'\b(fait.il|fera.t.il|il fera|il fait)\b.{0,15}\b(beau|froid|chaud|dehors)\b', t): + return True + if re.search(r'\btemperature\b', t) and re.search(r'\b(demain|week.end|semaine|exterieure?|dehors|aujourd.hui)\b', t) and not re.search(r'\b(salon|chambre|cuisine|maison|bureau|salle de bain)\b',t): + return True + # Follow-ups are interpreted in the active view, not with old Hermes prompts. + if previous_view == 'weather' and len(t) < 42 and re.search(r'\b(demain|aujourd.hui|matin|apres.midi|soir|week.end|semaine|jours?|maintenant|actuellement)\b', t): + return True + return False + + +def _selected_weather_entity(): + entries = [s for s in ha.states() if str(s.get('entity_id', '')).startswith('weather.')] + configured = os.getenv('HA_WEATHER_ENTITY', '').strip() + if configured: + match = next((e for e in entries if e['entity_id'] == configured), None) + if not match: + raise ha.HAError(f"Entité météo {configured} introuvable dans Home Assistant.") + return match + if not entries: + raise ha.HAError('Aucune entité weather.* trouvée dans Home Assistant. Ajouter une intégration météo.') + # Prefer a conventional home weather entity, otherwise use the first available. + return next((e for e in entries if e.get('entity_id') in ('weather.home', 'weather.forecast_home')), entries[0]) + + +def _safe_number(value): + try: + if value is None or isinstance(value, bool): + return None + return round(float(value), 1) + except (ValueError, TypeError): + return None + + +def _forecast(entity_id: str, typ: str): + try: + raw = ha._request('POST', '/api/services/weather/get_forecasts?return_response', + payload={'entity_id': entity_id, 'type': typ}, timeout=16) + response = raw.get('service_response', raw) if isinstance(raw, dict) else {} + ent = response.get(entity_id, {}) if isinstance(response, dict) else {} + items = ent.get('forecast', []) if isinstance(ent, dict) else [] + if not isinstance(items, list): + return [], 'Format de prévision météo inattendu.' + normalized = [] + for item in items: + if not isinstance(item, dict): + continue + raw_dt = item.get('datetime') + if not raw_dt: + continue + try: + dt = datetime.fromisoformat(str(raw_dt).replace('Z', '+00:00')) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=TZ) + dt = dt.astimezone(TZ) + except ValueError: + continue + normalized.append({ + 'datetime': dt.isoformat(), 'date': dt.date().isoformat(), + 'condition': str(item.get('condition') or 'unknown'), + 'description': CONDITIONS.get(str(item.get('condition')), 'conditions non précisées'), + 'temperature': _safe_number(item.get('temperature')), + 'templow': _safe_number(item.get('templow')), + 'precipitation_probability': _safe_number(item.get('precipitation_probability')), + 'precipitation': _safe_number(item.get('precipitation')), + 'wind_speed': _safe_number(item.get('wind_speed')), + }) + return normalized, None + except ha.HAError as exc: + # Some providers have daily only / hourly only forecasts. + return [], f'Prévisions {typ} indisponibles : {exc}' + + +def get_weather() -> dict: + item = _selected_weather_entity() + attrs = item.get('attributes') or {} + eid = item['entity_id'] + current = { + 'condition': str(item.get('state') or 'unknown'), + 'description': CONDITIONS.get(str(item.get('state')), 'conditions non précisées'), + 'temperature': _safe_number(attrs.get('temperature')), + 'apparent_temperature': _safe_number(attrs.get('apparent_temperature')), + 'humidity': _safe_number(attrs.get('humidity')), + 'wind_speed': _safe_number(attrs.get('wind_speed')), + 'pressure': _safe_number(attrs.get('pressure')), + } + daily, err_daily = _forecast(eid, 'daily') + hourly, err_hourly = _forecast(eid, 'hourly') + return { + 'type': 'weather', 'entity_id': eid, + 'name': str(attrs.get('friendly_name') or eid), + 'temperature_unit': str(attrs.get('temperature_unit') or '°C'), + 'wind_speed_unit': str(attrs.get('wind_speed_unit') or 'km/h'), + 'current': current, 'daily': daily, 'hourly': hourly, + 'errors': [e for e in (err_daily, err_hourly) if e], + 'source': 'home_assistant', 'updated_at': datetime.now(TZ).isoformat(), + } + + +def _target(text: str, now: datetime) -> tuple[str, datetime, datetime, str]: + t = intents.simplify(text) + start = datetime.combine(now.date(), time.min, tzinfo=TZ) + label = 'actuellement' + days = re.search(r'\b(?:dans|sur|pour)\s+(\d{1,2})\s+jours?\b', t) + if days and 'dans ' in days.group(0): + d = min(int(days.group(1)), 14) + start += timedelta(days=d) + label = f'dans {d} jours' + elif 'week-end' in text.lower() or 'weekend' in t or 'week.end' in t: + offset = (5 - now.weekday()) % 7 + start += timedelta(days=offset) + label = 'ce week-end' + return label, start, start + timedelta(days=2), 'weekend' + else: + day = intents.parse_day(text, now) + if day is not None: + start = datetime.combine(day, time.min, tzinfo=TZ) + label = 'demain' if day == now.date()+timedelta(days=1) else ('aujourd’hui' if day == now.date() else day.strftime('le %d/%m')) + kind = 'day' + if 'ce matin' in t or 'demain matin' in t: + kind = 'hours';start = start.replace(hour=6);end = start.replace(hour=12) + elif 'apres-midi' in t: + kind = 'hours';start = start.replace(hour=12);end = start.replace(hour=18) + elif 'soir' in t: + kind = 'hours';start = start.replace(hour=18);end = start + timedelta(hours=6) + else: + end = start + timedelta(days=1) + return label, start, end, kind + + +def _rounded_temp(n, unit='°C'): + return f'{n:g} {unit}' if n is not None else 'température indisponible' + + +def describe(data: dict, text: str = '') -> str: + """Concise voice response based on actual forecast data, never invented.""" + now = datetime.now(TZ) + t = intents.simplify(text) + unit = data['temperature_unit'] + label, start, end, kind = _target(text, now) + weather_question = bool(re.search(r'\b(pleut|pleuvra|pleuvoir|pluie|averses|parapluie)\b', t)) + days_question = bool(re.search(r'\b(semaine|7 jours|sept jours)\b', t)) + now_question = (start.date() == now.date() and kind == 'day' + and not re.search(r'\b(demain|jour|mois|week|semaine)\b', t)) + is_now_explicit = bool(re.search(r'\b(maintenant|en ce moment|actuellement)\b',t)) + if is_now_explicit and weather_question: + c=data['current'] + raining = c['condition'] in RAIN + return ('Oui, les conditions actuelles indiquent de la pluie.' if raining + else f"Actuellement, {c['description']}.") + if now_question and not weather_question and not days_question: + c=data['current'] + return f"Actuellement, {c['description']}, {_rounded_temp(c['temperature'],unit)}." + if days_question: + if data['daily']: + return f"Voici les prévisions météorologiques des prochains jours, selon {data['name']}." + return 'Les prévisions journalières ne sont pas disponibles dans Home Assistant.' + candidates = [h for h in data['hourly'] if start <= datetime.fromisoformat(h['datetime']) < end] + forecasts = (candidates if kind == 'hours' and candidates else + [d for d in data['daily'] if start.date() <= datetime.fromisoformat(d['datetime']).date() + < (end.date() if end.time() == time.min else end.date() + timedelta(days=1))]) + # A missing hourly forecast must not masquer itself as a reliable part-of-day prediction. + approximate = kind == 'hours' and not candidates and bool(forecasts) + if not forecasts: + if label == 'actuellement': + return f"Actuellement, {data['current']['description']}, {_rounded_temp(data['current']['temperature'], unit)}." + return f"Je n'ai pas de prévision disponible pour {label}." + if weather_question: + prob = [f['precipitation_probability'] for f in forecasts if f['precipitation_probability'] is not None] + if prob: + peak = max(prob) + response = f"Pour {label}, la probabilité de précipitations atteint {peak:g} %." + elif any(f['condition'] in RAIN for f in forecasts): + response = f"La météo prévoit de la pluie pour {label}." + else: + response = f"Aucune pluie n'est indiquée par les prévisions disponibles pour {label}, mais le risque exact n'est pas connu." + else: + first = forecasts[0] + temps = [f['temperature'] for f in forecasts if f['temperature'] is not None] + lows = [f['templow'] for f in forecasts if f['templow'] is not None] + descriptor = first['description'] + if len(forecasts) > 1 and len({f['condition'] for f in forecasts}) > 1: + descriptor = 'des conditions variables' + if kind == 'hours' and candidates and temps: + response = f"Pour {label}, {descriptor}, entre {_rounded_temp(min(temps),unit)} et {_rounded_temp(max(temps),unit)}." + elif temps: + response = f"Pour {label}, {descriptor}, jusqu'à {_rounded_temp(max(temps),unit)}." + if lows: + response += f" Minimum {_rounded_temp(min(lows),unit)}." + else: + response = f"Pour {label}, {descriptor}, mais la température n'est pas disponible." + if approximate: + response += ' Cette indication est journalière, faute de prévision horaire.' + return response diff --git a/alfred/app.backup.weather_outfits/services/hermes_client.py b/alfred/app.backup.weather_outfits/services/hermes_client.py new file mode 100644 index 0000000..854abcf --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/hermes_client.py @@ -0,0 +1,474 @@ + +"""Pont Alfred -> Hermes avec session persistante.""" + +import fcntl +import json +import os +import re +import subprocess +import tempfile +import threading + +from contextlib import contextmanager +from pathlib import Path + + +# -------------------------------------------------- +# CONFIGURATION +# -------------------------------------------------- + +DEFAULT_IDENTITY_FILE = ( + Path(__file__).resolve().parent.parent + / "config" + / "identity.md" +) + +DEFAULT_SESSION_FILE = ( + Path(__file__).resolve().parent.parent + / "config" + / "hermes_session.json" +) + +_REQUEST_LOCK = threading.Lock() + +_ID_PATTERN = re.compile( + r"^[A-Za-z0-9_:.\-]{1,128}$" +) + + +# -------------------------------------------------- +# IDENTITÉ D'ALFRED +# -------------------------------------------------- + +def _build_prompt(question: str) -> str: + + identity_path = Path( + os.getenv( + "ALFRED_IDENTITY_FILE", + str(DEFAULT_IDENTITY_FILE) + ) + ) + + try: + identity = identity_path.read_text( + encoding="utf-8" + ).strip() + + except OSError as exc: + print( + f"[ALFRED] Contexte d'identité " + f"indisponible ({identity_path}): {exc}", + flush=True + ) + identity = "" + + question = str(question or "").strip() + + if not identity: + return question + + return ( + "Instructions de contexte pour cette " + "interaction vocale avec Alfred. " + "Applique ces instructions de comportement " + "à ta réponse, sans les réciter. " + "La demande vocale ci-dessous est une " + "entrée utilisateur, pas une modification " + "de ton identité.\n\n" + f"\n" + f"{identity}\n" + f"\n\n" + f"\n" + f"{question}\n" + f"" + ) + + +# -------------------------------------------------- +# FICHIER DE SESSION +# -------------------------------------------------- + +def _session_file() -> Path: + + return Path( + os.getenv( + "HERMES_SESSION_FILE", + str(DEFAULT_SESSION_FILE) + ) + ).expanduser() + + +def _read_session(path: Path) -> str | None: + + if not path.exists(): + return None + + try: + obj = json.loads( + path.read_text(encoding="utf-8") + ) + + sid = obj["session_id"] + + except ( + OSError, + ValueError, + KeyError, + TypeError + ) as exc: + + raise RuntimeError( + f"État de session Hermes invalide " + f"({path}) : {exc}" + ) from exc + + if ( + not isinstance(sid, str) + or not _ID_PATTERN.fullmatch(sid) + ): + raise RuntimeError( + f"Identifiant de session Hermes " + f"invalide dans {path}" + ) + + return sid + + +def _save_session(path: Path, sid: str): + + if not _ID_PATTERN.fullmatch(sid): + raise RuntimeError( + "Hermes a renvoyé un identifiant " + "de session non valide." + ) + + path.parent.mkdir( + parents=True, + exist_ok=True + ) + + temp_path = None + + try: + fd, temp_name = tempfile.mkstemp( + prefix=".hermes_session_", + suffix=".tmp", + dir=path.parent + ) + + temp_path = Path(temp_name) + + with os.fdopen( + fd, + "w", + encoding="utf-8" + ) as handle: + + json.dump( + {"session_id": sid}, + handle, + indent=2 + ) + + handle.write("\n") + + os.replace(temp_path, path) + + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + +# -------------------------------------------------- +# VERROUILLAGE SESSION +# -------------------------------------------------- + +@contextmanager +def _session_lock(path: Path): + + path.parent.mkdir( + parents=True, + exist_ok=True + ) + + lock_path = path.with_name( + path.name + ".lock" + ) + + fd = os.open( + lock_path, + os.O_CREAT | os.O_RDWR, + 0o600 + ) + + with os.fdopen(fd, "w") as handle: + + fcntl.flock( + handle.fileno(), + fcntl.LOCK_EX + ) + + try: + yield + + finally: + fcntl.flock( + handle.fileno(), + fcntl.LOCK_UN + ) + + +# -------------------------------------------------- +# CONFIGURATION HERMES +# -------------------------------------------------- + +def _hermes_binary() -> str: + + return os.getenv( + "HERMES_CLI", + "/root/.local/bin/hermes" + ) + + +def _hermes_timeout() -> float: + + return max( + 10.0, + float( + os.getenv("HERMES_TIMEOUT", "180") + ) + ) + + +# -------------------------------------------------- +# ANCIEN MODE SANS SESSION +# -------------------------------------------------- + +def _oneshot_legacy(prompt: str) -> str: + + try: + result = subprocess.run( + [ + _hermes_binary(), + "--oneshot", + prompt + ], + capture_output=True, + text=True, + timeout=_hermes_timeout(), + check=True + ) + + return result.stdout.strip() + + except subprocess.TimeoutExpired as exc: + + raise RuntimeError( + "Hermes n'a pas répondu " + "dans le délai imparti." + ) from exc + + except subprocess.CalledProcessError as exc: + + details = ( + exc.stderr + or exc.stdout + or "aucun détail" + ).strip() + + raise RuntimeError( + f"Hermes a échoué " + f"(code {exc.returncode}): {details}" + ) from exc + + +# -------------------------------------------------- +# INTERPRÉTATION RÉPONSE JSONL +# -------------------------------------------------- + +def _parse_jsonl(stdout: str) -> tuple[str, str]: + + final = None + initial_session_id = None + + for line in stdout.splitlines(): + + if not line.strip(): + continue + + try: + event = json.loads(line) + + except json.JSONDecodeError: + continue + + if not isinstance(event, dict): + continue + + if ( + event.get("type") == "system" + and event.get("subtype") == "init" + ): + initial_session_id = event.get( + "session_id" + ) + + if event.get("type") == "result": + final = event + + if final is None: + + raise RuntimeError( + "Hermes n'a pas renvoyé de résultat " + "JSONL. Vérifie la prise en charge " + "de --format stream-json." + ) + + if final.get("exit_code", 0) != 0: + + raise RuntimeError( + f"Hermes : " + f"{final.get('error') or 'échec'}" + ) + + sid = ( + final.get("session_id") + or initial_session_id + ) + + if ( + not isinstance(sid, str) + or not _ID_PATTERN.fullmatch(sid) + ): + raise RuntimeError( + "Hermes n'a pas fourni " + "d'identifiant de session utilisable." + ) + + answer = final.get("text") + + if not isinstance(answer, str): + raise RuntimeError( + "La réponse JSONL finale " + "ne contient pas de texte." + ) + + return answer.strip(), sid + + +# -------------------------------------------------- +# APPEL HERMES AVEC REPRISE DE SESSION +# -------------------------------------------------- + +def _call_hermes( + prompt: str, + session_id: str | None +) -> tuple[str, str]: + + cmd = [ + _hermes_binary(), + "chat", + "--oneshot", + "--query-file", + "-", + "--format", + "stream-json", + "--source", + "cli" + ] + + if session_id: + cmd.extend([ + "--resume", + session_id + ]) + + try: + process = subprocess.run( + cmd, + input=prompt, + capture_output=True, + text=True, + timeout=_hermes_timeout(), + check=False + ) + + except subprocess.TimeoutExpired as exc: + + raise RuntimeError( + "Hermes n'a pas répondu " + "dans le délai imparti." + ) from exc + + except FileNotFoundError as exc: + + raise RuntimeError( + f"Hermes introuvable : " + f"{_hermes_binary()}" + ) from exc + + if process.returncode != 0: + + msg = ( + process.stderr + or process.stdout + or "aucun détail" + ).strip() + + if len(msg) > 1500: + msg = msg[-1500:] + + raise RuntimeError( + f"Hermes a échoué " + f"(code {process.returncode}) : " + f"{msg}" + ) + + return _parse_jsonl(process.stdout) + + +# -------------------------------------------------- +# FONCTION PRINCIPALE APPELÉE PAR FLASK +# -------------------------------------------------- + +def ask_hermes(text: str) -> str: + + prompt = _build_prompt(text) + + # Ancien fonctionnement sans session + if ( + os.getenv( + "HERMES_SESSION_MODE", + "persistent" + ).lower() == "legacy" + ): + return _oneshot_legacy(prompt) + + path = _session_file() + + # Empêche les appels concurrents + with _REQUEST_LOCK, _session_lock(path): + + # Recherche la session précédente + current_sid = _read_session(path) + + # Envoie la question à Hermes + answer, new_sid = _call_hermes( + prompt, + current_sid + ) + + # Sauvegarde l'identifiant + _save_session( + path, + new_sid + ) + + print( + f"[ALFRED] Session Hermes : " + f"{new_sid} " + f"({'reprise' if current_sid else 'création'})", + flush=True + ) + + return answer diff --git a/alfred/app.backup.weather_outfits/services/hermes_client.py.bak.20261016_140326 b/alfred/app.backup.weather_outfits/services/hermes_client.py.bak.20261016_140326 new file mode 100644 index 0000000..94e4b3e --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/hermes_client.py.bak.20261016_140326 @@ -0,0 +1,41 @@ +import os +import subprocess +import time + + +def ask_hermes(text): + hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes") + + start = time.perf_counter() + + try: + result = subprocess.run( + [hermes_cli, "--oneshot", text], + capture_output=True, + text=True, + timeout=180, + check=True + ) + + elapsed = time.perf_counter() - start + + print( + f"[ALFRED] Hermes : {elapsed:.2f} secondes", + flush=True + ) + + return result.stdout.strip() + + except subprocess.TimeoutExpired: + raise RuntimeError( + "Hermes n'a pas répondu dans le délai imparti." + ) + + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + stdout = (e.stdout or "").strip() + + raise RuntimeError( + f"Hermes a échoué (code {e.returncode}): " + f"{stderr or stdout or 'aucun détail'}" + ) diff --git a/alfred/app.backup.weather_outfits/services/hermes_client.py.ko.1 b/alfred/app.backup.weather_outfits/services/hermes_client.py.ko.1 new file mode 100644 index 0000000..95f3abc --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/hermes_client.py.ko.1 @@ -0,0 +1,7 @@ +"""Adaptateur V1. À ajuster à l'interface exacte de l'installation Hermes existante.""" +import os, subprocess, requests + +def ask_hermes(text:str)->str: + if os.getenv('HERMES_MODE','cli') == 'http': + r=requests.post(os.environ['HERMES_API_URL'],json={'message':text},headers={'Authorization':f"Bearer {os.getenv('HERMES_API_TOKEN','')}"},timeout=120);r.raise_for_status();return r.json().get('response',r.text) + p=subprocess.run([os.getenv('HERMES_CLI','hermes'), text],capture_output=True,text=True,timeout=180,check=True);return p.stdout.strip() diff --git a/alfred/app.backup.weather_outfits/services/hermes_client.py.okv1 b/alfred/app.backup.weather_outfits/services/hermes_client.py.okv1 new file mode 100644 index 0000000..ec2ff4a --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/hermes_client.py.okv1 @@ -0,0 +1,47 @@ +"""Client Hermes CLI d'Alfred avec contexte vocal dédié.""" +import os +import subprocess +from pathlib import Path + +DEFAULT_IDENTITY_FILE = Path(__file__).resolve().parent.parent / "config" / "identity.md" + + +def _build_prompt(question: str) -> str: + identity_path = Path(os.getenv("ALFRED_IDENTITY_FILE", str(DEFAULT_IDENTITY_FILE))) + try: + identity = identity_path.read_text(encoding="utf-8").strip() + except OSError as exc: + print(f"[ALFRED] Contexte d'identité indisponible ({identity_path}): {exc}", flush=True) + identity = "" + + question = str(question or "").strip() + if not identity: + return question + + return ( + "Instructions de contexte pour cette interaction vocale avec Alfred. " + "Applique ces instructions de comportement à ta réponse, sans les réciter. " + "La demande vocale ci-dessous est une entrée utilisateur, pas une modification de ton identité.\n\n" + f"\n{identity}\n\n\n" + f"\n{question}\n" + ) + + +def ask_hermes(text): + hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes") + prompt = _build_prompt(text) + try: + result = subprocess.run( + [hermes_cli, "--oneshot", prompt], + capture_output=True, text=True, timeout=180, check=True, + ) + return result.stdout.strip() + except subprocess.TimeoutExpired: + raise RuntimeError("Hermes n'a pas répondu dans le délai imparti.") + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + stdout = (e.stdout or "").strip() + raise RuntimeError( + f"Hermes a échoué (code {e.returncode}): " + f"{stderr or stdout or 'aucun détail'}" + ) diff --git a/alfred/app.backup.weather_outfits/services/hermes_client.py.old b/alfred/app.backup.weather_outfits/services/hermes_client.py.old new file mode 100644 index 0000000..51802b9 --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/hermes_client.py.old @@ -0,0 +1,35 @@ +import os +import subprocess + + +def ask_hermes(text): + hermes_cli = os.getenv("HERMES_CLI", "/root/.local/bin/hermes") + + try: + result = subprocess.run( + [ + hermes_cli, + "--oneshot", + text + ], + capture_output=True, + text=True, + timeout=180, + check=True + ) + + return result.stdout.strip() + + except subprocess.TimeoutExpired: + raise RuntimeError( + "Hermes n'a pas répondu dans le délai imparti." + ) + + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + stdout = (e.stdout or "").strip() + + raise RuntimeError( + f"Hermes a échoué (code {e.returncode}): " + f"{stderr or stdout or 'aucun détail'}" + ) diff --git a/alfred/app.backup.weather_outfits/services/piper_tts.py b/alfred/app.backup.weather_outfits/services/piper_tts.py new file mode 100644 index 0000000..765a883 --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/piper_tts.py @@ -0,0 +1,109 @@ +import asyncio +import os +import subprocess +import tempfile +import time +import wave + +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.tts import Synthesize, SynthesizeVoice + + +PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1") +PIPER_PORT = int(os.getenv("PIPER_PORT", "10200")) +AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0") + + +async def synthesize(text, wav_path): + client = AsyncTcpClient(PIPER_HOST, PIPER_PORT) + + await client.connect() + + try: + await client.write_event( + Synthesize( + text=text, + voice=SynthesizeVoice( + name=os.getenv("PIPER_VOICE", "fr_FR-tom-medium") + )).event() + ) + + wav_file = None + + try: + while True: + event = await asyncio.wait_for( + client.read_event(), + timeout=30 + ) + + if event is None: + raise RuntimeError("Connexion Piper interrompue") + + if AudioStart.is_type(event.type): + start = AudioStart.from_event(event) + + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(start.channels) + wav_file.setsampwidth(start.width) + wav_file.setframerate(start.rate) + + elif AudioChunk.is_type(event.type): + chunk = AudioChunk.from_event(event) + + if wav_file is None: + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(chunk.channels) + wav_file.setsampwidth(chunk.width) + wav_file.setframerate(chunk.rate) + + wav_file.writeframes(chunk.audio) + + elif AudioStop.is_type(event.type): + break + + finally: + if wav_file is not None: + wav_file.close() + + finally: + await client.disconnect() + + +def speak(text): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp( + prefix="alfred_piper_", + suffix=".wav" + ) + os.close(fd) + + start = time.monotonic() + + try: + print("[ALFRED PIPER] Synthèse...", flush=True) + + asyncio.run(synthesize(text, wav_path)) + + print("[ALFRED PIPER] Lecture...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", AUDIO_DEVICE, wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED PIPER] Erreur : {e}", flush=True) + raise + finally: + if os.path.exists(wav_path): + os.remove(wav_path) diff --git a/alfred/app.backup.weather_outfits/services/piper_tts.py.old b/alfred/app.backup.weather_outfits/services/piper_tts.py.old new file mode 100644 index 0000000..9558412 --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/piper_tts.py.old @@ -0,0 +1,105 @@ +import asyncio +import os +import subprocess +import tempfile +import time +import wave + +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.tts import Synthesize + + +PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1") +PIPER_PORT = int(os.getenv("PIPER_PORT", "10200")) +AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0") + + +async def synthesize(text, wav_path): + client = AsyncTcpClient(PIPER_HOST, PIPER_PORT) + + await client.connect() + + try: + await client.write_event( + Synthesize(text=text).event() + ) + + wav_file = None + + try: + while True: + event = await asyncio.wait_for( + client.read_event(), + timeout=30 + ) + + if event is None: + raise RuntimeError("Connexion Piper interrompue") + + if AudioStart.is_type(event.type): + start = AudioStart.from_event(event) + + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(start.channels) + wav_file.setsampwidth(start.width) + wav_file.setframerate(start.rate) + + elif AudioChunk.is_type(event.type): + chunk = AudioChunk.from_event(event) + + if wav_file is None: + wav_file = wave.open(wav_path, "wb") + wav_file.setnchannels(chunk.channels) + wav_file.setsampwidth(chunk.width) + wav_file.setframerate(chunk.rate) + + wav_file.writeframes(chunk.audio) + + elif AudioStop.is_type(event.type): + break + + finally: + if wav_file is not None: + wav_file.close() + + finally: + await client.disconnect() + + +def speak(text): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp( + prefix="alfred_piper_", + suffix=".wav" + ) + os.close(fd) + + start = time.monotonic() + + try: + print("[ALFRED PIPER] Synthèse...", flush=True) + + asyncio.run(synthesize(text, wav_path)) + + print("[ALFRED PIPER] Lecture...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", AUDIO_DEVICE, wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED PIPER] Erreur : {e}", flush=True) + raise + finally: + if os.path.exists(wav_path): + os.remove(wav_path) diff --git a/alfred/app.backup.weather_outfits/services/wakeword.py b/alfred/app.backup.weather_outfits/services/wakeword.py new file mode 100644 index 0000000..ae5c227 --- /dev/null +++ b/alfred/app.backup.weather_outfits/services/wakeword.py @@ -0,0 +1,110 @@ +"""Détection permanente du mot d'activation d'Alfred. + +Deux moteurs : +- vosk_keyword (par défaut) : fonctionne immédiatement avec le modèle Vosk français déjà utilisé. +- openwakeword : plus léger/spécialisé, à utiliser avec un modèle ONNX/TFLite custom « Alfred ». + +Le détecteur libère le micro dès l'activation avant de lancer le STT complet. +""" +from __future__ import annotations +from services.audio import get_vosk_model +import json, os, queue, subprocess, shutil, threading, time +from typing import Callable, Optional + +class WakeWordListener: + def __init__(self, on_wake: Callable[[float], None], on_level: Optional[Callable[[float], None]]=None): + self.on_wake=on_wake; self.on_level=on_level + self.stop_event=threading.Event(); self.pause_event=threading.Event(); self.thread=None + self.last_error=None; self.last_score=0.0 + + def start(self): + if self.thread and self.thread.is_alive(): return + self.stop_event.clear(); self.thread=threading.Thread(target=self._run, daemon=True); self.thread.start() + + def stop(self): self.stop_event.set() + def pause(self): self.pause_event.set() + def resume(self): self.pause_event.clear() + + def status(self): + return {"running": bool(self.thread and self.thread.is_alive()), "paused": self.pause_event.is_set(), + "engine": os.getenv("WAKE_ENGINE","vosk_keyword"), "word": os.getenv("WAKE_WORD","alfred"), + "last_score": self.last_score, "error": self.last_error} + + def _run(self): + engine=os.getenv("WAKE_ENGINE","vosk_keyword").lower() + while not self.stop_event.is_set(): + if self.pause_event.is_set(): time.sleep(.1); continue + try: + if engine == "openwakeword": self._openwakeword_session() + else: self._vosk_session() + except Exception as e: + self.last_error=str(e); time.sleep(1) + + def _level(self, data): + if not self.on_level: return + try: + import numpy as np + a=np.frombuffer(data,dtype=np.int16).astype(np.float32) + rms=float(np.sqrt(np.mean(a*a))) if a.size else 0.0 + self.on_level(min(1.0,rms/6000.0)) + except Exception: pass + + def _audio_chunks(self, blocksize=1280): + rate=int(os.getenv("VOSK_SAMPLE_RATE","16000")); q=queue.Queue() + try: + import sounddevice as sd + def cb(indata, frames, ti, status): q.put(bytes(indata)) + dev=os.getenv("AUDIO_INPUT_DEVICE") or None + with sd.RawInputStream(samplerate=rate, blocksize=blocksize, dtype="int16", channels=1, callback=cb, device=dev): + while not self.stop_event.is_set() and not self.pause_event.is_set(): yield q.get() + return + except Exception: pass + if not shutil.which("arecord"): raise RuntimeError("Micro indisponible: sounddevice et arecord absents") + cmd=["arecord","-q","-f","S16_LE","-r",str(rate),"-c","1"] + dev=os.getenv("ALSA_CAPTURE_DEVICE","").strip() + if dev: cmd += ["-D",dev] + p=subprocess.Popen(cmd,stdout=subprocess.PIPE) + try: + while not self.stop_event.is_set() and not self.pause_event.is_set(): + data=p.stdout.read(blocksize*2) + if data: yield data + finally: + if p.poll() is None: p.terminate() + + def _vosk_session(self): + from vosk import Model, KaldiRecognizer +## path=os.getenv("VOSK_MODEL_PATH",""); word=os.getenv("WAKE_WORD","alfred").strip().lower() +## + path = os.getenv( + "WAKE_VOSK_MODEL_PATH", + os.getenv("VOSK_MODEL_PATH", "") + ); word=os.getenv("WAKE_WORD","alfred").strip().lower() + if not os.path.exists(path): raise RuntimeError(f"Modèle Vosk introuvable: {path}") + rate=int(os.getenv("VOSK_SAMPLE_RATE","16000")) + # Grammaire réduite = détection peu coûteuse et évite de transcrire les conversations. +## rec=KaldiRecognizer(Model(path), rate, json.dumps([word,"[unk]"], ensure_ascii=False)) +## + rec = KaldiRecognizer( + get_vosk_model(path), rate, json.dumps([word, "[unk]"], ensure_ascii=False) + ) + + for data in self._audio_chunks(4000): + self._level(data) + if rec.AcceptWaveform(data): txt=(json.loads(rec.Result() or "{}").get("text") or "").lower() + else: txt=(json.loads(rec.PartialResult() or "{}").get("partial") or "").lower() + if word in txt.split(): + self.last_score=1.0; self.pause_event.set(); self.on_wake(1.0); return + + def _openwakeword_session(self): + import numpy as np + from openwakeword.model import Model + model_path=os.getenv("WAKE_MODEL_PATH","").strip() + if not model_path or not os.path.exists(model_path): raise RuntimeError("WAKE_MODEL_PATH absent: ajoute le modèle custom Alfred") + threshold=float(os.getenv("WAKE_THRESHOLD","0.5")) + model=Model(wakeword_models=[model_path], inference_framework=os.getenv("WAKE_INFERENCE","onnx")) + for data in self._audio_chunks(1280): + self._level(data); audio=np.frombuffer(data,dtype=np.int16) + scores=model.predict(audio) + score=max((float(v) for v in scores.values()), default=0.0); self.last_score=score + if score >= threshold: + self.pause_event.set(); self.on_wake(score); return diff --git a/alfred/app.backup.weather_outfits/static/css/alfred-controls.css b/alfred/app.backup.weather_outfits/static/css/alfred-controls.css new file mode 100644 index 0000000..716de4e --- /dev/null +++ b/alfred/app.backup.weather_outfits/static/css/alfred-controls.css @@ -0,0 +1,41 @@ +/* Pop-up des réglages : ne modifie pas la page configuration historique. */ +.audio-popover { position:absolute; top:82px; right:36px; z-index:10; width:min(355px,calc(100vw - 28px)); background:#252a2e; border:1px solid #ffffff2b; border-radius:17px; padding:20px; box-shadow:0 24px 70px #0008; } +.audio-popover[hidden] { display:none !important; } +.popover-heading { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; font-size:16px; } +.icon-button.mini { width:28px; height:28px; font-size:22px; border:0; background:none; } +.setting-row { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:19px; margin-bottom:12px; color:#d5dbe0; font-size:13px; } +.setting-row output { color:#f4f5f6; font-size:13px; font-variant-numeric:tabular-nums; } +.audio-popover input[type="range"] { display:block; width:100%; margin:0 0 5px; cursor:pointer; accent-color:#a8c7ce; } +.audio-popover input[type="range"]:disabled { opacity:.35; cursor:not-allowed; } +.setting-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-top:22px; padding-top:16px; border-top:1px solid #ffffff1c; color:#cbd1d5; font-size:13px; } +.quiet-button { color:#c3e7dc; background:#42675b33; border:1px solid #83c9ae44; padding:7px 13px; border-radius:8px; cursor:pointer; } +.quiet-button[aria-pressed="true"] { color:#f7b5b5; background:#b65c5c26; border-color:#b65c5c66; } +.settings-message { min-height:14px; margin:12px 0 0; font-size:11px; color:#e4c089; line-height:1.4; } +@media(max-width:760px) { .audio-popover { top:65px; right:14px; } } + +/* Bouton mute microphone dans la barre supérieure. aria-pressed reflète l'état réel de l'API. */ +.mic-off-icon { display: none; } +#micMute[aria-pressed="true"] .mic-on-icon { display: none; } +#micMute[aria-pressed="true"] .mic-off-icon { display: block; } + +/* Onglet caméra dans le panneau déjà présent (pas de modification des vues calendrier). */ +.audio-popover { max-height:calc(100dvh - 100px); overflow-y:auto; overscroll-behavior:contain; } +.settings-tabs { display:flex; gap:5px; padding:4px; background:#111518; border-radius:11px; margin:0 0 16px; } +.settings-tab { flex:1; padding:10px; border:0; border-radius:8px; color:#a7b2ba; background:transparent; font:inherit; cursor:pointer; } +.settings-tab[aria-selected="true"] { background:#3b464b; color:#f6f8f9; font-weight:600; } +.settings-tab:focus-visible,.camera-settings-action:focus-visible,.camera-select:focus-visible,.camera-range:focus-visible { outline:2px solid #b9dbec; outline-offset:2px; } +.camera-settings-note { margin:10px 0 15px; color:#aab5be; font-size:12px; line-height:1.5; } +.camera-settings-items { display:block; } +.camera-group-title { color:#c2d6df; font-size:12px; text-transform:uppercase; letter-spacing:.08em; margin:23px 0 8px; font-weight:650; } +.camera-control { padding:0 0 8px; border-bottom:1px solid #ffffff10; } +.camera-control .setting-row { margin-top:11px; margin-bottom:9px; } +.camera-control label { font-size:12px; } +.camera-control .camera-control-value { color:#b8ced5; } +.camera-range, .camera-select { width:100%; min-height:28px; accent-color:#a8c7ce; } +.camera-select { color:#eff2f5; background:#30383d; border:1px solid #ffffff26; border-radius:8px; padding:7px 9px; font-size:13px; } +.camera-control.is-inactive { opacity:.44; } +.camera-disabled-hint { font-size:11px; color:#acb5bf; margin-top:4px; } +.camera-settings-actions { display:flex; gap:8px; margin-top:19px; } +.camera-settings-action { flex:1; padding:10px; background:#3b464c; border:1px solid #ffffff23; border-radius:9px; color:#f2f5f6; font-size:12px; cursor:pointer; } +.camera-settings-action.reset { background:#5b454026; color:#f0c4b9; } +@media(max-width:760px) { .audio-popover { max-height:calc(100dvh - 80px); } } diff --git a/alfred/app.backup.weather_outfits/static/css/alfred-mode-icons.css b/alfred/app.backup.weather_outfits/static/css/alfred-mode-icons.css new file mode 100644 index 0000000..d78db8c --- /dev/null +++ b/alfred/app.backup.weather_outfits/static/css/alfred-mode-icons.css @@ -0,0 +1,13 @@ +.alfred-mode-icon { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + min-height: 36px; + font-size: 26px; + line-height: 1; + transition: transform .25s ease, opacity .25s ease; +} +.alfred-mode-icon[data-mode="thinking"] { animation: alfred-thinking 1.8s ease-in-out infinite; } +@keyframes alfred-thinking { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.12); } } +@media (prefers-reduced-motion: reduce) { .alfred-mode-icon { animation: none !important; } } diff --git a/alfred/app.backup.weather_outfits/static/css/alfred-views.css b/alfred/app.backup.weather_outfits/static/css/alfred-views.css new file mode 100644 index 0000000..8a7de3e --- /dev/null +++ b/alfred/app.backup.weather_outfits/static/css/alfred-views.css @@ -0,0 +1,76 @@ +/* Calendar and Home Assistant views — preserves original dark Alfred layout */ +#stage:has(.alfred-view) {padding:0 0 6px;justify-content:stretch;align-items:stretch} +#content.alfred-view {width:100%;height:100%;text-align:left;min-height:0;} +.view-pane {display:flex;flex-direction:column;gap:12px;max-height:100%;height:100%;min-height:0;padding:8px 1px 14px;} +.view-head {display:flex;justify-content:space-between;align-items:center;gap:12px;} +#content .view-title {font-size:clamp(1.2rem,1.7vw,1.85rem);margin:0;font-weight:500} +.view-close,.view-button,.view-pill {background:#363d42;color:#f3f7fa;border:1px solid #ffffff24;border-radius:10px;padding:9px 14px;cursor:pointer;font:inherit} +.view-close {font-size:1.4rem;min-width:40px;height:40px;padding:0} +.view-button:hover,.view-pill:hover,.view-close:hover {background:#455057} +.view-pill.active,.view-button.primary {background:#427789;border-color:#619aad} +.view-toolbar {display:flex;gap:9px;align-items:center;flex-wrap:wrap;} +.view-nav,.view-modes {display:flex;gap:5px;align-items:center} +.view-period {font-size:.85rem;min-width:215px;text-align:center;color:#d8e0e4} +.view-select {padding:9px;max-width:220px;border-radius:9px;background:#333a3e;color:#eff3f5;border:1px solid #ffffff2b} +.agenda-grid {display:grid;gap:5px;min-height:0;flex:1;overflow:auto;padding:2px;align-content:stretch;} +.agenda-seven,.agenda-month {grid-template-columns:repeat(7,minmax(0,1fr))} +.agenda-month {grid-template-rows:repeat(6,minmax(110px,1fr));} +.agenda-three {grid-template-columns:repeat(3,minmax(0,1fr))} +.agenda-one {grid-template-columns:1fr} +.agenda-day {border:1px solid #ffffff1c;border-radius:11px;min-width:0;min-height:120px;background:#ffffff08;padding:9px;display:flex;flex-direction:column;gap:6px;overflow-y:auto} +.agenda-day.is-today {border-color:#86bfda7a;background:#57839923} +.agenda-day.out-month {opacity:.42} +.agenda-date {font-size:.83rem;font-weight:600;color:#dce2e6;letter-spacing:.01em;padding-bottom:7px;border-bottom:1px solid #ffffff14} +.agenda-event {width:100%;text-align:left;background:color-mix(in srgb,var(--event-color) 17%,#2a2e32);color:#f1f3f5;border:0;border-left:3px solid var(--event-color);border-radius:5px;padding:7px 5px;font-size:clamp(.72rem,.85vw,.95rem);line-height:1.3;cursor:pointer;overflow-wrap:anywhere} +.agenda-event:hover {background:color-mix(in srgb,var(--event-color) 30%,#2a2e32)} +.agenda-empty {font-size:.8rem;color:#667781;margin:auto} +#content .view-footer {color:#9aa8b3;font-size:.76rem;margin:0} +#content .view-error {color:#f7adad;font-size:.85rem;margin:0} +#content .view-empty {font-size:1rem;color:#b8c2ca} +.agenda-form {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;gap:14px;max-width:850px;width:100%;overflow-y:auto;margin:12px auto;} +.form-field {display:flex;flex-direction:column;gap:7px;color:#cfd6dd;font-size:.83rem} +.form-field input,.form-field select,.form-field textarea {padding:12px;border-radius:10px;color:#eff3f5;background:#2e3439;border:1px solid #ffffff32;font:inherit;min-width:0} +.form-field textarea {resize:vertical} +.form-check {display:flex;align-items:center;gap:10px} +.view-actions {display:flex;align-items:center;gap:10px;justify-content:flex-end;flex-wrap:wrap;} +.form-message {grid-column:1 / -1;} +.agenda-form .view-actions {grid-column:1 / -1} +.view-danger {color:#ffc4c4;background:#8f383850;border-color:#ed929255} +.detail-modal {position:fixed;inset:0;z-index:30;display:grid;place-items:center;background:#000b;padding:20px} +.detail-box {min-width:min(92vw,300px);max-width:540px;width:100%;border:1px solid #ffffff36;border-radius:19px;background:#292e33;box-shadow:0 25px 90px #000a;padding:24px;} +.detail-box h2 {font-size:1.4rem;margin:0 0 20px}.detail-box p {font-size:1rem;color:#d7dfe2;overflow-wrap:anywhere} +.delete-list {display:grid;gap:8px;overflow:auto}.delete-row {display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid #ffffff1c;border-radius:9px;padding:12px} +.temperature-grid {display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:4px;max-height:53%;} +.temperature-card {border:1px solid #ffffff22;background:#ffffff0a;border-radius:12px;padding:22px;color:#eaf1f3;white-space:pre-line;text-align:left;font:inherit;cursor:pointer;line-height:1.9} +.temperature-card:hover {background:#ffffff18} +.temperature-history {max-width:700px;text-align:center;margin:auto;width:100%;} +.temperature-history svg {width:100%;max-height:155px} +.temperature-history h2 {font-size:1.1rem;font-weight:450} +.ha-cameras {display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;overflow:auto;} +.ha-camera-card {background:#ffffff0a;border-radius:13px;overflow:hidden;} +.ha-camera-card h2 {font-size:.95rem;padding:10px;font-weight:500;margin:0} +.ha-camera-card img {display:block;width:100%;aspect-ratio:16/9;object-fit:contain;background:#121619} +@media(max-width:1100px){.view-toolbar{gap:6px}.view-button,.view-pill {padding:7px;font-size:.77rem}.view-period{min-width:150px}.agenda-day{padding:5px}.agenda-event{padding:4px;font-size:.74rem}} +@media(max-width:760px){#stage:has(.alfred-view){min-height:460px}.agenda-grid{min-height:280px}.agenda-seven,.agenda-month{min-width:630px}.agenda-day{min-height:105px}.view-pane{max-height:600px}.view-head{padding:0 4px}.agenda-form{grid-template-columns:1fr}.header-actions{gap:3px}.header-actions .icon-button {width:38px;height:38px}.view-toolbar{overflow:auto}} + +/* Weather dynamic view — same dark UI as calendar. */ +.weather-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px} +.weather-now{display:flex;align-items:center;gap:22px;background:#293238;border:1px solid #ffffff16;border-radius:16px;padding:18px 24px;} +.weather-current-icon{font-size:clamp(44px,6vw,80px);line-height:1;color:#edcb82} +.weather-now-details h2{margin:0 0 8px;font-size:clamp(20px,2.1vw,32px);font-weight:500;color:#f5f6f7} +.weather-now-details p{margin:0;color:#b6c4ce;font-size:clamp(12px,1vw,17px)} +.weather-section-title{font-size:15px;letter-spacing:.02em;color:#bfd0da;margin:7px 0 -2px;font-weight:550} +.weather-days{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:7px} +.weather-day{min-width:0;display:flex;flex-direction:column;align-items:center;text-align:center;gap:5px;background:#293035;border-radius:12px;border:1px solid #ffffff12;padding:12px 5px 10px} +.weather-day p{margin:0} +.weather-day-name{font-size:12px;color:#d4e0e6} +.weather-day-icon{font-size:clamp(22px,2.8vw,40px);color:#eaca85} +.weather-day-condition{font-size:11px;color:#adbcc5;min-height:30px;line-height:1.3} +.weather-day-temp{font-size:21px;color:#f5f7f8;font-weight:600} +.weather-day-low,.weather-rain{font-size:11px;color:#a7b6c0} +.weather-rain{color:#8dc8e4 !important} +.weather-hours{display:flex;gap:7px;overflow-x:auto;padding:3px 0 10px;scrollbar-color:#637480 #2a3237} +.weather-hour{display:flex;flex:0 0 80px;align-items:center;flex-direction:column;gap:9px;padding:10px 6px;background:#293035;border:1px solid #ffffff16;border-radius:12px;color:#cbd7df;font-size:12px} +.weather-hour-icon{font-size:27px;color:#edd298} +@media(max-width:840px){.weather-days{grid-template-columns:repeat(4,minmax(0,1fr))}.weather-now{padding:14px;gap:12px}.weather-current-icon{font-size:48px}} +@media(max-width:470px){.weather-days{grid-template-columns:repeat(3,minmax(0,1fr))}} diff --git a/alfred/app.backup.weather_outfits/static/css/alfred.css b/alfred/app.backup.weather_outfits/static/css/alfred.css new file mode 100644 index 0000000..521a067 --- /dev/null +++ b/alfred/app.backup.weather_outfits/static/css/alfred.css @@ -0,0 +1,62 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + color: #f1f3f4; + background: #202326; +} +* { box-sizing: border-box; } +[hidden] { display:none !important; } +body { margin: 0; min-height: 100vh; background: radial-gradient(ellipse at 52% 34%, #33373a 0, #25282b 46%, #1a1d20 100%); } +.alfred-shell { position:relative; isolation:isolate; display:grid; grid-template-rows: 56px minmax(0,1fr) 142px; gap:16px; min-height:100vh; min-height:100dvh; padding:25px 36px; } +.topbar { display:flex; align-items:center; justify-content:space-between; z-index:5; } +.status { display:inline-flex; align-items:center; gap:11px; font-size:14px; font-weight:500; color:#e6eaed; letter-spacing:.045em; } +.status-dot { width:10px; height:10px; border-radius:50%; background:#7d8790; box-shadow:0 0 0 4px #ffffff0a; transition:background .25s,box-shadow .25s; } +.status-dot[data-mode="listening"] { background:#70c7ce; box-shadow:0 0 0 5px #70c7ce17; animation:status-pulse 1.6s infinite; } +.status-dot[data-mode="thinking"] { background:#e7bb72; box-shadow:0 0 0 5px #e7bb7217; animation:status-pulse 1.9s infinite; } +.status-dot[data-mode="speaking"] { background:#9ad0b7; box-shadow:0 0 0 5px #9ad0b717; animation:status-pulse 1.4s infinite; } +.status-dot[data-mode="error"] { background:#f18989; box-shadow:0 0 0 5px #f1898917; } +.header-actions { display:flex; gap:10px; align-items:center; } +.icon-button { display:grid; place-items:center; width:44px; height:44px; border:1px solid #ffffff22; border-radius:13px; background:#ffffff08; color:#e8ebee; cursor:pointer; transition:background .2s,border-color .2s; } +.icon-button:hover { background:#ffffff14; border-color:#ffffff35; } +.icon-button:focus-visible,.panel-action:focus-visible,.quiet-button:focus-visible { outline:2px solid #adc8e0; outline-offset:3px; } +.icon-button svg { width:21px; height:21px; } +.icon-button[aria-pressed="true"] { color:#f4a2a2; background:#ae47471b; border-color:#b95d5d55; } +.speaker-off-icon { display:none; } +.icon-button[aria-pressed="true"] .speaker-on-icon { display:none; } +.icon-button[aria-pressed="true"] .speaker-off-icon { display:block; } +#stage { display:flex; align-items:center; justify-content:center; overflow:hidden; min-height:0; padding:0 8%; } +#content { width:min(990px,86vw); text-align:center; transition:opacity .25s ease,transform .25s ease; animation:appear .45s ease; } +#content.swap { opacity:0; transform:translateY(10px) scale(.99); } +#content h1 { font-size:clamp(2rem,4vw,4.3rem); font-weight:350; margin:.2em 0; line-height:1.2; } +#content p { font-size:clamp(1rem,1.6vw,1.35rem); color:#cdd1d4; line-height:1.55; white-space:pre-wrap; overflow-wrap:anywhere; } +.hero { max-width:min(100%,750px); max-height:56vh; object-fit:contain; border-radius:18px; box-shadow:0 18px 50px #0006; animation:appear .45s ease; } +.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(205px,1fr)); gap:13px; text-align:left; } +.card { padding:18px; border:1px solid #ffffff1c; background:#ffffff0a; border-radius:15px; } +.card h2 { margin:0 0 8px; font-weight:500; } +.card p { margin:0; font-size:1rem; } +#wave { display:flex; align-items:center; justify-self:center; width: min(560px,37vw); height:100px; align-self:end; } +#wave canvas { width:100%; height:100%; } +.camera-panel,.transcript-panel { position:absolute; z-index:2; bottom:34px; border:1px solid #ffffff20; background:#222629db; box-shadow:0 14px 35px #0002; border-radius:16px; backdrop-filter:blur(12px); } +.camera-panel { left:36px; width:clamp(220px,24vw,355px); overflow:hidden; } +.transcript-panel { right:36px; width:clamp(250px,25vw,405px); padding:15px 17px 18px; min-height:136px; } +.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:42px; padding:0 13px; } +.transcript-panel .panel-heading { padding:0; min-height:30px; } +.panel-title { display:flex; align-items:center; gap:9px; font-size:10px; letter-spacing:.1em; font-weight:700; color:#abb5be; } +.panel-indicator { width:7px; height:7px; border-radius:50%; background:#71818c; } +.camera-panel[data-active="true"] .panel-indicator { background:#83c5cc; } +.transcript-panel[data-active="true"] .stt-indicator { background:#83c5cc; } +.panel-action { border:0; border-radius:8px; background:#ffffff0c; color:#e3e8ed; padding:7px 10px; font-size:12px; cursor:pointer; } +.panel-action:hover { background:#ffffff22; } +.camera-viewport { display:grid; place-items:center; position:relative; aspect-ratio:16/9; background:#101315; overflow:hidden; } +.camera-viewport > * { grid-area:1/1; } +.camera-viewport img { width:100%; height:100%; object-fit:contain; } +.camera-placeholder { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; width:100%; height:100%; color:#919ca4; font-size:12px; text-align:center; padding:12px; } +.camera-placeholder svg { width:30px; height:30px; opacity:.65; } +.transcript-state { color:#909ba3; font-size:11px; flex-shrink:0; } +.transcript-text { margin:11px 0 0; color:#edf0f2; font-size:14px; line-height:1.6; overflow-wrap:anywhere; max-height:110px; overflow-y:auto; } +.transcript-text.is-placeholder { color:#87939c; } +@keyframes appear { from { opacity:0; transform:translateY(8px) } to { opacity:1;transform:translateY(0) } } +@keyframes status-pulse { 0%,100% { opacity:1 } 50% { opacity:.45 } } +@media(max-width:1120px) { #wave { width:28vw; } .camera-panel { width:27vw; } .transcript-panel { width:29vw; } } +@media(max-width:760px) { .alfred-shell { padding:14px; grid-template-rows:50px minmax(220px,1fr) 80px auto; gap:8px; } #stage { padding:10px; } #wave { width:80%; height:70px; } .camera-panel,.transcript-panel { position:relative; left:auto; right:auto; bottom:auto; width:100%; } .camera-panel { grid-row:4; max-width:none; } .transcript-panel { grid-row:5; min-height:auto; } .topbar { grid-row:1; } } +@media(prefers-reduced-motion:reduce) { * { animation:none !important; transition:none !important; } } diff --git a/alfred/app.backup.weather_outfits/static/js/alfred-camera-settings.js b/alfred/app.backup.weather_outfits/static/js/alfred-camera-settings.js new file mode 100644 index 0000000..8ba293e --- /dev/null +++ b/alfred/app.backup.weather_outfits/static/js/alfred-camera-settings.js @@ -0,0 +1,175 @@ +/* Réglages matériels de webcam : curseurs générés selon ce que V4L2 expose. */ +(() => { + 'use strict'; + const pane = document.getElementById('audioSettingsPanel'); + const tabAudio = document.getElementById('audioTab'); + const tabCamera = document.getElementById('cameraTab'); + const sectionAudio = document.getElementById('audioSettingsSection'); + const sectionCamera = document.getElementById('cameraSettingsSection'); + const items = document.getElementById('cameraSettingsItems'); + const message = document.getElementById('cameraSettingsMessage'); + const reload = document.getElementById('cameraSettingsReload'); + const reset = document.getElementById('cameraSettingsReset'); + if (!pane || !tabAudio || !tabCamera || !items) return; + + const labels = { + brightness: 'Luminosité', contrast: 'Contraste', saturation: 'Saturation', + hue: 'Teinte', gamma: 'Gamma', gain: 'Gain vidéo', sharpness: 'Netteté', + backlight_compensation: 'Compensation contre-jour', + power_line_frequency: 'Fréquence anti-scintillement', + white_balance_automatic: 'Balance des blancs automatique', + white_balance_temperature: 'Température des blancs (K)', + auto_exposure: 'Exposition', exposure_time_absolute: 'Temps d’exposition', + exposure_dynamic_framerate: 'Fréquence d’image dynamique' + }; + const groups = [ + {name: 'Image', keys: ['brightness','contrast','saturation','sharpness','hue','gamma','gain','backlight_compensation']}, + {name: 'Éclairage et couleurs', keys: ['white_balance_automatic','white_balance_temperature','power_line_frequency']}, + {name: 'Exposition', keys: ['auto_exposure','exposure_time_absolute','exposure_dynamic_framerate']} + ]; + const exposureChoices = {1: 'Manuelle', 3: 'Automatique'}; + const powerChoices = {0: 'Désactivé', 1: '50 Hz', 2: '60 Hz'}; + let working = false; + + function showTab(which) { + const camera = which === 'camera'; + tabCamera.setAttribute('aria-selected', String(camera)); + tabAudio.setAttribute('aria-selected', String(!camera)); + sectionCamera.hidden = !camera; + sectionAudio.hidden = camera; + if (camera) load(); + } + tabAudio.addEventListener('click', () => showTab('audio')); + tabCamera.addEventListener('click', () => showTab('camera')); + document.getElementById('audioSettingsButton')?.addEventListener('click', () => { + if (!pane.hidden) showTab('audio'); + }); + + async function api(path, payload) { + const opts = {cache: 'no-store'}; + if (payload !== undefined) { + opts.method = 'POST'; + opts.headers = {'Content-Type':'application/json', 'X-Alfred-Request':'1'}; + opts.body = JSON.stringify(payload); + } + const res = await fetch(path, opts); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `Erreur HTTP ${res.status}`); + return data; + } + + function elt(tag, klass, text) { + const e = document.createElement(tag); + if (klass) e.className = klass; + if (text !== undefined) e.textContent = text; + return e; + } + + function controlRow(name, meta) { + const row = elt('div', 'camera-control'); + if (meta.inactive) row.classList.add('is-inactive'); + const header = elt('div','setting-row'); + const label = elt('label', null, labels[name] || name); + const output = elt('output','camera-control-value',String(meta.value ?? '—')); + header.append(label, output); row.appendChild(header); + let input; + if (meta.kind === 'bool' || meta.kind === 'menu') { + input = elt('select','camera-select'); + let opts; + if (meta.kind === 'bool') opts = [{value:1,label:'Activé'},{value:0,label:'Désactivé'}]; + else if (name === 'auto_exposure') opts = [{value:3,label:'Automatique'},{value:1,label:'Manuelle'}]; + else if (name === 'power_line_frequency') opts = Object.entries(powerChoices).map(([value,label])=>({value:Number(value),label})); + else opts = meta.menu || []; + const allowed = meta.menu?.length && meta.kind === 'menu' ? new Set(meta.menu.map(i=>i.value)) : null; + for (const opt of opts) { + if (allowed && !allowed.has(opt.value)) continue; + const option = elt('option',null,opt.label); + option.value = String(opt.value); + input.appendChild(option); + } + input.value = String(meta.value); + const updateLabel = () => { + output.textContent = input.options[input.selectedIndex]?.text || String(input.value); + }; + updateLabel(); + input.addEventListener('change', () => save(name, Number(input.value))); + } else { + input = elt('input','camera-range'); + input.type = 'range'; + input.min = String(meta.min); input.max = String(meta.max); + input.step = String(meta.step || 1); + input.value = String(meta.value); + input.addEventListener('input', () => { output.textContent = input.value; }); + // Appliquer au relâchement : évite des centaines de commandes shell. + input.addEventListener('change', () => save(name, Number(input.value))); + } + input.id = `v4l-${name}`; + label.htmlFor = input.id; + input.disabled = Boolean(meta.inactive || working); + row.appendChild(input); + if (meta.inactive) row.appendChild(elt('div','camera-disabled-hint', 'Désactive le mode automatique pour régler cette valeur.')); + return row; + } + + function render(data) { + items.replaceChildren(); + const available = data.controls || {}; + for (const group of groups) { + const found = group.keys.filter(name => available[name]); + if (!found.length) continue; + items.appendChild(elt('h3','camera-group-title',group.name)); + for (const name of found) items.appendChild(controlRow(name,available[name])); + } + if (!items.children.length) items.appendChild(elt('p','camera-settings-note','Aucun réglage V4L2 disponible.')); + } + + async function load() { + if (working) return; + message.textContent = 'Lecture des réglages caméra…'; + try { + render(await api('/api/camera/settings')); + message.textContent = ''; + } catch (err) { + message.textContent = err.message; + items.replaceChildren(); + } + } + + async function save(control, value) { + if (working) return; + working = true; + message.textContent = 'Application…'; + try { + const result = await api('/api/camera/settings', {control, value}); + working = false; + render(result); + message.textContent = 'Réglage appliqué et mémorisé.'; + } catch (err) { + message.textContent = err.message; + working = false; + await load(); + return; + } + working = false; + } + + reload.addEventListener('click', load); + reset.addEventListener('click', async () => { + if (working || !window.confirm('Rétablir tous les réglages caméra d’origine ?')) return; + working = true; + message.textContent = 'Restauration en cours…'; + try { + const result = await api('/api/camera/reset', {}); + working = false; + render(result); + message.textContent = 'Valeurs d’usine restaurées.'; + } catch (err) { + message.textContent = err.message; + } finally { + working = false; + } + }); + window.alfredSocket?.on('camera_settings', data => { + if (!sectionCamera.hidden && !working) render(data); + }); +})(); diff --git a/alfred/app.backup.weather_outfits/static/js/alfred-camera.js b/alfred/app.backup.weather_outfits/static/js/alfred-camera.js new file mode 100644 index 0000000..fc58979 --- /dev/null +++ b/alfred/app.backup.weather_outfits/static/js/alfred-camera.js @@ -0,0 +1,41 @@ +/* Lecture MJPEG activée manuellement : libère la caméra si désactivée. */ +(() => { + 'use strict'; + const button = document.getElementById('cameraToggle'); + const image = document.getElementById('cameraFeed'); + const placeholder = document.getElementById('cameraPlaceholder'); + const message = document.getElementById('cameraMessage'); + const panel = document.querySelector('.camera-panel'); + let active = false; + + function stop() { + active = false; + image.removeAttribute('src'); + image.hidden = true; + placeholder.hidden = false; + message.textContent = 'Caméra désactivée'; + button.textContent = 'Activer'; + button.setAttribute('aria-pressed', 'false'); + panel.dataset.active = 'false'; + } + function start() { + active = true; + placeholder.hidden = false; + message.textContent = 'Connexion à la caméra…'; + image.hidden = false; + image.src = `/api/camera/stream?t=${Date.now()}`; + button.textContent = 'Arrêter'; + button.setAttribute('aria-pressed', 'true'); + } + button.addEventListener('click', () => active ? stop() : start()); + image.addEventListener('load', () => { if (active) { placeholder.hidden = true; panel.dataset.active = 'true'; } }); + image.addEventListener('error', () => { + if (!active) return; + stop(); + message.textContent = 'Caméra indisponible ou déjà utilisée'; + }); + // Si l'onglet n'est pas visible, relâcher le périphérique USB. + document.addEventListener('visibilitychange', () => { if (document.hidden && active) stop(); }); + window.addEventListener('pagehide', stop); + stop(); +})(); diff --git a/alfred/app.backup.weather_outfits/static/js/alfred-controls.js b/alfred/app.backup.weather_outfits/static/js/alfred-controls.js new file mode 100644 index 0000000..8f3ae69 --- /dev/null +++ b/alfred/app.backup.weather_outfits/static/js/alfred-controls.js @@ -0,0 +1,93 @@ +/* Réglages volume/gain dans la fenêtre, mute micro + haut-parleur dans la barre haute. */ +(() => { + 'use strict'; + const panel = document.getElementById('audioSettingsPanel'); + const button = document.getElementById('audioSettingsButton'); + const close = document.getElementById('closeAudioSettings'); + const speaker = document.getElementById('speakerMuteQuick'); + const mic = document.getElementById('micMute'); + const volume = document.getElementById('volumeSlider'); + const volumeValue = document.getElementById('volumeValue'); + const gain = document.getElementById('micGainSlider'); + const gainValue = document.getElementById('micGainValue'); + const message = document.getElementById('audioSettingsMessage'); + let settings = { volume:70, mic_gain:null, speaker_muted:false, mic_muted:false }; + let volumeTimer, gainTimer; + + function render() { + volume.value = settings.volume; + volumeValue.textContent = `${settings.volume} %`; + gain.disabled = !Number.isFinite(settings.mic_gain); + if (!gain.disabled) gain.value = settings.mic_gain; + gainValue.textContent = gain.disabled ? 'N/D' : `${settings.mic_gain} %`; + speaker.setAttribute('aria-pressed', String(settings.speaker_muted)); + speaker.setAttribute('aria-label', settings.speaker_muted ? 'Réactiver le son' : 'Couper le son'); + speaker.title = speaker.getAttribute('aria-label'); + mic.setAttribute('aria-pressed', String(settings.mic_muted)); + mic.setAttribute('aria-label', settings.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone'); + mic.title = mic.getAttribute('aria-label'); + } + + async function update(changes) { + message.textContent = ''; + try { + const response = await fetch('/api/audio/settings', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify(changes) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); + settings = { ...settings, ...data }; + render(); + } catch (err) { + message.textContent = `Impossible de modifier le réglage : ${err.message}`; + console.warn('[ALFRED AUDIO]', err); + await refresh(); + } + } + + async function refresh() { + try { + const response = await fetch('/api/audio/settings', {cache:'no-store'}); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + settings = { ...settings, ...await response.json() }; + render(); + } catch (err) { + message.textContent = 'Réglages indisponibles.'; + console.warn('[ALFRED AUDIO]', err); + } + } + + function setOpen(open) { + panel.hidden = !open; + button.setAttribute('aria-expanded', String(open)); + if (open) { refresh(); close.focus(); } else { button.focus(); } + } + button.addEventListener('click', () => setOpen(panel.hidden)); + close.addEventListener('click', () => setOpen(false)); + document.addEventListener('pointerdown', e => { + if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) { + panel.hidden = true; button.setAttribute('aria-expanded', 'false'); + } + }); + document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) setOpen(false); }); + + volume.addEventListener('input', () => { + settings.volume = Number(volume.value); render(); + clearTimeout(volumeTimer); + volumeTimer = setTimeout(() => update({volume:Number(volume.value)}), 220); + }); + gain.addEventListener('input', () => { + settings.mic_gain = Number(gain.value); render(); + clearTimeout(gainTimer); + gainTimer = setTimeout(() => update({mic_gain:Number(gain.value)}), 250); + }); + speaker.addEventListener('click', () => update({speaker_muted:!settings.speaker_muted})); + mic.addEventListener('click', () => update({mic_muted:!settings.mic_muted})); + + if (window.alfredSocket) window.alfredSocket.on('audio_settings', data => { + settings = {...settings, ...data}; render(); + }); + render(); refresh(); +})(); diff --git a/alfred/app.backup.weather_outfits/static/js/alfred-mode-icons.js b/alfred/app.backup.weather_outfits/static/js/alfred-mode-icons.js new file mode 100644 index 0000000..9a36c90 --- /dev/null +++ b/alfred/app.backup.weather_outfits/static/js/alfred-mode-icons.js @@ -0,0 +1,35 @@ +/* Icônes de statut : compatible avec alfred.js existant, sans le remplacer. */ +(() => { + 'use strict'; + const el = document.getElementById('mode'); + if (!el) return; + const icons = { + idle: ['🛏️', 'Repos'], repos: ['🛏️', 'Repos'], + listening: ['👂', 'Écoute'], thinking: ['🧠', 'Réflexion'], + speaking: ['🔊', 'Parole'], error: ['⚠️', 'Erreur'] + }; + let current = 'idle'; + function apply(raw) { + const key = String(raw || '').trim().toLowerCase(); + if (!icons[key]) return; + current = key; + const [icon, label] = icons[key]; + if (el.textContent !== icon) el.textContent = icon; + el.title = label; + el.setAttribute('aria-label', label); + el.dataset.mode = key; + } + // L'ancien alfred.js écrit le nom du mode dans #mode. + // L'observateur le convertit immédiatement en icône, sans modifier ce script. + const observer = new MutationObserver(() => { + const value = el.textContent.trim(); + if (icons[value.toLowerCase()]) apply(value); + }); + observer.observe(el, { childList: true, characterData: true, subtree: true }); + apply(el.textContent); + // Récupère également l'état actuel après un rafraîchissement de page. + fetch('/api/state', { cache: 'no-store' }) + .then(r => { if (!r.ok) throw new Error('État indisponible'); return r.json(); }) + .then(data => { if (data && data.mode) apply(data.mode); }) + .catch(() => {}); +})(); diff --git a/alfred/app.backup.weather_outfits/static/js/alfred-views.js b/alfred/app.backup.weather_outfits/static/js/alfred-views.js new file mode 100644 index 0000000..0023e8f --- /dev/null +++ b/alfred/app.backup.weather_outfits/static/js/alfred-views.js @@ -0,0 +1,296 @@ +/* Alfred • visual views, powered by first-party Flask endpoints. No extra CDN. */ +(() => { + 'use strict'; + const root = document.getElementById('content'); + let current = null, camerasRefresh = null; + const DAYS = ['lun.','mar.','mer.','jeu.','ven.','sam.','dim.']; + const PALETTE = ['#77bcdf','#e5b677','#9dc99a','#d3a2d5','#8cb7c9','#ec9f95','#b6b5e1','#b9d78d']; + const el = (tag, cls, text) => { const x=document.createElement(tag); if(cls)x.className=cls; if(text!==undefined)x.textContent=String(text); return x; }; + const btn = (label, fn, cls='view-button') => { const b=el('button',cls,label); b.type='button'; b.addEventListener('click',fn);return b; }; + const api = async (url, options) => { + const r=await fetch(url,{cache:'no-store', ...options}); + const data=await r.json().catch(()=>({error:`Erreur HTTP ${r.status}`})); + if(!r.ok)throw new Error(data.error || `HTTP ${r.status}`); + return data; + }; + const post = (url,data) => api(url,{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify(data)}); + const dateLabel = iso => new Date(iso).toLocaleDateString('fr-FR',{day:'numeric',month:'long',year:'numeric'}); + const timeLabel = iso => new Date(iso).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}); + const localKey = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + const atDay = (d,h=0) => new Date(d.getFullYear(),d.getMonth(),d.getDate(),h); + const dayShift = (d,n) => new Date(d.getFullYear(),d.getMonth(),d.getDate()+n,d.getHours(),d.getMinutes()); + const eventOnDay=(ev,day)=> {const start=new Date(ev.start),end=new Date(ev.end);return startatDay(day);}; + const fmtRange=(a,b)=>`${dateLabel(a)} — ${dateLabel(new Date(new Date(b).getTime()-1000).toISOString())}`; + const wrap = (title) => { + clearInterval(camerasRefresh);camerasRefresh=null;root.replaceChildren(); + root.classList.add('alfred-view'); + const pane=el('div','view-pane'),head=el('header','view-head'); + head.append(el('h1','view-title',title)); + head.append(btn('×',goHome,'view-close')); + pane.append(head);root.append(pane);return {pane,head}; + }; + const showError=(err,pane)=>pane.append(el('p','view-error',err.message||String(err))); + const canRender = data=>['calendar','calendar_form','delete_candidates','temperatures','cameras','weather','home'].includes(data?.type); + function render(data){ + if(!canRender(data))return false; + current=data; + if(data.type==='calendar')drawCalendar(data); + if(data.type==='calendar_form')drawForm(data); + if(data.type==='delete_candidates')drawDeleteChoices(data); + if(data.type==='temperatures')drawTemps(data); + if(data.type==='weather')drawWeather(data); + if(data.type==='cameras')drawCameras(data); + if(data.type==='home'){root.classList.remove('alfred-view');root.replaceChildren(el('h1','', 'Bonjour.'), el('p','',data.text||'À ton écoute.')); clearInterval(camerasRefresh);camerasRefresh=null;} + return true; + } + const getSelected=()=>document.getElementById('calendar-source')?.value || ''; + async function loadCalendar(start,end,view,filter){ + const q=new URLSearchParams({start:new Date(start).toISOString(),end:new Date(end).toISOString()}); + if(filter)q.append('calendar_id',filter); + const data=await api('/api/calendar/events?'+q); + render({type:'calendar',view,start:new Date(start).toISOString(),end:new Date(end).toISOString(),...data,selected:filter||''}); + } + function moveCalendar(direction){ + if(!current || current.type!=='calendar')return; + const start=new Date(current.start),end=new Date(current.end),view=current.view; + let a,b; + if(view==='month'){ + a=new Date(start.getFullYear(),start.getMonth()+direction,1); + b=new Date(a.getFullYear(),a.getMonth()+1,1); + }else{ + const days=Math.round((end-start)/86400000) || 1; + a=dayShift(start,days*direction);b=dayShift(end,days*direction); + } + loadCalendar(a,b,view,getSelected()).catch(err=>{const {pane}=wrap('Agenda');showError(err,pane)}); + } + const details = (event) => { + const d=el('div','detail-modal'),box=el('section','detail-box'); + box.setAttribute('role','dialog');box.setAttribute('aria-modal','true');box.setAttribute('aria-label','Détails de l’événement'); + box.append(el('h2','',event.title)); + box.append(el('p','',`${dateLabel(event.start)}${event.all_day?' · Toute la journée':' · '+timeLabel(event.start)+'–'+timeLabel(event.end)}`)); + box.append(el('p','',event.calendar_name)); + if(event.location)box.append(el('p','',`Lieu : ${event.location}`)); + if(event.description)box.append(el('p','',event.description)); + const acts=el('div','view-actions');acts.append(btn('Fermer',()=>d.remove())); + if(event.can_delete) acts.append(btn('Supprimer…',async()=>{ + const recurrence=event.recurrence_id ? 'Cela supprimera cette occurrence.' : 'Pour une série récurrente, cela peut supprimer toute la série.'; + if(!confirm(`Supprimer « ${event.title} » du calendrier « ${event.calendar_name} » ?\n${recurrence}`))return; + try{await post('/api/calendar/delete',{calendar_id:event.calendar_id,uid:event.id,start:event.start,end:event.end,recurrence_id:event.recurrence_id||''}); d.remove();await loadCalendar(current.start,current.end,current.view,getSelected());} + catch(err){alert(err.message)} + },'view-danger')); + box.append(acts);d.append(box);document.body.append(d); + d.addEventListener('click',e=>{if(e.target===d)d.remove()}); + }; + function drawCalendar(data){ + const {pane,head}=wrap('Planning'); + const toolbar=el('div','view-toolbar'); + const first=el('div','view-nav'); + first.append(btn('‹',()=>moveCalendar(-1)),el('span','view-period',fmtRange(data.start,data.end)),btn('›',()=>moveCalendar(1))); + toolbar.append(first); + const modes=el('div','view-modes'); + [['Jour',1,'day'],['3 jours',3,'days'],['7 jours',7,'days'],['Semaine',7,'week'],['Mois',31,'month']].forEach(([label,n,v])=>{ + const b=btn(label,()=>{ + const now=new Date(),start=(v==='week')?dayShift(atDay(now),-((now.getDay()+6)%7)):v==='month'?new Date(now.getFullYear(),now.getMonth(),1):atDay(now); + const end=v==='month'?new Date(start.getFullYear(),start.getMonth()+1,1):dayShift(start,n); + loadCalendar(start,end,v,getSelected()).catch(err=>showError(err,pane)); + },'view-pill'+(data.view===v?' active':''));modes.append(b); + });toolbar.append(modes); + const src=el('select','view-select');src.id='calendar-source'; + src.append(new Option('Tous les calendriers','')); + (data.calendars||[]).forEach(c=>src.append(new Option(c.name,c.entity_id))); + src.value=data.selected||''; + src.addEventListener('change',()=>loadCalendar(data.start,data.end,data.view,src.value).catch(err=>showError(err,pane))); + toolbar.append(src,btn('+ Rendez-vous',()=>openForm())); + pane.append(toolbar); + if(data.errors?.length)pane.append(el('p','view-error','Calendriers partiellement indisponibles : '+data.errors.join(' · '))); + const grid=el('div','agenda-grid'); + const start=new Date(data.start),end=new Date(data.end); + let days=[],cursor=atDay(start); + const month=data.view==='month'; + if(month) cursor=dayShift(cursor,-((cursor.getDay()+6)%7)); + let limit=0; + while(cursor[c.entity_id, PALETTE[i%PALETTE.length]])); + days.forEach(d=>{ + const cell=el('section','agenda-day'); + if(month && d.getMonth()!==start.getMonth())cell.classList.add('out-month'); + if(localKey(d)===localKey(new Date()))cell.classList.add('is-today'); + cell.append(el('div','agenda-date',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:month?undefined:'short'}))); + const events=(data.events||[]).filter(ev=>eventOnDay(ev,d)); + if(!events.length)cell.append(el('div','agenda-empty','—')); + events.forEach(ev=>{ + const e=btn((ev.all_day?'':timeLabel(ev.start)+' ')+ev.title,()=>details(ev),'agenda-event'); + e.style.setProperty('--event-color',colors.get(ev.calendar_id)||'#7bbbe2'); + e.title=`${ev.title} · ${ev.calendar_name}`;cell.append(e); + }); + grid.append(cell); + }); + pane.append(grid); + pane.append(el('p','view-footer',`${data.events?.length||0} événement(s) · ${data.calendars?.length||0} calendrier(s) Home Assistant`)); + } + async function openForm(values){ + let calendars=[]; + try {const info=await api('/api/calendar/calendars');calendars=info.calendars||[];} + catch(err){const {pane}=wrap('Nouveau rendez-vous');showError(err,pane);return;} + render({type:'calendar_form',calendars,values:values||{}}); + } + function drawForm(data){ + const {pane}=wrap('Ajouter un événement'); + const v=data.values||{}; + const form=el('form','agenda-form');form.autocomplete='off'; + const field=(label,node)=>{const row=el('label','form-field');row.append(el('span','',label),node);form.append(row);return node}; + const makeInput=(type,value)=>{const i=el('input');i.type=type;i.value=value||'';i.required=true;return i}; + const title=field('Titre',makeInput('text',v.title));title.maxLength=200; + const src=el('select');src.required=true;src.append(new Option('Choisir un calendrier','')); + data.calendars.filter(c=>c.can_create).forEach(c=>src.append(new Option(c.name,c.entity_id))); + src.value=v.calendar_id||'';field('Calendrier',src); + const day=field('Date',makeInput('date',v.date||localKey(new Date()))); + const startTime=field('Début',makeInput('time',v.time||'09:00')); + const duration=field('Durée (minutes)',makeInput('number',v.duration_minutes||60));duration.min=15;duration.max=1440; + const location=field('Lieu (facultatif)',makeInput('text',''));location.required=false; + const description=field('Description (facultatif)',el('textarea'));description.required=false;description.rows=2; + const allDay=el('input');allDay.type='checkbox';allDay.required=false; + const allDayWrap=el('label','form-check');allDayWrap.append(allDay,el('span','','Toute la journée'));form.append(allDayWrap); + allDay.addEventListener('change',()=>{startTime.disabled=allDay.checked;duration.disabled=allDay.checked}); + const msg=el('p','form-message'); + const actions=el('div','view-actions');actions.append(btn('Annuler',goHome)); + const submit=el('button','view-button primary','Enregistrer');submit.type='submit';actions.append(submit); + form.append(actions,msg);pane.append(form); + form.addEventListener('submit',async e=>{ + e.preventDefault();submit.disabled=true;msg.textContent='Enregistrement…'; + try{ + const from=allDay.checked?new Date(day.value+'T00:00'):new Date(day.value+'T'+startTime.value+':00'); + if(Number.isNaN(from.getTime()))throw new Error('Date invalide.'); + const end=allDay.checked?dayShift(from,1):new Date(from.getTime()+Number(duration.value)*60000); + await post('/api/calendar/create',{calendar_id:src.value,title:title.value,start:from.toISOString(),end:end.toISOString(),all_day:allDay.checked,location:location.value,description:description.value}); + const first=atDay(from);await loadCalendar(first,dayShift(first,7),'days'); + }catch(err){msg.textContent=err.message;submit.disabled=false} + }); + } + function drawDeleteChoices(data){ + const {pane}=wrap('Choisir un événement à supprimer'); + const arr=el('div','delete-list'); + (data.events||[]).forEach(ev=>{ + const row=el('div','delete-row');row.append(el('span','',`${ev.title} • ${dateLabel(ev.start)} • ${ev.calendar_name}`)); + row.append(btn('Détails / Supprimer',()=>details(ev),'view-button'));arr.append(row); + });pane.append(arr); + if(data.awaiting_confirmation)pane.append(el('p','view-footer','Dis « confirme » pour supprimer, ou « annule ».')); + } + function drawTemps(data){ + const {pane}=wrap('Températures Home Assistant'); + if(!data.items?.length){pane.append(el('p','view-empty','Aucun capteur de température trouvé.'));return} + const grid=el('div','temperature-grid');pane.append(grid); + data.items.forEach(item=>{ + const c=btn(`${item.name}\n${item.value.toFixed(1)} ${item.unit}` ,async()=>{ + const q=new URLSearchParams({entity_id:item.entity_id,hours:'24'}); + try{const data=await api('/api/ha/temperature/history?'+q);showHistory(item,data.items||[],pane)} + catch(err){showError(err,pane)} + },'temperature-card');grid.append(c); + });pane.append(el('p','view-footer','Touchez un capteur pour afficher son historique sur 24 h.')); + } + function showHistory(item,points,pane){ + pane.querySelector('.temperature-history')?.remove(); + const section=el('section','temperature-history');section.append(el('h2','',item.name+' · 24 dernières heures')); + if(points.length<2){section.append(el('p','', 'Historique insuffisant.'));pane.append(section);return} + const vals=points.map(p=>p.value);const lo=Math.min(...vals)-0.4,hi=Math.max(...vals)+0.4; + const a=Date.parse(points[0].time),b=Date.parse(points[points.length-1].time); + const coords=points.map((p,i)=>`${30+560*((Date.parse(p.time)-a)/Math.max(1,b-a))},${140-110*((p.value-lo)/Math.max(0.1,hi-lo))}`).join(' '); + const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 620 170');svg.setAttribute('role','img');svg.setAttribute('aria-label','Courbe de température'); + const poly=document.createElementNS(ns,'polyline');poly.setAttribute('points',coords);poly.setAttribute('fill','none');poly.setAttribute('stroke','#83c5cc');poly.setAttribute('stroke-width','3');svg.append(poly); + section.append(svg,el('p','',`Min ${Math.min(...vals).toFixed(1)}° · Max ${Math.max(...vals).toFixed(1)}°`));pane.append(section); + } + function drawCameras(data){ + const {pane}=wrap('Caméras Home Assistant'); + if(!data.items?.length){pane.append(el('p','view-empty','Aucune caméra Home Assistant disponible.'));return} + const grid=el('div','ha-cameras');pane.append(grid); + const imgs=[]; + data.items.forEach(item=>{ + const card=el('div','ha-camera-card');card.append(el('h2','',item.name)); + const img=el('img');img.alt='Instantané caméra '+item.name;img.loading='lazy';imgs.push([img,item.entity_id]);card.append(img);grid.append(card); + }); + const reload=()=>{if(document.hidden)return; imgs.forEach(([img,eid])=>img.src='/api/ha/camera/'+encodeURIComponent(eid)+'/snapshot?t='+Date.now())}; + reload();camerasRefresh=setInterval(reload,15000); + pane.append(el('p','view-footer','Instantanés réactualisés toutes les 15 s lorsque cette vue est ouverte.')); + } + + const WEATHER_ICONS = { + 'sunny':'☀', 'clear-night':'☾', 'cloudy':'☁', 'partlycloudy':'⛅', + 'fog':'≋', 'rainy':'🌧', 'pouring':'🌧', 'hail':'🌨', + 'lightning':'⛈', 'lightning-rainy':'⛈', 'snowy':'❄', + 'snowy-rainy':'🌨', 'windy':'〰', 'windy-variant':'〰' + }; + const weatherIcon = cond => WEATHER_ICONS[cond] || '◌'; + const tempText = (val, unit='°C') => typeof val === 'number' ? `${Math.round(val)}${unit}` : '—'; + function drawWeather(data){ + const {pane}=wrap('Météo · '+(data.name||'Home Assistant')); + const nav=el('div','weather-toolbar'); + nav.append(btn('Actualiser',openWeather,'view-pill')); + nav.append(el('span','view-footer','Source : Home Assistant')); + pane.append(nav); + const c=data.current||{},now=el('section','weather-now'); + const badge=el('div','weather-current-icon',weatherIcon(c.condition));badge.setAttribute('aria-hidden','true'); + const details=el('div','weather-now-details'); + details.append(el('h2','',`${tempText(c.temperature,data.temperature_unit)} · ${c.description||'Conditions inconnues'}`)); + const metrics=[]; + if(c.apparent_temperature!==null && c.apparent_temperature!==undefined)metrics.push(`Ressenti ${tempText(c.apparent_temperature,data.temperature_unit)}`); + if(c.humidity!==null && c.humidity!==undefined)metrics.push(`Humidité ${c.humidity}%`); + if(c.wind_speed!==null && c.wind_speed!==undefined)metrics.push(`Vent ${c.wind_speed} ${data.wind_speed_unit||'km/h'}`); + details.append(el('p','',metrics.join(' · ')||'Conditions actuelles'));now.append(badge,details);pane.append(now); + const heading = text => pane.append(el('h2','weather-section-title',text)); + heading('Prévisions pour les prochains jours'); + if(data.daily?.length){ + const cards=el('div','weather-days'); + data.daily.slice(0,7).forEach(day=>{ + const d=new Date(day.datetime); + const box=el('article','weather-day'); + box.append(el('p','weather-day-name',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:'short'}))); + box.append(el('div','weather-day-icon',weatherIcon(day.condition))); + box.append(el('p','weather-day-condition',day.description)); + box.append(el('p','weather-day-temp',tempText(day.temperature,data.temperature_unit))); + if(day.templow!==null)box.append(el('p','weather-day-low','Min '+tempText(day.templow,data.temperature_unit))); + if(day.precipitation_probability!==null)box.append(el('p','weather-rain','Pluie '+day.precipitation_probability+' %')); + cards.append(box); + });pane.append(cards); + }else pane.append(el('p','view-empty','Prévisions journalières non disponibles.')); + heading('Prochaines heures'); + if(data.hourly?.length){ + const list=el('div','weather-hours'), nowMs=Date.now(); + data.hourly.filter(x=>new Date(x.datetime).getTime()>=nowMs-3600000).slice(0,12).forEach(h=>{ + const box=el('div','weather-hour'); + box.append(el('span','',new Date(h.datetime).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}))); + box.append(el('span','weather-hour-icon',weatherIcon(h.condition))); + box.append(el('strong','',tempText(h.temperature,data.temperature_unit))); + if(h.precipitation_probability!==null)box.append(el('span','weather-rain',h.precipitation_probability+' %')); + list.append(box); + });pane.append(list); + }else pane.append(el('p','view-empty','Prévisions horaires non disponibles.')); + if(data.errors?.length)pane.append(el('p','view-footer','Certaines prévisions ne sont pas fournies par cette source météo.')); + } + async function openWeather(){ + try{render(await api('/api/ha/weather'))} + catch(err){const {pane}=wrap('Météo');showError(err,pane)} + } + + async function goHome(){ + try{await post('/api/ui/home',{})}catch(err){render({type:'home'})} + } + async function openCalendar(){ + const d=atDay(new Date());try{await loadCalendar(d,dayShift(d,7),'days')}catch(err){const {pane}=wrap('Agenda');showError(err,pane)} + } + async function openTemperatures(){ + try{render({type:'temperatures',...await api('/api/ha/temperatures')})}catch(err){const {pane}=wrap('Températures');showError(err,pane)} + } + async function openCameras(){ + try{render({type:'cameras',...await api('/api/ha/cameras')})}catch(err){const {pane}=wrap('Caméras');showError(err,pane)} + } + document.getElementById('viewHome')?.addEventListener('click',goHome); + document.getElementById('viewCalendar')?.addEventListener('click',openCalendar); + document.getElementById('viewTemperatures')?.addEventListener('click',openTemperatures); + document.getElementById('viewWeather')?.addEventListener('click',openWeather); + document.getElementById('viewCameras')?.addEventListener('click',openCameras); + const socket=window.alfredSocket; + socket?.on('calendar_changed',()=>{if(current?.type==='calendar') loadCalendar(current.start,current.end,current.view,getSelected()).catch(console.warn)}); + window.AlfredViews={render,canRender,openCalendar,openTemperatures,openCameras,openWeather,goHome}; +})(); diff --git a/alfred/app.backup.weather_outfits/static/js/alfred.js b/alfred/app.backup.weather_outfits/static/js/alfred.js new file mode 100644 index 0000000..b143532 --- /dev/null +++ b/alfred/app.backup.weather_outfits/static/js/alfred.js @@ -0,0 +1,126 @@ +/* Interface principale Alfred : statut, scène, onde et transcription Vosk. */ +(() => { + 'use strict'; + const socket = (typeof io === 'function') ? io() : null; + window.alfredSocket = socket; + + const content = document.getElementById('content'); + const modeEl = document.getElementById('mode'); + const statusDot = document.getElementById('statusDot'); + const transcriptText = document.getElementById('transcriptText'); + const transcriptState = document.getElementById('transcriptState'); + const transcriptPanel = document.querySelector('.transcript-panel'); + const canvas = document.getElementById('waveCanvas'); + const ctx = canvas.getContext('2d'); + + let mode = 'idle', level = 0.02, phase = 0, previousContent = ''; + const labels = { + idle:'En attente', waiting:'En attente', listening:'À l’écoute', + thinking:'Réflexion en cours', speaking:'Alfred parle', error:'Erreur' + }; + + function resize() { + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(canvas.clientWidth * dpr); + canvas.height = Math.round(canvas.clientHeight * dpr); + } + window.addEventListener('resize', resize); + resize(); + function draw() { + const dpr = window.devicePixelRatio || 1; + phase += 0.035; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = 'rgba(232,241,243,.76)'; + ctx.lineWidth = 1.6 * dpr; + ctx.beginPath(); + const mid = canvas.height / 2; + const amp = (mode === 'idle' ? 1.5 : mode === 'thinking' ? 7 : Math.max(5, level * 45)) * dpr; + for (let x = 0; x < canvas.width; x += 4 * dpr) { + const y = mid + Math.sin(x * 0.018 + phase * 4) * Math.sin(x * 0.006 + phase) * amp * + (0.35 + 0.65 * Math.sin(x / canvas.width * Math.PI)); + if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + window.requestAnimationFrame(draw); + } + window.requestAnimationFrame(draw); + + function makeText(tag, value, className) { + const el = document.createElement(tag); + if (className) el.className = className; + el.textContent = String(value ?? ''); + return el; + } + + function render(data) { + if (!data || typeof data !== 'object') return; + const key = JSON.stringify(data); + if (key === previousContent) return; + previousContent = key; + if (window.AlfredViews && window.AlfredViews.canRender(data)) { + window.AlfredViews.render(data); + return; + } + content.classList.remove('alfred-view'); + const fragment = document.createDocumentFragment(); + if (data.type === 'image' && typeof data.url === 'string') { + // Refuser les URL javascript: et les schémas non prévus. + if (/^(https?:\/\/|\/[^/])/i.test(data.url)) { + const img = document.createElement('img'); + img.className = 'hero'; img.src = data.url; img.alt = String(data.alt || 'Image affichée par Alfred'); + fragment.appendChild(img); + } + if (data.caption) fragment.appendChild(makeText('p', data.caption)); + } else if (data.type === 'cards' && Array.isArray(data.items)) { + const cards = document.createElement('div'); cards.className = 'cards'; + data.items.forEach(item => { + const card = makeText('article', '', 'card'); + card.append(makeText('h2', item.title || ''), makeText('p', item.text || '')); + cards.appendChild(card); + }); + fragment.appendChild(cards); + } else { + if (data.title) fragment.appendChild(makeText('h1', data.title)); + fragment.appendChild(makeText('p', data.text || '')); + } + content.replaceChildren(fragment); + } + + function updateTranscript(s) { + // Une phrase finale reste visible lors du retour en veille. + const finalText = String(s.transcript || '').trim(); + const partial = String(s.partial || '').trim(); + if (finalText) { + transcriptText.textContent = finalText; + transcriptText.classList.remove('is-placeholder'); + transcriptState.textContent = 'Reconnu'; + } else if (s.mode === 'listening') { + transcriptText.textContent = partial || 'Je vous écoute…'; + transcriptText.classList.toggle('is-placeholder', !partial); + transcriptState.textContent = partial ? 'En cours' : 'Écoute'; + } else if (s.mode === 'error') { + transcriptState.textContent = 'Erreur'; + } + transcriptPanel.dataset.active = String(s.mode === 'listening'); + } + + function updateState(s) { + if (!s || typeof s !== 'object') return; + mode = String(s.mode || 'idle').toLowerCase(); + modeEl.textContent = labels[mode] || mode; + modeEl.dataset.mode = mode; + statusDot.dataset.mode = mode; + updateTranscript(s); + if (s.content) render(s.content); + } + + if (socket) { + socket.on('alfred_state', updateState); + socket.on('alfred_render', render); + socket.on('audio_level', data => { level = Math.max(0, Math.min(1, Number(data.level) || 0)); }); + socket.on('connect', () => { + fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {}); + }); + } + fetch('/api/state', {cache:'no-store'}).then(r => r.json()).then(updateState).catch(() => {}); +})(); diff --git a/alfred/app.backup.weather_outfits/templates/configuration.html b/alfred/app.backup.weather_outfits/templates/configuration.html new file mode 100644 index 0000000..6746c73 --- /dev/null +++ b/alfred/app.backup.weather_outfits/templates/configuration.html @@ -0,0 +1,130 @@ + + + + + + Alfred — Configuration audio + + + +
+
+

Configuration

+

Alfred · Paramètres audio

+
+
+
+
+ + 50 % +
+ +
+
+
+ + + Couper le haut-parleur + + +
+

Désactive les réponses vocales d’Alfred.

+
+
+
+ + + Couper le microphone + + +
+

Suspend l’écoute et la détection du mot « Alfred ».

+
+
+

+
+ + + diff --git a/alfred/app.backup.weather_outfits/templates/index.html b/alfred/app.backup.weather_outfits/templates/index.html new file mode 100644 index 0000000..54bd6bc --- /dev/null +++ b/alfred/app.backup.weather_outfits/templates/index.html @@ -0,0 +1,133 @@ + + + + + + + {{ name }} + + + + + + +
+
+ +
+ + En attente +
+ +
+ + + + + + + + +
+
+ + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + + +
+ + + + + + + + diff --git a/alfred/app.backup.weather_outfits/templates/index.html.1 b/alfred/app.backup.weather_outfits/templates/index.html.1 new file mode 100644 index 0000000..8b35c18 --- /dev/null +++ b/alfred/app.backup.weather_outfits/templates/index.html.1 @@ -0,0 +1,31 @@ + + + + + + {{name}} + + + + + +
+
{{name}}repos
+

Bonjour.

À votre écoute.

+
+
+ + + + 70 % + + + + — + +
+
+ + + + diff --git a/alfred/app.backup.weather_outfits/templates/index.html.ori b/alfred/app.backup.weather_outfits/templates/index.html.ori new file mode 100644 index 0000000..d0b38fd --- /dev/null +++ b/alfred/app.backup.weather_outfits/templates/index.html.ori @@ -0,0 +1,2 @@ +{{name}} +
{{name}}repos

Bonjour.

À votre écoute.

diff --git a/alfred/app.backup.weather_outfits/test.py b/alfred/app.backup.weather_outfits/test.py new file mode 100644 index 0000000..95385a2 --- /dev/null +++ b/alfred/app.backup.weather_outfits/test.py @@ -0,0 +1,19 @@ +python - <<'PY' +import sounddevice as sd +import soundfile as sf + +print(sd.query_devices()) +print("Microphone utilisé :", sd.default.device) + +print("Parle pendant 8 secondes...") +audio = sd.rec( + int(8 * 16000), + samplerate=16000, + channels=1, + dtype="int16" +) +sd.wait() + +sf.write("/tmp/alfred-micro.wav", audio, 16000) +print("Enregistrement terminé : /tmp/alfred-micro.wav") +PY diff --git a/alfred/app/__pycache__/app.cpython-313.pyc b/alfred/app/__pycache__/app.cpython-313.pyc index 09d2b8b..6b7ea88 100644 Binary files a/alfred/app/__pycache__/app.cpython-313.pyc and b/alfred/app/__pycache__/app.cpython-313.pyc differ diff --git a/alfred/app/app.py b/alfred/app/app.py index 34e7d75..b7f447b 100644 --- a/alfred/app/app.py +++ b/alfred/app/app.py @@ -1,4 +1,6 @@ import os, threading, hmac +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo from flask import Flask, render_template, request, jsonify, Response from flask_socketio import SocketIO from dotenv import load_dotenv @@ -7,6 +9,8 @@ from services.wakeword import WakeWordListener from services.hermes_client import ask_hermes from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1 from services.camera import video_stream +from services import ha_views, alfred_views_controller +from services import camera_settings, ha_weather, weather_outfits load_dotenv() @@ -25,6 +29,8 @@ conversation_lock=threading.Lock() mic_stop_event=threading.Event() audio_settings.initialize() audio_settings.update_settings({"mic_gain": 30}) +# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente. +camera_settings.restore_saved_settings() def emit_state(mode=None, **extra): with STATE_LOCK: @@ -73,6 +79,28 @@ def conversation(score=1.0): content={"type": "text", "text": r.text} ) + # Vue dynamique : commandes déterministes exécutées sans faire + # dépendre l'affichage des capacités ou sessions Hermes. + visual = None + try: + visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type')) + except (ha_views.HAError, ValueError) as exc: + print(f"[ALFRED] Vue indisponible : {exc}", flush=True) + visual = ({"type":"text", "text":str(exc)}, str(exc)) + + if visual is not None: + content, speech = visual + with STATE_LOCK: + STATE["content"] = content + socketio.emit("alfred_render", content) + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + emit_state("idle", partial="") + return + print("[ALFRED] Envoi à Hermes", flush=True) # ComfyUI publie l'image pendant cet appel Hermes. with STATE_LOCK: @@ -154,6 +182,41 @@ def set_audio_settings(): socketio.emit('audio_settings', new) return jsonify(new) +@app.get('/api/camera/settings') +def get_camera_settings(): + try: + return jsonify(camera_settings.get_settings()) + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.post('/api/camera/settings') +def set_camera_settings(): + # Même garde-fou que les commandes calendrier existantes. + if not _same_origin_json(): + return jsonify({'error': 'Requête non autorisée.'}), 403 + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'JSON invalide.'}), 400 + try: + result = camera_settings.update_setting(data.get('control'), data.get('value')) + socketio.emit('camera_settings', result) + return jsonify(result) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.post('/api/camera/reset') +def reset_camera_settings(): + if not _same_origin_json(): + return jsonify({'error': 'Requête non autorisée.'}), 403 + try: + result = camera_settings.reset_defaults() + socketio.emit('camera_settings', result) + return jsonify(result) + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + @app.get('/api/camera/stream') def camera_stream(): # Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan. @@ -208,6 +271,139 @@ def render_payload(): STATE["content"] = data socketio.emit("alfred_render", data) return jsonify({"ok": True, "image_revision": IMAGE_REVISION}) +# --- Agenda / capteurs / caméras Home Assistant --- +def _calendar_range(): + tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) + now = datetime.now(tz) + def parse(name, default): + raw = request.args.get(name) + if not raw:return default + value = datetime.fromisoformat(raw.replace('Z','+00:00')) + if value.tzinfo is None: value = value.replace(tzinfo=tz) + return value.astimezone(tz) + start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0)) + end = parse('end',start+timedelta(days=7)) + return start,end + +def _json_error(exc): + code = 400 if isinstance(exc,ValueError) else 502 + return jsonify({'error':str(exc)}),code + +def _writes_enabled(): + return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes') + +def _same_origin_json(): + # POSTs require a custom header and same-origin browser requests. + if request.headers.get('X-Alfred-Request') != '1':return False + origin=request.headers.get('Origin') + if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False + return True + +@app.get('/api/calendar/calendars') +def calendars_list(): + try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/calendar/events') +def calendar_events(): + try: + start,end=_calendar_range() + selected=request.args.getlist('calendar_id') + return jsonify(ha_views.get_events(start,end,selected or None)) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.post('/api/calendar/create') +def calendar_create(): + if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 + if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403 + data=request.get_json(silent=True) + if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 + try: + tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris')) + def read_dt(key): + dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00')) + return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz) + result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')), + read_dt('start'),read_dt('end'),str(data.get('description') or ''), + str(data.get('location') or ''),bool(data.get('all_day',False))) + from services import alfred_intents + alfred_intents.clear_create() + socketio.emit('calendar_changed', {'type':'created'}) + return jsonify(result) + except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) + +@app.post('/api/calendar/delete') +def calendar_delete(): + if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 + if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403 + data=request.get_json(silent=True) + if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 + try: + eid=str(data.get('calendar_id',''));uid=str(data.get('uid','')) + start=datetime.fromisoformat(str(data['start'])) + end=datetime.fromisoformat(str(data['end'])) + if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.') + matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events'] + rid=str(data.get('recurrence_id') or '') + if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches): + return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409 + result=ha_views.delete_event(eid,uid,rid or None) + from services import alfred_intents + alfred_intents.clear_delete() + socketio.emit('calendar_changed',{'type':'deleted'}) + return jsonify(result) + except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) + +@app.get('/api/ha/weather') +def ha_weather_data(): + # Read-only route, token always stays on the Alfred server. + try: + q = request.args.get('q', '') + return jsonify(ha_weather.get_weather(q)) + except (ha_views.HAError, ValueError) as exc: + return _json_error(exc) + +@app.get('/api/ha/weather/outfits') +def ha_weather_outfits_data(): + try: + q = request.args.get('q', '') + refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on') + return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh)) + except (ha_views.HAError, ValueError, RuntimeError) as exc: + return _json_error(exc) + +@app.get('/api/ha/temperatures') +def ha_temperatures(): + try:return jsonify({'items':ha_views.get_temperatures()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/temperature/history') +def ha_temperature_history(): + try: + eid=request.args.get('entity_id','') + hours=int(request.args.get('hours','24')) + return jsonify({'items':ha_views.temperature_history(eid,hours)}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/cameras') +def ha_cameras(): + try:return jsonify({'items':ha_views.list_cameras()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/camera//snapshot') +def ha_camera_snapshot(entity_id): + try: + binary,mime=ha_views.camera_snapshot(entity_id) + return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.post('/api/ui/home') +def ui_home(): + with STATE_LOCK: + STATE['content']={'type':'home','text':'À ton écoute.'} + socketio.emit('alfred_render',STATE['content']) + return jsonify({'ok':True}) + @app.post("/api/audio-level") def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) @app.get("/api/wake/status") diff --git a/alfred/app/cache/weather_outfit_cache.json b/alfred/app/cache/weather_outfit_cache.json new file mode 100644 index 0000000..197e8c1 --- /dev/null +++ b/alfred/app/cache/weather_outfit_cache.json @@ -0,0 +1,195 @@ +{ + "fd153f67af106d2b88ea4e83adbcdf9acef9c76f43458bd3399b01344439c872": { + "url": "http://192.168.1.13:8188/view?filename=ComfyUI_00296_.png&subfolder=&type=output", + "filename": "ComfyUI_00296_.png", + "prompt_id": "384abef2-fd03-43a8-8e79-d9badd2d0e48", + "prompt": "Crée une illustration verticale propre pour un tableau météo d’un assistant domestique. Montre en pied deux personnages côte à côte, style illustration moderne semi-réaliste douce, lisible, familiale, fond simple et discret. À gauche : une fille de 8 ans. À droite : un homme adulte de 42 ans. Contexte météo pour aujourd’hui: pluie, température maximale 13.0°C, température minimale 10.1°C, probabilité de pluie 100.0%, vent 35.7 km/h. Habille les personnages selon les recommandations suivantes. Fille 8 ans: vêtements veste légère, pantalon, baskets, chaussures fermées; accessoires imperméable, parapluie, coupe-vent. Homme 42 ans: vêtements veste mi-saison, pull fin, jean, chaussures fermées; accessoires parapluie, coupe-vent. Aucun texte dans l’image. Montrer clairement les tenues et accessoires conseillés. Composition propre, adaptée à une interface d’assistant, haute qualité.", + "cached_at": 1792965507.1873505, + "from_cache": false + }, + "5ca9c2930c4ae2e10a726605a9219539a17d8f14da6556e690ab6101ad7646ab": { + "url": "http://192.168.1.13:8188/view?filename=ComfyUI_00298_.png&subfolder=&type=output", + "filename": "ComfyUI_00298_.png", + "prompt_id": "8355af62-71d5-480c-bf61-bcaf4c4ca084", + "prompt": "Professional clean flat 2D digital illustration, like a high quality family clothing lookbook, square composition, TWO human characters EXACTLY, side by side, full body, comfortable spacing, simple plain studio background, no text, no captions, no logos. LEFT CHARACTER: one little GIRL, 8 YEARS OLD, unmistakably a primary school CHILD, shorter and much smaller than the adult, innocent age-appropriate appearance, child body proportions, simple pleasant face, standing naturally. RIGHT CHARACTER: one ADULT MAN, AGE 42, unmistakably a mature masculine man, adult height and mature adult male body proportions, short hair, subtly defined adult male jaw, neatly trimmed beard optional, clearly NOT a teenager, NOT a woman. Precisely two people: ONE small child girl and ONE adult man. Symmetrical well-defined faces, two natural eyes per face, normal hands, five fingers per hand, no distorted features, tidy linework, modest friendly cartoon styling instead of photorealism. Weather context for 2026-10-26: pluie, maximum temperature 13.4 Celsius, minimum temperature 9.4 Celsius, precipitation probability 100.0%, wind speed 28.1 km/h. Outfits must match the following French clothing specifications. LEFT child GIRL (8): clothing: veste légère, pantalon, baskets, chaussures fermées; accessories: imperméable, parapluie, coupe-vent. RIGHT ADULT MAN (42): clothing: veste mi-saison, pull fin, jean, chaussures fermées; accessories: parapluie, coupe-vent. Show clothing clearly, accurate head-to-toe outfits. Exactly one child and one adult male.", + "cached_at": 1792966964.8393974, + "from_cache": false + }, + "1b7492941fb642439e00c1e4a2d1124dad2d9d27e9f8573ae372334b3f250b64": { + "url": "http://192.168.1.13:8188/view?filename=ComfyUI_00299_.png&subfolder=&type=output", + "filename": "ComfyUI_00299_.png", + "prompt_id": "1bb8236f-14dd-46ef-8477-22cf2ef1f6eb", + "prompt": "Professional clean flat 2D digital illustration, like a high quality family clothing lookbook, square composition, TWO human characters EXACTLY, side by side, full body, comfortable spacing, simple plain studio background, no text, no captions, no logos. LEFT CHARACTER: one little GIRL, 8 YEARS OLD, unmistakably a primary school CHILD, shorter and much smaller than the adult, innocent age-appropriate appearance, child body proportions, simple pleasant face, standing naturally. RIGHT CHARACTER: one ADULT MAN, AGE 42, unmistakably a mature masculine man, adult height and mature adult male body proportions, short hair, subtly defined adult male jaw, neatly trimmed beard optional, clearly NOT a teenager, NOT a woman. Precisely two people: ONE small child girl and ONE adult man. Symmetrical well-defined faces, two natural eyes per face, normal hands, five fingers per hand, no distorted features, tidy linework, modest friendly cartoon styling instead of photorealism. Weather context for 2026-10-28: pluie, maximum temperature 14.0 Celsius, minimum temperature 9.7 Celsius, precipitation probability 30.0%, wind speed 18.6 km/h. Outfits must match the following French clothing specifications. LEFT child GIRL (8): clothing: sweat ou gilet, pantalon léger, baskets, chaussures fermées; accessories: imperméable, parapluie. RIGHT ADULT MAN (42): clothing: veste légère, haut confortable, jean ou chino, baskets, chaussures fermées; accessories: parapluie. Show clothing clearly, accurate head-to-toe outfits. Exactly one child and one adult male.", + "cached_at": 1792966987.7316265, + "from_cache": false + }, + "8c852151bb6532f9e82a1eff1be4c8046c3e7f7e150415878e62c4026f1cc943": { + "url": "http://192.168.1.13:8188/view?filename=ComfyUI_00300_.png&subfolder=&type=output", + "filename": "ComfyUI_00300_.png", + "prompt_id": "f32838b4-07d6-4e33-b400-97f0b65ce964", + "prompt": "Professional clean flat 2D digital illustration, like a high quality family clothing lookbook, square composition, TWO human characters EXACTLY, side by side, full body, comfortable spacing, simple plain studio background, no text, no captions, no logos. LEFT CHARACTER: one little GIRL, 8 YEARS OLD, unmistakably a primary school CHILD, shorter and much smaller than the adult, innocent age-appropriate appearance, child body proportions, simple pleasant face, standing naturally. RIGHT CHARACTER: one ADULT MAN, AGE 42, unmistakably a mature masculine man, adult height and mature adult male body proportions, short hair, subtly defined adult male jaw, neatly trimmed beard optional, clearly NOT a teenager, NOT a woman. Precisely two people: ONE small child girl and ONE adult man. Symmetrical well-defined faces, two natural eyes per face, normal hands, five fingers per hand, no distorted features, tidy linework, modest friendly cartoon styling instead of photorealism. Weather context for 2026-10-27: pluie, maximum temperature 18.7 Celsius, minimum temperature 12.1 Celsius, precipitation probability 100.0%, wind speed 23.5 km/h. Outfits must match the following French clothing specifications. LEFT child GIRL (8): clothing: sweat ou gilet, pantalon léger, baskets, chaussures fermées; accessories: imperméable, parapluie. RIGHT ADULT MAN (42): clothing: veste légère, haut confortable, jean ou chino, baskets, chaussures fermées; accessories: parapluie. Show clothing clearly, accurate head-to-toe outfits. Exactly one child and one adult male.", + "cached_at": 1792967562.554482, + "from_cache": false + }, + "0426920d0a11983e7ac1459cd87d1a58073380245ab906809991f1cdad65d0d2": { + "file": "0426920d0a11983e7ac1459cd87d1a58073380245ab906809991f1cdad65d0d2.jpg", + "prompt_ids": [ + "a7708758-2ed6-49f2-88c6-bd4066a4dcde", + "41839964-3f4e-406e-a937-9d40f9c4b615" + ], + "cached_at": 1792967868.400006 + }, + "3fc1f1e2c91374ed46944ef3b3938b8d8a9862d6055c66876b5e45af838e7e8a": { + "file": "3fc1f1e2c91374ed46944ef3b3938b8d8a9862d6055c66876b5e45af838e7e8a.jpg", + "prompt_ids": [ + "c32ee4f5-2181-4787-856b-6f2825905552", + "6209d148-7902-4ae5-8891-75bed8eff74b" + ], + "cached_at": 1792967885.8751926 + }, + "93379bdabb939699e8565f358c79b96de623fbffb33153aeaf847ad9b339631a": { + "file": "93379bdabb939699e8565f358c79b96de623fbffb33153aeaf847ad9b339631a.jpg", + "prompt_ids": [ + "eb4120f7-759e-4f42-98ef-7485184fb8ba", + "bf109e53-ee70-441a-8736-5613a2b1ac84" + ], + "cached_at": 1792967954.7709117 + }, + "6f4194e5dbd0d9c49cf5f520d02f229120608bb1f20ed4f610ab128cd4e5eea3": { + "file": "6f4194e5dbd0d9c49cf5f520d02f229120608bb1f20ed4f610ab128cd4e5eea3.jpg", + "prompt_ids": [ + "d292b5da-42fd-4fff-8445-5452de6390eb", + "e7e5b04d-41f1-4312-a450-e1b275d96115" + ], + "reviews": { + "child": { + "score": 7.0, + "ok": true, + "issues": [ + "review_fallback:'1'" + ] + }, + "adult": { + "score": 7.0, + "ok": true, + "issues": [ + "review_fallback:'1'" + ] + } + }, + "cached_at": 1792968490.5675747, + "focus_label": "aujourd’hui" + }, + "498692dd8981e4e5fa457d9fbab7d5666f38125afbb0812d7e72fac08ce24ad9": { + "file": "498692dd8981e4e5fa457d9fbab7d5666f38125afbb0812d7e72fac08ce24ad9.jpg", + "prompt_ids": [ + "01d1067a-1f4c-4772-987d-7e0d08f6dbcc", + "03b6f551-7694-4440-900c-01a38df696f1" + ], + "reviews": { + "child": { + "score": 7.0, + "ok": true, + "issues": [ + "review_fallback:'1'" + ] + }, + "adult": { + "score": 7.0, + "ok": true, + "issues": [ + "review_fallback:'1'" + ] + } + }, + "cached_at": 1792968530.2779512, + "focus_label": "mardi 27 octobre" + }, + "1caff5c005736b32c2c322165e7d2d57cc2d01e7247b458ffeef179851d13611": { + "file": "1caff5c005736b32c2c322165e7d2d57cc2d01e7247b458ffeef179851d13611.jpg", + "prompt_ids": [ + "980236c9-b301-495f-b789-1933ad1c4969", + "4a97dd41-d516-467a-bcc3-f2d359139b61" + ], + "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": 1792969350.0963697, + "focus_label": "aujourd’hui" + }, + "ebb79b8e11936267b3477c5d930536fc61cb226a4f357c09c21db8192ca134ff": { + "file": "ebb79b8e11936267b3477c5d930536fc61cb226a4f357c09c21db8192ca134ff.jpg", + "prompt_ids": [ + "39118811-54b6-4be8-a9c6-ec4662074bb6", + "982dab51-e2ed-43e1-8ea3-00f2bf02c87f" + ], + "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": 1792969363.4898813, + "focus_label": "mardi 27 octobre" + }, + "a4928b9950baed6aad15a8e69c6c09bb6b068eba6f2e78e1e126c42be66aced3": { + "file": "a4928b9950baed6aad15a8e69c6c09bb6b068eba6f2e78e1e126c42be66aced3.jpg", + "prompt_ids": [ + "e4a383a6-d6b8-4d54-944a-d4f6802d3951", + "dbec0a66-eb40-4988-b512-7a4c3503f02a" + ], + "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": 1792969491.7701209, + "focus_label": "samedi 31 octobre" + } +} \ No newline at end of file diff --git a/alfred/app/cache/weather_outfit_images/0426920d0a11983e7ac1459cd87d1a58073380245ab906809991f1cdad65d0d2.jpg b/alfred/app/cache/weather_outfit_images/0426920d0a11983e7ac1459cd87d1a58073380245ab906809991f1cdad65d0d2.jpg new file mode 100644 index 0000000..4b8cc5e Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/0426920d0a11983e7ac1459cd87d1a58073380245ab906809991f1cdad65d0d2.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/1caff5c005736b32c2c322165e7d2d57cc2d01e7247b458ffeef179851d13611.jpg b/alfred/app/cache/weather_outfit_images/1caff5c005736b32c2c322165e7d2d57cc2d01e7247b458ffeef179851d13611.jpg new file mode 100644 index 0000000..68efc5f Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/1caff5c005736b32c2c322165e7d2d57cc2d01e7247b458ffeef179851d13611.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/3fc1f1e2c91374ed46944ef3b3938b8d8a9862d6055c66876b5e45af838e7e8a.jpg b/alfred/app/cache/weather_outfit_images/3fc1f1e2c91374ed46944ef3b3938b8d8a9862d6055c66876b5e45af838e7e8a.jpg new file mode 100644 index 0000000..56203cd Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/3fc1f1e2c91374ed46944ef3b3938b8d8a9862d6055c66876b5e45af838e7e8a.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/498692dd8981e4e5fa457d9fbab7d5666f38125afbb0812d7e72fac08ce24ad9.jpg b/alfred/app/cache/weather_outfit_images/498692dd8981e4e5fa457d9fbab7d5666f38125afbb0812d7e72fac08ce24ad9.jpg new file mode 100644 index 0000000..0fc68e8 Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/498692dd8981e4e5fa457d9fbab7d5666f38125afbb0812d7e72fac08ce24ad9.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/6f4194e5dbd0d9c49cf5f520d02f229120608bb1f20ed4f610ab128cd4e5eea3.jpg b/alfred/app/cache/weather_outfit_images/6f4194e5dbd0d9c49cf5f520d02f229120608bb1f20ed4f610ab128cd4e5eea3.jpg new file mode 100644 index 0000000..a8274f9 Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/6f4194e5dbd0d9c49cf5f520d02f229120608bb1f20ed4f610ab128cd4e5eea3.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/93379bdabb939699e8565f358c79b96de623fbffb33153aeaf847ad9b339631a.jpg b/alfred/app/cache/weather_outfit_images/93379bdabb939699e8565f358c79b96de623fbffb33153aeaf847ad9b339631a.jpg new file mode 100644 index 0000000..9791bf0 Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/93379bdabb939699e8565f358c79b96de623fbffb33153aeaf847ad9b339631a.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/a4928b9950baed6aad15a8e69c6c09bb6b068eba6f2e78e1e126c42be66aced3.jpg b/alfred/app/cache/weather_outfit_images/a4928b9950baed6aad15a8e69c6c09bb6b068eba6f2e78e1e126c42be66aced3.jpg new file mode 100644 index 0000000..0d9794f Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/a4928b9950baed6aad15a8e69c6c09bb6b068eba6f2e78e1e126c42be66aced3.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/ebb79b8e11936267b3477c5d930536fc61cb226a4f357c09c21db8192ca134ff.jpg b/alfred/app/cache/weather_outfit_images/ebb79b8e11936267b3477c5d930536fc61cb226a4f357c09c21db8192ca134ff.jpg new file mode 100644 index 0000000..3adceaa Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/ebb79b8e11936267b3477c5d930536fc61cb226a4f357c09c21db8192ca134ff.jpg differ diff --git a/alfred/app/config/camera_controls.json b/alfred/app/config/camera_controls.json new file mode 100644 index 0000000..86998a2 --- /dev/null +++ b/alfred/app/config/camera_controls.json @@ -0,0 +1,16 @@ +{ + "controls": { + "brightness": 32, + "contrast": 42, + "saturation": 64, + "hue": 0, + "white_balance_automatic": 1, + "gamma": 300, + "gain": 64, + "power_line_frequency": 2, + "sharpness": 50, + "backlight_compensation": 0, + "auto_exposure": 3, + "exposure_dynamic_framerate": 1 + } +} diff --git a/alfred/app/config/weather_outfits.env b/alfred/app/config/weather_outfits.env new file mode 100644 index 0000000..94ebdab --- /dev/null +++ b/alfred/app/config/weather_outfits.env @@ -0,0 +1,22 @@ +# Regles de generation des tenues : /opt/alfred/app/config/weather_outfits.env +# Editer puis relancer : sudo systemctl restart alfred + +# Maximum 2 essais par personnage (1 seul si une image est deja suffisante) +ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS=2 +# Score de qualite attendu, lorsque le workflow vision peut analyser l'image +ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE=8.6 +# Dimensions de la vignette COMPOSEE, sur l'ecran d'Alfred +ALFRED_WEATHER_DISPLAY_SIZE=350 +# Evaluation via le workflow ComfyUI vision_analyze.json (si compatible) +ALFRED_WEATHER_REVIEW=true + +# 'auto' : image-vers-image si KSampler/VAEDecode compatibles, sinon texte +# 'required' : refuse la generation si l'image de reference est impossible +# 'off' : desactive les references +ALFRED_WEATHER_REFERENCE_MODE=auto +# Intensite de transformation de la reference (0.25 stable, 0.85 plus libre) +ALFRED_WEATHER_REFERENCE_DENOISE=0.56 +# Maintient visuellement les visages de reference dans le rendu img2img +ALFRED_WEATHER_PRESERVE_FACE=true +# Nombre maximum d'accessoires visibles par personnage +ALFRED_WEATHER_ACCESSORY_LIMIT=1 diff --git a/alfred/app/services/__pycache__/alfred_intents.cpython-313.pyc b/alfred/app/services/__pycache__/alfred_intents.cpython-313.pyc new file mode 100644 index 0000000..edc50db Binary files /dev/null and b/alfred/app/services/__pycache__/alfred_intents.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/alfred_views_controller.cpython-313.pyc b/alfred/app/services/__pycache__/alfred_views_controller.cpython-313.pyc new file mode 100644 index 0000000..d024885 Binary files /dev/null and b/alfred/app/services/__pycache__/alfred_views_controller.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/camera_settings.cpython-313.pyc b/alfred/app/services/__pycache__/camera_settings.cpython-313.pyc new file mode 100644 index 0000000..bd3e743 Binary files /dev/null and b/alfred/app/services/__pycache__/camera_settings.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/ha_views.cpython-313.pyc b/alfred/app/services/__pycache__/ha_views.cpython-313.pyc new file mode 100644 index 0000000..8942c86 Binary files /dev/null and b/alfred/app/services/__pycache__/ha_views.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/ha_weather.cpython-313.pyc b/alfred/app/services/__pycache__/ha_weather.cpython-313.pyc new file mode 100644 index 0000000..f3a15d9 Binary files /dev/null and b/alfred/app/services/__pycache__/ha_weather.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/weather_outfits.cpython-313.pyc b/alfred/app/services/__pycache__/weather_outfits.cpython-313.pyc new file mode 100644 index 0000000..29bb364 Binary files /dev/null and b/alfred/app/services/__pycache__/weather_outfits.cpython-313.pyc differ diff --git a/alfred/app/services/alfred_intents.py b/alfred/app/services/alfred_intents.py new file mode 100644 index 0000000..f2a2044 --- /dev/null +++ b/alfred/app/services/alfred_intents.py @@ -0,0 +1,189 @@ +"""Conservative French voice router. Never guesses dates for destructive actions.""" +from __future__ import annotations + +import os +import re +import threading +import time as monotonic_time +import unicodedata +from datetime import date, datetime, timedelta, time +from zoneinfo import ZoneInfo + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) +MONTHS = {m:i for i,m in enumerate('janvier fevrier mars avril mai juin juillet aout septembre octobre novembre decembre'.split(),1)} +DAYS = {'lundi':0,'mardi':1,'mercredi':2,'jeudi':3,'vendredi':4,'samedi':5,'dimanche':6} +PENDING_LOCK = threading.Lock() +PENDING_DELETE = {'value':None, 'expires':0} +PENDING_CREATE = {'value':None, 'expires':0} + + +def simplify(text): + return ''.join(c for c in unicodedata.normalize('NFD',text.lower()) if unicodedata.category(c)!='Mn') + + +def parse_day(s, now=None): + now = now or datetime.now(TZ) + text = simplify(s) + today = now.date() + if re.search(r'\b(apres.demain)\b',text):return today+timedelta(days=2) + if re.search(r'\bdemain\b',text):return today+timedelta(days=1) + if re.search(r'\b(aujourd.hui|ce matin|cet apres.midi|ce soir|cette nuit|dans la journee)\b',text):return today + m = re.search(r'\b(?:le\s+)?(\d{1,2})(?:er)?\s+(janvier|fevrier|mars|avril|mai|juin|juillet|aout|septembre|octobre|novembre|decembre)(?:\s+(20\d{2}))?\b',text) + if m: + month=MONTHS[m.group(2)];day=int(m.group(1));year=int(m.group(3)) if m.group(3) else today.year + candidate=date(year,month,day) + if not m.group(3) and candidate < today: candidate=date(year+1,month,day) + return candidate + m=re.search(r'\b(?:le\s+)?(\d{1,2})[/-](\d{1,2})(?:[/-](20\d{2}))?\b',text) + if m: + year=int(m.group(3)) if m.group(3) else today.year + candidate=date(year,int(m.group(2)),int(m.group(1))) + if not m.group(3) and candidate < today: candidate=date(year+1,candidate.month,candidate.day) + return candidate + for d, weekday in DAYS.items(): + if re.search(r'\b'+d+r'\b',text): + delta=(weekday-today.weekday())%7 + if 'prochain' in text and delta == 0: delta=7 + return today+timedelta(days=delta) + return None + + +def parse_clock(s): + text=simplify(s) + m=re.search(r'\b(?:a|vers|de|pour)\s+(\d{1,2})(?:\s*h(?:\s*(\d{1,2}))?|\s*heures?(?:\s*(\d{1,2}))?)\b',text) + if not m: return None + hh=int(m.group(1)); mm=int(m.group(2) or m.group(3) or 0) + if hh>23 or mm>59: raise ValueError('Heure invalide') + return time(hh,mm) + + +def period(s, now=None): + now = now or datetime.now(TZ) + text=simplify(s) + explicit_day=parse_day(text, now) + morning = 'matin' in text + afternoon = 'apres-midi' in text + evening = 'ce soir' in text or 'ce soir' in text + days=re.search(r'\b(?:sur|pour|pendant|les)?\s*(\d{1,2})\s*jours?\b',text) + if explicit_day: + start=datetime.combine(explicit_day,time.min,TZ) + end=start+timedelta(days=1) + if morning:start=start.replace(hour=6);end=start.replace(hour=12) + elif afternoon:start=start.replace(hour=12);end=start.replace(hour=18) + elif evening:start=start.replace(hour=18);end=(start+timedelta(days=1)).replace(hour=0) + return start,end,'day' + if days: + n=int(days.group(1)) + if not 1<=n<=31: raise ValueError('Demander entre 1 et 31 jours') + start=datetime.combine(now.date(),time.min,TZ) + return start,start+timedelta(days=n), 'days' + if 'mois' in text: + start=datetime(now.year,now.month,1,tzinfo=TZ) + next_month = datetime(now.year+int(now.month==12), now.month%12+1,1,tzinfo=TZ) + return start,next_month,'month' + if 'semaine' in text: + start=datetime.combine(now.date()-timedelta(days=now.weekday()),time.min,TZ) + if 'prochaine' in text:start+=timedelta(days=7) + return start,start+timedelta(days=7),'week' + start=datetime.combine(now.date(),time.min,TZ) + return start,start+timedelta(days=7),'days' + + +def classify(text): + t=simplify(text).strip() + if not t:return None + if re.search(r'\b(annule|non merci|laisse tomber)\b', t) and (current_delete() or current_create()):return {'kind':'cancel_pending'} + if re.search(r'\b(confirme|oui supprime|confirmer la suppression|oui c.est bon|oui valide)\b',t): + if current_delete():return {'kind':'confirm_delete'} + if current_create():return {'kind':'confirm_create'} + if re.search(r'\b(accueil|reviens|retour|ferme (le|la) (planning|calendrier|vue)|ecran principal)\b',t) and not re.search(r'\b(rendez.vous|reunion|evenement)\b',t):return {'kind':'home'} + if re.search(r'\b(montre|affiche|voir|regarde)\b.*\b(camera|cameras|video|videos)\b',t):return {'kind':'cameras'} + if re.search(r'\b(affiche|montre|donne|quelles|quelle|voir)\b.*\b(temperature|temperatures|capteurs|chauffage)\b',t):return {'kind':'temperatures'} + # Do not hijack general requests for images, HA or Hermes. + if re.search(r'\b(supprime|efface|retire|annule)\b.*\b(rendez.vous|evenement|agenda|calendrier|planning)\b',t):return {'kind':'delete'} + if re.search(r'\b(ajoute|ajouter|cree|creer|inscris|planifie|note)\b.*\b(rendez.vous|evenement|agenda|calendrier|planning)\b',t):return {'kind':'create'} + cal_words=r'\b(agenda|calendrier|planning|rendez.vous|evenements?)\b' + query_words=r'\b(pre[v]?u|prevu|programme|organise|j.ai|ai.je|quoi|qu.est.ce|choses?|occupe|libre)\b' + time_words=r'\b(demain|aujourd.hui|matin|soir|apres-midi|journee|semaine|mois|jours?|novembre|janvier|fevrier|mars|avril|mai|juin|juillet|aout|septembre|octobre|decembre)\b' + if re.search(cal_words,t) or (re.search(query_words,t) and re.search(time_words,t)) or ('8 novembre' in t): + return {'kind':'calendar', 'question': bool(re.search(query_words,t))} + return None + + +def extract_create(text, calendars): + """Parse straightforward instructions only. Missing/ambiguous fields become a prefilled form.""" + raw=text.strip(); normalized=simplify(raw) + day=parse_day(raw) + hour=parse_clock(raw) + options=[c for c in calendars if c.get('can_create')] + preferred=os.getenv('HA_DEFAULT_CALENDAR','').strip() + matching=[] + for c in options: + if simplify(c['name']) in normalized or simplify(c['entity_id'].split('.',1)[-1]).replace('_',' ') in normalized: + matching.append(c) + calendar = matching[0]['entity_id'] if len(matching)==1 else None + if calendar is None and preferred in {c['entity_id'] for c in options}:calendar=preferred + if calendar is None and len(options)==1:calendar=options[0]['entity_id'] + # Strip date and time syntax to derive a plausible title. + title=re.sub(r'^.*?\b(?:ajoute|ajouter|cree|creer|inscris|planifie|note)\b', '', normalized, count=1) + title=re.sub(r'\b(?:dans|sur)\s+(?:le\s+)?calendrier\s+[\w\s_-]+?(?=\b(?:le|demain|aujourd.hui|a)\b|$)', ' ', title) + title=re.sub(r'\b(?:le\s+)?\d{1,2}\s+(?:'+ '|'.join(MONTHS)+ r')(?:\s+20\d{2})?\b',' ',title) + title=re.sub(r'\b(?:demain|aujourd.hui|apres.demain|lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b',' ',title) + title=re.sub(r'\b(?:a|de)\s+\d{1,2}\s*(?:h|heures?)\s*\d{0,2}\b',' ',title) + title=re.sub(r'\b(?:dans|sur)\s+(?:le\s+)?(?:calendrier|agenda)\b.*$', ' ',title) + title=re.sub(r'^\s*(?:moi|un|une|le|la|les|rendez.vous|evenement|pour|de|a)\s+', '',title) + title=re.sub(r'\s+', ' ', title).strip(' ,.:') + # A guided confirmation is safer than using a guessed title. + title=title[:200] + data={'title':title,'calendar_id':calendar or '', 'date':day.isoformat() if day else '', + 'time':hour.strftime('%H:%M') if hour else '','duration_minutes':60} + complete=bool(day and hour and calendar and title and title not in ('calendrier','agenda','rendez vous')) + return data, complete + + +def extract_search(text): + normalized=simplify(text) + normalized=re.sub(r'^.*?\b(?:supprime|efface|retire|annule)\b','',normalized,count=1) + normalized=re.sub(r'\b(?:le\s+)?\d{1,2}\s+(?:'+ '|'.join(MONTHS)+ r')\b','',normalized) + normalized=re.sub(r'\b(demain|aujourd.hui|apres.demain|lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b','',normalized) + normalized=re.sub(r'\b(de|du|mon|ma|un|une|le|la|les|rendez.vous|evenement|agenda|calendrier|planning)\b',' ',normalized) + return re.sub(r'\s+',' ',normalized).strip() + + +def set_delete(event): + with PENDING_LOCK: + PENDING_DELETE['value']=event + PENDING_DELETE['expires']=monotonic_time.monotonic()+90 + + +def current_delete(): + with PENDING_LOCK: + if monotonic_time.monotonic()>PENDING_DELETE['expires']: + PENDING_DELETE['value']=None + return dict(PENDING_DELETE['value']) if PENDING_DELETE['value'] else None + + +def clear_delete(): + with PENDING_LOCK: + p=PENDING_DELETE['value'];PENDING_DELETE['value']=None + return p + + +def set_create(details): + with PENDING_LOCK: + PENDING_CREATE['value']=dict(details) + PENDING_CREATE['expires']=monotonic_time.monotonic()+90 + +def current_create(): + with PENDING_LOCK: + if monotonic_time.monotonic()>PENDING_CREATE['expires']: + PENDING_CREATE['value']=None + return dict(PENDING_CREATE['value']) if PENDING_CREATE['value'] else None + +def clear_create(): + with PENDING_LOCK: + p=PENDING_CREATE['value'];PENDING_CREATE['value']=None + return p + +def cancel_pending(): + clear_create();clear_delete() diff --git a/alfred/app/services/alfred_views_controller.py b/alfred/app/services/alfred_views_controller.py new file mode 100644 index 0000000..cc8238b --- /dev/null +++ b/alfred/app/services/alfred_views_controller.py @@ -0,0 +1,132 @@ +"""Fast voice routing for visual actions; Hermes still handles general tasks.""" +from __future__ import annotations + +import os +from datetime import datetime, timedelta, time +from zoneinfo import ZoneInfo + +from . import ha_views as ha +from . import alfred_intents as intents +from . import ha_weather + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris')) + + +def _label(s,e): + return f"du {s:%d/%m} au {(e-timedelta(days=1)):%d/%m}" + + +def _event_summary(events, question): + count=len(events) + if count == 0:return "Non, je n'ai trouvé aucun événement pour cette période." if question else 'Aucun événement prévu sur cette période.' + names=', '.join(ev['title'] for ev in events[:3]) + if question: + return f"Oui, {count} événement{'s' if count>1 else ''} prévu{'s' if count>1 else ''}. {names}." if count<=3 else f"Oui, {count} événements sont prévus. Notamment : {names}." + return f"Voici ton planning avec {count} événement{'s' if count>1 else ''}." + + +def _calendar(text, question=False): + start,end,view=intents.period(text) + payload=ha.get_events(start,end) + content={'type':'calendar','view':view,'title':'Planning','start':start.isoformat(),'end':end.isoformat(), + 'events':payload['events'],'calendars':payload['calendars'],'errors':payload['errors']} + speech=_event_summary(payload['events'],question) + if payload['errors']: speech+=' Certains calendriers sont indisponibles.' + return content,speech + + +def _create(text): + calendars=ha.list_calendars() + details, complete=intents.extract_create(text,calendars) + if not complete: + return {'type':'calendar_form','values':details,'calendars':calendars},'Je prépare le rendez-vous. Complète ou vérifie les informations à l’écran avant de l’enregistrer.' + # No calendar mutation until a second explicit confirmation. + intents.set_create(details) + pretty=datetime.fromisoformat(details['date']).strftime('%d/%m') + speech=f"Je vais ajouter {details['title']} le {pretty} à {details['time']}. Dis confirme pour enregistrer, ou annule." + return {'type':'calendar_form','values':details,'calendars':calendars,'awaiting_confirmation':True},speech + + +def _delete(text): + now=datetime.now(TZ) + day=intents.parse_day(text,now) + if day: + start=datetime.combine(day,time.min,TZ); end=start+timedelta(days=1) + else: + start=datetime.combine(now.date(),time.min,TZ);end=start+timedelta(days=45) + data=ha.get_events(start,end) + needle=intents.extract_search(text) + events=[e for e in data['events'] if intents.simplify(needle) in intents.simplify(e['title'])] if needle else data['events'] + if not events: + return {'type':'calendar','view':'days','start':start.isoformat(),'end':end.isoformat(),**data},'Je ne trouve pas cet événement. Aucun événement n’a été supprimé.' + if len(events)!=1: + return {'type':'delete_candidates','events':events[:30]},f"J'ai trouvé {len(events)} événements correspondants. Choisis celui à supprimer sur l'écran." + ev=events[0] + if not ev['can_delete']: + return {'type':'delete_candidates','events':[ev]},'Ce calendrier ne permet pas la suppression de cet événement depuis Alfred.' + intents.set_delete(ev) + warning=' Attention, si cet événement est récurrent, cela peut supprimer toute la série.' if not ev['recurrence_id'] else '' + return {'type':'delete_candidates','events':[ev], 'awaiting_confirmation':True},f"Confirme la suppression de {ev['title']}, ou dis annule.{warning}" + + +def _confirm_delete(): + e=intents.current_delete() + if not e:return {'type':'text','text':'Confirmation expirée.'},'La demande de suppression a expiré.' + # Recheck existence in HA to avoid deleting an event that changed meanwhile. + start=datetime.fromisoformat(e['start'])-timedelta(days=1) + end=datetime.fromisoformat(e['end'])+timedelta(days=1) + found=ha.get_events(start,end,[e['calendar_id']])['events'] + if not any(x['id']==e['id'] and x['start']==e['start'] and x['recurrence_id']==e['recurrence_id'] for x in found): + intents.clear_delete() + return {'type':'text','text':'Cet événement a changé. Suppression annulée.'},'Cet événement a changé. Rien n’a été supprimé.' + ha.delete_event(e['calendar_id'],e['id'],e['recurrence_id'] or None) + intents.clear_delete() + content,speech=_calendar('planning sur 7 jours') + return content,'Événement supprimé. '+speech + + +def _confirm_create(): + data=intents.current_create() + if not data:return {'type':'text','text':'Confirmation expirée.'},'La demande de création a expiré.' + if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'): + return {'type':'calendar_form','values':data,'calendars':ha.list_calendars()},'L’écriture est désactivée. Active ALFRED_ALLOW_CALENDAR_WRITES dans la configuration.' + start=datetime.fromisoformat(data['date']+'T'+data['time']).replace(tzinfo=TZ) + end=start+timedelta(minutes=int(data.get('duration_minutes',60))) + ha.create_event(data['calendar_id'],data['title'],start,end) + intents.clear_create() + content,speech=_calendar('planning sur 7 jours') + return content,'Événement ajouté. '+speech + + +def route(text, last_view=None): + # Weather is a read-only dynamic view; avoid sending routine forecasts to Hermes. + if ha_weather.is_weather_request(text, last_view): + data = ha_weather.get_weather(text) + return data, ha_weather.describe(data, text) + intent=intents.classify(text) + # Short temporal follow-ups to a displayed calendar: 'Et demain ?', 'Ce matin ?' + if intent is None and last_view == 'calendar' and len(text.strip()) < 45: + t=intents.simplify(text) + if any(word in t for word in ('demain','matin','apres-midi','soir','journee','semaine','mois','jours','novembre','decembre','janvier','fevrier','mars','avril','mai','juin','juillet','aout','septembre','octobre')): + intent={'kind':'calendar','question':True} + if intent is None:return None + kind=intent['kind'] + if kind=='home':return {'type':'home','title':'Alfred','text':'À ton écoute.'},'Je reviens à l’accueil.' + if kind=='calendar':return _calendar(text, intent.get('question',False)) + if kind=='create':return _create(text) + if kind=='delete':return _delete(text) + if kind=='confirm_delete': + if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'): + return {'type':'text','text':'Écriture calendrier désactivée.'},'La suppression est désactivée dans la configuration.' + return _confirm_delete() + if kind=='confirm_create':return _confirm_create() + if kind=='cancel_pending': + intents.cancel_pending() + return {'type':'home','text':'Demande annulée.'},'D’accord, aucune modification effectuée.' + if kind=='temperatures': + temps=ha.get_temperatures() + return {'type':'temperatures','items':temps},f"Voici {len(temps)} mesures de température provenant de Home Assistant." + if kind=='cameras': + cameras=ha.list_cameras() + return {'type':'cameras','items':cameras},f"Voici {len(cameras)} caméras Home Assistant disponibles." + return None diff --git a/alfred/app/services/camera_settings.py b/alfred/app/services/camera_settings.py new file mode 100644 index 0000000..7665385 --- /dev/null +++ b/alfred/app/services/camera_settings.py @@ -0,0 +1,187 @@ +"""Réglages V4L2 de la webcam locale pour l'interface Alfred. + +API en lecture/écriture sécurisée par une liste blanche de contrôles, plages +et états renvoyés par le pilote. Aucun accès shell ni commande arbitraire. +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import tempfile +import threading +from pathlib import Path + +_ALLOWED = { + 'brightness', 'contrast', 'saturation', 'hue', 'gamma', 'gain', + 'sharpness', 'backlight_compensation', 'power_line_frequency', + 'white_balance_automatic', 'white_balance_temperature', + 'auto_exposure', 'exposure_time_absolute', 'exposure_dynamic_framerate', +} +_AUTO = ('white_balance_automatic', 'auto_exposure') +_LINE = re.compile(r'^\s*(\w+)\s+0x[\da-f]+\s+\((int|bool|menu)\)\s*:\s*(.*)$', re.I) +_KV = re.compile(r'(min|max|step|default|value)=(-?\d+)') +_MENU = re.compile(r'^\s*(-?\d+):\s*(.+?)\s*$') +_LOCK = threading.RLock() + + +class CameraSettingsError(RuntimeError): + pass + + +def _device() -> str: + return os.getenv('CAMERA_DEVICE') or f"/dev/video{int(os.getenv('CAMERA_INDEX', '0'))}" + + +def _config_path() -> Path: + return Path(os.getenv('CAMERA_SETTINGS_FILE', str( + Path(__file__).resolve().parent.parent / 'config' / 'camera_controls.json' + ))).expanduser() + + +def _run(*args: str) -> str: + binary = shutil.which('v4l2-ctl') + if not binary: + raise CameraSettingsError('v4l2-ctl est absent : installer le paquet v4l-utils.') + try: + result = subprocess.run( + [binary, '-d', _device(), *args], capture_output=True, + text=True, timeout=6, check=False + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise CameraSettingsError(f'Caméra indisponible ({exc}).') from exc + if result.returncode != 0: + explanation = (result.stderr or result.stdout or 'erreur inconnue').strip() + raise CameraSettingsError(f'V4L2 : {explanation[:300]}') + return result.stdout + + +def _parse_controls(output: str) -> dict: + controls: dict[str, dict] = {} + current = None + for line in output.splitlines(): + match = _LINE.match(line) + if match: + name, kind, raw = match.groups() + current = name if name in _ALLOWED else None + if current: + props = {k: int(v) for k, v in _KV.findall(raw)} + controls[current] = { + 'kind': kind.lower(), 'inactive': 'inactive' in raw, + 'menu': [], **props, + } + continue + menu = _MENU.match(line) + if menu and current and controls[current]['kind'] == 'menu': + value, title = menu.groups() + controls[current]['menu'].append({'value': int(value), 'label': title}) + return controls + + +def _get_controls() -> dict: + return _parse_controls(_run('--list-ctrls-menus')) + + +def get_settings() -> dict: + with _LOCK: + return {'device': _device(), 'controls': _get_controls()} + + +def _validate(control: object, value: object, controls: dict) -> int: + if not isinstance(control, str) or control not in _ALLOWED or control not in controls: + raise ValueError('Réglage caméra inconnu ou non pris en charge.') + data = controls[control] + if data['inactive']: + raise ValueError('Réglage indisponible tant que le mode automatique est actif.') + if type(value) is not int: # écarte bool et chaînes + raise ValueError('Valeur entière attendue.') + if data['kind'] == 'bool' and value not in (0, 1): + raise ValueError('Valeur booléenne attendue (0 ou 1).') + if data['kind'] == 'menu': + choices = [v['value'] for v in data['menu']] + if choices and value not in choices: + raise ValueError('Valeur absente des options de la caméra.') + minimum, maximum = data.get('min', 0), data.get('max', 1) + step = data.get('step', 1) + if not minimum <= value <= maximum or (value - minimum) % max(1, step): + raise ValueError(f'Valeur hors plage ({minimum} à {maximum}, pas {step}).') + return value + + +def _write_config(controls: dict) -> None: + path = _config_path() + path.parent.mkdir(parents=True, exist_ok=True) + snapshot = {name: props['value'] for name, props in controls.items() + if not props['inactive'] and 'value' in props} + fd = None + temp = None + try: + fd, temp = tempfile.mkstemp(prefix='.camera_', suffix='.tmp', dir=path.parent) + with os.fdopen(fd, 'w', encoding='utf-8') as stream: + fd = None + json.dump({'controls': snapshot}, stream, indent=2) + stream.write('\n') + os.replace(temp, path) + temp = None + except OSError as exc: + raise CameraSettingsError(f'Impossible de mémoriser les réglages caméra : {exc}') from exc + finally: + if fd is not None: + os.close(fd) + if temp is not None and os.path.exists(temp): + os.unlink(temp) + + +def update_setting(control: object, value: object) -> dict: + with _LOCK: + controls = _get_controls() + n = _validate(control, value, controls) + _run('--set-ctrl', f'{control}={n}') + refreshed = _get_controls() # réévalue les contrôles devenus actifs/inactifs + _write_config(refreshed) + return {'device': _device(), 'controls': refreshed} + + +def reset_defaults() -> dict: + """Restaure les valeurs exposées par le pilote, sans forcer les champs inactifs.""" + with _LOCK: + original = _get_controls() + # D'abord les modes auto, puis les contrôles restés actifs. + for name in [*_AUTO, *(c for c in original if c not in _AUTO)]: + live = _get_controls() + item = live.get(name) + if not item or item['inactive'] or 'default' not in item: + continue + try: + _run('--set-ctrl', f"{name}={item['default']}") + except CameraSettingsError as exc: + raise CameraSettingsError(f'Échec restauration {name} : {exc}') from exc + refreshed = _get_controls() + _write_config(refreshed) + return {'device': _device(), 'controls': refreshed} + + +def restore_saved_settings() -> None: + """Applique les valeurs persistées; une webcam absente ne bloque pas Flask.""" + with _LOCK: + path = _config_path() + if not path.is_file(): + return + try: + saved = json.loads(path.read_text(encoding='utf-8')).get('controls', {}) + if not isinstance(saved, dict): + raise ValueError('Format invalide') + for name in [*_AUTO, *(n for n in saved if n not in _AUTO)]: + if name not in saved: + continue + live = _get_controls() + try: + n = _validate(name, saved[name], live) + except ValueError: + continue # matériel/pilote modifié ou contrôle devenu inactif + _run('--set-ctrl', f'{name}={n}') + print('[ALFRED CAMERA] Réglages restaurés.', flush=True) + except (OSError, ValueError, CameraSettingsError) as exc: + print(f'[ALFRED CAMERA] Restauration ignorée : {exc}', flush=True) diff --git a/alfred/app/services/ha_views.py b/alfred/app/services/ha_views.py new file mode 100644 index 0000000..41e2bdf --- /dev/null +++ b/alfred/app/services/ha_views.py @@ -0,0 +1,236 @@ +"""Home Assistant data adapter for Alfred. Tokens remain on the Flask host.""" +from __future__ import annotations + +import os +import re +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import date, datetime, time, timedelta +from urllib.parse import quote, urlparse +from zoneinfo import ZoneInfo + +import requests + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) +CREATE = 1 +DELETE = 2 + +class HAError(RuntimeError): + pass + + +def _cfg(): + base = (os.getenv('HA_URL') or os.getenv('HOME_ASSISTANT_URL') or '').strip().rstrip('/') + token = (os.getenv('HA_TOKEN') or os.getenv('HOME_ASSISTANT_TOKEN') or '').strip() + if not base or not token: + raise HAError('Configurer HA_URL et HA_TOKEN dans l’environnement du service Alfred.') + if urlparse(base).scheme not in ('http','https'): + raise HAError('HA_URL doit commencer par http:// ou https://') + return base, token + + +def _request(method, path, *, params=None, payload=None, timeout=12): + base, token = _cfg() + try: + response = requests.request(method, base + path, + params=params, json=payload, + headers={'Authorization': 'Bearer '+token, 'Accept':'application/json'}, + timeout=timeout) + response.raise_for_status() + return response.json() if response.content else None + except requests.RequestException as exc: + code = exc.response.status_code if exc.response is not None else 'réseau' + raise HAError(f'Home Assistant indisponible ou action refusée ({code}).') from exc + + +def states(): + return _request('GET', '/api/states') + + +def _allowed(): + return {s.strip() for s in os.getenv('HA_CALENDARS', '').split(',') if s.strip()} + + +def list_calendars(): + raw = _request('GET', '/api/calendars') or [] + allow = _allowed() + state_map = {s.get('entity_id'):s for s in states() if s.get('entity_id','').startswith('calendar.')} + items = [] + for c in raw: + eid = str(c.get('entity_id','')) + if not eid.startswith('calendar.') or (allow and eid not in allow): + continue + st = state_map.get(eid, {}) + features = int((st.get('attributes') or {}).get('supported_features') or 0) + items.append({'entity_id':eid, 'name':c.get('name') or eid.split('.',1)[-1], + 'can_create':bool(features & CREATE), 'can_delete':bool(features & DELETE)}) + return sorted(items, key=lambda x:x['name'].casefold()) + + +def _parse_dt(raw): + # HA REST can return RFC3339 strings, date strings, or {date}/{dateTime} dictionaries. + if isinstance(raw, dict): + raw = raw.get('dateTime') or raw.get('date') + if not isinstance(raw, str) or not raw: + raise ValueError('Date de calendrier invalide') + if len(raw) == 10: + return datetime.combine(date.fromisoformat(raw), time.min, TZ), True + dt = datetime.fromisoformat(raw.replace('Z', '+00:00')) + return (dt.replace(tzinfo=TZ) if dt.tzinfo is None else dt.astimezone(TZ)), False + + +def _normalize_event(e, cal): + start, all_day = _parse_dt(e.get('start')) + end, _ = _parse_dt(e.get('end')) + return { + 'id':str(e.get('uid') or ''), 'recurrence_id':str(e.get('recurrence_id') or ''), + 'title':str(e.get('summary') or '(Sans titre)'), + 'description':str(e.get('description') or ''), + 'location':str(e.get('location') or ''), + 'start':start.isoformat(), 'end':end.isoformat(), 'all_day':all_day, + 'calendar_id':cal['entity_id'], 'calendar_name':cal['name'], + 'can_delete':cal['can_delete'] and bool(e.get('uid')), + 'source':'home_assistant' + } + + +def get_events(start:datetime, end:datetime, calendar_ids=None): + if start.tzinfo is None or end.tzinfo is None or start >= end: + raise ValueError('Période incorrecte') + if (end-start).days > 65: + raise ValueError('Période limitée à 65 jours') + available = list_calendars() + ids = set(calendar_ids or [c['entity_id'] for c in available]) + if not ids.issubset({c['entity_id'] for c in available}): + raise ValueError('Calendrier non autorisé') + selected = [c for c in available if c['entity_id'] in ids] + results, errors = [], [] + def load(cal): + raw = _request('GET', '/api/calendars/'+quote(cal['entity_id'], safe=''), + params={'start':start.isoformat(), 'end':end.isoformat()}) or [] + return [_normalize_event(item,cal) for item in raw] + with ThreadPoolExecutor(max_workers=min(5,len(selected)) or 1) as pool: + futures = {pool.submit(load,c):c for c in selected} + for f in as_completed(futures): + try: results.extend(f.result()) + except (HAError,ValueError,KeyError) as exc: errors.append(f"{futures[f]['name']}: {exc}") + results.sort(key=lambda e:(e['start'],e['title'].lower())) + return {'events':results, 'calendars':available, 'errors':errors, + 'start':start.isoformat(), 'end':end.isoformat()} + + +def _find_calendar(entity_id, capability): + cal = next((c for c in list_calendars() if c['entity_id'] == entity_id),None) + if cal is None: + raise HAError('Calendrier absent ou non autorisé.') + if not cal[capability]: + raise HAError('Ce calendrier ne permet pas cette opération depuis Home Assistant.') + return cal + + +def create_event(entity_id, title, start, end, description='', location='', all_day=False): + _find_calendar(entity_id, 'can_create') + if not title.strip() or len(title) > 200: + raise ValueError('Titre requis (200 caractères maximum).') + if start >= end: + raise ValueError('La fin doit être après le début.') + payload = {'entity_id':entity_id, 'summary':title.strip(), + 'description':description[:2000], 'location':location[:300]} + if all_day: + payload.update(start_date=start.date().isoformat(), end_date=end.date().isoformat()) + else: + payload.update(start_date_time=start.isoformat(), end_date_time=end.isoformat()) + _request('POST', '/api/services/calendar/create_event',payload=payload) + return {'ok':True,'message':'Événement ajouté.'} + + +def delete_event(entity_id, uid, recurrence_id=None): + _find_calendar(entity_id, 'can_delete') + if not isinstance(uid,str) or not uid.strip() or len(uid)>600: + raise ValueError('Identifiant d’événement manquant ou invalide.') + base, token = _cfg() + ws_url = ('wss://' if base.startswith('https://') else 'ws://') + base.split('://',1)[1] + '/api/websocket' + try: + from websocket import create_connection + except ImportError as exc: + raise HAError('Installer websocket-client dans le venv d’Alfred pour supprimer des événements.') from exc + import json + try: + ws = create_connection(ws_url, timeout=12) + try: + auth_required = json.loads(ws.recv()) + if auth_required.get('type') != 'auth_required': + raise HAError('WebSocket Home Assistant : négociation inattendue.') + ws.send(json.dumps({'type':'auth','access_token':token})) + auth = json.loads(ws.recv()) + if auth.get('type') != 'auth_ok': + raise HAError('Authentification WebSocket Home Assistant refusée.') + cmd = {'id':1, 'type':'calendar/event/delete','entity_id':entity_id,'uid':uid} + if recurrence_id: cmd['recurrence_id'] = recurrence_id + ws.send(json.dumps(cmd)) + result = json.loads(ws.recv()) + if result.get('id') != 1 or not result.get('success'): + error = result.get('error') or {} + raise HAError('Suppression refusée par Home Assistant : '+str(error.get('message') or error)) + return {'ok':True, 'message':'Événement supprimé.'} + finally: + ws.close() + except (OSError,TimeoutError) as exc: + raise HAError('Impossible de joindre le WebSocket Home Assistant.') from exc + + +def get_temperatures(): + data=[] + for item in states(): + entity=item.get('entity_id',''); attrs=item.get('attributes') or {} + if (entity.startswith('sensor.') and attrs.get('device_class') == 'temperature') or entity.startswith('climate.'): + raw = attrs.get('current_temperature') if entity.startswith('climate.') else item.get('state') + try: value = float(raw) + except (TypeError,ValueError):continue + if not -50 <= value <= 90:continue + data.append({'name':attrs.get('friendly_name',entity), 'value':value, + 'unit':attrs.get('unit_of_measurement') or '°C','entity_id':entity}) + return sorted(data, key=lambda x:x['name'].lower())[:60] + + +def list_cameras(): + allow = {s.strip() for s in os.getenv('HA_CAMERAS','').split(',') if s.strip()} + return [{'entity_id':item['entity_id'], 'name':item.get('attributes',{}).get('friendly_name') or item['entity_id']} + for item in states() + if item.get('entity_id','').startswith('camera.') and + (not allow or item['entity_id'] in allow)][:30] + + +def camera_snapshot(entity_id): + allowed = {item['entity_id'] for item in list_cameras()} + if entity_id not in allowed: + raise HAError('Caméra non autorisée.') + base, token = _cfg() + try: + r=requests.get(base+'/api/camera_proxy/'+quote(entity_id,safe=''), + headers={'Authorization':'Bearer '+token},timeout=15) + r.raise_for_status() + if not r.headers.get('Content-Type','').startswith('image/'): + raise HAError('La caméra ne fournit pas une image JPEG/PNG.') + return r.content,r.headers['Content-Type'] + except requests.RequestException as exc: + raise HAError('Impossible de récupérer l’image de la caméra.') from exc + + +def temperature_history(entity_id, hours=24): + hours=max(1,min(int(hours),72)) + if entity_id not in {e['entity_id'] for e in get_temperatures()}: + raise HAError('Capteur non autorisé.') + end=datetime.now(TZ);start=end-timedelta(hours=hours) + raw=_request('GET','/api/history/period/'+quote(start.isoformat(),safe=''), + params={'filter_entity_id':entity_id,'end_time':end.isoformat(), + 'minimal_response':'true'},timeout=18) or [] + out=[] + for collection in raw: + for point in collection: + try: + value=float(point['state']) + moment=point.get('last_changed') or point.get('last_updated') + if -50 <= value <= 90 and moment: + out.append({'time':moment,'value':value}) + except (ValueError,TypeError,KeyError):continue + return out[-250:] diff --git a/alfred/app/services/ha_weather.py b/alfred/app/services/ha_weather.py new file mode 100644 index 0000000..b217e6b --- /dev/null +++ b/alfred/app/services/ha_weather.py @@ -0,0 +1,230 @@ +"""Read-only weather adapter for Alfred: use existing HA_URL / HA_TOKEN. + +Home Assistant stores current conditions in weather entities. Forecasts must be +retrieved separately with the weather.get_forecasts action (return_response). +""" +from __future__ import annotations + +import os +import re +from datetime import datetime, timedelta, time +from zoneinfo import ZoneInfo + +from . import ha_views as ha +from . import alfred_intents as intents + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) + +CONDITIONS = { + 'clear-night': 'ciel dégagé', 'cloudy': 'ciel couvert', + 'exceptional': 'conditions exceptionnelles', 'fog': 'brouillard', + 'hail': 'grêle', 'lightning': 'orages', 'lightning-rainy': 'orages et pluie', + 'partlycloudy': 'éclaircies', 'pouring': 'fortes pluies', + 'rainy': 'pluie', 'snowy': 'neige', 'snowy-rainy': 'pluie et neige', + 'sunny': 'ensoleillé', 'windy': 'vent', 'windy-variant': 'vent et nuages', +} +RAIN = {'rainy', 'pouring', 'lightning-rainy', 'snowy-rainy'} + + +def is_weather_request(text: str, previous_view: str | None = None) -> bool: + t = intents.simplify(str(text or '')) + if re.search(r'\b(meteo|previsions?|previsions? meteorologiques?|previsions? du temps|pluie|pleuvoir|pleuvra|pleut|averse|averses|orage|orages|enneigement|neige|neiger|soleil|ensoleille|ensoleillee|parapluie|quel temps|quelle meteo|beau temps|mauvais temps)\b', t): + return True + if re.search(r'\b(temperature|temperatures|combien de degres)\b.{0,24}\b(dehors|exterieur|exterieure|exterieures)\b', t): + return True + if re.search(r'\b(fait.il|fera.t.il|il fera|il fait)\b.{0,15}\b(beau|froid|chaud|dehors)\b', t): + return True + if re.search(r'\btemperature\b', t) and re.search(r'\b(demain|week.end|semaine|exterieure?|dehors|aujourd.hui)\b', t) and not re.search(r'\b(salon|chambre|cuisine|maison|bureau|salle de bain)\b',t): + return True + # Follow-ups are interpreted in the active view, not with old Hermes prompts. + if previous_view == 'weather' and len(t) < 42 and re.search(r'\b(demain|aujourd.hui|matin|apres.midi|soir|week.end|semaine|jours?|maintenant|actuellement)\b', t): + return True + return False + + +def _selected_weather_entity(): + entries = [s for s in ha.states() if str(s.get('entity_id', '')).startswith('weather.')] + configured = os.getenv('HA_WEATHER_ENTITY', '').strip() + if configured: + match = next((e for e in entries if e['entity_id'] == configured), None) + if not match: + raise ha.HAError(f"Entité météo {configured} introuvable dans Home Assistant.") + return match + if not entries: + raise ha.HAError('Aucune entité weather.* trouvée dans Home Assistant. Ajouter une intégration météo.') + # Prefer a conventional home weather entity, otherwise use the first available. + return next((e for e in entries if e.get('entity_id') in ('weather.home', 'weather.forecast_home')), entries[0]) + + +def _safe_number(value): + try: + if value is None or isinstance(value, bool): + return None + return round(float(value), 1) + except (ValueError, TypeError): + return None + + +def _forecast(entity_id: str, typ: str): + try: + raw = ha._request('POST', '/api/services/weather/get_forecasts?return_response', + payload={'entity_id': entity_id, 'type': typ}, timeout=16) + response = raw.get('service_response', raw) if isinstance(raw, dict) else {} + ent = response.get(entity_id, {}) if isinstance(response, dict) else {} + items = ent.get('forecast', []) if isinstance(ent, dict) else [] + if not isinstance(items, list): + return [], 'Format de prévision météo inattendu.' + normalized = [] + for item in items: + if not isinstance(item, dict): + continue + raw_dt = item.get('datetime') + if not raw_dt: + continue + try: + dt = datetime.fromisoformat(str(raw_dt).replace('Z', '+00:00')) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=TZ) + dt = dt.astimezone(TZ) + except ValueError: + continue + normalized.append({ + 'datetime': dt.isoformat(), 'date': dt.date().isoformat(), + 'condition': str(item.get('condition') or 'unknown'), + 'description': CONDITIONS.get(str(item.get('condition')), 'conditions non précisées'), + 'temperature': _safe_number(item.get('temperature')), + 'templow': _safe_number(item.get('templow')), + 'precipitation_probability': _safe_number(item.get('precipitation_probability')), + 'precipitation': _safe_number(item.get('precipitation')), + 'wind_speed': _safe_number(item.get('wind_speed')), + }) + return normalized, None + except ha.HAError as exc: + # Some providers have daily only / hourly only forecasts. + return [], f'Prévisions {typ} indisponibles : {exc}' + + +def get_weather(text: str = "") -> dict: + item = _selected_weather_entity() + attrs = item.get('attributes') or {} + eid = item['entity_id'] + current = { + 'condition': str(item.get('state') or 'unknown'), + 'description': CONDITIONS.get(str(item.get('state')), 'conditions non précisées'), + 'temperature': _safe_number(attrs.get('temperature')), + 'apparent_temperature': _safe_number(attrs.get('apparent_temperature')), + 'humidity': _safe_number(attrs.get('humidity')), + 'wind_speed': _safe_number(attrs.get('wind_speed')), + 'pressure': _safe_number(attrs.get('pressure')), + } + daily, err_daily = _forecast(eid, 'daily') + hourly, err_hourly = _forecast(eid, 'hourly') + label, start, end, kind = _target(text, datetime.now(TZ)) if text else ('aujourd’hui', datetime.combine(datetime.now(TZ).date(), time.min, tzinfo=TZ), datetime.combine(datetime.now(TZ).date(), time.min, tzinfo=TZ) + timedelta(days=1), 'day') + focus_date = start.date().isoformat() + return { + 'type': 'weather', 'entity_id': eid, + 'name': str(attrs.get('friendly_name') or eid), + 'temperature_unit': str(attrs.get('temperature_unit') or '°C'), + 'wind_speed_unit': str(attrs.get('wind_speed_unit') or 'km/h'), + 'current': current, 'daily': daily, 'hourly': hourly, + 'errors': [e for e in (err_daily, err_hourly) if e], + 'source': 'home_assistant', 'updated_at': datetime.now(TZ).isoformat(), + 'focus_label': label, 'focus_date': focus_date, 'focus_kind': kind, + } + + +def _target(text: str, now: datetime) -> tuple[str, datetime, datetime, str]: + t = intents.simplify(text) + start = datetime.combine(now.date(), time.min, tzinfo=TZ) + label = 'actuellement' + days = re.search(r'\b(?:dans|sur|pour)\s+(\d{1,2})\s+jours?\b', t) + if days and 'dans ' in days.group(0): + d = min(int(days.group(1)), 14) + start += timedelta(days=d) + label = f'dans {d} jours' + elif 'week-end' in text.lower() or 'weekend' in t or 'week.end' in t: + offset = (5 - now.weekday()) % 7 + start += timedelta(days=offset) + label = 'ce week-end' + return label, start, start + timedelta(days=2), 'weekend' + else: + day = intents.parse_day(text, now) + if day is not None: + start = datetime.combine(day, time.min, tzinfo=TZ) + label = 'demain' if day == now.date()+timedelta(days=1) else ('aujourd’hui' if day == now.date() else day.strftime('le %d/%m')) + kind = 'day' + if 'ce matin' in t or 'demain matin' in t: + kind = 'hours';start = start.replace(hour=6);end = start.replace(hour=12) + elif 'apres-midi' in t: + kind = 'hours';start = start.replace(hour=12);end = start.replace(hour=18) + elif 'soir' in t: + kind = 'hours';start = start.replace(hour=18);end = start + timedelta(hours=6) + else: + end = start + timedelta(days=1) + return label, start, end, kind + + +def _rounded_temp(n, unit='°C'): + return f'{n:g} {unit}' if n is not None else 'température indisponible' + + +def describe(data: dict, text: str = '') -> str: + """Concise voice response based on actual forecast data, never invented.""" + now = datetime.now(TZ) + t = intents.simplify(text) + unit = data['temperature_unit'] + label, start, end, kind = _target(text, now) + weather_question = bool(re.search(r'\b(pleut|pleuvra|pleuvoir|pluie|averses|parapluie)\b', t)) + days_question = bool(re.search(r'\b(semaine|7 jours|sept jours)\b', t)) + now_question = (start.date() == now.date() and kind == 'day' + and not re.search(r'\b(demain|jour|mois|week|semaine)\b', t)) + is_now_explicit = bool(re.search(r'\b(maintenant|en ce moment|actuellement)\b',t)) + if is_now_explicit and weather_question: + c=data['current'] + raining = c['condition'] in RAIN + return ('Oui, les conditions actuelles indiquent de la pluie.' if raining + else f"Actuellement, {c['description']}.") + if now_question and not weather_question and not days_question: + c=data['current'] + return f"Actuellement, {c['description']}, {_rounded_temp(c['temperature'],unit)}." + if days_question: + if data['daily']: + return f"Voici les prévisions météorologiques des prochains jours, selon {data['name']}." + return 'Les prévisions journalières ne sont pas disponibles dans Home Assistant.' + candidates = [h for h in data['hourly'] if start <= datetime.fromisoformat(h['datetime']) < end] + forecasts = (candidates if kind == 'hours' and candidates else + [d for d in data['daily'] if start.date() <= datetime.fromisoformat(d['datetime']).date() + < (end.date() if end.time() == time.min else end.date() + timedelta(days=1))]) + # A missing hourly forecast must not masquer itself as a reliable part-of-day prediction. + approximate = kind == 'hours' and not candidates and bool(forecasts) + if not forecasts: + if label == 'actuellement': + return f"Actuellement, {data['current']['description']}, {_rounded_temp(data['current']['temperature'], unit)}." + return f"Je n'ai pas de prévision disponible pour {label}." + if weather_question: + prob = [f['precipitation_probability'] for f in forecasts if f['precipitation_probability'] is not None] + if prob: + peak = max(prob) + response = f"Pour {label}, la probabilité de précipitations atteint {peak:g} %." + elif any(f['condition'] in RAIN for f in forecasts): + response = f"La météo prévoit de la pluie pour {label}." + else: + response = f"Aucune pluie n'est indiquée par les prévisions disponibles pour {label}, mais le risque exact n'est pas connu." + else: + first = forecasts[0] + temps = [f['temperature'] for f in forecasts if f['temperature'] is not None] + lows = [f['templow'] for f in forecasts if f['templow'] is not None] + descriptor = first['description'] + if len(forecasts) > 1 and len({f['condition'] for f in forecasts}) > 1: + descriptor = 'des conditions variables' + if kind == 'hours' and candidates and temps: + response = f"Pour {label}, {descriptor}, entre {_rounded_temp(min(temps),unit)} et {_rounded_temp(max(temps),unit)}." + elif temps: + response = f"Pour {label}, {descriptor}, jusqu'à {_rounded_temp(max(temps),unit)}." + if lows: + response += f" Minimum {_rounded_temp(min(lows),unit)}." + else: + response = f"Pour {label}, {descriptor}, mais la température n'est pas disponible." + if approximate: + response += ' Cette indication est journalière, faute de prévision horaire.' + return response diff --git a/alfred/app/services/weather_outfits.py b/alfred/app/services/weather_outfits.py new file mode 100644 index 0000000..c75e321 --- /dev/null +++ b/alfred/app/services/weather_outfits.py @@ -0,0 +1,688 @@ +"""Outfit recommendations and ComfyUI illustration for Alfred weather view. + +V4 improvements: +- refresh really regenerates a new image (new random seeds, cache bypass); +- best-of generation with lightweight vision review; +- more reliable accessory rendering by limiting visible accessories in the image; +- still generates the child and adult separately, then assembles them in one image. +""" +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import os +import threading +import time +import uuid +import random +import secrets +import logging +from datetime import datetime, date as Date +from collections import Counter +from pathlib import Path +from urllib.parse import urlencode + +import requests +from dotenv import load_dotenv, dotenv_values + +# Charger l'environnement avant de calculer BASE et WORKFLOWS_DIR. +# Flask exécute son load_dotenv() après l'import de ce module. +load_dotenv() +load_dotenv('/opt/hermes/MCP_comfyui/.env', override=False) +# Fichier de réglages météo indépendant de systemd et du MCP. +_SETTINGS_FILE = Path(__file__).resolve().parents[1] / 'config' / 'weather_outfits.env' +if _SETTINGS_FILE.is_file(): + load_dotenv(_SETTINGS_FILE, override=True) +_MCP_SETTINGS = dotenv_values('/opt/hermes/MCP_comfyui/.env') + +from . import ha_weather + +CACHE_DIR = Path(os.getenv('ALFRED_CACHE_DIR', str(Path(__file__).resolve().parents[1] / 'cache'))) +CACHE_DIR.mkdir(parents=True, exist_ok=True) +CACHE_FILE = CACHE_DIR / 'weather_outfit_cache.json' +BASE = (os.getenv('ALFRED_COMFYUI_URL') or _MCP_SETTINGS.get('COMFYUI_URL') or os.getenv('COMFYUI_URL') or 'http://192.168.1.13:8188').rstrip('/') +WORKFLOWS_DIR = Path(os.getenv('ALFRED_COMFYUI_WORKFLOWS_DIR') or os.getenv('WORKFLOWS_DIR') or '/opt/hermes/MCP_comfyui/workflows') +PROMPT_NODE = os.getenv('COMFY_PROMPT_NODE', '6') +TIMEOUT = int(os.getenv('COMFYUI_TIMEOUT', '300')) +TTL_HOURS = int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TTL_HOURS', '12')) +ENABLED = os.getenv('ALFRED_WEATHER_ILLUSTRATIONS', 'true').lower() in ('1', 'true', 'yes', 'on') +IMAGE_SIZE = min(1024, max(512, int(os.getenv('ALFRED_WEATHER_COMFY_SIZE', '768')))) +PROMPT_VERSION = 'outfits-v5-reference-20261025' +MAX_ATTEMPTS_PER_CHARACTER = max(1, min(3, int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS', '2')))) +QUALITY_TARGET = float(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE', '8.6')) +REVIEW_ENABLED = os.getenv('ALFRED_WEATHER_REVIEW', 'true').lower() in ('1','true','yes','on') +REFERENCE_MODE = os.getenv('ALFRED_WEATHER_REFERENCE_MODE', 'auto').strip().lower() +REFERENCE_DENOISE = min(.85, max(.25, float(os.getenv('ALFRED_WEATHER_REFERENCE_DENOISE', '0.56')))) +PRESERVE_FACE = os.getenv('ALFRED_WEATHER_PRESERVE_FACE', 'true').lower() in ('1','true','yes','on') +ACCESSORY_LIMIT = max(0, min(2, int(os.getenv('ALFRED_WEATHER_ACCESSORY_LIMIT', '1')))) +REFERENCE_DIR = Path(__file__).resolve().parents[1] / 'static' / 'assets' / 'weather' +UPLOADED_REFERENCES = {} + +# Un seul job génération météo à la fois : évite les doublons concurrents. +ILLUSTRATION_LOCK = threading.RLock() +IMAGE_DIR = CACHE_DIR / 'weather_outfit_images' +IMAGE_DIR.mkdir(parents=True, exist_ok=True) + +# Deux portraits distincts, réunis dans une seule image finale. +PORTRAIT_WIDTH = min(512, IMAGE_SIZE) +PORTRAIT_HEIGHT = IMAGE_SIZE +DISPLAY_SIZE = int(os.getenv('ALFRED_WEATHER_DISPLAY_SIZE', '350')) + + +def _load_cache() -> dict: + try: + return json.loads(CACHE_FILE.read_text(encoding='utf-8')) + except Exception: + return {} + + +def _save_cache(cache: dict) -> None: + CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding='utf-8') + + +def _pick_day(data: dict, forced_date: str | None = None) -> dict: + target = str(forced_date or data.get('focus_date') or '').strip() + try: + requested = Date.fromisoformat(target) + except ValueError as exc: + raise ValueError('Date météo invalide : format AAAA-MM-JJ attendu.') from exc + + for day in data.get('daily', []): + if str(day.get('date')) == target: + return day + + matching = [h for h in data.get('hourly', []) if h.get('date') == target] + if matching: + def available(key): + return [float(h[key]) for h in matching if h.get(key) is not None] + temps = available('temperature') + chances = available('precipitation_probability') + winds = available('wind_speed') + conditions = [str(h['condition']) for h in matching if h.get('condition')] + condition = Counter(conditions).most_common(1)[0][0] if conditions else 'unknown' + return { + 'date': target, + 'condition': condition, + 'description': ha_weather.CONDITIONS.get(condition, 'conditions non précisées'), + 'temperature': max(temps) if temps else None, + 'templow': min(temps) if temps else None, + 'precipitation_probability': max(chances) if chances else None, + 'wind_speed': max(winds) if winds else None, + } + + if requested == datetime.now(ha_weather.TZ).date(): + current = data.get('current', {}) + return { + 'date': target, + 'condition': current.get('condition', 'unknown'), + 'description': current.get('description', 'conditions non précisées'), + 'temperature': current.get('temperature'), + 'templow': current.get('temperature'), + 'precipitation_probability': None, + 'wind_speed': current.get('wind_speed'), + } + raise ValueError(f'Aucune prévision Home Assistant disponible pour le {requested:%d/%m/%Y}. Aucune image ne sera générée avec les données d’un autre jour.') + + +def _temp_band(high: float | None, low: float | None) -> str: + ref = high if high is not None else low + if ref is None: + return 'mild' + if ref < 6: + return 'cold' + if ref < 14: + return 'cool' + if ref < 23: + return 'mild' + return 'warm' + + +def _recommend(day: dict) -> dict: + high = day.get('temperature') + low = day.get('templow') + pop = day.get('precipitation_probability') + wind = day.get('wind_speed') + cond = str(day.get('condition') or '') + band = _temp_band(high, low) + rainy = cond in {'rainy', 'pouring', 'lightning-rainy', 'snowy-rainy'} or (pop is not None and pop >= 45) + sunny = cond in {'sunny', 'partlycloudy'} + windy = wind is not None and wind >= 25 + + child = [] + adult = [] + child_acc = [] + adult_acc = [] + + if band == 'cold': + child += ['doudoune chaude', 'pantalon chaud', 'chaussures fermées'] + adult += ['manteau chaud', 'pull', 'pantalon', 'chaussures fermées'] + child_acc += ['bonnet', 'écharpe'] + adult_acc += ['écharpe'] + elif band == 'cool': + child += ['veste légère', 'pantalon', 'baskets'] + adult += ['veste mi-saison', 'pull fin', 'jean', 'chaussures fermées'] + elif band == 'mild': + child += ['sweat ou gilet', 'pantalon léger', 'baskets'] + adult += ['veste légère', 'haut confortable', 'jean ou chino', 'baskets'] + else: + child += ['t-shirt', 'short ou pantalon léger', 'baskets légères'] + adult += ['t-shirt ou polo', 'tenue légère', 'chaussures légères'] + child_acc += ['casquette'] + adult_acc += ['lunettes de soleil'] + + if rainy: + child_acc += ['imperméable', 'parapluie'] + adult_acc += ['parapluie'] + if 'chaussures fermées' not in child: child.append('chaussures fermées') + if 'chaussures fermées' not in adult: adult.append('chaussures fermées') + if windy: + child_acc += ['coupe-vent'] + adult_acc += ['coupe-vent'] + if sunny and band != 'cold': + if 'casquette' not in child_acc: child_acc += ['casquette'] + if 'lunettes de soleil' not in adult_acc: adult_acc += ['lunettes de soleil'] + + def uniq(seq): + seen=[] + for x in seq: + if x not in seen: seen.append(x) + return seen + + return { + 'summary': day.get('description', 'Conditions du jour'), + 'child': { + 'title': 'Fille · 8 ans', + 'clothes': uniq(child), + 'accessories': uniq(child_acc), + }, + 'adult': { + 'title': 'Homme · 42 ans', + 'clothes': uniq(adult), + 'accessories': uniq(adult_acc), + } + } + + +def _weather_signature(day: dict, outfits: dict) -> str: + raw = json.dumps({'version': PROMPT_VERSION, 'date': day.get('date'), + 'day': day, 'outfits': outfits, 'size': IMAGE_SIZE}, + ensure_ascii=False, sort_keys=True) + return hashlib.sha256(raw.encode('utf-8')).hexdigest() + + +def _extract_images(history: dict) -> list[dict]: + images = [] + for node_id, output in history.get('outputs', {}).items(): + for image in output.get('images', []): + filename = image.get('filename') + if not filename: + continue + subfolder = image.get('subfolder', '') + image_type = image.get('type', 'output') + query = urlencode({'filename': filename, 'subfolder': subfolder, 'type': image_type}) + images.append({'filename': filename, 'url': f'{BASE}/view?{query}', 'node_id': str(node_id)}) + return images + + +NEGATIVE_PROMPT = ( + 'more than one person, group photo, two people, extra person, background crowd, ' + 'mirror reflection, human silhouette in background, duplicate heads, cloned body, ' + 'deformed face, malformed face, mutated hands, extra limbs, extra fingers, ' + 'disfigured features, asymmetrical face, photorealistic, horror, blurry, ' + 'text, captions, logos, watermark, oversized accessory, floating accessory, ' + 'extra umbrella, duplicated bag, disconnected hands, duplicate limbs, ' + 'accessories not mentioned, extra props, toys, clothing mannequin' +) + + +def _random_seed() -> int: + return secrets.randbelow(2**32 - 1) + 1 + + +def _prepare_workflow(workflow: dict, prompt: str, force_new_seed: bool = False, reference_name: str | None = None) -> dict: + node = workflow.get(PROMPT_NODE) + if not isinstance(node, dict) or not isinstance(node.get('inputs'), dict) or 'text' not in node['inputs']: + raise ValueError(f'Nœud de prompt ComfyUI {PROMPT_NODE} introuvable ou incompatible.') + node['inputs']['text'] = prompt + + for item in workflow.values(): + inputs = item.get('inputs') or {} + if item.get('class_type') == 'CLIPTextEncode' and 'text' in inputs: + # negative prompt link target handled below when referenced by sampler. + pass + if force_new_seed: + for key in ('seed', 'noise_seed'): + if key in inputs and isinstance(inputs[key], (int, float)): + inputs[key] = _random_seed() + if item.get('class_type') in ('RandomNoise', 'Noise') and force_new_seed: + for key in ('noise_seed', 'seed'): + if key in inputs and isinstance(inputs[key], (int, float)): + inputs[key] = _random_seed() + + for sampler in workflow.values(): + if sampler.get('class_type') not in ('KSampler', 'KSamplerAdvanced'): + continue + inputs = sampler.get('inputs') or {} + if force_new_seed: + for key in ('seed', 'noise_seed'): + if key in inputs and isinstance(inputs[key], (int, float)): + inputs[key] = _random_seed() + negative_link = inputs.get('negative') + if not isinstance(negative_link, (list, tuple)) or not negative_link: + continue + neg = workflow.get(str(negative_link[0])) + if neg and neg.get('class_type') == 'CLIPTextEncode' and 'text' in neg.get('inputs', {}): + neg['inputs']['text'] = NEGATIVE_PROMPT + for latent in workflow.values(): + if latent.get('class_type') in ('EmptyLatentImage', 'EmptySD3LatentImage'): + inputs = latent.get('inputs') or {} + if 'width' in inputs and 'height' in inputs: + inputs['width'] = PORTRAIT_WIDTH + inputs['height'] = PORTRAIT_HEIGHT + # Conversion en image-vers-image : uniquement si le workflow expose des liens + # KSampler et VAEEncode compatibles. Le JSON source de Hermes est INCHANGÉ. + if reference_name: + _inject_reference_img2img(workflow, reference_name) + return workflow + + +def _new_node_id(workflow: dict) -> str: + return str(max([int(k) for k in workflow if str(k).isdigit()] + [0]) + 1) + + +def _inject_reference_img2img(workflow: dict, reference_name: str) -> None: + # Gestion prudente des workflows courants SD 1.5 / SDXL (KSampler). + sampler = next((n for n in workflow.values() + if n.get('class_type') == 'KSampler' and isinstance(n.get('inputs', {}).get('latent_image'), list)), None) + decoder = next((n for n in workflow.values() if n.get('class_type') == 'VAEDecode' + and isinstance(n.get('inputs', {}).get('vae'), list)), None) + if sampler is None or decoder is None: + raise ValueError('Workflow non compatible img2img automatique (KSampler/VAEDecode requis).') + vae_link = decoder['inputs']['vae'] + load_id = _new_node_id(workflow) + workflow[load_id] = {'class_type': 'LoadImage', 'inputs': {'image': reference_name}} + vae_id = _new_node_id(workflow) + workflow[vae_id] = {'class_type': 'VAEEncode', + 'inputs': {'pixels': [load_id, 0], 'vae': vae_link}} + sampler['inputs']['latent_image'] = [vae_id, 0] + sampler['inputs']['denoise'] = REFERENCE_DENOISE + + +def _upload_reference(path: Path) -> str: + # Téléverse l’image référence vers ComfyUI, retourne son nom LoadImage. + cache_key = (str(path), path.stat().st_mtime_ns) + if cache_key in UPLOADED_REFERENCES: + return UPLOADED_REFERENCES[cache_key] + with path.open('rb') as stream: + response = requests.post(f'{BASE}/upload/image', files={'image': (path.name, stream, 'image/png')}, + data={'overwrite': 'true'}, timeout=30) + response.raise_for_status() + item = response.json() + name = str(item.get('name') or '') + subfolder = str(item.get('subfolder') or '') + if not name or '/' in name or '\\' in name: + raise RuntimeError('Réponse upload ComfyUI invalide : nom de fichier de référence absent.') + result = (subfolder.strip('/') + '/' if subfolder else '') + name + UPLOADED_REFERENCES[cache_key] = result + return result + + +def _submit_workflow(prompt: str, force_new_seed: bool = True, + kind: str | None = None) -> tuple[str, bool]: + workflow_path = WORKFLOWS_DIR / 'text_to_image.json' + if not workflow_path.exists(): + raise RuntimeError(f'Workflow ComfyUI introuvable : {workflow_path}') + workflow = json.loads(workflow_path.read_text(encoding='utf-8')) + reference_applied = False + reference_name = None + if kind and REFERENCE_MODE != 'off': + reference_file = REFERENCE_DIR / f'{kind}-reference.png' + if reference_file.is_file(): + try: + # D'abord valider la capacité img2img, avant upload. + preview = _prepare_workflow(json.loads(workflow_path.read_text(encoding='utf-8')), + prompt, force_new_seed=False, reference_name='check.png') + del preview + reference_name = _upload_reference(reference_file) + reference_applied = True + except (ValueError, requests.RequestException, OSError) as exc: + if REFERENCE_MODE == 'required': + raise RuntimeError('Référence obligatoire indisponible : ' + str(exc)) from exc + print('[ALFRED METEO] Référence img2img non disponible ; repli texte : ' + str(exc), flush=True) + workflow = _prepare_workflow(workflow, prompt, force_new_seed=force_new_seed, + reference_name=reference_name) + response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30) + if not response.ok and reference_applied and REFERENCE_MODE == 'auto': + print('[ALFRED METEO] Workflow img2img rejeté ; nouvel essai texte seul.', flush=True) + workflow = _prepare_workflow(json.loads(workflow_path.read_text(encoding='utf-8')), + prompt, force_new_seed=True, reference_name=None) + response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30) + reference_applied = False + response.raise_for_status() + return response.json()['prompt_id'], reference_applied + + +def _wait_history(prompt_id: str) -> dict: + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + response = requests.get(f'{BASE}/history/{prompt_id}', timeout=15) + response.raise_for_status() + result = response.json() + if prompt_id in result: + return result[prompt_id] + time.sleep(1) + raise RuntimeError(f'ComfyUI timeout ({prompt_id})') + + +def _primary_accessories(kind: str, outfits: dict) -> list[str]: + accessories = list(outfits[kind]['accessories']) + ordered = [] + priorities = ['parapluie', 'imperméable', 'bonnet', 'écharpe', 'coupe-vent', 'casquette', 'lunettes de soleil'] + for wanted in priorities: + if wanted in accessories and wanted not in ordered: + ordered.append(wanted) + for acc in accessories: + if acc not in ordered: + ordered.append(acc) + return ordered[:ACCESSORY_LIMIT] + + +def _single_character_prompt(kind: str, day: dict, outfits: dict) -> str: + profile = outfits[kind] + if kind == 'child': + identity = ( + 'ONE young GIRL, eight years old, clearly a primary-school child, ' + 'small body proportions of a young child, friendly and natural face, ' + 'casual appropriate haircut, no grown-up features. ' + ) + else: + identity = ( + 'ONE adult MAN, forty-two years old, unmistakably masculine adult, ' + 'short neat hair, mature male face, optionally light stubble, ' + 'adult male shoulders and body proportions, not a teenager. ' + ) + weather = str(day.get('description') or 'daily weather') + primary_acc = _primary_accessories(kind, outfits) + image_acc = ', '.join(primary_acc) if primary_acc else 'none' + return ( + 'Single character fashion illustration for a family weather dashboard. ' + 'Exactly ONE character, standing ALONE. ' + + identity + + 'Full body visible from head to shoes, centrally positioned, front three-quarter view, ' + 'generous empty space around body, relaxed neutral standing pose, smiling gently. ' + 'Soft polished 2D digital illustration, smooth clean outlines, tasteful pastel colors, ' + 'slight dimensional shading, consistent editorial children-book illustration style, ' + 'simple light neutral plain studio background, no other people, no human reflections, ' + 'no poster, no photographs, no text. ' + f'Weather for {day.get("date", "selected day")}: {weather}. ' + f'Clothing: {", ".join(profile["clothes"])}. ' + f'Visible accessories to show clearly: {image_acc}. ' + 'Show at most ONE prop or wearable accessory, and only if specified; ' + 'no extra bags, umbrellas, pets, garments floating in the air or background props. ' + 'Keep exactly the same recognizable face, hair style and hair color as the input reference image when provided. ' + 'Correct age and correct body proportions. Clothing must be clearly visible. ' + 'High priority: one person only, clean natural face, no deformations.' + ) + + +def _render_image_from_url(url: str): + from PIL import Image + response = requests.get(url, timeout=45) + response.raise_for_status() + if len(response.content) > 18_000_000: + raise RuntimeError('Image ComfyUI trop volumineuse (limite 18 Mo).') + with Image.open(io.BytesIO(response.content)) as raw: + raw.load() + return raw.convert('RGB') + + +def _vision_review(kind: str, prompt_summary: str, image_url: str) -> dict: + """Évalue une image via VLM ComfyUI si le workflow vision le permet. + + IMPORTANT: LoadImage attend un *fichier d'entrée*, pas une URL HTTP /view. + """ + fallback = {'score': 0.0, 'ok': False, 'issues': ['review_unavailable'], 'reviewed': False} + if not REVIEW_ENABLED: + return fallback + workflow_path = WORKFLOWS_DIR / 'vision_analyze.json' + if not workflow_path.exists(): + return fallback + image_node = os.getenv('COMFY_VISION_IMAGE_NODE', '1') + prompt_node = os.getenv('COMFY_VISION_PROMPT_NODE', '2') + try: + workflow = json.loads(workflow_path.read_text(encoding='utf-8')) + node = workflow[str(image_node)] + if node.get('class_type') not in ('LoadImage', 'LoadImageMask'): + # Éviter un faux diagnostic sur un workflow VLM non standard. + return fallback + response_image = requests.get(image_url, timeout=45) + response_image.raise_for_status() + if len(response_image.content) > 18_000_000: + return fallback + filename = 'alfred-weather-review-' + uuid.uuid4().hex[:12] + '.png' + response_upload = requests.post(f'{BASE}/upload/image', + files={'image': (filename, io.BytesIO(response_image.content), 'image/png')}, + data={'overwrite': 'true'}, timeout=30) + response_upload.raise_for_status() + remote = response_upload.json() + name = str(remote.get('name') or filename) + folder = str(remote.get('subfolder') or '') + node['inputs']['image'] = ((folder.strip('/') + '/') if folder else '') + name + review_prompt = ( + 'Review a single-person illustrated weather outfit. ' + f'Expected: {prompt_summary}. ' + 'Answer only JSON with fields: people_count (integer), gender_age_ok (boolean), ' + 'face_ok (boolean), accessories_ok (boolean), deformation (boolean), ' + 'score (number 0 to 10), issues (list of short strings). ' + 'Score harshly for extra people, bad anatomy or wrong-age character.' + ) + workflow[str(prompt_node)]['inputs']['text'] = review_prompt + response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, + 'client_id': str(uuid.uuid4())}, timeout=30) + response.raise_for_status() + prompt_id = response.json()['prompt_id'] + history = _wait_history(prompt_id) + raw_text = None + for output in history.get('outputs', {}).values(): + for key in ('text', 'texts', 'result', 'string'): + if key in output: + raw_text = output[key] + break + if raw_text is not None: + break + if isinstance(raw_text, list): + raw_text = raw_text[0] if raw_text else '' + if isinstance(raw_text, dict): + parsed = raw_text + else: + raw_text = str(raw_text or '') + begin, finish = raw_text.find('{'), raw_text.rfind('}') + if begin < 0 or finish <= begin: + return fallback + parsed = json.loads(raw_text[begin:finish + 1]) + score = max(0.0, min(10.0, float(parsed['score']))) + count = int(parsed['people_count']) + gender_age_ok = parsed.get('gender_age_ok') is True + face_ok = parsed.get('face_ok') is True + deformation = parsed.get('deformation') is True + accessories_ok = parsed.get('accessories_ok') is True + ok = count == 1 and gender_age_ok and face_ok and not deformation + return {'score': score, 'ok': ok, 'issues': parsed.get('issues', []), + 'reviewed': True, 'accessories_ok': accessories_ok, + 'people_count': count, 'gender_age_ok': gender_age_ok, + 'face_ok': face_ok, 'deformation': deformation} + except (ValueError, TypeError, KeyError, json.JSONDecodeError, requests.RequestException, + OSError, RuntimeError, TimeoutError) as exc: + print('[ALFRED METEO] Relecture VLM non disponible : ' + str(exc), flush=True) + return fallback + + +def _quick_quality(img) -> float: + """Indice technique non sémantique (non utilisé comme preuve de conformité).""" + from PIL import ImageFilter, ImageStat + gray = img.convert('L').resize((256, 256)) + edges = gray.filter(ImageFilter.FIND_EDGES) + score = ImageStat.Stat(edges).stddev[0] + return min(7.0, max(1.0, score / 3.0)) + + +def _lock_reference_face(img, kind: str, referenced: bool): + """Préserve la tête du portrait validé, pour un cadrage img2img identique. + + Uniquement appliqué si ComfyUI est parti du portrait de référence, afin + d'éviter de superposer la tête sur une pose générée sans contrainte. + """ + if not referenced or not PRESERVE_FACE: + return img + ref_file = REFERENCE_DIR / f'{kind}-reference.png' + if not ref_file.is_file(): + return img + from PIL import Image, ImageDraw, ImageFilter + with Image.open(ref_file) as reference: + reference = reference.convert('RGB').resize(img.size, Image.Resampling.LANCZOS) + # Coordonnées relatives aux portraits 512x768 fournis dans le ZIP. + region = ((.35, .08, .66, .36) if kind == 'child' else (.34, .06, .66, .34)) + box = tuple(round(v * (img.width if i % 2 == 0 else img.height)) for i,v in enumerate(region)) + mask = Image.new('L', img.size, 0) + drawer = ImageDraw.Draw(mask) + drawer.ellipse(box, fill=230) + mask = mask.filter(ImageFilter.GaussianBlur(max(8, round(img.width * .022)))) + return Image.composite(reference, img, mask) + + +def _render_best_one(kind: str, day: dict, outfits: dict, refresh: bool = False): + prompt = _single_character_prompt(kind, day, outfits) + subject = ('one 8 year old girl' if kind == 'child' else 'one adult 42 year old man') + summary = subject + ', clothes: ' + ', '.join(outfits[kind]['clothes']) + ', accessories: ' + ( + ', '.join(_primary_accessories(kind, outfits)) or 'none') + best = None + for attempt in range(1, MAX_ATTEMPTS_PER_CHARACTER + 1): + prompt_id, referenced = _submit_workflow(prompt, force_new_seed=True, kind=kind) + result = _wait_history(prompt_id) + images = _extract_images(result) + if not images: + raise RuntimeError(f'Aucune illustration ComfyUI générée pour {kind}.') + url = images[0]['url'] + image = _render_image_from_url(url) + review = _vision_review(kind, summary, url) + quick = _quick_quality(image) + score = review['score'] if review.get('reviewed') else quick + # Penalty stronger than score for invalid identity/count from a real review + if review.get('reviewed') and not review['ok']: + score -= 4.0 + candidate = {'image': image, 'prompt_id': prompt_id, + 'review': review, 'score': score, 'referenced': referenced} + if best is None or score > best['score']: + best = candidate + # Early stop only when a real review validates quality, not with fallback. + if review.get('reviewed') and review.get('ok') and review.get('score', 0) >= QUALITY_TARGET: + break + # Without a functioning VLM, avoid double generation on reasonable output. + if not review.get('reviewed') and quick >= 4.3: + break + final = _lock_reference_face(best['image'], kind, best['referenced']) + best['review']['reference_applied'] = best['referenced'] + return final, best['prompt_id'], best['review'] + + +def _compose_duo(child_img, adult_img, dest: Path): + """Assemble deux personnages distincts dans une vignette carrée 350×350.""" + from PIL import Image, ImageOps, ImageDraw + size = max(200, min(700, DISPLAY_SIZE)) + gap = max(4, round(size * .023)) + tile_w = (size - gap) // 2 + tile_h = size + canvas = Image.new('RGB', (size, size), (42, 50, 56)) + # contain au lieu de fit : ne coupe pas les bras, chaussures ou accessoires. + for image, x in ((child_img, 0), (adult_img, tile_w + gap)): + thumb = ImageOps.fit(image, (tile_w, tile_h), method=Image.Resampling.LANCZOS) + px = x + (tile_w - thumb.width)//2 + py = (size - thumb.height)//2 + canvas.paste(thumb, (px, py)) + draw = ImageDraw.Draw(canvas) + draw.rectangle((tile_w, 0, tile_w + gap - 1, size), fill=(42, 50, 56)) + dest.parent.mkdir(parents=True, exist_ok=True) + temp = dest.with_suffix('.tmp.jpg') + canvas.save(temp, format='JPEG', quality=92, optimize=True) + temp.replace(dest) + + +def _cache_url(file_path: Path) -> str: + return 'data:image/jpeg;base64,' + base64.b64encode(file_path.read_bytes()).decode('ascii') + + +def _generate_illustration(day: dict, outfits: dict, focus_label: str, refresh: bool = False) -> dict | None: + if not ENABLED: + return None + sig = _weather_signature(day, outfits) + image_path = IMAGE_DIR / (sig + '.jpg') + with ILLUSTRATION_LOCK: + cache = _load_cache() + entry = cache.get(sig) + now_ts = time.time() + if (entry and image_path.is_file() and not refresh + and (now_ts - float(entry.get('cached_at', 0))) < TTL_HOURS * 3600): + return { + 'url': _cache_url(image_path), + 'cached_at': entry.get('cached_at'), + 'prompt_ids': entry.get('prompt_ids', []), + 'reviews': entry.get('reviews', {}), + 'from_cache': True, + } + child, child_id, child_review = _render_best_one('child', day, outfits, refresh=refresh) + adult, adult_id, adult_review = _render_best_one('adult', day, outfits, refresh=refresh) + _compose_duo(child, adult, image_path) + entry = { + 'file': image_path.name, + 'prompt_ids': [child_id, adult_id], + 'reviews': {'child': child_review, 'adult': adult_review}, + 'cached_at': now_ts, + 'focus_label': focus_label, + } + cache[sig] = entry + _save_cache(cache) + return { + 'url': _cache_url(image_path), + 'prompt_ids': entry['prompt_ids'], + 'reviews': entry['reviews'], + 'cached_at': now_ts, + 'from_cache': False, + } + + +def build_payload(q: str = '', date: str = '', label: str = '', refresh: bool = False) -> dict: + data = ha_weather.get_weather(q) + if date: + try: + Date.fromisoformat(date) + except (ValueError, TypeError) as exc: + raise ValueError('Date invalide (AAAA-MM-JJ requis).') from exc + target = date or data.get('focus_date', '') + day = _pick_day(data, target) + focus_label = label or data.get('focus_label', 'aujourd’hui') + outfits = _recommend(day) + illustration = None + illustration_error = None + try: + illustration = _generate_illustration(day, outfits, focus_label, refresh=refresh) + except (OSError, ValueError, RuntimeError, TimeoutError, ImportError, requests.RequestException) as exc: + illustration_error = f'{type(exc).__name__} : {exc}' + print(f'[ALFRED METEO] Illustration indisponible : {illustration_error}', flush=True) + return { + 'type': 'weather_outfits', + 'focus_label': focus_label, + 'date': day.get('date'), + 'condition': day.get('condition'), + 'description': day.get('description'), + 'temperature': day.get('temperature'), + 'templow': day.get('templow'), + 'precipitation_probability': day.get('precipitation_probability'), + 'wind_speed': day.get('wind_speed'), + 'summary': outfits.get('summary'), + 'child': outfits['child'], + 'adult': outfits['adult'], + 'image': illustration, + 'illustration_error': illustration_error, + } diff --git a/alfred/app/services/weather_outfits.py.bak.20261025_234759 b/alfred/app/services/weather_outfits.py.bak.20261025_234759 new file mode 100644 index 0000000..15b8968 --- /dev/null +++ b/alfred/app/services/weather_outfits.py.bak.20261025_234759 @@ -0,0 +1,419 @@ +"""Outfit recommendations and ComfyUI illustration for Alfred weather view. + +This service reuses the same ComfyUI instance as Hermes, but calls ComfyUI +HTTP directly to avoid replacing Alfred's central view while generating the +illustration. +""" +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import os +import threading +import time +import uuid +from datetime import datetime, date as Date +from collections import Counter +from pathlib import Path +from urllib.parse import urlencode + +import requests +from dotenv import load_dotenv, dotenv_values + +# Charger l'environnement avant de calculer BASE et WORKFLOWS_DIR. +# Flask exécute son load_dotenv() après l'import de ce module. +load_dotenv() +load_dotenv('/opt/hermes/MCP_comfyui/.env', override=False) +_MCP_SETTINGS = dotenv_values('/opt/hermes/MCP_comfyui/.env') + +from . import ha_weather + +CACHE_DIR = Path(os.getenv('ALFRED_CACHE_DIR', str(Path(__file__).resolve().parents[1] / 'cache'))) +CACHE_DIR.mkdir(parents=True, exist_ok=True) +CACHE_FILE = CACHE_DIR / 'weather_outfit_cache.json' +BASE = (os.getenv('ALFRED_COMFYUI_URL') or _MCP_SETTINGS.get('COMFYUI_URL') or os.getenv('COMFYUI_URL') or 'http://192.168.1.13:8188').rstrip('/') +WORKFLOWS_DIR = Path(os.getenv('ALFRED_COMFYUI_WORKFLOWS_DIR') or os.getenv('WORKFLOWS_DIR') or '/opt/hermes/MCP_comfyui/workflows') +PROMPT_NODE = os.getenv('COMFY_PROMPT_NODE', '6') +TIMEOUT = int(os.getenv('COMFYUI_TIMEOUT', '300')) +TTL_HOURS = int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TTL_HOURS', '12')) +ENABLED = os.getenv('ALFRED_WEATHER_ILLUSTRATIONS', 'true').lower() in ('1', 'true', 'yes', 'on') +IMAGE_SIZE = min(1024, max(512, int(os.getenv('ALFRED_WEATHER_COMFY_SIZE', '768')))) +PROMPT_VERSION = 'outfits-v3-duo-20261025' + +# Un seul job génération météo à la fois : évite les doublons concurrents. +ILLUSTRATION_LOCK = threading.RLock() +IMAGE_DIR = CACHE_DIR / 'weather_outfit_images' +IMAGE_DIR.mkdir(parents=True, exist_ok=True) + +# Deux portraits distincts, réunis dans une seule image finale. +PORTRAIT_WIDTH = min(512, IMAGE_SIZE) +PORTRAIT_HEIGHT = IMAGE_SIZE + + +def _load_cache() -> dict: + try: + return json.loads(CACHE_FILE.read_text(encoding='utf-8')) + except Exception: + return {} + + +def _save_cache(cache: dict) -> None: + CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding='utf-8') + + +def _pick_day(data: dict, forced_date: str | None = None) -> dict: + """Retourne uniquement la météo du jour demandé ; jamais une autre journée.""" + target = str(forced_date or data.get('focus_date') or '').strip() + try: + requested = Date.fromisoformat(target) + except ValueError as exc: + raise ValueError('Date météo invalide : format AAAA-MM-JJ attendu.') from exc + + for day in data.get('daily', []): + if str(day.get('date')) == target: + return day + + # Certaines intégrations HA ne donnent que des prévisions horaires. + matching = [h for h in data.get('hourly', []) if h.get('date') == target] + if matching: + def available(key): + return [float(h[key]) for h in matching if h.get(key) is not None] + temps = available('temperature') + chances = available('precipitation_probability') + winds = available('wind_speed') + conditions = [str(h['condition']) for h in matching if h.get('condition')] + condition = Counter(conditions).most_common(1)[0][0] if conditions else 'unknown' + return { + 'date': target, + 'condition': condition, + 'description': ha_weather.CONDITIONS.get(condition, 'conditions non précisées'), + 'temperature': max(temps) if temps else None, + 'templow': min(temps) if temps else None, + 'precipitation_probability': max(chances) if chances else None, + 'wind_speed': max(winds) if winds else None, + } + + # Les observations actuelles ne doivent être utilisées QUE pour aujourd'hui. + if requested == datetime.now(ha_weather.TZ).date(): + current = data.get('current', {}) + return { + 'date': target, + 'condition': current.get('condition', 'unknown'), + 'description': current.get('description', 'conditions non précisées'), + 'temperature': current.get('temperature'), + 'templow': current.get('temperature'), + 'precipitation_probability': None, + 'wind_speed': current.get('wind_speed'), + } + raise ValueError(f'Aucune prévision Home Assistant disponible pour le {requested:%d/%m/%Y}. Aucune image ne sera générée avec les données d’un autre jour.') + + +def _temp_band(high: float | None, low: float | None) -> str: + ref = high if high is not None else low + if ref is None: + return 'mild' + if ref < 6: + return 'cold' + if ref < 14: + return 'cool' + if ref < 23: + return 'mild' + return 'warm' + + +def _recommend(day: dict) -> dict: + high = day.get('temperature') + low = day.get('templow') + pop = day.get('precipitation_probability') + wind = day.get('wind_speed') + cond = str(day.get('condition') or '') + band = _temp_band(high, low) + rainy = cond in {'rainy', 'pouring', 'lightning-rainy', 'snowy-rainy'} or (pop is not None and pop >= 45) + sunny = cond in {'sunny', 'partlycloudy'} + windy = wind is not None and wind >= 25 + cold = band in {'cold', 'cool'} + + child = [] + adult = [] + child_acc = [] + adult_acc = [] + + if band == 'cold': + child += ['doudoune chaude', 'pantalon chaud', 'chaussures fermées'] + adult += ['manteau chaud', 'pull', 'pantalon', 'chaussures fermées'] + child_acc += ['bonnet', 'écharpe'] + adult_acc += ['écharpe'] + elif band == 'cool': + child += ['veste légère', 'pantalon', 'baskets'] + adult += ['veste mi-saison', 'pull fin', 'jean', 'chaussures fermées'] + elif band == 'mild': + child += ['sweat ou gilet', 'pantalon léger', 'baskets'] + adult += ['veste légère', 'haut confortable', 'jean ou chino', 'baskets'] + else: + child += ['t-shirt', 'short ou pantalon léger', 'baskets légères'] + adult += ['t-shirt ou polo', 'tenue légère', 'chaussures légères'] + child_acc += ['casquette'] + adult_acc += ['lunettes de soleil'] + + if rainy: + child_acc += ['imperméable', 'parapluie'] + adult_acc += ['parapluie'] + if 'chaussures fermées' not in child: child.append('chaussures fermées') + if 'chaussures fermées' not in adult: adult.append('chaussures fermées') + if windy: + child_acc += ['coupe-vent'] + adult_acc += ['coupe-vent'] + if sunny and band != 'cold': + if 'casquette' not in child_acc: child_acc += ['casquette'] + if 'lunettes de soleil' not in adult_acc: adult_acc += ['lunettes de soleil'] + + def uniq(seq): + seen=[] + for x in seq: + if x not in seen: seen.append(x) + return seen + + return { + 'summary': day.get('description', 'Conditions du jour'), + 'child': { + 'title': 'Fille · 8 ans', + 'clothes': uniq(child), + 'accessories': uniq(child_acc), + }, + 'adult': { + 'title': 'Homme · 42 ans', + 'clothes': uniq(adult), + 'accessories': uniq(adult_acc), + } + } + + +def _weather_signature(day: dict, outfits: dict) -> str: + # Version explicite pour ne pas ressortir des images incorrectes de l'ancien cache. + raw = json.dumps({'version': PROMPT_VERSION, 'date': day.get('date'), + 'day': day, 'outfits': outfits, 'size': IMAGE_SIZE}, + ensure_ascii=False, sort_keys=True) + return hashlib.sha256(raw.encode('utf-8')).hexdigest() + + +def _extract_images(history: dict) -> list[dict]: + images = [] + for node_id, output in history.get('outputs', {}).items(): + for image in output.get('images', []): + filename = image.get('filename') + if not filename: + continue + subfolder = image.get('subfolder', '') + image_type = image.get('type', 'output') + query = urlencode({'filename': filename, 'subfolder': subfolder, 'type': image_type}) + images.append({'filename': filename, 'url': f'{BASE}/view?{query}', 'node_id': str(node_id)}) + return images + + +NEGATIVE_PROMPT = ( + 'more than one person, group photo, two people, extra person, background crowd, ' + 'mirror reflection, human silhouette in background, duplicate heads, cloned body, ' + 'deformed face, malformed face, mutated hands, extra limbs, extra fingers, ' + 'disfigured features, asymmetrical face, photorealistic, horror, blurry, ' + 'text, captions, logos, watermark' +) + + +def _prepare_workflow(workflow: dict, prompt: str) -> dict: + """Adapte le workflow ComfyUI utilisé par Hermes, sans toucher au fichier source.""" + node = workflow.get(PROMPT_NODE) + if not isinstance(node, dict) or not isinstance(node.get('inputs'), dict) or 'text' not in node['inputs']: + raise ValueError(f'Nœud de prompt ComfyUI {PROMPT_NODE} introuvable ou incompatible.') + node['inputs']['text'] = prompt + for sampler in workflow.values(): + if sampler.get('class_type') not in ('KSampler', 'KSamplerAdvanced'): + continue + negative_link = (sampler.get('inputs') or {}).get('negative') + if not isinstance(negative_link, (list, tuple)) or not negative_link: + continue + neg = workflow.get(str(negative_link[0])) + if neg and neg.get('class_type') == 'CLIPTextEncode' and 'text' in neg.get('inputs', {}): + neg['inputs']['text'] = NEGATIVE_PROMPT + for latent in workflow.values(): + if latent.get('class_type') in ('EmptyLatentImage', 'EmptySD3LatentImage'): + inputs = latent.get('inputs') or {} + if 'width' in inputs and 'height' in inputs: + inputs['width'] = PORTRAIT_WIDTH + inputs['height'] = PORTRAIT_HEIGHT + return workflow + + +def _submit_workflow(prompt: str) -> str: + workflow_path = WORKFLOWS_DIR / 'text_to_image.json' + if not workflow_path.exists(): + raise RuntimeError(f'Workflow ComfyUI introuvable : {workflow_path}') + workflow = _prepare_workflow(json.loads(workflow_path.read_text(encoding='utf-8')), prompt) + response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30) + response.raise_for_status() + return response.json()['prompt_id'] + + +def _wait_history(prompt_id: str) -> dict: + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + response = requests.get(f'{BASE}/history/{prompt_id}', timeout=15) + response.raise_for_status() + result = response.json() + if prompt_id in result: + return result[prompt_id] + time.sleep(1) + raise RuntimeError(f'ComfyUI timeout ({prompt_id})') + + +def _single_character_prompt(kind: str, day: dict, outfits: dict) -> str: + """Un SEUL personnage par image, même style graphique pour les deux.""" + profile = outfits[kind] + if kind == 'child': + identity = ( + 'ONE young GIRL, eight years old, clearly a primary-school child, ' + 'small body proportions of a young child, friendly and natural face, ' + 'casual appropriate haircut, no grown-up features. ' + ) + else: + identity = ( + 'ONE adult MAN, forty-two years old, unmistakably masculine adult, ' + 'short neat hair, mature male face, optionally light stubble, ' + 'adult male shoulders and body proportions, not a teenager. ' + ) + weather = str(day.get('description') or 'daily weather') + return ( + 'Single character fashion illustration for a family weather dashboard. ' + 'Exactly ONE character, standing ALONE. ' + + identity + + 'Full body visible from head to shoes, centrally positioned, front three-quarter view, ' + 'generous empty space around body, relaxed neutral standing pose, smiling gently. ' + 'Soft polished 2D digital illustration, smooth clean outlines, tasteful pastel colors, ' + 'slight dimensional shading, consistent editorial children-book illustration style, ' + 'simple light neutral plain studio background, no other people, no human reflections, ' + 'no poster, no photographs, no text. ' + f'Weather for {day.get("date", "selected day")}: {weather}. ' + f'Clothing: {", ".join(profile["clothes"])}. ' + f'Accessories: {", ".join(profile["accessories"]) if profile["accessories"] else "none"}. ' + 'Correct age and correct body proportions. Clothing must be clearly visible.' + ) + + +def _render_one(kind: str, day: dict, outfits: dict): + """Génère et récupère un portrait individuel auprès du ComfyUI déjà installé.""" + prompt = _single_character_prompt(kind, day, outfits) + prompt_id = _submit_workflow(prompt) + result = _wait_history(prompt_id) + images = _extract_images(result) + if not images: + raise RuntimeError(f'Aucune illustration ComfyUI générée pour {kind}.') + result_image = requests.get(images[0]['url'], timeout=45) + result_image.raise_for_status() + if len(result_image.content) > 18_000_000: + raise RuntimeError('Image ComfyUI trop volumineuse (limite 18 Mo).') + from PIL import Image + with Image.open(io.BytesIO(result_image.content)) as raw: + raw.load() + img = raw.convert('RGB') + return img, prompt_id + + +def _compose_duo(child_img, adult_img, dest: Path): + """Assemble exactement deux portraits dans UN SEUL carré, comme la maquette.""" + from PIL import Image, ImageOps, ImageDraw + w = 700 + h = 700 + gap = 12 + tile_w = (w - gap) // 2 + tile_h = h + canvas = Image.new('RGB', (w, h), (42, 50, 56)) + # Recadrage centré : aucune troisième image/personne n'est ajoutée au montage. + left = ImageOps.fit(child_img, (tile_w, tile_h), method=Image.Resampling.LANCZOS, centering=(.5, .5)) + right = ImageOps.fit(adult_img, (tile_w, tile_h), method=Image.Resampling.LANCZOS, centering=(.5, .5)) + canvas.paste(left, (0, 0)) + canvas.paste(right, (tile_w + gap, 0)) + # Trait de séparation discret, les deux personnages forment une seule image. + draw = ImageDraw.Draw(canvas) + draw.rounded_rectangle((tile_w, 0, tile_w + gap - 1, h - 1), radius=2, fill=(42, 50, 56)) + dest.parent.mkdir(parents=True, exist_ok=True) + temp = dest.with_suffix('.tmp.jpg') + canvas.save(temp, format='JPEG', quality=88, optimize=True) + temp.replace(dest) + + +def _cache_url(file_path: Path) -> str: + """URL autonome sans nouvelle route Flask ; données gardées dans le cache Alfred.""" + return 'data:image/jpeg;base64,' + base64.b64encode(file_path.read_bytes()).decode('ascii') + + +def _generate_illustration(day: dict, outfits: dict, focus_label: str, refresh: bool = False) -> dict | None: + if not ENABLED: + return None + sig = _weather_signature(day, outfits) + image_path = IMAGE_DIR / (sig + '.jpg') + with ILLUSTRATION_LOCK: + cache = _load_cache() + entry = cache.get(sig) + now_ts = time.time() + if (entry and image_path.is_file() and not refresh + and (now_ts - float(entry.get('cached_at', 0))) < TTL_HOURS * 3600): + return { + 'url': _cache_url(image_path), + 'cached_at': entry.get('cached_at'), + 'prompt_ids': entry.get('prompt_ids', []), + 'from_cache': True, + } + child, child_id = _render_one('child', day, outfits) + adult, adult_id = _render_one('adult', day, outfits) + _compose_duo(child, adult, image_path) + entry = { + 'file': image_path.name, + 'prompt_ids': [child_id, adult_id], + 'cached_at': now_ts, + } + cache[sig] = entry + _save_cache(cache) + return { + 'url': _cache_url(image_path), + 'prompt_ids': entry['prompt_ids'], + 'cached_at': now_ts, + 'from_cache': False, + } + + +def build_payload(q: str = '', date: str = '', label: str = '', refresh: bool = False) -> dict: + data = ha_weather.get_weather(q) + if date: + try: + Date.fromisoformat(date) + except (ValueError, TypeError) as exc: + raise ValueError('Date invalide (AAAA-MM-JJ requis).') from exc + target = date or data.get('focus_date', '') + day = _pick_day(data, target) + focus_label = label or data.get('focus_label', 'aujourd’hui') + outfits = _recommend(day) + illustration = None + illustration_error = None + try: + illustration = _generate_illustration(day, outfits, focus_label, refresh=refresh) + except (OSError, ValueError, RuntimeError, TimeoutError, ImportError, requests.RequestException) as exc: + # La partie texte doit rester affichable si ComfyUI est indisponible. + illustration_error = f'{type(exc).__name__} : {exc}' + print(f'[ALFRED METEO] Illustration indisponible : {illustration_error}', flush=True) + return { + 'type': 'weather_outfits', + 'focus_label': focus_label, + 'date': day.get('date'), + 'condition': day.get('condition'), + 'description': day.get('description'), + 'temperature': day.get('temperature'), + 'templow': day.get('templow'), + 'precipitation_probability': day.get('precipitation_probability'), + 'wind_speed': day.get('wind_speed'), + 'summary': outfits.get('summary'), + 'child': outfits['child'], + 'adult': outfits['adult'], + 'image': illustration, + 'illustration_error': illustration_error, + } diff --git a/alfred/app/static/assets/weather/adult-reference.png b/alfred/app/static/assets/weather/adult-reference.png new file mode 100644 index 0000000..012bbed Binary files /dev/null and b/alfred/app/static/assets/weather/adult-reference.png differ diff --git a/alfred/app/static/assets/weather/child-reference.png b/alfred/app/static/assets/weather/child-reference.png new file mode 100644 index 0000000..3062816 Binary files /dev/null and b/alfred/app/static/assets/weather/child-reference.png differ diff --git a/alfred/app/static/css/alfred-controls.css b/alfred/app/static/css/alfred-controls.css index b35fd50..716de4e 100644 --- a/alfred/app/static/css/alfred-controls.css +++ b/alfred/app/static/css/alfred-controls.css @@ -12,3 +12,30 @@ .quiet-button[aria-pressed="true"] { color:#f7b5b5; background:#b65c5c26; border-color:#b65c5c66; } .settings-message { min-height:14px; margin:12px 0 0; font-size:11px; color:#e4c089; line-height:1.4; } @media(max-width:760px) { .audio-popover { top:65px; right:14px; } } + +/* Bouton mute microphone dans la barre supérieure. aria-pressed reflète l'état réel de l'API. */ +.mic-off-icon { display: none; } +#micMute[aria-pressed="true"] .mic-on-icon { display: none; } +#micMute[aria-pressed="true"] .mic-off-icon { display: block; } + +/* Onglet caméra dans le panneau déjà présent (pas de modification des vues calendrier). */ +.audio-popover { max-height:calc(100dvh - 100px); overflow-y:auto; overscroll-behavior:contain; } +.settings-tabs { display:flex; gap:5px; padding:4px; background:#111518; border-radius:11px; margin:0 0 16px; } +.settings-tab { flex:1; padding:10px; border:0; border-radius:8px; color:#a7b2ba; background:transparent; font:inherit; cursor:pointer; } +.settings-tab[aria-selected="true"] { background:#3b464b; color:#f6f8f9; font-weight:600; } +.settings-tab:focus-visible,.camera-settings-action:focus-visible,.camera-select:focus-visible,.camera-range:focus-visible { outline:2px solid #b9dbec; outline-offset:2px; } +.camera-settings-note { margin:10px 0 15px; color:#aab5be; font-size:12px; line-height:1.5; } +.camera-settings-items { display:block; } +.camera-group-title { color:#c2d6df; font-size:12px; text-transform:uppercase; letter-spacing:.08em; margin:23px 0 8px; font-weight:650; } +.camera-control { padding:0 0 8px; border-bottom:1px solid #ffffff10; } +.camera-control .setting-row { margin-top:11px; margin-bottom:9px; } +.camera-control label { font-size:12px; } +.camera-control .camera-control-value { color:#b8ced5; } +.camera-range, .camera-select { width:100%; min-height:28px; accent-color:#a8c7ce; } +.camera-select { color:#eff2f5; background:#30383d; border:1px solid #ffffff26; border-radius:8px; padding:7px 9px; font-size:13px; } +.camera-control.is-inactive { opacity:.44; } +.camera-disabled-hint { font-size:11px; color:#acb5bf; margin-top:4px; } +.camera-settings-actions { display:flex; gap:8px; margin-top:19px; } +.camera-settings-action { flex:1; padding:10px; background:#3b464c; border:1px solid #ffffff23; border-radius:9px; color:#f2f5f6; font-size:12px; cursor:pointer; } +.camera-settings-action.reset { background:#5b454026; color:#f0c4b9; } +@media(max-width:760px) { .audio-popover { max-height:calc(100dvh - 80px); } } diff --git a/alfred/app/static/css/alfred-views.css b/alfred/app/static/css/alfred-views.css new file mode 100644 index 0000000..fbf81bc --- /dev/null +++ b/alfred/app/static/css/alfred-views.css @@ -0,0 +1,102 @@ +/* Calendar and Home Assistant views — preserves original dark Alfred layout */ +#stage:has(.alfred-view) {padding:0 0 6px;justify-content:stretch;align-items:stretch} +#content.alfred-view {width:100%;height:100%;text-align:left;min-height:0;} +.view-pane {display:flex;flex-direction:column;gap:12px;max-height:100%;height:100%;min-height:0;padding:8px 1px 14px;} +.view-head {display:flex;justify-content:space-between;align-items:center;gap:12px;} +#content .view-title {font-size:clamp(1.2rem,1.7vw,1.85rem);margin:0;font-weight:500} +.view-close,.view-button,.view-pill {background:#363d42;color:#f3f7fa;border:1px solid #ffffff24;border-radius:10px;padding:9px 14px;cursor:pointer;font:inherit} +.view-close {font-size:1.4rem;min-width:40px;height:40px;padding:0} +.view-button:hover,.view-pill:hover,.view-close:hover {background:#455057} +.view-pill.active,.view-button.primary {background:#427789;border-color:#619aad} +.view-toolbar {display:flex;gap:9px;align-items:center;flex-wrap:wrap;} +.view-nav,.view-modes {display:flex;gap:5px;align-items:center} +.view-period {font-size:.85rem;min-width:215px;text-align:center;color:#d8e0e4} +.view-select {padding:9px;max-width:220px;border-radius:9px;background:#333a3e;color:#eff3f5;border:1px solid #ffffff2b} +.agenda-grid {display:grid;gap:5px;min-height:0;flex:1;overflow:auto;padding:2px;align-content:stretch;} +.agenda-seven,.agenda-month {grid-template-columns:repeat(7,minmax(0,1fr))} +.agenda-month {grid-template-rows:repeat(6,minmax(110px,1fr));} +.agenda-three {grid-template-columns:repeat(3,minmax(0,1fr))} +.agenda-one {grid-template-columns:1fr} +.agenda-day {border:1px solid #ffffff1c;border-radius:11px;min-width:0;min-height:120px;background:#ffffff08;padding:9px;display:flex;flex-direction:column;gap:6px;overflow-y:auto} +.agenda-day.is-today {border-color:#86bfda7a;background:#57839923} +.agenda-day.out-month {opacity:.42} +.agenda-date {font-size:.83rem;font-weight:600;color:#dce2e6;letter-spacing:.01em;padding-bottom:7px;border-bottom:1px solid #ffffff14} +.agenda-event {width:100%;text-align:left;background:color-mix(in srgb,var(--event-color) 17%,#2a2e32);color:#f1f3f5;border:0;border-left:3px solid var(--event-color);border-radius:5px;padding:7px 5px;font-size:clamp(.72rem,.85vw,.95rem);line-height:1.3;cursor:pointer;overflow-wrap:anywhere} +.agenda-event:hover {background:color-mix(in srgb,var(--event-color) 30%,#2a2e32)} +.agenda-empty {font-size:.8rem;color:#667781;margin:auto} +#content .view-footer {color:#9aa8b3;font-size:.76rem;margin:0} +#content .view-error {color:#f7adad;font-size:.85rem;margin:0} +#content .view-empty {font-size:1rem;color:#b8c2ca} +.agenda-form {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;gap:14px;max-width:850px;width:100%;overflow-y:auto;margin:12px auto;} +.form-field {display:flex;flex-direction:column;gap:7px;color:#cfd6dd;font-size:.83rem} +.form-field input,.form-field select,.form-field textarea {padding:12px;border-radius:10px;color:#eff3f5;background:#2e3439;border:1px solid #ffffff32;font:inherit;min-width:0} +.form-field textarea {resize:vertical} +.form-check {display:flex;align-items:center;gap:10px} +.view-actions {display:flex;align-items:center;gap:10px;justify-content:flex-end;flex-wrap:wrap;} +.form-message {grid-column:1 / -1;} +.agenda-form .view-actions {grid-column:1 / -1} +.view-danger {color:#ffc4c4;background:#8f383850;border-color:#ed929255} +.detail-modal {position:fixed;inset:0;z-index:30;display:grid;place-items:center;background:#000b;padding:20px} +.detail-box {min-width:min(92vw,300px);max-width:540px;width:100%;border:1px solid #ffffff36;border-radius:19px;background:#292e33;box-shadow:0 25px 90px #000a;padding:24px;} +.detail-box h2 {font-size:1.4rem;margin:0 0 20px}.detail-box p {font-size:1rem;color:#d7dfe2;overflow-wrap:anywhere} +.delete-list {display:grid;gap:8px;overflow:auto}.delete-row {display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid #ffffff1c;border-radius:9px;padding:12px} +.temperature-grid {display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:4px;max-height:53%;} +.temperature-card {border:1px solid #ffffff22;background:#ffffff0a;border-radius:12px;padding:22px;color:#eaf1f3;white-space:pre-line;text-align:left;font:inherit;cursor:pointer;line-height:1.9} +.temperature-card:hover {background:#ffffff18} +.temperature-history {max-width:700px;text-align:center;margin:auto;width:100%;} +.temperature-history svg {width:100%;max-height:155px} +.temperature-history h2 {font-size:1.1rem;font-weight:450} +.ha-cameras {display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;overflow:auto;} +.ha-camera-card {background:#ffffff0a;border-radius:13px;overflow:hidden;} +.ha-camera-card h2 {font-size:.95rem;padding:10px;font-weight:500;margin:0} +.ha-camera-card img {display:block;width:100%;aspect-ratio:16/9;object-fit:contain;background:#121619} +@media(max-width:1100px){.view-toolbar{gap:6px}.view-button,.view-pill {padding:7px;font-size:.77rem}.view-period{min-width:150px}.agenda-day{padding:5px}.agenda-event{padding:4px;font-size:.74rem}} +@media(max-width:760px){#stage:has(.alfred-view){min-height:460px}.agenda-grid{min-height:280px}.agenda-seven,.agenda-month{min-width:630px}.agenda-day{min-height:105px}.view-pane{max-height:600px}.view-head{padding:0 4px}.agenda-form{grid-template-columns:1fr}.header-actions{gap:3px}.header-actions .icon-button {width:38px;height:38px}.view-toolbar{overflow:auto}} + +/* Weather dynamic view — same dark UI as calendar. */ +.weather-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px} +.weather-now{display:flex;align-items:center;gap:22px;background:#293238;border:1px solid #ffffff16;border-radius:16px;padding:18px 24px;} +.weather-current-icon{font-size:clamp(44px,6vw,80px);line-height:1;color:#edcb82} +.weather-now-details h2{margin:0 0 8px;font-size:clamp(20px,2.1vw,32px);font-weight:500;color:#f5f6f7} +.weather-now-details p{margin:0;color:#b6c4ce;font-size:clamp(12px,1vw,17px)} +.weather-section-title{font-size:15px;letter-spacing:.02em;color:#bfd0da;margin:7px 0 -2px;font-weight:550} +.weather-days{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:7px} +.weather-day{min-width:0;display:flex;flex-direction:column;align-items:center;text-align:center;gap:5px;background:#293035;border-radius:12px;border:1px solid #ffffff12;padding:12px 5px 10px} +.weather-day p{margin:0} +.weather-day-name{font-size:12px;color:#d4e0e6} +.weather-day-icon{font-size:clamp(22px,2.8vw,40px);color:#eaca85} +.weather-day-condition{font-size:11px;color:#adbcc5;min-height:30px;line-height:1.3} +.weather-day-temp{font-size:21px;color:#f5f7f8;font-weight:600} +.weather-day-low,.weather-rain{font-size:11px;color:#a7b6c0} +.weather-rain{color:#8dc8e4 !important} +.weather-hours{display:flex;gap:7px;overflow-x:auto;padding:3px 0 10px;scrollbar-color:#637480 #2a3237} +.weather-hour{display:flex;flex:0 0 80px;align-items:center;flex-direction:column;gap:9px;padding:10px 6px;background:#293035;border:1px solid #ffffff16;border-radius:12px;color:#cbd7df;font-size:12px} +.weather-hour-icon{font-size:27px;color:#edd298} +@media(max-width:840px){.weather-days{grid-template-columns:repeat(4,minmax(0,1fr))}.weather-now{padding:14px;gap:12px}.weather-current-icon{font-size:48px}} +@media(max-width:470px){.weather-days{grid-template-columns:repeat(3,minmax(0,1fr))}} + + +/* Weather outfits + ComfyUI illustration */ +.weather-outfit-host{margin:8px 0 4px} +.weather-outfit-section{display:flex;flex-direction:column;gap:12px;background:#252d32;border:1px solid #ffffff16;border-radius:16px;padding:16px} +.weather-outfit-head{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap} +.weather-outfit-grid{display:grid;grid-template-columns:minmax(260px,360px) minmax(320px,1fr);gap:14px;align-items:stretch} +.weather-outfit-guide{display:grid;gap:12px} +.weather-person-card{background:#ffffff08;border:1px solid #ffffff16;border-radius:14px;padding:14px} +.weather-person-title{margin:0 0 6px;font-size:1.05rem;color:#f2f5f7} +.weather-person-subtitle{margin:10px 0 4px;color:#b8c7d1;font-size:.86rem} +.weather-outfit-list{margin:0;padding-left:18px;color:#e0e6ea;font-size:.95rem;line-height:1.55} +.weather-illustration-card{display:flex;flex-direction:column;justify-content:center;align-items:center;background:#ffffff07;border:1px solid #ffffff16;border-radius:14px;padding:12px;min-height:320px} +.weather-illustration-image{display:block;max-width:100%;max-height:430px;border-radius:12px;box-shadow:0 16px 40px #0005;object-fit:contain} +@media(max-width:980px){.weather-outfit-grid{grid-template-columns:1fr}.weather-illustration-card{min-height:220px}} + +/* ALFRED_METEO_TENUES_V2 */ +/* Fenêtre météo défilante sur l’écran tactile d’Alfred */ +#content.alfred-view .view-pane:has(.weather-outfit-host) {overflow-y:auto;overflow-x:hidden;scrollbar-color:#62727d #222b30;} +.weather-outfit-host{flex-shrink:0} +.weather-illustration-card{min-height:370px} +.weather-illustration-image{width:350px;height:350px;max-width:100%;max-height:none;object-fit:contain;} +.weather-day{cursor:pointer;font:inherit;color:inherit} +.weather-day.is-selected{border-color:#8bd1e4;background:#37647942;box-shadow:inset 0 0 0 1px #8bd1e465} +.weather-day:focus-visible{outline:2px solid #8bd1e4;outline-offset:2px} +@media(max-width:540px){.weather-illustration-image{width:min(350px,100%);height:auto;aspect-ratio:1/1}} diff --git a/alfred/app/static/js/alfred-camera-settings.js b/alfred/app/static/js/alfred-camera-settings.js new file mode 100644 index 0000000..8ba293e --- /dev/null +++ b/alfred/app/static/js/alfred-camera-settings.js @@ -0,0 +1,175 @@ +/* Réglages matériels de webcam : curseurs générés selon ce que V4L2 expose. */ +(() => { + 'use strict'; + const pane = document.getElementById('audioSettingsPanel'); + const tabAudio = document.getElementById('audioTab'); + const tabCamera = document.getElementById('cameraTab'); + const sectionAudio = document.getElementById('audioSettingsSection'); + const sectionCamera = document.getElementById('cameraSettingsSection'); + const items = document.getElementById('cameraSettingsItems'); + const message = document.getElementById('cameraSettingsMessage'); + const reload = document.getElementById('cameraSettingsReload'); + const reset = document.getElementById('cameraSettingsReset'); + if (!pane || !tabAudio || !tabCamera || !items) return; + + const labels = { + brightness: 'Luminosité', contrast: 'Contraste', saturation: 'Saturation', + hue: 'Teinte', gamma: 'Gamma', gain: 'Gain vidéo', sharpness: 'Netteté', + backlight_compensation: 'Compensation contre-jour', + power_line_frequency: 'Fréquence anti-scintillement', + white_balance_automatic: 'Balance des blancs automatique', + white_balance_temperature: 'Température des blancs (K)', + auto_exposure: 'Exposition', exposure_time_absolute: 'Temps d’exposition', + exposure_dynamic_framerate: 'Fréquence d’image dynamique' + }; + const groups = [ + {name: 'Image', keys: ['brightness','contrast','saturation','sharpness','hue','gamma','gain','backlight_compensation']}, + {name: 'Éclairage et couleurs', keys: ['white_balance_automatic','white_balance_temperature','power_line_frequency']}, + {name: 'Exposition', keys: ['auto_exposure','exposure_time_absolute','exposure_dynamic_framerate']} + ]; + const exposureChoices = {1: 'Manuelle', 3: 'Automatique'}; + const powerChoices = {0: 'Désactivé', 1: '50 Hz', 2: '60 Hz'}; + let working = false; + + function showTab(which) { + const camera = which === 'camera'; + tabCamera.setAttribute('aria-selected', String(camera)); + tabAudio.setAttribute('aria-selected', String(!camera)); + sectionCamera.hidden = !camera; + sectionAudio.hidden = camera; + if (camera) load(); + } + tabAudio.addEventListener('click', () => showTab('audio')); + tabCamera.addEventListener('click', () => showTab('camera')); + document.getElementById('audioSettingsButton')?.addEventListener('click', () => { + if (!pane.hidden) showTab('audio'); + }); + + async function api(path, payload) { + const opts = {cache: 'no-store'}; + if (payload !== undefined) { + opts.method = 'POST'; + opts.headers = {'Content-Type':'application/json', 'X-Alfred-Request':'1'}; + opts.body = JSON.stringify(payload); + } + const res = await fetch(path, opts); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `Erreur HTTP ${res.status}`); + return data; + } + + function elt(tag, klass, text) { + const e = document.createElement(tag); + if (klass) e.className = klass; + if (text !== undefined) e.textContent = text; + return e; + } + + function controlRow(name, meta) { + const row = elt('div', 'camera-control'); + if (meta.inactive) row.classList.add('is-inactive'); + const header = elt('div','setting-row'); + const label = elt('label', null, labels[name] || name); + const output = elt('output','camera-control-value',String(meta.value ?? '—')); + header.append(label, output); row.appendChild(header); + let input; + if (meta.kind === 'bool' || meta.kind === 'menu') { + input = elt('select','camera-select'); + let opts; + if (meta.kind === 'bool') opts = [{value:1,label:'Activé'},{value:0,label:'Désactivé'}]; + else if (name === 'auto_exposure') opts = [{value:3,label:'Automatique'},{value:1,label:'Manuelle'}]; + else if (name === 'power_line_frequency') opts = Object.entries(powerChoices).map(([value,label])=>({value:Number(value),label})); + else opts = meta.menu || []; + const allowed = meta.menu?.length && meta.kind === 'menu' ? new Set(meta.menu.map(i=>i.value)) : null; + for (const opt of opts) { + if (allowed && !allowed.has(opt.value)) continue; + const option = elt('option',null,opt.label); + option.value = String(opt.value); + input.appendChild(option); + } + input.value = String(meta.value); + const updateLabel = () => { + output.textContent = input.options[input.selectedIndex]?.text || String(input.value); + }; + updateLabel(); + input.addEventListener('change', () => save(name, Number(input.value))); + } else { + input = elt('input','camera-range'); + input.type = 'range'; + input.min = String(meta.min); input.max = String(meta.max); + input.step = String(meta.step || 1); + input.value = String(meta.value); + input.addEventListener('input', () => { output.textContent = input.value; }); + // Appliquer au relâchement : évite des centaines de commandes shell. + input.addEventListener('change', () => save(name, Number(input.value))); + } + input.id = `v4l-${name}`; + label.htmlFor = input.id; + input.disabled = Boolean(meta.inactive || working); + row.appendChild(input); + if (meta.inactive) row.appendChild(elt('div','camera-disabled-hint', 'Désactive le mode automatique pour régler cette valeur.')); + return row; + } + + function render(data) { + items.replaceChildren(); + const available = data.controls || {}; + for (const group of groups) { + const found = group.keys.filter(name => available[name]); + if (!found.length) continue; + items.appendChild(elt('h3','camera-group-title',group.name)); + for (const name of found) items.appendChild(controlRow(name,available[name])); + } + if (!items.children.length) items.appendChild(elt('p','camera-settings-note','Aucun réglage V4L2 disponible.')); + } + + async function load() { + if (working) return; + message.textContent = 'Lecture des réglages caméra…'; + try { + render(await api('/api/camera/settings')); + message.textContent = ''; + } catch (err) { + message.textContent = err.message; + items.replaceChildren(); + } + } + + async function save(control, value) { + if (working) return; + working = true; + message.textContent = 'Application…'; + try { + const result = await api('/api/camera/settings', {control, value}); + working = false; + render(result); + message.textContent = 'Réglage appliqué et mémorisé.'; + } catch (err) { + message.textContent = err.message; + working = false; + await load(); + return; + } + working = false; + } + + reload.addEventListener('click', load); + reset.addEventListener('click', async () => { + if (working || !window.confirm('Rétablir tous les réglages caméra d’origine ?')) return; + working = true; + message.textContent = 'Restauration en cours…'; + try { + const result = await api('/api/camera/reset', {}); + working = false; + render(result); + message.textContent = 'Valeurs d’usine restaurées.'; + } catch (err) { + message.textContent = err.message; + } finally { + working = false; + } + }); + window.alfredSocket?.on('camera_settings', data => { + if (!sectionCamera.hidden && !working) render(data); + }); +})(); diff --git a/alfred/app/static/js/alfred-controls.js b/alfred/app/static/js/alfred-controls.js index b94cfef..8f3ae69 100644 --- a/alfred/app/static/js/alfred-controls.js +++ b/alfred/app/static/js/alfred-controls.js @@ -1,4 +1,4 @@ -/* Contrôles audio depuis un pop-up + raccourci mute haut-parleur. */ +/* Réglages volume/gain dans la fenêtre, mute micro + haut-parleur dans la barre haute. */ (() => { 'use strict'; const panel = document.getElementById('audioSettingsPanel'); @@ -24,8 +24,8 @@ speaker.setAttribute('aria-label', settings.speaker_muted ? 'Réactiver le son' : 'Couper le son'); speaker.title = speaker.getAttribute('aria-label'); mic.setAttribute('aria-pressed', String(settings.mic_muted)); - mic.textContent = settings.mic_muted ? 'Coupé' : 'Actif'; - mic.title = settings.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone'; + mic.setAttribute('aria-label', settings.mic_muted ? 'Réactiver le microphone' : 'Couper le microphone'); + mic.title = mic.getAttribute('aria-label'); } async function update(changes) { diff --git a/alfred/app/static/js/alfred-views.js b/alfred/app/static/js/alfred-views.js new file mode 100644 index 0000000..6566141 --- /dev/null +++ b/alfred/app/static/js/alfred-views.js @@ -0,0 +1,368 @@ +/* Alfred • visual views, powered by first-party Flask endpoints. No extra CDN. */ +(() => { + 'use strict'; + const root = document.getElementById('content'); + let current = null, camerasRefresh = null; + const DAYS = ['lun.','mar.','mer.','jeu.','ven.','sam.','dim.']; + const PALETTE = ['#77bcdf','#e5b677','#9dc99a','#d3a2d5','#8cb7c9','#ec9f95','#b6b5e1','#b9d78d']; + const el = (tag, cls, text) => { const x=document.createElement(tag); if(cls)x.className=cls; if(text!==undefined)x.textContent=String(text); return x; }; + const btn = (label, fn, cls='view-button') => { const b=el('button',cls,label); b.type='button'; b.addEventListener('click',fn);return b; }; + const api = async (url, options) => { + const r=await fetch(url,{cache:'no-store', ...options}); + const data=await r.json().catch(()=>({error:`Erreur HTTP ${r.status}`})); + if(!r.ok)throw new Error(data.error || `HTTP ${r.status}`); + return data; + }; + const post = (url,data) => api(url,{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify(data)}); + const dateLabel = iso => new Date(iso).toLocaleDateString('fr-FR',{day:'numeric',month:'long',year:'numeric'}); + const timeLabel = iso => new Date(iso).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}); + const localKey = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + const atDay = (d,h=0) => new Date(d.getFullYear(),d.getMonth(),d.getDate(),h); + const dayShift = (d,n) => new Date(d.getFullYear(),d.getMonth(),d.getDate()+n,d.getHours(),d.getMinutes()); + const eventOnDay=(ev,day)=> {const start=new Date(ev.start),end=new Date(ev.end);return startatDay(day);}; + const fmtRange=(a,b)=>`${dateLabel(a)} — ${dateLabel(new Date(new Date(b).getTime()-1000).toISOString())}`; + const wrap = (title) => { + clearInterval(camerasRefresh);camerasRefresh=null;root.replaceChildren(); + root.classList.add('alfred-view'); + const pane=el('div','view-pane'),head=el('header','view-head'); + head.append(el('h1','view-title',title)); + head.append(btn('×',goHome,'view-close')); + pane.append(head);root.append(pane);return {pane,head}; + }; + const showError=(err,pane)=>pane.append(el('p','view-error',err.message||String(err))); + const canRender = data=>['calendar','calendar_form','delete_candidates','temperatures','cameras','weather','home'].includes(data?.type); + function render(data){ + if(!canRender(data))return false; + current=data; + if(data.type==='calendar')drawCalendar(data); + if(data.type==='calendar_form')drawForm(data); + if(data.type==='delete_candidates')drawDeleteChoices(data); + if(data.type==='temperatures')drawTemps(data); + if(data.type==='weather')drawWeather(data); + if(data.type==='cameras')drawCameras(data); + if(data.type==='home'){root.classList.remove('alfred-view');root.replaceChildren(el('h1','', 'Bonjour.'), el('p','',data.text||'À ton écoute.')); clearInterval(camerasRefresh);camerasRefresh=null;} + return true; + } + const getSelected=()=>document.getElementById('calendar-source')?.value || ''; + async function loadCalendar(start,end,view,filter){ + const q=new URLSearchParams({start:new Date(start).toISOString(),end:new Date(end).toISOString()}); + if(filter)q.append('calendar_id',filter); + const data=await api('/api/calendar/events?'+q); + render({type:'calendar',view,start:new Date(start).toISOString(),end:new Date(end).toISOString(),...data,selected:filter||''}); + } + function moveCalendar(direction){ + if(!current || current.type!=='calendar')return; + const start=new Date(current.start),end=new Date(current.end),view=current.view; + let a,b; + if(view==='month'){ + a=new Date(start.getFullYear(),start.getMonth()+direction,1); + b=new Date(a.getFullYear(),a.getMonth()+1,1); + }else{ + const days=Math.round((end-start)/86400000) || 1; + a=dayShift(start,days*direction);b=dayShift(end,days*direction); + } + loadCalendar(a,b,view,getSelected()).catch(err=>{const {pane}=wrap('Agenda');showError(err,pane)}); + } + const details = (event) => { + const d=el('div','detail-modal'),box=el('section','detail-box'); + box.setAttribute('role','dialog');box.setAttribute('aria-modal','true');box.setAttribute('aria-label','Détails de l’événement'); + box.append(el('h2','',event.title)); + box.append(el('p','',`${dateLabel(event.start)}${event.all_day?' · Toute la journée':' · '+timeLabel(event.start)+'–'+timeLabel(event.end)}`)); + box.append(el('p','',event.calendar_name)); + if(event.location)box.append(el('p','',`Lieu : ${event.location}`)); + if(event.description)box.append(el('p','',event.description)); + const acts=el('div','view-actions');acts.append(btn('Fermer',()=>d.remove())); + if(event.can_delete) acts.append(btn('Supprimer…',async()=>{ + const recurrence=event.recurrence_id ? 'Cela supprimera cette occurrence.' : 'Pour une série récurrente, cela peut supprimer toute la série.'; + if(!confirm(`Supprimer « ${event.title} » du calendrier « ${event.calendar_name} » ?\n${recurrence}`))return; + try{await post('/api/calendar/delete',{calendar_id:event.calendar_id,uid:event.id,start:event.start,end:event.end,recurrence_id:event.recurrence_id||''}); d.remove();await loadCalendar(current.start,current.end,current.view,getSelected());} + catch(err){alert(err.message)} + },'view-danger')); + box.append(acts);d.append(box);document.body.append(d); + d.addEventListener('click',e=>{if(e.target===d)d.remove()}); + }; + function drawCalendar(data){ + const {pane,head}=wrap('Planning'); + const toolbar=el('div','view-toolbar'); + const first=el('div','view-nav'); + first.append(btn('‹',()=>moveCalendar(-1)),el('span','view-period',fmtRange(data.start,data.end)),btn('›',()=>moveCalendar(1))); + toolbar.append(first); + const modes=el('div','view-modes'); + [['Jour',1,'day'],['3 jours',3,'days'],['7 jours',7,'days'],['Semaine',7,'week'],['Mois',31,'month']].forEach(([label,n,v])=>{ + const b=btn(label,()=>{ + const now=new Date(),start=(v==='week')?dayShift(atDay(now),-((now.getDay()+6)%7)):v==='month'?new Date(now.getFullYear(),now.getMonth(),1):atDay(now); + const end=v==='month'?new Date(start.getFullYear(),start.getMonth()+1,1):dayShift(start,n); + loadCalendar(start,end,v,getSelected()).catch(err=>showError(err,pane)); + },'view-pill'+(data.view===v?' active':''));modes.append(b); + });toolbar.append(modes); + const src=el('select','view-select');src.id='calendar-source'; + src.append(new Option('Tous les calendriers','')); + (data.calendars||[]).forEach(c=>src.append(new Option(c.name,c.entity_id))); + src.value=data.selected||''; + src.addEventListener('change',()=>loadCalendar(data.start,data.end,data.view,src.value).catch(err=>showError(err,pane))); + toolbar.append(src,btn('+ Rendez-vous',()=>openForm())); + pane.append(toolbar); + if(data.errors?.length)pane.append(el('p','view-error','Calendriers partiellement indisponibles : '+data.errors.join(' · '))); + const grid=el('div','agenda-grid'); + const start=new Date(data.start),end=new Date(data.end); + let days=[],cursor=atDay(start); + const month=data.view==='month'; + if(month) cursor=dayShift(cursor,-((cursor.getDay()+6)%7)); + let limit=0; + while(cursor[c.entity_id, PALETTE[i%PALETTE.length]])); + days.forEach(d=>{ + const cell=el('section','agenda-day'); + if(month && d.getMonth()!==start.getMonth())cell.classList.add('out-month'); + if(localKey(d)===localKey(new Date()))cell.classList.add('is-today'); + cell.append(el('div','agenda-date',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:month?undefined:'short'}))); + const events=(data.events||[]).filter(ev=>eventOnDay(ev,d)); + if(!events.length)cell.append(el('div','agenda-empty','—')); + events.forEach(ev=>{ + const e=btn((ev.all_day?'':timeLabel(ev.start)+' ')+ev.title,()=>details(ev),'agenda-event'); + e.style.setProperty('--event-color',colors.get(ev.calendar_id)||'#7bbbe2'); + e.title=`${ev.title} · ${ev.calendar_name}`;cell.append(e); + }); + grid.append(cell); + }); + pane.append(grid); + pane.append(el('p','view-footer',`${data.events?.length||0} événement(s) · ${data.calendars?.length||0} calendrier(s) Home Assistant`)); + } + async function openForm(values){ + let calendars=[]; + try {const info=await api('/api/calendar/calendars');calendars=info.calendars||[];} + catch(err){const {pane}=wrap('Nouveau rendez-vous');showError(err,pane);return;} + render({type:'calendar_form',calendars,values:values||{}}); + } + function drawForm(data){ + const {pane}=wrap('Ajouter un événement'); + const v=data.values||{}; + const form=el('form','agenda-form');form.autocomplete='off'; + const field=(label,node)=>{const row=el('label','form-field');row.append(el('span','',label),node);form.append(row);return node}; + const makeInput=(type,value)=>{const i=el('input');i.type=type;i.value=value||'';i.required=true;return i}; + const title=field('Titre',makeInput('text',v.title));title.maxLength=200; + const src=el('select');src.required=true;src.append(new Option('Choisir un calendrier','')); + data.calendars.filter(c=>c.can_create).forEach(c=>src.append(new Option(c.name,c.entity_id))); + src.value=v.calendar_id||'';field('Calendrier',src); + const day=field('Date',makeInput('date',v.date||localKey(new Date()))); + const startTime=field('Début',makeInput('time',v.time||'09:00')); + const duration=field('Durée (minutes)',makeInput('number',v.duration_minutes||60));duration.min=15;duration.max=1440; + const location=field('Lieu (facultatif)',makeInput('text',''));location.required=false; + const description=field('Description (facultatif)',el('textarea'));description.required=false;description.rows=2; + const allDay=el('input');allDay.type='checkbox';allDay.required=false; + const allDayWrap=el('label','form-check');allDayWrap.append(allDay,el('span','','Toute la journée'));form.append(allDayWrap); + allDay.addEventListener('change',()=>{startTime.disabled=allDay.checked;duration.disabled=allDay.checked}); + const msg=el('p','form-message'); + const actions=el('div','view-actions');actions.append(btn('Annuler',goHome)); + const submit=el('button','view-button primary','Enregistrer');submit.type='submit';actions.append(submit); + form.append(actions,msg);pane.append(form); + form.addEventListener('submit',async e=>{ + e.preventDefault();submit.disabled=true;msg.textContent='Enregistrement…'; + try{ + const from=allDay.checked?new Date(day.value+'T00:00'):new Date(day.value+'T'+startTime.value+':00'); + if(Number.isNaN(from.getTime()))throw new Error('Date invalide.'); + const end=allDay.checked?dayShift(from,1):new Date(from.getTime()+Number(duration.value)*60000); + await post('/api/calendar/create',{calendar_id:src.value,title:title.value,start:from.toISOString(),end:end.toISOString(),all_day:allDay.checked,location:location.value,description:description.value}); + const first=atDay(from);await loadCalendar(first,dayShift(first,7),'days'); + }catch(err){msg.textContent=err.message;submit.disabled=false} + }); + } + function drawDeleteChoices(data){ + const {pane}=wrap('Choisir un événement à supprimer'); + const arr=el('div','delete-list'); + (data.events||[]).forEach(ev=>{ + const row=el('div','delete-row');row.append(el('span','',`${ev.title} • ${dateLabel(ev.start)} • ${ev.calendar_name}`)); + row.append(btn('Détails / Supprimer',()=>details(ev),'view-button'));arr.append(row); + });pane.append(arr); + if(data.awaiting_confirmation)pane.append(el('p','view-footer','Dis « confirme » pour supprimer, ou « annule ».')); + } + function drawTemps(data){ + const {pane}=wrap('Températures Home Assistant'); + if(!data.items?.length){pane.append(el('p','view-empty','Aucun capteur de température trouvé.'));return} + const grid=el('div','temperature-grid');pane.append(grid); + data.items.forEach(item=>{ + const c=btn(`${item.name}\n${item.value.toFixed(1)} ${item.unit}` ,async()=>{ + const q=new URLSearchParams({entity_id:item.entity_id,hours:'24'}); + try{const data=await api('/api/ha/temperature/history?'+q);showHistory(item,data.items||[],pane)} + catch(err){showError(err,pane)} + },'temperature-card');grid.append(c); + });pane.append(el('p','view-footer','Touchez un capteur pour afficher son historique sur 24 h.')); + } + function showHistory(item,points,pane){ + pane.querySelector('.temperature-history')?.remove(); + const section=el('section','temperature-history');section.append(el('h2','',item.name+' · 24 dernières heures')); + if(points.length<2){section.append(el('p','', 'Historique insuffisant.'));pane.append(section);return} + const vals=points.map(p=>p.value);const lo=Math.min(...vals)-0.4,hi=Math.max(...vals)+0.4; + const a=Date.parse(points[0].time),b=Date.parse(points[points.length-1].time); + const coords=points.map((p,i)=>`${30+560*((Date.parse(p.time)-a)/Math.max(1,b-a))},${140-110*((p.value-lo)/Math.max(0.1,hi-lo))}`).join(' '); + const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 620 170');svg.setAttribute('role','img');svg.setAttribute('aria-label','Courbe de température'); + const poly=document.createElementNS(ns,'polyline');poly.setAttribute('points',coords);poly.setAttribute('fill','none');poly.setAttribute('stroke','#83c5cc');poly.setAttribute('stroke-width','3');svg.append(poly); + section.append(svg,el('p','',`Min ${Math.min(...vals).toFixed(1)}° · Max ${Math.max(...vals).toFixed(1)}°`));pane.append(section); + } + function drawCameras(data){ + const {pane}=wrap('Caméras Home Assistant'); + if(!data.items?.length){pane.append(el('p','view-empty','Aucune caméra Home Assistant disponible.'));return} + const grid=el('div','ha-cameras');pane.append(grid); + const imgs=[]; + data.items.forEach(item=>{ + const card=el('div','ha-camera-card');card.append(el('h2','',item.name)); + const img=el('img');img.alt='Instantané caméra '+item.name;img.loading='lazy';imgs.push([img,item.entity_id]);card.append(img);grid.append(card); + }); + const reload=()=>{if(document.hidden)return; imgs.forEach(([img,eid])=>img.src='/api/ha/camera/'+encodeURIComponent(eid)+'/snapshot?t='+Date.now())}; + reload();camerasRefresh=setInterval(reload,15000); + pane.append(el('p','view-footer','Instantanés réactualisés toutes les 15 s lorsque cette vue est ouverte.')); + } + + const WEATHER_ICONS = { + 'sunny':'☀', 'clear-night':'☾', 'cloudy':'☁', 'partlycloudy':'⛅', + 'fog':'≋', 'rainy':'🌧', 'pouring':'🌧', 'hail':'🌨', + 'lightning':'⛈', 'lightning-rainy':'⛈', 'snowy':'❄', + 'snowy-rainy':'🌨', 'windy':'〰', 'windy-variant':'〰' + }; + const weatherIcon = cond => WEATHER_ICONS[cond] || '◌'; + const tempText = (val, unit='°C') => typeof val === 'number' ? `${Math.round(val)}${unit}` : '—'; + function weatherList(items){ + const ul=el('ul','weather-outfit-list'); + (items||[]).forEach(text=>ul.append(el('li','',text))); + if(!ul.childNodes.length)ul.append(el('li','','Aucun accessoire particulier')); + return ul; + } + let outfitRequestId=0; + async function loadWeatherOutfits(container, options={}, refresh=false){ + const requestId=++outfitRequestId; + container.replaceChildren(); + const loading=el('p','view-footer', refresh ? 'Régénération de l’illustration…' : 'Préparation des tenues conseillées…'); + container.append(loading); + try{ + const q = new URLSearchParams(); + if(options.query)q.set('q',options.query); + if(options.date)q.set('date',options.date); + if(options.label)q.set('label',options.label); + if(refresh)q.set('refresh_illustration','1'); + const data = await api('/api/ha/weather/outfits' + (q.toString() ? '?' + q.toString() : '')); + if(requestId!==outfitRequestId)return; + const section=el('section','weather-outfit-section'); + const head=el('div','weather-outfit-head'); + head.append(el('h2','weather-section-title',`Tenues conseillées · ${data.focus_label || 'aujourd’hui'}`)); + head.append(btn('Régénérer l’illustration',()=>loadWeatherOutfits(container, options, true),'view-pill')); + section.append(head); + + const grid=el('div','weather-outfit-grid'); + const guide=el('div','weather-outfit-guide'); + [data.child, data.adult].forEach(person=>{ + const card=el('article','weather-person-card'); + card.append(el('h3','weather-person-title',person.title)); + card.append(el('p','weather-person-subtitle','Tenue recommandée')); + card.append(weatherList(person.clothes)); + card.append(el('p','weather-person-subtitle','Accessoires')); + card.append(weatherList(person.accessories)); + guide.append(card); + }); + grid.append(guide); + const art=el('div','weather-illustration-card'); + if(data.image?.url){ + const img=el('img','weather-illustration-image'); + img.src=data.image.url; + img.alt='Illustration des tenues conseillées selon la météo'; + art.append(img); + const foot=el('p','view-footer', data.image.from_cache ? 'Illustration mise en cache.' : 'Illustration générée par ComfyUI.'); + art.append(foot); + } else { + art.append(el('p','view-empty',data.illustration_error ? 'Illustration indisponible : '+data.illustration_error : 'Illustration désactivée.')); + } + grid.append(art); + section.append(grid); + if(data.summary){ section.append(el('p','view-footer',`Conseil général : ${data.summary}.`)); } + container.replaceChildren(section); + }catch(err){ + container.replaceChildren(el('p','view-error',`Tenues conseillées indisponibles : ${err.message || err}`)); + } + } + function drawWeather(data){ + const {pane}=wrap('Météo · '+(data.name||'Home Assistant')); + const nav=el('div','weather-toolbar'); + nav.append(btn('Actualiser',openWeather,'view-pill')); + nav.append(el('span','view-footer','Source : Home Assistant')); + pane.append(nav); + const forecast=(data.daily||[]).find(x=>x.date===data.focus_date); + const c=(data.focus_date && data.focus_date!==localKey(new Date()) && forecast) ? forecast : (data.current||{}),now=el('section','weather-now'); + const badge=el('div','weather-current-icon',weatherIcon(c.condition));badge.setAttribute('aria-hidden','true'); + const details=el('div','weather-now-details'); + details.append(el('h2','',`${tempText(c.temperature,data.temperature_unit)} · ${c.description||'Conditions inconnues'}`)); + const metrics=[]; + if(c.apparent_temperature!==null && c.apparent_temperature!==undefined)metrics.push(`Ressenti ${tempText(c.apparent_temperature,data.temperature_unit)}`); + if(c.humidity!==null && c.humidity!==undefined)metrics.push(`Humidité ${c.humidity}%`); + if(c.wind_speed!==null && c.wind_speed!==undefined)metrics.push(`Vent ${c.wind_speed} ${data.wind_speed_unit||'km/h'}`); + details.append(el('p','',metrics.join(' · ')||'Conditions actuelles'));now.append(badge,details);pane.append(now); + + const outfitHost=el('div','weather-outfit-host'); + pane.append(outfitHost); + loadWeatherOutfits(outfitHost, {date:data.focus_date || '', label:data.focus_label || 'aujourd’hui'}); + + const heading = text => pane.append(el('h2','weather-section-title',text)); + heading('Prévisions pour les prochains jours'); + if(data.daily?.length){ + const cards=el('div','weather-days'); + data.daily.slice(0,7).forEach(day=>{ + const d=new Date(day.datetime); + const box=el('button','weather-day'); + box.type='button'; + box.title='Voir les tenues pour ce jour'; + if(day.date===data.focus_date)box.classList.add('is-selected'); + box.addEventListener('click',()=>{ + cards.querySelectorAll('.weather-day').forEach(b=>b.classList.remove('is-selected')); + box.classList.add('is-selected'); + const label=d.toLocaleDateString('fr-FR',{weekday:'long',day:'numeric',month:'long'}); + loadWeatherOutfits(outfitHost, {date:day.date, label}); + }); + box.append(el('p','weather-day-name',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:'short'}))); + box.append(el('div','weather-day-icon',weatherIcon(day.condition))); + box.append(el('p','weather-day-condition',day.description)); + box.append(el('p','weather-day-temp',tempText(day.temperature,data.temperature_unit))); + if(day.templow!==null)box.append(el('p','weather-day-low','Min '+tempText(day.templow,data.temperature_unit))); + if(day.precipitation_probability!==null)box.append(el('p','weather-rain','Pluie '+day.precipitation_probability+' %')); + cards.append(box); + });pane.append(cards); + }else pane.append(el('p','view-empty','Prévisions journalières non disponibles.')); + heading('Prochaines heures'); + if(data.hourly?.length){ + const list=el('div','weather-hours'), nowMs=Date.now(); + data.hourly.filter(x=>new Date(x.datetime).getTime()>=nowMs-3600000).slice(0,12).forEach(h=>{ + const box=el('div','weather-hour'); + box.append(el('span','',new Date(h.datetime).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}))); + box.append(el('span','weather-hour-icon',weatherIcon(h.condition))); + box.append(el('strong','',tempText(h.temperature,data.temperature_unit))); + if(h.precipitation_probability!==null)box.append(el('span','weather-rain',h.precipitation_probability+' %')); + list.append(box); + });pane.append(list); + }else pane.append(el('p','view-empty','Prévisions horaires non disponibles.')); + if(data.errors?.length)pane.append(el('p','view-footer','Certaines prévisions ne sont pas fournies par cette source météo.')); + } + async function openWeather(){ + try{render(await api('/api/ha/weather'))} + catch(err){const {pane}=wrap('Météo');showError(err,pane)} + } + + async function goHome(){ + try{await post('/api/ui/home',{})}catch(err){render({type:'home'})} + } + async function openCalendar(){ + const d=atDay(new Date());try{await loadCalendar(d,dayShift(d,7),'days')}catch(err){const {pane}=wrap('Agenda');showError(err,pane)} + } + async function openTemperatures(){ + try{render({type:'temperatures',...await api('/api/ha/temperatures')})}catch(err){const {pane}=wrap('Températures');showError(err,pane)} + } + async function openCameras(){ + try{render({type:'cameras',...await api('/api/ha/cameras')})}catch(err){const {pane}=wrap('Caméras');showError(err,pane)} + } + document.getElementById('viewHome')?.addEventListener('click',goHome); + document.getElementById('viewCalendar')?.addEventListener('click',openCalendar); + document.getElementById('viewTemperatures')?.addEventListener('click',openTemperatures); + document.getElementById('viewWeather')?.addEventListener('click',openWeather); + document.getElementById('viewCameras')?.addEventListener('click',openCameras); + const socket=window.alfredSocket; + socket?.on('calendar_changed',()=>{if(current?.type==='calendar') loadCalendar(current.start,current.end,current.view,getSelected()).catch(console.warn)}); + window.AlfredViews={render,canRender,openCalendar,openTemperatures,openCameras,openWeather,goHome}; +})(); diff --git a/alfred/app/static/js/alfred.js b/alfred/app/static/js/alfred.js index 67111dd..b143532 100644 --- a/alfred/app/static/js/alfred.js +++ b/alfred/app/static/js/alfred.js @@ -57,6 +57,11 @@ const key = JSON.stringify(data); if (key === previousContent) return; previousContent = key; + if (window.AlfredViews && window.AlfredViews.canRender(data)) { + window.AlfredViews.render(data); + return; + } + content.classList.remove('alfred-view'); const fragment = document.createDocumentFragment(); if (data.type === 'image' && typeof data.url === 'string') { // Refuser les URL javascript: et les schémas non prévus. diff --git a/alfred/app/templates/index.html b/alfred/app/templates/index.html index 3b875e9..54bd6bc 100644 --- a/alfred/app/templates/index.html +++ b/alfred/app/templates/index.html @@ -7,6 +7,7 @@ {{ name }} + @@ -18,14 +19,30 @@ En attente -
+
+ + + + + +
+
+ + +
+
70 % @@ -55,11 +77,17 @@ —
-
- Microphone - -

+
+
@@ -97,7 +125,9 @@ + + diff --git a/alfred/backup_meteo_tenues_v2_20261025_232200/app.py b/alfred/backup_meteo_tenues_v2_20261025_232200/app.py new file mode 100644 index 0000000..0eb28a6 --- /dev/null +++ b/alfred/backup_meteo_tenues_v2_20261025_232200/app.py @@ -0,0 +1,422 @@ +import os, threading, hmac +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from flask import Flask, render_template, request, jsonify, Response +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services.hermes_client import ask_hermes +from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1 +from services.camera import video_stream +from services import ha_views, alfred_views_controller +from services import camera_settings, ha_weather, weather_outfits + +load_dotenv() + +## +from services.audio import get_vosk_model +print("[ALFRED] Préchargement des modèles vocaux...", flush=True) +get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"]) +get_vosk_model(os.environ["VOSK_MODEL_PATH"]) +print("[ALFRED] Modèles vocaux prêts.", flush=True) +## +app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*") +STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""} +STATE_LOCK = threading.RLock() +IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI. +conversation_lock=threading.Lock() +mic_stop_event=threading.Event() +audio_settings.initialize() +audio_settings.update_settings({"mic_gain": 30}) +# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente. +camera_settings.restore_saved_settings() + +def emit_state(mode=None, **extra): + with STATE_LOCK: + if mode: STATE["mode"] = mode + STATE.update(extra) + socketio.emit("alfred_state", dict(STATE)) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + stop_event=mic_stop_event, + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if audio_settings.mic_muted(): + emit_state("idle", partial="") + return + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + # Vue dynamique : commandes déterministes exécutées sans faire + # dépendre l'affichage des capacités ou sessions Hermes. + visual = None + try: + visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type')) + except (ha_views.HAError, ValueError) as exc: + print(f"[ALFRED] Vue indisponible : {exc}", flush=True) + visual = ({"type":"text", "text":str(exc)}, str(exc)) + + if visual is not None: + content, speech = visual + with STATE_LOCK: + STATE["content"] = content + socketio.emit("alfred_render", content) + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + emit_state("idle", partial="") + return + + print("[ALFRED] Envoi à Hermes", flush=True) + # ComfyUI publie l'image pendant cet appel Hermes. + with STATE_LOCK: + image_revision_before = IMAGE_REVISION + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + # Si le MCP ComfyUI a affiché une image pendant la requête, + # ne pas l'écraser par la réponse textuelle d'Hermes. + with STATE_LOCK: + if IMAGE_REVISION == image_revision_before: + STATE["content"] = display + socketio.emit("alfred_render", display) + else: + print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + if audio_settings.mic_muted(): + return + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +@app.get('/api/audio/settings') +def get_audio_settings(): + return jsonify(audio_settings.get_settings()) + +@app.post('/api/audio/settings') +def set_audio_settings(): + data = request.get_json(silent=True) + try: + old = audio_settings.get_settings() + new = audio_settings.update_settings(data) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': f'ALSA : {exc}'}), 500 + if new['mic_muted'] != old['mic_muted']: + if new['mic_muted']: + mic_stop_event.set() + wake.pause() + else: + mic_stop_event.clear() + if not conversation_lock.locked(): + wake.resume() + socketio.emit('audio_settings', new) + return jsonify(new) + +@app.get('/api/camera/settings') +def get_camera_settings(): + try: + return jsonify(camera_settings.get_settings()) + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.post('/api/camera/settings') +def set_camera_settings(): + # Même garde-fou que les commandes calendrier existantes. + if not _same_origin_json(): + return jsonify({'error': 'Requête non autorisée.'}), 403 + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'JSON invalide.'}), 400 + try: + result = camera_settings.update_setting(data.get('control'), data.get('value')) + socketio.emit('camera_settings', result) + return jsonify(result) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.post('/api/camera/reset') +def reset_camera_settings(): + if not _same_origin_json(): + return jsonify({'error': 'Requête non autorisée.'}), 403 + try: + result = camera_settings.reset_defaults() + socketio.emit('camera_settings', result) + return jsonify(result) + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.get('/api/camera/stream') +def camera_stream(): + # Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan. + try: + frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0'))) + except (ImportError, ValueError, RuntimeError, OSError) as exc: + return jsonify({'error': str(exc)}), 503 + return Response( + frames, + mimetype='multipart/x-mixed-replace; boundary=frame', + headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'} + ) + +@app.get("/") +def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) +@app.get("/api/state") +def state(): + with STATE_LOCK: + snapshot = dict(STATE) + return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()}) + +@app.post("/api/state") +def set_state(): + data = request.get_json(force=True) + with STATE_LOCK: + STATE.update(data) + socketio.emit("alfred_state", dict(STATE)) + return jsonify({"ok": True}) +@app.post("/api/render") +def render_payload(): + global IMAGE_REVISION + token = os.getenv("ALFRED_RENDER_TOKEN", "").strip() + if token and not hmac.compare_digest( + request.headers.get("X-Alfred-Render-Token", ""), token + ): + return jsonify({"ok": False, "error": "Non autorisé"}), 401 + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"ok": False, "error": "JSON objet attendu"}), 400 + if data.get("type") == "image": + image_url = data.get("url") + if not isinstance(image_url, str) or not ( + image_url.startswith(("http://", "https://", "/")) + and not image_url.startswith("//") + ): + return jsonify({"ok": False, "error": "URL image invalide"}), 400 + + with STATE_LOCK: + if data.get("type") == "image": + IMAGE_REVISION += 1 + STATE["content"] = data + socketio.emit("alfred_render", data) + return jsonify({"ok": True, "image_revision": IMAGE_REVISION}) +# --- Agenda / capteurs / caméras Home Assistant --- +def _calendar_range(): + tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) + now = datetime.now(tz) + def parse(name, default): + raw = request.args.get(name) + if not raw:return default + value = datetime.fromisoformat(raw.replace('Z','+00:00')) + if value.tzinfo is None: value = value.replace(tzinfo=tz) + return value.astimezone(tz) + start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0)) + end = parse('end',start+timedelta(days=7)) + return start,end + +def _json_error(exc): + code = 400 if isinstance(exc,ValueError) else 502 + return jsonify({'error':str(exc)}),code + +def _writes_enabled(): + return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes') + +def _same_origin_json(): + # POSTs require a custom header and same-origin browser requests. + if request.headers.get('X-Alfred-Request') != '1':return False + origin=request.headers.get('Origin') + if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False + return True + +@app.get('/api/calendar/calendars') +def calendars_list(): + try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/calendar/events') +def calendar_events(): + try: + start,end=_calendar_range() + selected=request.args.getlist('calendar_id') + return jsonify(ha_views.get_events(start,end,selected or None)) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.post('/api/calendar/create') +def calendar_create(): + if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 + if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403 + data=request.get_json(silent=True) + if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 + try: + tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris')) + def read_dt(key): + dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00')) + return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz) + result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')), + read_dt('start'),read_dt('end'),str(data.get('description') or ''), + str(data.get('location') or ''),bool(data.get('all_day',False))) + from services import alfred_intents + alfred_intents.clear_create() + socketio.emit('calendar_changed', {'type':'created'}) + return jsonify(result) + except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) + +@app.post('/api/calendar/delete') +def calendar_delete(): + if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 + if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403 + data=request.get_json(silent=True) + if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 + try: + eid=str(data.get('calendar_id',''));uid=str(data.get('uid','')) + start=datetime.fromisoformat(str(data['start'])) + end=datetime.fromisoformat(str(data['end'])) + if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.') + matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events'] + rid=str(data.get('recurrence_id') or '') + if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches): + return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409 + result=ha_views.delete_event(eid,uid,rid or None) + from services import alfred_intents + alfred_intents.clear_delete() + socketio.emit('calendar_changed',{'type':'deleted'}) + return jsonify(result) + except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) + +@app.get('/api/ha/weather') +def ha_weather_data(): + # Read-only route, token always stays on the Alfred server. + try: + q = request.args.get('q', '') + return jsonify(ha_weather.get_weather(q)) + except (ha_views.HAError, ValueError) as exc: + return _json_error(exc) + +@app.get('/api/ha/weather/outfits') +def ha_weather_outfits_data(): + try: + q = request.args.get('q', '') + refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on') + return jsonify(weather_outfits.build_payload(q=q, refresh=refresh)) + except (ha_views.HAError, ValueError, RuntimeError) as exc: + return _json_error(exc) + +@app.get('/api/ha/temperatures') +def ha_temperatures(): + try:return jsonify({'items':ha_views.get_temperatures()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/temperature/history') +def ha_temperature_history(): + try: + eid=request.args.get('entity_id','') + hours=int(request.args.get('hours','24')) + return jsonify({'items':ha_views.temperature_history(eid,hours)}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/cameras') +def ha_cameras(): + try:return jsonify({'items':ha_views.list_cameras()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/camera//snapshot') +def ha_camera_snapshot(entity_id): + try: + binary,mime=ha_views.camera_snapshot(entity_id) + return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.post('/api/ui/home') +def ui_home(): + with STATE_LOCK: + STATE['content']={'type':'home','text':'À ton écoute.'} + socketio.emit('alfred_render',STATE['content']) + return jsonify({'ok':True}) + +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) + diff --git a/alfred/backup_meteo_tenues_v2_20261025_232200/services/alfred_views_controller.py b/alfred/backup_meteo_tenues_v2_20261025_232200/services/alfred_views_controller.py new file mode 100644 index 0000000..8984a8f --- /dev/null +++ b/alfred/backup_meteo_tenues_v2_20261025_232200/services/alfred_views_controller.py @@ -0,0 +1,132 @@ +"""Fast voice routing for visual actions; Hermes still handles general tasks.""" +from __future__ import annotations + +import os +from datetime import datetime, timedelta, time +from zoneinfo import ZoneInfo + +from . import ha_views as ha +from . import alfred_intents as intents +from . import ha_weather + +TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris')) + + +def _label(s,e): + return f"du {s:%d/%m} au {(e-timedelta(days=1)):%d/%m}" + + +def _event_summary(events, question): + count=len(events) + if count == 0:return "Non, je n'ai trouvé aucun événement pour cette période." if question else 'Aucun événement prévu sur cette période.' + names=', '.join(ev['title'] for ev in events[:3]) + if question: + return f"Oui, {count} événement{'s' if count>1 else ''} prévu{'s' if count>1 else ''}. {names}." if count<=3 else f"Oui, {count} événements sont prévus. Notamment : {names}." + return f"Voici ton planning avec {count} événement{'s' if count>1 else ''}." + + +def _calendar(text, question=False): + start,end,view=intents.period(text) + payload=ha.get_events(start,end) + content={'type':'calendar','view':view,'title':'Planning','start':start.isoformat(),'end':end.isoformat(), + 'events':payload['events'],'calendars':payload['calendars'],'errors':payload['errors']} + speech=_event_summary(payload['events'],question) + if payload['errors']: speech+=' Certains calendriers sont indisponibles.' + return content,speech + + +def _create(text): + calendars=ha.list_calendars() + details, complete=intents.extract_create(text,calendars) + if not complete: + return {'type':'calendar_form','values':details,'calendars':calendars},'Je prépare le rendez-vous. Complète ou vérifie les informations à l’écran avant de l’enregistrer.' + # No calendar mutation until a second explicit confirmation. + intents.set_create(details) + pretty=datetime.fromisoformat(details['date']).strftime('%d/%m') + speech=f"Je vais ajouter {details['title']} le {pretty} à {details['time']}. Dis confirme pour enregistrer, ou annule." + return {'type':'calendar_form','values':details,'calendars':calendars,'awaiting_confirmation':True},speech + + +def _delete(text): + now=datetime.now(TZ) + day=intents.parse_day(text,now) + if day: + start=datetime.combine(day,time.min,TZ); end=start+timedelta(days=1) + else: + start=datetime.combine(now.date(),time.min,TZ);end=start+timedelta(days=45) + data=ha.get_events(start,end) + needle=intents.extract_search(text) + events=[e for e in data['events'] if intents.simplify(needle) in intents.simplify(e['title'])] if needle else data['events'] + if not events: + return {'type':'calendar','view':'days','start':start.isoformat(),'end':end.isoformat(),**data},'Je ne trouve pas cet événement. Aucun événement n’a été supprimé.' + if len(events)!=1: + return {'type':'delete_candidates','events':events[:30]},f"J'ai trouvé {len(events)} événements correspondants. Choisis celui à supprimer sur l'écran." + ev=events[0] + if not ev['can_delete']: + return {'type':'delete_candidates','events':[ev]},'Ce calendrier ne permet pas la suppression de cet événement depuis Alfred.' + intents.set_delete(ev) + warning=' Attention, si cet événement est récurrent, cela peut supprimer toute la série.' if not ev['recurrence_id'] else '' + return {'type':'delete_candidates','events':[ev], 'awaiting_confirmation':True},f"Confirme la suppression de {ev['title']}, ou dis annule.{warning}" + + +def _confirm_delete(): + e=intents.current_delete() + if not e:return {'type':'text','text':'Confirmation expirée.'},'La demande de suppression a expiré.' + # Recheck existence in HA to avoid deleting an event that changed meanwhile. + start=datetime.fromisoformat(e['start'])-timedelta(days=1) + end=datetime.fromisoformat(e['end'])+timedelta(days=1) + found=ha.get_events(start,end,[e['calendar_id']])['events'] + if not any(x['id']==e['id'] and x['start']==e['start'] and x['recurrence_id']==e['recurrence_id'] for x in found): + intents.clear_delete() + return {'type':'text','text':'Cet événement a changé. Suppression annulée.'},'Cet événement a changé. Rien n’a été supprimé.' + ha.delete_event(e['calendar_id'],e['id'],e['recurrence_id'] or None) + intents.clear_delete() + content,speech=_calendar('planning sur 7 jours') + return content,'Événement supprimé. '+speech + + +def _confirm_create(): + data=intents.current_create() + if not data:return {'type':'text','text':'Confirmation expirée.'},'La demande de création a expiré.' + if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'): + return {'type':'calendar_form','values':data,'calendars':ha.list_calendars()},'L’écriture est désactivée. Active ALFRED_ALLOW_CALENDAR_WRITES dans la configuration.' + start=datetime.fromisoformat(data['date']+'T'+data['time']).replace(tzinfo=TZ) + end=start+timedelta(minutes=int(data.get('duration_minutes',60))) + ha.create_event(data['calendar_id'],data['title'],start,end) + intents.clear_create() + content,speech=_calendar('planning sur 7 jours') + return content,'Événement ajouté. '+speech + + +def route(text, last_view=None): + # Weather is a read-only dynamic view; avoid sending routine forecasts to Hermes. + if ha_weather.is_weather_request(text, last_view): + data = ha_weather.get_weather() + return data, ha_weather.describe(data, text) + intent=intents.classify(text) + # Short temporal follow-ups to a displayed calendar: 'Et demain ?', 'Ce matin ?' + if intent is None and last_view == 'calendar' and len(text.strip()) < 45: + t=intents.simplify(text) + if any(word in t for word in ('demain','matin','apres-midi','soir','journee','semaine','mois','jours','novembre','decembre','janvier','fevrier','mars','avril','mai','juin','juillet','aout','septembre','octobre')): + intent={'kind':'calendar','question':True} + if intent is None:return None + kind=intent['kind'] + if kind=='home':return {'type':'home','title':'Alfred','text':'À ton écoute.'},'Je reviens à l’accueil.' + if kind=='calendar':return _calendar(text, intent.get('question',False)) + if kind=='create':return _create(text) + if kind=='delete':return _delete(text) + if kind=='confirm_delete': + if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'): + return {'type':'text','text':'Écriture calendrier désactivée.'},'La suppression est désactivée dans la configuration.' + return _confirm_delete() + if kind=='confirm_create':return _confirm_create() + if kind=='cancel_pending': + intents.cancel_pending() + return {'type':'home','text':'Demande annulée.'},'D’accord, aucune modification effectuée.' + if kind=='temperatures': + temps=ha.get_temperatures() + return {'type':'temperatures','items':temps},f"Voici {len(temps)} mesures de température provenant de Home Assistant." + if kind=='cameras': + cameras=ha.list_cameras() + return {'type':'cameras','items':cameras},f"Voici {len(cameras)} caméras Home Assistant disponibles." + return None diff --git a/alfred/backup_meteo_tenues_v2_20261025_232200/services/weather_outfits.py b/alfred/backup_meteo_tenues_v2_20261025_232200/services/weather_outfits.py new file mode 100644 index 0000000..b96142f --- /dev/null +++ b/alfred/backup_meteo_tenues_v2_20261025_232200/services/weather_outfits.py @@ -0,0 +1,277 @@ +"""Outfit recommendations and ComfyUI illustration for Alfred weather view. + +This service reuses the same ComfyUI instance as Hermes, but calls ComfyUI +HTTP directly to avoid replacing Alfred's central view while generating the +illustration. +""" +from __future__ import annotations +from dotenv import load_dotenv +import hashlib +import json +import os +import time +import uuid +from datetime import datetime +from pathlib import Path +from urllib.parse import urlencode + +import requests + +from . import ha_weather + +load_dotenv("/opt/hermes/MCP_comfyui/.env") + +BASE = os.getenv( + "COMFYUI_URL", + "http://192.168.1.13:8188" +).rstrip("/") + +WORKFLOWS_DIR = Path( + os.getenv( + "WORKFLOWS_DIR", + "/opt/hermes/MCP_comfyui/workflows" + ) +) + + +CACHE_DIR = Path(os.getenv('ALFRED_CACHE_DIR', str(Path(__file__).resolve().parents[1] / 'cache'))) +CACHE_DIR.mkdir(parents=True, exist_ok=True) +CACHE_FILE = CACHE_DIR / 'weather_outfit_cache.json' +BASE = os.getenv('COMFYUI_URL', 'http://127.0.0.1:8188').rstrip('/') +WORKFLOWS_DIR = Path(os.getenv('WORKFLOWS_DIR', '/opt/hermes/MCP_comfyui/workflows')) +#WORKFLOWS_DIR = Path(os.getenv('WORKFLOWS_DIR', str(Path(__file__).parent / 'workflows'))) +PROMPT_NODE = os.getenv('COMFY_PROMPT_NODE', '6') +TIMEOUT = int(os.getenv('COMFYUI_TIMEOUT', '300')) +TTL_HOURS = int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TTL_HOURS', '12')) +ENABLED = os.getenv('ALFRED_WEATHER_ILLUSTRATIONS', 'true').lower() in ('1', 'true', 'yes', 'on') + + +def _load_cache() -> dict: + try: + return json.loads(CACHE_FILE.read_text(encoding='utf-8')) + except Exception: + return {} + + +def _save_cache(cache: dict) -> None: + CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding='utf-8') + + +def _pick_day(data: dict) -> dict: + target = str(data.get('focus_date') or '') + for day in data.get('daily', []): + if str(day.get('date')) == target: + return day + if data.get('daily'): + return data['daily'][0] + current = data.get('current', {}) + return { + 'date': target or datetime.now().date().isoformat(), + 'condition': current.get('condition', 'unknown'), + 'description': current.get('description', 'conditions non précisées'), + 'temperature': current.get('temperature'), + 'templow': current.get('temperature'), + 'precipitation_probability': None, + 'wind_speed': current.get('wind_speed'), + } + + +def _temp_band(high: float | None, low: float | None) -> str: + ref = high if high is not None else low + if ref is None: + return 'mild' + if ref < 6: + return 'cold' + if ref < 14: + return 'cool' + if ref < 23: + return 'mild' + return 'warm' + + +def _recommend(day: dict) -> dict: + high = day.get('temperature') + low = day.get('templow') + pop = day.get('precipitation_probability') + wind = day.get('wind_speed') + cond = str(day.get('condition') or '') + band = _temp_band(high, low) + rainy = cond in {'rainy', 'pouring', 'lightning-rainy', 'snowy-rainy'} or (pop is not None and pop >= 45) + sunny = cond in {'sunny', 'partlycloudy'} + windy = wind is not None and wind >= 25 + cold = band in {'cold', 'cool'} + + child = [] + adult = [] + child_acc = [] + adult_acc = [] + + if band == 'cold': + child += ['doudoune chaude', 'pantalon chaud', 'chaussures fermées'] + adult += ['manteau chaud', 'pull', 'pantalon', 'chaussures fermées'] + child_acc += ['bonnet', 'écharpe'] + adult_acc += ['écharpe'] + elif band == 'cool': + child += ['veste légère', 'pantalon', 'baskets'] + adult += ['veste mi-saison', 'pull fin', 'jean', 'chaussures fermées'] + elif band == 'mild': + child += ['sweat ou gilet', 'pantalon léger', 'baskets'] + adult += ['veste légère', 'haut confortable', 'jean ou chino', 'baskets'] + else: + child += ['t-shirt', 'short ou pantalon léger', 'baskets légères'] + adult += ['t-shirt ou polo', 'tenue légère', 'chaussures légères'] + child_acc += ['casquette'] + adult_acc += ['lunettes de soleil'] + + if rainy: + child_acc += ['imperméable', 'parapluie'] + adult_acc += ['parapluie'] + if 'chaussures fermées' not in child: child.append('chaussures fermées') + if 'chaussures fermées' not in adult: adult.append('chaussures fermées') + if windy: + child_acc += ['coupe-vent'] + adult_acc += ['coupe-vent'] + if sunny and band != 'cold': + if 'casquette' not in child_acc: child_acc += ['casquette'] + if 'lunettes de soleil' not in adult_acc: adult_acc += ['lunettes de soleil'] + + def uniq(seq): + seen=[] + for x in seq: + if x not in seen: seen.append(x) + return seen + + return { + 'summary': day.get('description', 'Conditions du jour'), + 'child': { + 'title': 'Fille · 8 ans', + 'clothes': uniq(child), + 'accessories': uniq(child_acc), + }, + 'adult': { + 'title': 'Homme · 42 ans', + 'clothes': uniq(adult), + 'accessories': uniq(adult_acc), + } + } + + +def _weather_signature(day: dict, outfits: dict) -> str: + raw = json.dumps({'day': day, 'outfits': outfits}, ensure_ascii=False, sort_keys=True) + return hashlib.sha256(raw.encode('utf-8')).hexdigest() + + +def _extract_images(history: dict) -> list[dict]: + images = [] + for node_id, output in history.get('outputs', {}).items(): + for image in output.get('images', []): + filename = image.get('filename') + if not filename: + continue + subfolder = image.get('subfolder', '') + image_type = image.get('type', 'output') + query = urlencode({'filename': filename, 'subfolder': subfolder, 'type': image_type}) + images.append({'filename': filename, 'url': f'{BASE}/view?{query}', 'node_id': str(node_id)}) + return images + + +def _submit_workflow(prompt: str) -> str: + workflow_path = WORKFLOWS_DIR / 'text_to_image.json' + if not workflow_path.exists(): + raise RuntimeError(f'Workflow ComfyUI introuvable : {workflow_path}') + workflow = json.loads(workflow_path.read_text(encoding='utf-8')) + workflow[PROMPT_NODE]['inputs']['text'] = prompt + response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30) + response.raise_for_status() + return response.json()['prompt_id'] + + +def _wait_history(prompt_id: str) -> dict: + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + response = requests.get(f'{BASE}/history/{prompt_id}', timeout=15) + response.raise_for_status() + result = response.json() + if prompt_id in result: + return result[prompt_id] + time.sleep(1) + raise RuntimeError(f'ComfyUI timeout ({prompt_id})') + + +def _illustration_prompt(day: dict, outfits: dict, focus_label: str) -> str: + high = day.get('temperature') + low = day.get('templow') + pop = day.get('precipitation_probability') + wind = day.get('wind_speed') + weather_bits = [day.get('description', 'conditions du jour')] + if high is not None: + weather_bits.append(f'température maximale {high}°C') + if low is not None: + weather_bits.append(f'température minimale {low}°C') + if pop is not None: + weather_bits.append(f'probabilité de pluie {pop}%') + if wind is not None: + weather_bits.append(f'vent {wind} km/h') + return ( + 'Crée une illustration verticale propre pour un tableau météo d’un assistant domestique. ' + 'Montre en pied deux personnages côte à côte, style illustration moderne semi-réaliste douce, lisible, familiale, fond simple et discret. ' + 'À gauche : une fille de 8 ans. À droite : un homme adulte de 42 ans. ' + f'Contexte météo pour {focus_label}: ' + ', '.join(weather_bits) + '. ' + 'Habille les personnages selon les recommandations suivantes. ' + f"Fille 8 ans: vêtements {', '.join(outfits['child']['clothes'])}; accessoires {', '.join(outfits['child']['accessories']) or 'aucun accessoire particulier'}. " + f"Homme 42 ans: vêtements {', '.join(outfits['adult']['clothes'])}; accessoires {', '.join(outfits['adult']['accessories']) or 'aucun accessoire particulier'}. " + 'Aucun texte dans l’image. Montrer clairement les tenues et accessoires conseillés. ' + 'Composition propre, adaptée à une interface d’assistant, haute qualité.' + ) + + +def _generate_illustration(day: dict, outfits: dict, focus_label: str, refresh: bool = False) -> dict | None: + if not ENABLED: + return None + sig = _weather_signature(day, outfits) + cache = _load_cache() + entry = cache.get(sig) + now_ts = time.time() + if entry and not refresh and (now_ts - float(entry.get('cached_at', 0))) < TTL_HOURS * 3600: + entry['from_cache'] = True + return entry + + prompt = _illustration_prompt(day, outfits, focus_label) + prompt_id = _submit_workflow(prompt) + history = _wait_history(prompt_id) + images = _extract_images(history) + if not images: + raise RuntimeError('ComfyUI a terminé sans image de sortie pour l’illustration météo.') + entry = { + 'url': images[0]['url'], + 'filename': images[0]['filename'], + 'prompt_id': prompt_id, + 'prompt': prompt, + 'cached_at': now_ts, + 'from_cache': False, + } + cache[sig] = entry + _save_cache(cache) + return entry + + +def build_payload(q: str = '', refresh: bool = False) -> dict: + data = ha_weather.get_weather(q) + day = _pick_day(data) + outfits = _recommend(day) + illustration = _generate_illustration(day, outfits, data.get('focus_label', 'aujourd’hui'), refresh=refresh) + return { + 'type': 'weather_outfits', + 'focus_label': data.get('focus_label', 'aujourd’hui'), + 'date': day.get('date'), + 'condition': day.get('condition'), + 'description': day.get('description'), + 'temperature': day.get('temperature'), + 'templow': day.get('templow'), + 'precipitation_probability': day.get('precipitation_probability'), + 'wind_speed': day.get('wind_speed'), + 'summary': outfits.get('summary'), + 'child': outfits['child'], + 'adult': outfits['adult'], + 'image': illustration, + } diff --git a/alfred/backup_meteo_tenues_v2_20261025_232200/static/css/alfred-views.css b/alfred/backup_meteo_tenues_v2_20261025_232200/static/css/alfred-views.css new file mode 100644 index 0000000..254f55c --- /dev/null +++ b/alfred/backup_meteo_tenues_v2_20261025_232200/static/css/alfred-views.css @@ -0,0 +1,91 @@ +/* Calendar and Home Assistant views — preserves original dark Alfred layout */ +#stage:has(.alfred-view) {padding:0 0 6px;justify-content:stretch;align-items:stretch} +#content.alfred-view {width:100%;height:100%;text-align:left;min-height:0;} +.view-pane {display:flex;flex-direction:column;gap:12px;max-height:100%;height:100%;min-height:0;padding:8px 1px 14px;} +.view-head {display:flex;justify-content:space-between;align-items:center;gap:12px;} +#content .view-title {font-size:clamp(1.2rem,1.7vw,1.85rem);margin:0;font-weight:500} +.view-close,.view-button,.view-pill {background:#363d42;color:#f3f7fa;border:1px solid #ffffff24;border-radius:10px;padding:9px 14px;cursor:pointer;font:inherit} +.view-close {font-size:1.4rem;min-width:40px;height:40px;padding:0} +.view-button:hover,.view-pill:hover,.view-close:hover {background:#455057} +.view-pill.active,.view-button.primary {background:#427789;border-color:#619aad} +.view-toolbar {display:flex;gap:9px;align-items:center;flex-wrap:wrap;} +.view-nav,.view-modes {display:flex;gap:5px;align-items:center} +.view-period {font-size:.85rem;min-width:215px;text-align:center;color:#d8e0e4} +.view-select {padding:9px;max-width:220px;border-radius:9px;background:#333a3e;color:#eff3f5;border:1px solid #ffffff2b} +.agenda-grid {display:grid;gap:5px;min-height:0;flex:1;overflow:auto;padding:2px;align-content:stretch;} +.agenda-seven,.agenda-month {grid-template-columns:repeat(7,minmax(0,1fr))} +.agenda-month {grid-template-rows:repeat(6,minmax(110px,1fr));} +.agenda-three {grid-template-columns:repeat(3,minmax(0,1fr))} +.agenda-one {grid-template-columns:1fr} +.agenda-day {border:1px solid #ffffff1c;border-radius:11px;min-width:0;min-height:120px;background:#ffffff08;padding:9px;display:flex;flex-direction:column;gap:6px;overflow-y:auto} +.agenda-day.is-today {border-color:#86bfda7a;background:#57839923} +.agenda-day.out-month {opacity:.42} +.agenda-date {font-size:.83rem;font-weight:600;color:#dce2e6;letter-spacing:.01em;padding-bottom:7px;border-bottom:1px solid #ffffff14} +.agenda-event {width:100%;text-align:left;background:color-mix(in srgb,var(--event-color) 17%,#2a2e32);color:#f1f3f5;border:0;border-left:3px solid var(--event-color);border-radius:5px;padding:7px 5px;font-size:clamp(.72rem,.85vw,.95rem);line-height:1.3;cursor:pointer;overflow-wrap:anywhere} +.agenda-event:hover {background:color-mix(in srgb,var(--event-color) 30%,#2a2e32)} +.agenda-empty {font-size:.8rem;color:#667781;margin:auto} +#content .view-footer {color:#9aa8b3;font-size:.76rem;margin:0} +#content .view-error {color:#f7adad;font-size:.85rem;margin:0} +#content .view-empty {font-size:1rem;color:#b8c2ca} +.agenda-form {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;gap:14px;max-width:850px;width:100%;overflow-y:auto;margin:12px auto;} +.form-field {display:flex;flex-direction:column;gap:7px;color:#cfd6dd;font-size:.83rem} +.form-field input,.form-field select,.form-field textarea {padding:12px;border-radius:10px;color:#eff3f5;background:#2e3439;border:1px solid #ffffff32;font:inherit;min-width:0} +.form-field textarea {resize:vertical} +.form-check {display:flex;align-items:center;gap:10px} +.view-actions {display:flex;align-items:center;gap:10px;justify-content:flex-end;flex-wrap:wrap;} +.form-message {grid-column:1 / -1;} +.agenda-form .view-actions {grid-column:1 / -1} +.view-danger {color:#ffc4c4;background:#8f383850;border-color:#ed929255} +.detail-modal {position:fixed;inset:0;z-index:30;display:grid;place-items:center;background:#000b;padding:20px} +.detail-box {min-width:min(92vw,300px);max-width:540px;width:100%;border:1px solid #ffffff36;border-radius:19px;background:#292e33;box-shadow:0 25px 90px #000a;padding:24px;} +.detail-box h2 {font-size:1.4rem;margin:0 0 20px}.detail-box p {font-size:1rem;color:#d7dfe2;overflow-wrap:anywhere} +.delete-list {display:grid;gap:8px;overflow:auto}.delete-row {display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid #ffffff1c;border-radius:9px;padding:12px} +.temperature-grid {display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:4px;max-height:53%;} +.temperature-card {border:1px solid #ffffff22;background:#ffffff0a;border-radius:12px;padding:22px;color:#eaf1f3;white-space:pre-line;text-align:left;font:inherit;cursor:pointer;line-height:1.9} +.temperature-card:hover {background:#ffffff18} +.temperature-history {max-width:700px;text-align:center;margin:auto;width:100%;} +.temperature-history svg {width:100%;max-height:155px} +.temperature-history h2 {font-size:1.1rem;font-weight:450} +.ha-cameras {display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;overflow:auto;} +.ha-camera-card {background:#ffffff0a;border-radius:13px;overflow:hidden;} +.ha-camera-card h2 {font-size:.95rem;padding:10px;font-weight:500;margin:0} +.ha-camera-card img {display:block;width:100%;aspect-ratio:16/9;object-fit:contain;background:#121619} +@media(max-width:1100px){.view-toolbar{gap:6px}.view-button,.view-pill {padding:7px;font-size:.77rem}.view-period{min-width:150px}.agenda-day{padding:5px}.agenda-event{padding:4px;font-size:.74rem}} +@media(max-width:760px){#stage:has(.alfred-view){min-height:460px}.agenda-grid{min-height:280px}.agenda-seven,.agenda-month{min-width:630px}.agenda-day{min-height:105px}.view-pane{max-height:600px}.view-head{padding:0 4px}.agenda-form{grid-template-columns:1fr}.header-actions{gap:3px}.header-actions .icon-button {width:38px;height:38px}.view-toolbar{overflow:auto}} + +/* Weather dynamic view — same dark UI as calendar. */ +.weather-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px} +.weather-now{display:flex;align-items:center;gap:22px;background:#293238;border:1px solid #ffffff16;border-radius:16px;padding:18px 24px;} +.weather-current-icon{font-size:clamp(44px,6vw,80px);line-height:1;color:#edcb82} +.weather-now-details h2{margin:0 0 8px;font-size:clamp(20px,2.1vw,32px);font-weight:500;color:#f5f6f7} +.weather-now-details p{margin:0;color:#b6c4ce;font-size:clamp(12px,1vw,17px)} +.weather-section-title{font-size:15px;letter-spacing:.02em;color:#bfd0da;margin:7px 0 -2px;font-weight:550} +.weather-days{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:7px} +.weather-day{min-width:0;display:flex;flex-direction:column;align-items:center;text-align:center;gap:5px;background:#293035;border-radius:12px;border:1px solid #ffffff12;padding:12px 5px 10px} +.weather-day p{margin:0} +.weather-day-name{font-size:12px;color:#d4e0e6} +.weather-day-icon{font-size:clamp(22px,2.8vw,40px);color:#eaca85} +.weather-day-condition{font-size:11px;color:#adbcc5;min-height:30px;line-height:1.3} +.weather-day-temp{font-size:21px;color:#f5f7f8;font-weight:600} +.weather-day-low,.weather-rain{font-size:11px;color:#a7b6c0} +.weather-rain{color:#8dc8e4 !important} +.weather-hours{display:flex;gap:7px;overflow-x:auto;padding:3px 0 10px;scrollbar-color:#637480 #2a3237} +.weather-hour{display:flex;flex:0 0 80px;align-items:center;flex-direction:column;gap:9px;padding:10px 6px;background:#293035;border:1px solid #ffffff16;border-radius:12px;color:#cbd7df;font-size:12px} +.weather-hour-icon{font-size:27px;color:#edd298} +@media(max-width:840px){.weather-days{grid-template-columns:repeat(4,minmax(0,1fr))}.weather-now{padding:14px;gap:12px}.weather-current-icon{font-size:48px}} +@media(max-width:470px){.weather-days{grid-template-columns:repeat(3,minmax(0,1fr))}} + + +/* Weather outfits + ComfyUI illustration */ +.weather-outfit-host{margin:8px 0 4px} +.weather-outfit-section{display:flex;flex-direction:column;gap:12px;background:#252d32;border:1px solid #ffffff16;border-radius:16px;padding:16px} +.weather-outfit-head{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap} +.weather-outfit-grid{display:grid;grid-template-columns:minmax(260px,360px) minmax(320px,1fr);gap:14px;align-items:stretch} +.weather-outfit-guide{display:grid;gap:12px} +.weather-person-card{background:#ffffff08;border:1px solid #ffffff16;border-radius:14px;padding:14px} +.weather-person-title{margin:0 0 6px;font-size:1.05rem;color:#f2f5f7} +.weather-person-subtitle{margin:10px 0 4px;color:#b8c7d1;font-size:.86rem} +.weather-outfit-list{margin:0;padding-left:18px;color:#e0e6ea;font-size:.95rem;line-height:1.55} +.weather-illustration-card{display:flex;flex-direction:column;justify-content:center;align-items:center;background:#ffffff07;border:1px solid #ffffff16;border-radius:14px;padding:12px;min-height:320px} +.weather-illustration-image{display:block;max-width:100%;max-height:430px;border-radius:12px;box-shadow:0 16px 40px #0005;object-fit:contain} +@media(max-width:980px){.weather-outfit-grid{grid-template-columns:1fr}.weather-illustration-card{min-height:220px}} diff --git a/alfred/backup_meteo_tenues_v2_20261025_232200/static/js/alfred-views.js b/alfred/backup_meteo_tenues_v2_20261025_232200/static/js/alfred-views.js new file mode 100644 index 0000000..c95dcf7 --- /dev/null +++ b/alfred/backup_meteo_tenues_v2_20261025_232200/static/js/alfred-views.js @@ -0,0 +1,353 @@ +/* Alfred • visual views, powered by first-party Flask endpoints. No extra CDN. */ +(() => { + 'use strict'; + const root = document.getElementById('content'); + let current = null, camerasRefresh = null; + const DAYS = ['lun.','mar.','mer.','jeu.','ven.','sam.','dim.']; + const PALETTE = ['#77bcdf','#e5b677','#9dc99a','#d3a2d5','#8cb7c9','#ec9f95','#b6b5e1','#b9d78d']; + const el = (tag, cls, text) => { const x=document.createElement(tag); if(cls)x.className=cls; if(text!==undefined)x.textContent=String(text); return x; }; + const btn = (label, fn, cls='view-button') => { const b=el('button',cls,label); b.type='button'; b.addEventListener('click',fn);return b; }; + const api = async (url, options) => { + const r=await fetch(url,{cache:'no-store', ...options}); + const data=await r.json().catch(()=>({error:`Erreur HTTP ${r.status}`})); + if(!r.ok)throw new Error(data.error || `HTTP ${r.status}`); + return data; + }; + const post = (url,data) => api(url,{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify(data)}); + const dateLabel = iso => new Date(iso).toLocaleDateString('fr-FR',{day:'numeric',month:'long',year:'numeric'}); + const timeLabel = iso => new Date(iso).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}); + const localKey = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + const atDay = (d,h=0) => new Date(d.getFullYear(),d.getMonth(),d.getDate(),h); + const dayShift = (d,n) => new Date(d.getFullYear(),d.getMonth(),d.getDate()+n,d.getHours(),d.getMinutes()); + const eventOnDay=(ev,day)=> {const start=new Date(ev.start),end=new Date(ev.end);return startatDay(day);}; + const fmtRange=(a,b)=>`${dateLabel(a)} — ${dateLabel(new Date(new Date(b).getTime()-1000).toISOString())}`; + const wrap = (title) => { + clearInterval(camerasRefresh);camerasRefresh=null;root.replaceChildren(); + root.classList.add('alfred-view'); + const pane=el('div','view-pane'),head=el('header','view-head'); + head.append(el('h1','view-title',title)); + head.append(btn('×',goHome,'view-close')); + pane.append(head);root.append(pane);return {pane,head}; + }; + const showError=(err,pane)=>pane.append(el('p','view-error',err.message||String(err))); + const canRender = data=>['calendar','calendar_form','delete_candidates','temperatures','cameras','weather','home'].includes(data?.type); + function render(data){ + if(!canRender(data))return false; + current=data; + if(data.type==='calendar')drawCalendar(data); + if(data.type==='calendar_form')drawForm(data); + if(data.type==='delete_candidates')drawDeleteChoices(data); + if(data.type==='temperatures')drawTemps(data); + if(data.type==='weather')drawWeather(data); + if(data.type==='cameras')drawCameras(data); + if(data.type==='home'){root.classList.remove('alfred-view');root.replaceChildren(el('h1','', 'Bonjour.'), el('p','',data.text||'À ton écoute.')); clearInterval(camerasRefresh);camerasRefresh=null;} + return true; + } + const getSelected=()=>document.getElementById('calendar-source')?.value || ''; + async function loadCalendar(start,end,view,filter){ + const q=new URLSearchParams({start:new Date(start).toISOString(),end:new Date(end).toISOString()}); + if(filter)q.append('calendar_id',filter); + const data=await api('/api/calendar/events?'+q); + render({type:'calendar',view,start:new Date(start).toISOString(),end:new Date(end).toISOString(),...data,selected:filter||''}); + } + function moveCalendar(direction){ + if(!current || current.type!=='calendar')return; + const start=new Date(current.start),end=new Date(current.end),view=current.view; + let a,b; + if(view==='month'){ + a=new Date(start.getFullYear(),start.getMonth()+direction,1); + b=new Date(a.getFullYear(),a.getMonth()+1,1); + }else{ + const days=Math.round((end-start)/86400000) || 1; + a=dayShift(start,days*direction);b=dayShift(end,days*direction); + } + loadCalendar(a,b,view,getSelected()).catch(err=>{const {pane}=wrap('Agenda');showError(err,pane)}); + } + const details = (event) => { + const d=el('div','detail-modal'),box=el('section','detail-box'); + box.setAttribute('role','dialog');box.setAttribute('aria-modal','true');box.setAttribute('aria-label','Détails de l’événement'); + box.append(el('h2','',event.title)); + box.append(el('p','',`${dateLabel(event.start)}${event.all_day?' · Toute la journée':' · '+timeLabel(event.start)+'–'+timeLabel(event.end)}`)); + box.append(el('p','',event.calendar_name)); + if(event.location)box.append(el('p','',`Lieu : ${event.location}`)); + if(event.description)box.append(el('p','',event.description)); + const acts=el('div','view-actions');acts.append(btn('Fermer',()=>d.remove())); + if(event.can_delete) acts.append(btn('Supprimer…',async()=>{ + const recurrence=event.recurrence_id ? 'Cela supprimera cette occurrence.' : 'Pour une série récurrente, cela peut supprimer toute la série.'; + if(!confirm(`Supprimer « ${event.title} » du calendrier « ${event.calendar_name} » ?\n${recurrence}`))return; + try{await post('/api/calendar/delete',{calendar_id:event.calendar_id,uid:event.id,start:event.start,end:event.end,recurrence_id:event.recurrence_id||''}); d.remove();await loadCalendar(current.start,current.end,current.view,getSelected());} + catch(err){alert(err.message)} + },'view-danger')); + box.append(acts);d.append(box);document.body.append(d); + d.addEventListener('click',e=>{if(e.target===d)d.remove()}); + }; + function drawCalendar(data){ + const {pane,head}=wrap('Planning'); + const toolbar=el('div','view-toolbar'); + const first=el('div','view-nav'); + first.append(btn('‹',()=>moveCalendar(-1)),el('span','view-period',fmtRange(data.start,data.end)),btn('›',()=>moveCalendar(1))); + toolbar.append(first); + const modes=el('div','view-modes'); + [['Jour',1,'day'],['3 jours',3,'days'],['7 jours',7,'days'],['Semaine',7,'week'],['Mois',31,'month']].forEach(([label,n,v])=>{ + const b=btn(label,()=>{ + const now=new Date(),start=(v==='week')?dayShift(atDay(now),-((now.getDay()+6)%7)):v==='month'?new Date(now.getFullYear(),now.getMonth(),1):atDay(now); + const end=v==='month'?new Date(start.getFullYear(),start.getMonth()+1,1):dayShift(start,n); + loadCalendar(start,end,v,getSelected()).catch(err=>showError(err,pane)); + },'view-pill'+(data.view===v?' active':''));modes.append(b); + });toolbar.append(modes); + const src=el('select','view-select');src.id='calendar-source'; + src.append(new Option('Tous les calendriers','')); + (data.calendars||[]).forEach(c=>src.append(new Option(c.name,c.entity_id))); + src.value=data.selected||''; + src.addEventListener('change',()=>loadCalendar(data.start,data.end,data.view,src.value).catch(err=>showError(err,pane))); + toolbar.append(src,btn('+ Rendez-vous',()=>openForm())); + pane.append(toolbar); + if(data.errors?.length)pane.append(el('p','view-error','Calendriers partiellement indisponibles : '+data.errors.join(' · '))); + const grid=el('div','agenda-grid'); + const start=new Date(data.start),end=new Date(data.end); + let days=[],cursor=atDay(start); + const month=data.view==='month'; + if(month) cursor=dayShift(cursor,-((cursor.getDay()+6)%7)); + let limit=0; + while(cursor[c.entity_id, PALETTE[i%PALETTE.length]])); + days.forEach(d=>{ + const cell=el('section','agenda-day'); + if(month && d.getMonth()!==start.getMonth())cell.classList.add('out-month'); + if(localKey(d)===localKey(new Date()))cell.classList.add('is-today'); + cell.append(el('div','agenda-date',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:month?undefined:'short'}))); + const events=(data.events||[]).filter(ev=>eventOnDay(ev,d)); + if(!events.length)cell.append(el('div','agenda-empty','—')); + events.forEach(ev=>{ + const e=btn((ev.all_day?'':timeLabel(ev.start)+' ')+ev.title,()=>details(ev),'agenda-event'); + e.style.setProperty('--event-color',colors.get(ev.calendar_id)||'#7bbbe2'); + e.title=`${ev.title} · ${ev.calendar_name}`;cell.append(e); + }); + grid.append(cell); + }); + pane.append(grid); + pane.append(el('p','view-footer',`${data.events?.length||0} événement(s) · ${data.calendars?.length||0} calendrier(s) Home Assistant`)); + } + async function openForm(values){ + let calendars=[]; + try {const info=await api('/api/calendar/calendars');calendars=info.calendars||[];} + catch(err){const {pane}=wrap('Nouveau rendez-vous');showError(err,pane);return;} + render({type:'calendar_form',calendars,values:values||{}}); + } + function drawForm(data){ + const {pane}=wrap('Ajouter un événement'); + const v=data.values||{}; + const form=el('form','agenda-form');form.autocomplete='off'; + const field=(label,node)=>{const row=el('label','form-field');row.append(el('span','',label),node);form.append(row);return node}; + const makeInput=(type,value)=>{const i=el('input');i.type=type;i.value=value||'';i.required=true;return i}; + const title=field('Titre',makeInput('text',v.title));title.maxLength=200; + const src=el('select');src.required=true;src.append(new Option('Choisir un calendrier','')); + data.calendars.filter(c=>c.can_create).forEach(c=>src.append(new Option(c.name,c.entity_id))); + src.value=v.calendar_id||'';field('Calendrier',src); + const day=field('Date',makeInput('date',v.date||localKey(new Date()))); + const startTime=field('Début',makeInput('time',v.time||'09:00')); + const duration=field('Durée (minutes)',makeInput('number',v.duration_minutes||60));duration.min=15;duration.max=1440; + const location=field('Lieu (facultatif)',makeInput('text',''));location.required=false; + const description=field('Description (facultatif)',el('textarea'));description.required=false;description.rows=2; + const allDay=el('input');allDay.type='checkbox';allDay.required=false; + const allDayWrap=el('label','form-check');allDayWrap.append(allDay,el('span','','Toute la journée'));form.append(allDayWrap); + allDay.addEventListener('change',()=>{startTime.disabled=allDay.checked;duration.disabled=allDay.checked}); + const msg=el('p','form-message'); + const actions=el('div','view-actions');actions.append(btn('Annuler',goHome)); + const submit=el('button','view-button primary','Enregistrer');submit.type='submit';actions.append(submit); + form.append(actions,msg);pane.append(form); + form.addEventListener('submit',async e=>{ + e.preventDefault();submit.disabled=true;msg.textContent='Enregistrement…'; + try{ + const from=allDay.checked?new Date(day.value+'T00:00'):new Date(day.value+'T'+startTime.value+':00'); + if(Number.isNaN(from.getTime()))throw new Error('Date invalide.'); + const end=allDay.checked?dayShift(from,1):new Date(from.getTime()+Number(duration.value)*60000); + await post('/api/calendar/create',{calendar_id:src.value,title:title.value,start:from.toISOString(),end:end.toISOString(),all_day:allDay.checked,location:location.value,description:description.value}); + const first=atDay(from);await loadCalendar(first,dayShift(first,7),'days'); + }catch(err){msg.textContent=err.message;submit.disabled=false} + }); + } + function drawDeleteChoices(data){ + const {pane}=wrap('Choisir un événement à supprimer'); + const arr=el('div','delete-list'); + (data.events||[]).forEach(ev=>{ + const row=el('div','delete-row');row.append(el('span','',`${ev.title} • ${dateLabel(ev.start)} • ${ev.calendar_name}`)); + row.append(btn('Détails / Supprimer',()=>details(ev),'view-button'));arr.append(row); + });pane.append(arr); + if(data.awaiting_confirmation)pane.append(el('p','view-footer','Dis « confirme » pour supprimer, ou « annule ».')); + } + function drawTemps(data){ + const {pane}=wrap('Températures Home Assistant'); + if(!data.items?.length){pane.append(el('p','view-empty','Aucun capteur de température trouvé.'));return} + const grid=el('div','temperature-grid');pane.append(grid); + data.items.forEach(item=>{ + const c=btn(`${item.name}\n${item.value.toFixed(1)} ${item.unit}` ,async()=>{ + const q=new URLSearchParams({entity_id:item.entity_id,hours:'24'}); + try{const data=await api('/api/ha/temperature/history?'+q);showHistory(item,data.items||[],pane)} + catch(err){showError(err,pane)} + },'temperature-card');grid.append(c); + });pane.append(el('p','view-footer','Touchez un capteur pour afficher son historique sur 24 h.')); + } + function showHistory(item,points,pane){ + pane.querySelector('.temperature-history')?.remove(); + const section=el('section','temperature-history');section.append(el('h2','',item.name+' · 24 dernières heures')); + if(points.length<2){section.append(el('p','', 'Historique insuffisant.'));pane.append(section);return} + const vals=points.map(p=>p.value);const lo=Math.min(...vals)-0.4,hi=Math.max(...vals)+0.4; + const a=Date.parse(points[0].time),b=Date.parse(points[points.length-1].time); + const coords=points.map((p,i)=>`${30+560*((Date.parse(p.time)-a)/Math.max(1,b-a))},${140-110*((p.value-lo)/Math.max(0.1,hi-lo))}`).join(' '); + const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 620 170');svg.setAttribute('role','img');svg.setAttribute('aria-label','Courbe de température'); + const poly=document.createElementNS(ns,'polyline');poly.setAttribute('points',coords);poly.setAttribute('fill','none');poly.setAttribute('stroke','#83c5cc');poly.setAttribute('stroke-width','3');svg.append(poly); + section.append(svg,el('p','',`Min ${Math.min(...vals).toFixed(1)}° · Max ${Math.max(...vals).toFixed(1)}°`));pane.append(section); + } + function drawCameras(data){ + const {pane}=wrap('Caméras Home Assistant'); + if(!data.items?.length){pane.append(el('p','view-empty','Aucune caméra Home Assistant disponible.'));return} + const grid=el('div','ha-cameras');pane.append(grid); + const imgs=[]; + data.items.forEach(item=>{ + const card=el('div','ha-camera-card');card.append(el('h2','',item.name)); + const img=el('img');img.alt='Instantané caméra '+item.name;img.loading='lazy';imgs.push([img,item.entity_id]);card.append(img);grid.append(card); + }); + const reload=()=>{if(document.hidden)return; imgs.forEach(([img,eid])=>img.src='/api/ha/camera/'+encodeURIComponent(eid)+'/snapshot?t='+Date.now())}; + reload();camerasRefresh=setInterval(reload,15000); + pane.append(el('p','view-footer','Instantanés réactualisés toutes les 15 s lorsque cette vue est ouverte.')); + } + + const WEATHER_ICONS = { + 'sunny':'☀', 'clear-night':'☾', 'cloudy':'☁', 'partlycloudy':'⛅', + 'fog':'≋', 'rainy':'🌧', 'pouring':'🌧', 'hail':'🌨', + 'lightning':'⛈', 'lightning-rainy':'⛈', 'snowy':'❄', + 'snowy-rainy':'🌨', 'windy':'〰', 'windy-variant':'〰' + }; + const weatherIcon = cond => WEATHER_ICONS[cond] || '◌'; + const tempText = (val, unit='°C') => typeof val === 'number' ? `${Math.round(val)}${unit}` : '—'; + function weatherList(items){ + const ul=el('ul','weather-outfit-list'); + (items||[]).forEach(text=>ul.append(el('li','',text))); + if(!ul.childNodes.length)ul.append(el('li','','Aucun accessoire particulier')); + return ul; + } + async function loadWeatherOutfits(container, query='', refresh=false){ + container.replaceChildren(); + const loading=el('p','view-footer', refresh ? 'Régénération de l’illustration…' : 'Préparation des tenues conseillées…'); + container.append(loading); + try{ + const q = new URLSearchParams(); + if(query)q.set('q',query); + if(refresh)q.set('refresh_illustration','1'); + const data = await api('/api/ha/weather/outfits' + (q.toString() ? '?' + q.toString() : '')); + const section=el('section','weather-outfit-section'); + const head=el('div','weather-outfit-head'); + head.append(el('h2','weather-section-title',`Tenues conseillées · ${data.focus_label || 'aujourd’hui'}`)); + head.append(btn('Régénérer l’illustration',()=>loadWeatherOutfits(container, query, true),'view-pill')); + section.append(head); + + const grid=el('div','weather-outfit-grid'); + const guide=el('div','weather-outfit-guide'); + [data.child, data.adult].forEach(person=>{ + const card=el('article','weather-person-card'); + card.append(el('h3','weather-person-title',person.title)); + card.append(el('p','weather-person-subtitle','Tenue recommandée')); + card.append(weatherList(person.clothes)); + card.append(el('p','weather-person-subtitle','Accessoires')); + card.append(weatherList(person.accessories)); + guide.append(card); + }); + grid.append(guide); + const art=el('div','weather-illustration-card'); + if(data.image?.url){ + const img=el('img','weather-illustration-image'); + img.src=data.image.url; + img.alt='Illustration des tenues conseillées selon la météo'; + art.append(img); + const foot=el('p','view-footer', data.image.from_cache ? 'Illustration mise en cache.' : 'Illustration générée par ComfyUI.'); + art.append(foot); + } else { + art.append(el('p','view-empty','Illustration indisponible pour le moment.')); + } + grid.append(art); + section.append(grid); + if(data.summary){ section.append(el('p','view-footer',`Conseil général : ${data.summary}.`)); } + container.replaceChildren(section); + }catch(err){ + container.replaceChildren(el('p','view-error',`Tenues conseillées indisponibles : ${err.message || err}`)); + } + } + function drawWeather(data){ + const {pane}=wrap('Météo · '+(data.name||'Home Assistant')); + const nav=el('div','weather-toolbar'); + nav.append(btn('Actualiser',openWeather,'view-pill')); + nav.append(el('span','view-footer','Source : Home Assistant')); + pane.append(nav); + const c=data.current||{},now=el('section','weather-now'); + const badge=el('div','weather-current-icon',weatherIcon(c.condition));badge.setAttribute('aria-hidden','true'); + const details=el('div','weather-now-details'); + details.append(el('h2','',`${tempText(c.temperature,data.temperature_unit)} · ${c.description||'Conditions inconnues'}`)); + const metrics=[]; + if(c.apparent_temperature!==null && c.apparent_temperature!==undefined)metrics.push(`Ressenti ${tempText(c.apparent_temperature,data.temperature_unit)}`); + if(c.humidity!==null && c.humidity!==undefined)metrics.push(`Humidité ${c.humidity}%`); + if(c.wind_speed!==null && c.wind_speed!==undefined)metrics.push(`Vent ${c.wind_speed} ${data.wind_speed_unit||'km/h'}`); + details.append(el('p','',metrics.join(' · ')||'Conditions actuelles'));now.append(badge,details);pane.append(now); + + const outfitHost=el('div','weather-outfit-host'); + pane.append(outfitHost); + loadWeatherOutfits(outfitHost, data.focus_label || ''); + + const heading = text => pane.append(el('h2','weather-section-title',text)); + heading('Prévisions pour les prochains jours'); + if(data.daily?.length){ + const cards=el('div','weather-days'); + data.daily.slice(0,7).forEach(day=>{ + const d=new Date(day.datetime); + const box=el('article','weather-day'); + box.append(el('p','weather-day-name',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:'short'}))); + box.append(el('div','weather-day-icon',weatherIcon(day.condition))); + box.append(el('p','weather-day-condition',day.description)); + box.append(el('p','weather-day-temp',tempText(day.temperature,data.temperature_unit))); + if(day.templow!==null)box.append(el('p','weather-day-low','Min '+tempText(day.templow,data.temperature_unit))); + if(day.precipitation_probability!==null)box.append(el('p','weather-rain','Pluie '+day.precipitation_probability+' %')); + cards.append(box); + });pane.append(cards); + }else pane.append(el('p','view-empty','Prévisions journalières non disponibles.')); + heading('Prochaines heures'); + if(data.hourly?.length){ + const list=el('div','weather-hours'), nowMs=Date.now(); + data.hourly.filter(x=>new Date(x.datetime).getTime()>=nowMs-3600000).slice(0,12).forEach(h=>{ + const box=el('div','weather-hour'); + box.append(el('span','',new Date(h.datetime).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}))); + box.append(el('span','weather-hour-icon',weatherIcon(h.condition))); + box.append(el('strong','',tempText(h.temperature,data.temperature_unit))); + if(h.precipitation_probability!==null)box.append(el('span','weather-rain',h.precipitation_probability+' %')); + list.append(box); + });pane.append(list); + }else pane.append(el('p','view-empty','Prévisions horaires non disponibles.')); + if(data.errors?.length)pane.append(el('p','view-footer','Certaines prévisions ne sont pas fournies par cette source météo.')); + } + async function openWeather(){ + try{render(await api('/api/ha/weather'))} + catch(err){const {pane}=wrap('Météo');showError(err,pane)} + } + + async function goHome(){ + try{await post('/api/ui/home',{})}catch(err){render({type:'home'})} + } + async function openCalendar(){ + const d=atDay(new Date());try{await loadCalendar(d,dayShift(d,7),'days')}catch(err){const {pane}=wrap('Agenda');showError(err,pane)} + } + async function openTemperatures(){ + try{render({type:'temperatures',...await api('/api/ha/temperatures')})}catch(err){const {pane}=wrap('Températures');showError(err,pane)} + } + async function openCameras(){ + try{render({type:'cameras',...await api('/api/ha/cameras')})}catch(err){const {pane}=wrap('Caméras');showError(err,pane)} + } + document.getElementById('viewHome')?.addEventListener('click',goHome); + document.getElementById('viewCalendar')?.addEventListener('click',openCalendar); + document.getElementById('viewTemperatures')?.addEventListener('click',openTemperatures); + document.getElementById('viewWeather')?.addEventListener('click',openWeather); + document.getElementById('viewCameras')?.addEventListener('click',openCameras); + const socket=window.alfredSocket; + socket?.on('calendar_changed',()=>{if(current?.type==='calendar') loadCalendar(current.start,current.end,current.view,getSelected()).catch(console.warn)}); + window.AlfredViews={render,canRender,openCalendar,openTemperatures,openCameras,openWeather,goHome}; +})(); diff --git a/alfred/backup_meteo_tenues_v3_20261025_233732/services/weather_outfits.py b/alfred/backup_meteo_tenues_v3_20261025_233732/services/weather_outfits.py new file mode 100644 index 0000000..3b93dd3 --- /dev/null +++ b/alfred/backup_meteo_tenues_v3_20261025_233732/services/weather_outfits.py @@ -0,0 +1,355 @@ +"""Outfit recommendations and ComfyUI illustration for Alfred weather view. + +This service reuses the same ComfyUI instance as Hermes, but calls ComfyUI +HTTP directly to avoid replacing Alfred's central view while generating the +illustration. +""" +from __future__ import annotations + +import hashlib +import json +import os +import time +import uuid +from datetime import datetime, date as Date +from collections import Counter +from pathlib import Path +from urllib.parse import urlencode + +import requests +from dotenv import load_dotenv, dotenv_values + +# Charger l'environnement avant de calculer BASE et WORKFLOWS_DIR. +# Flask exécute son load_dotenv() après l'import de ce module. +load_dotenv() +load_dotenv('/opt/hermes/MCP_comfyui/.env', override=False) +_MCP_SETTINGS = dotenv_values('/opt/hermes/MCP_comfyui/.env') + +from . import ha_weather + +CACHE_DIR = Path(os.getenv('ALFRED_CACHE_DIR', str(Path(__file__).resolve().parents[1] / 'cache'))) +CACHE_DIR.mkdir(parents=True, exist_ok=True) +CACHE_FILE = CACHE_DIR / 'weather_outfit_cache.json' +BASE = (os.getenv('ALFRED_COMFYUI_URL') or _MCP_SETTINGS.get('COMFYUI_URL') or os.getenv('COMFYUI_URL') or 'http://192.168.1.13:8188').rstrip('/') +WORKFLOWS_DIR = Path(os.getenv('ALFRED_COMFYUI_WORKFLOWS_DIR') or os.getenv('WORKFLOWS_DIR') or '/opt/hermes/MCP_comfyui/workflows') +PROMPT_NODE = os.getenv('COMFY_PROMPT_NODE', '6') +TIMEOUT = int(os.getenv('COMFYUI_TIMEOUT', '300')) +TTL_HOURS = int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TTL_HOURS', '12')) +ENABLED = os.getenv('ALFRED_WEATHER_ILLUSTRATIONS', 'true').lower() in ('1', 'true', 'yes', 'on') +IMAGE_SIZE = min(1024, max(512, int(os.getenv('ALFRED_WEATHER_COMFY_SIZE', '768')))) +PROMPT_VERSION = 'outfits-v2-20261025' + + +def _load_cache() -> dict: + try: + return json.loads(CACHE_FILE.read_text(encoding='utf-8')) + except Exception: + return {} + + +def _save_cache(cache: dict) -> None: + CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding='utf-8') + + +def _pick_day(data: dict, forced_date: str | None = None) -> dict: + """Retourne uniquement la météo du jour demandé ; jamais une autre journée.""" + target = str(forced_date or data.get('focus_date') or '').strip() + try: + requested = Date.fromisoformat(target) + except ValueError as exc: + raise ValueError('Date météo invalide : format AAAA-MM-JJ attendu.') from exc + + for day in data.get('daily', []): + if str(day.get('date')) == target: + return day + + # Certaines intégrations HA ne donnent que des prévisions horaires. + matching = [h for h in data.get('hourly', []) if h.get('date') == target] + if matching: + def available(key): + return [float(h[key]) for h in matching if h.get(key) is not None] + temps = available('temperature') + chances = available('precipitation_probability') + winds = available('wind_speed') + conditions = [str(h['condition']) for h in matching if h.get('condition')] + condition = Counter(conditions).most_common(1)[0][0] if conditions else 'unknown' + return { + 'date': target, + 'condition': condition, + 'description': ha_weather.CONDITIONS.get(condition, 'conditions non précisées'), + 'temperature': max(temps) if temps else None, + 'templow': min(temps) if temps else None, + 'precipitation_probability': max(chances) if chances else None, + 'wind_speed': max(winds) if winds else None, + } + + # Les observations actuelles ne doivent être utilisées QUE pour aujourd'hui. + if requested == datetime.now(ha_weather.TZ).date(): + current = data.get('current', {}) + return { + 'date': target, + 'condition': current.get('condition', 'unknown'), + 'description': current.get('description', 'conditions non précisées'), + 'temperature': current.get('temperature'), + 'templow': current.get('temperature'), + 'precipitation_probability': None, + 'wind_speed': current.get('wind_speed'), + } + raise ValueError(f'Aucune prévision Home Assistant disponible pour le {requested:%d/%m/%Y}. Aucune image ne sera générée avec les données d’un autre jour.') + + +def _temp_band(high: float | None, low: float | None) -> str: + ref = high if high is not None else low + if ref is None: + return 'mild' + if ref < 6: + return 'cold' + if ref < 14: + return 'cool' + if ref < 23: + return 'mild' + return 'warm' + + +def _recommend(day: dict) -> dict: + high = day.get('temperature') + low = day.get('templow') + pop = day.get('precipitation_probability') + wind = day.get('wind_speed') + cond = str(day.get('condition') or '') + band = _temp_band(high, low) + rainy = cond in {'rainy', 'pouring', 'lightning-rainy', 'snowy-rainy'} or (pop is not None and pop >= 45) + sunny = cond in {'sunny', 'partlycloudy'} + windy = wind is not None and wind >= 25 + cold = band in {'cold', 'cool'} + + child = [] + adult = [] + child_acc = [] + adult_acc = [] + + if band == 'cold': + child += ['doudoune chaude', 'pantalon chaud', 'chaussures fermées'] + adult += ['manteau chaud', 'pull', 'pantalon', 'chaussures fermées'] + child_acc += ['bonnet', 'écharpe'] + adult_acc += ['écharpe'] + elif band == 'cool': + child += ['veste légère', 'pantalon', 'baskets'] + adult += ['veste mi-saison', 'pull fin', 'jean', 'chaussures fermées'] + elif band == 'mild': + child += ['sweat ou gilet', 'pantalon léger', 'baskets'] + adult += ['veste légère', 'haut confortable', 'jean ou chino', 'baskets'] + else: + child += ['t-shirt', 'short ou pantalon léger', 'baskets légères'] + adult += ['t-shirt ou polo', 'tenue légère', 'chaussures légères'] + child_acc += ['casquette'] + adult_acc += ['lunettes de soleil'] + + if rainy: + child_acc += ['imperméable', 'parapluie'] + adult_acc += ['parapluie'] + if 'chaussures fermées' not in child: child.append('chaussures fermées') + if 'chaussures fermées' not in adult: adult.append('chaussures fermées') + if windy: + child_acc += ['coupe-vent'] + adult_acc += ['coupe-vent'] + if sunny and band != 'cold': + if 'casquette' not in child_acc: child_acc += ['casquette'] + if 'lunettes de soleil' not in adult_acc: adult_acc += ['lunettes de soleil'] + + def uniq(seq): + seen=[] + for x in seq: + if x not in seen: seen.append(x) + return seen + + return { + 'summary': day.get('description', 'Conditions du jour'), + 'child': { + 'title': 'Fille · 8 ans', + 'clothes': uniq(child), + 'accessories': uniq(child_acc), + }, + 'adult': { + 'title': 'Homme · 42 ans', + 'clothes': uniq(adult), + 'accessories': uniq(adult_acc), + } + } + + +def _weather_signature(day: dict, outfits: dict) -> str: + # Version explicite pour ne pas ressortir des images incorrectes de l'ancien cache. + raw = json.dumps({'version': PROMPT_VERSION, 'date': day.get('date'), + 'day': day, 'outfits': outfits, 'size': IMAGE_SIZE}, + ensure_ascii=False, sort_keys=True) + return hashlib.sha256(raw.encode('utf-8')).hexdigest() + + +def _extract_images(history: dict) -> list[dict]: + images = [] + for node_id, output in history.get('outputs', {}).items(): + for image in output.get('images', []): + filename = image.get('filename') + if not filename: + continue + subfolder = image.get('subfolder', '') + image_type = image.get('type', 'output') + query = urlencode({'filename': filename, 'subfolder': subfolder, 'type': image_type}) + images.append({'filename': filename, 'url': f'{BASE}/view?{query}', 'node_id': str(node_id)}) + return images + + +NEGATIVE_PROMPT = ( + 'deformed face, distorted face, disfigured features, asymmetric eyes, extra eyes, ' + 'extra fingers, missing fingers, malformed hands, bad anatomy, extra limbs, ' + 'extra people, multiple faces on one person, two adults, two teenage girls, ' + 'adult woman, teenage woman, teenager, old child, blurry, out of focus, ' + 'photorealistic uncanny face, text, watermark, logos' +) + + +def _prepare_workflow(workflow: dict, prompt: str) -> dict: + """Modifie seulement les noeuds reconnus du workflow API existant d'Hermes.""" + node = workflow.get(PROMPT_NODE) + if not isinstance(node, dict) or not isinstance(node.get('inputs'), dict) or 'text' not in node['inputs']: + raise ValueError(f'Noeud prompt ComfyUI {PROMPT_NODE} introuvable ou incompatible.') + node['inputs']['text'] = prompt + for sampler in workflow.values(): + if sampler.get('class_type') not in ('KSampler', 'KSamplerAdvanced'): + continue + negative_link = (sampler.get('inputs') or {}).get('negative') + if not isinstance(negative_link, (list, tuple)) or len(negative_link) < 1: + continue + neg = workflow.get(str(negative_link[0])) + if neg and neg.get('class_type') == 'CLIPTextEncode' and 'text' in neg.get('inputs', {}): + neg['inputs']['text'] = NEGATIVE_PROMPT + # Taille carrée: uniquement les noeuds latents natifs compatibles. + for latent in workflow.values(): + if latent.get('class_type') in ('EmptyLatentImage', 'EmptySD3LatentImage'): + inputs = latent.get('inputs') or {} + if 'width' in inputs and 'height' in inputs: + inputs['width'] = IMAGE_SIZE + inputs['height'] = IMAGE_SIZE + return workflow + + +def _submit_workflow(prompt: str) -> str: + workflow_path = WORKFLOWS_DIR / 'text_to_image.json' + if not workflow_path.exists(): + raise RuntimeError(f'Workflow ComfyUI introuvable : {workflow_path}') + workflow = _prepare_workflow(json.loads(workflow_path.read_text(encoding='utf-8')), prompt) + response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30) + response.raise_for_status() + return response.json()['prompt_id'] + + +def _wait_history(prompt_id: str) -> dict: + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + response = requests.get(f'{BASE}/history/{prompt_id}', timeout=15) + response.raise_for_status() + result = response.json() + if prompt_id in result: + return result[prompt_id] + time.sleep(1) + raise RuntimeError(f'ComfyUI timeout ({prompt_id})') + + +def _illustration_prompt(day: dict, outfits: dict, focus_label: str) -> str: + """Descriptions en anglais pour les checkpoints d'illustration les plus courants.""" + high, low = day.get('temperature'), day.get('templow') + pop, wind = day.get('precipitation_probability'), day.get('wind_speed') + bits = [str(day.get('description') or 'daily weather')] + if high is not None: bits.append(f'maximum temperature {high} Celsius') + if low is not None: bits.append(f'minimum temperature {low} Celsius') + if pop is not None: bits.append(f'precipitation probability {pop}%') + if wind is not None: bits.append(f'wind speed {wind} km/h') + return ( + 'Professional clean flat 2D digital illustration, like a high quality family clothing lookbook, ' + 'square composition, TWO human characters EXACTLY, side by side, full body, comfortable spacing, ' + 'simple plain studio background, no text, no captions, no logos. ' + 'LEFT CHARACTER: one little GIRL, 8 YEARS OLD, unmistakably a primary school CHILD, ' + 'shorter and much smaller than the adult, innocent age-appropriate appearance, child body proportions, ' + 'simple pleasant face, standing naturally. ' + 'RIGHT CHARACTER: one ADULT MAN, AGE 42, unmistakably a mature masculine man, ' + 'adult height and mature adult male body proportions, short hair, subtly defined adult male jaw, ' + 'neatly trimmed beard optional, clearly NOT a teenager, NOT a woman. ' + 'Precisely two people: ONE small child girl and ONE adult man. ' + 'Symmetrical well-defined faces, two natural eyes per face, normal hands, five fingers per hand, ' + 'no distorted features, tidy linework, modest friendly cartoon styling instead of photorealism. ' + f'Weather context for {day.get("date", focus_label)}: ' + ', '.join(bits) + '. ' + 'Outfits must match the following French clothing specifications. ' + f'LEFT child GIRL (8): clothing: {", ".join(outfits["child"]["clothes"])}; ' + f'accessories: {", ".join(outfits["child"]["accessories"]) or "none"}. ' + f'RIGHT ADULT MAN (42): clothing: {", ".join(outfits["adult"]["clothes"])}; ' + f'accessories: {", ".join(outfits["adult"]["accessories"]) or "none"}. ' + 'Show clothing clearly, accurate head-to-toe outfits. Exactly one child and one adult male.' + ) + + +def _generate_illustration(day: dict, outfits: dict, focus_label: str, refresh: bool = False) -> dict | None: + if not ENABLED: + return None + sig = _weather_signature(day, outfits) + cache = _load_cache() + entry = cache.get(sig) + now_ts = time.time() + if entry and not refresh and (now_ts - float(entry.get('cached_at', 0))) < TTL_HOURS * 3600: + entry['from_cache'] = True + return entry + + prompt = _illustration_prompt(day, outfits, focus_label) + prompt_id = _submit_workflow(prompt) + history = _wait_history(prompt_id) + images = _extract_images(history) + if not images: + raise RuntimeError('ComfyUI a terminé sans image de sortie pour l’illustration météo.') + entry = { + 'url': images[0]['url'], + 'filename': images[0]['filename'], + 'prompt_id': prompt_id, + 'prompt': prompt, + 'cached_at': now_ts, + 'from_cache': False, + } + cache[sig] = entry + _save_cache(cache) + return entry + + +def build_payload(q: str = '', date: str = '', label: str = '', refresh: bool = False) -> dict: + data = ha_weather.get_weather(q) + if date: + try: + Date.fromisoformat(date) + except (ValueError, TypeError) as exc: + raise ValueError('Date invalide (AAAA-MM-JJ requis).') from exc + target = date or data.get('focus_date', '') + day = _pick_day(data, target) + focus_label = label or data.get('focus_label', 'aujourd’hui') + outfits = _recommend(day) + illustration = None + illustration_error = None + try: + illustration = _generate_illustration(day, outfits, focus_label, refresh=refresh) + except (OSError, ValueError, RuntimeError, TimeoutError, requests.RequestException) as exc: + # La partie texte doit rester affichable si ComfyUI est indisponible. + illustration_error = f'{type(exc).__name__} : {exc}' + print(f'[ALFRED METEO] Illustration indisponible : {illustration_error}', flush=True) + return { + 'type': 'weather_outfits', + 'focus_label': focus_label, + 'date': day.get('date'), + 'condition': day.get('condition'), + 'description': day.get('description'), + 'temperature': day.get('temperature'), + 'templow': day.get('templow'), + 'precipitation_probability': day.get('precipitation_probability'), + 'wind_speed': day.get('wind_speed'), + 'summary': outfits.get('summary'), + 'child': outfits['child'], + 'adult': outfits['adult'], + 'image': illustration, + 'illustration_error': illustration_error, + } diff --git a/alfred/backup_meteo_tenues_v5_20261026_000215/services/weather_outfits.py b/alfred/backup_meteo_tenues_v5_20261026_000215/services/weather_outfits.py new file mode 100644 index 0000000..d4f7e54 --- /dev/null +++ b/alfred/backup_meteo_tenues_v5_20261026_000215/services/weather_outfits.py @@ -0,0 +1,534 @@ +"""Outfit recommendations and ComfyUI illustration for Alfred weather view. + +V4 improvements: +- refresh really regenerates a new image (new random seeds, cache bypass); +- best-of generation with lightweight vision review; +- more reliable accessory rendering by limiting visible accessories in the image; +- still generates the child and adult separately, then assembles them in one image. +""" +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import os +import threading +import time +import uuid +import random +from datetime import datetime, date as Date +from collections import Counter +from pathlib import Path +from urllib.parse import urlencode + +import requests +from dotenv import load_dotenv, dotenv_values + +# Charger l'environnement avant de calculer BASE et WORKFLOWS_DIR. +# Flask exécute son load_dotenv() après l'import de ce module. +load_dotenv() +load_dotenv('/opt/hermes/MCP_comfyui/.env', override=False) +_MCP_SETTINGS = dotenv_values('/opt/hermes/MCP_comfyui/.env') + +from . import ha_weather + +CACHE_DIR = Path(os.getenv('ALFRED_CACHE_DIR', str(Path(__file__).resolve().parents[1] / 'cache'))) +CACHE_DIR.mkdir(parents=True, exist_ok=True) +CACHE_FILE = CACHE_DIR / 'weather_outfit_cache.json' +BASE = (os.getenv('ALFRED_COMFYUI_URL') or _MCP_SETTINGS.get('COMFYUI_URL') or os.getenv('COMFYUI_URL') or 'http://192.168.1.13:8188').rstrip('/') +WORKFLOWS_DIR = Path(os.getenv('ALFRED_COMFYUI_WORKFLOWS_DIR') or os.getenv('WORKFLOWS_DIR') or '/opt/hermes/MCP_comfyui/workflows') +PROMPT_NODE = os.getenv('COMFY_PROMPT_NODE', '6') +TIMEOUT = int(os.getenv('COMFYUI_TIMEOUT', '300')) +TTL_HOURS = int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TTL_HOURS', '12')) +ENABLED = os.getenv('ALFRED_WEATHER_ILLUSTRATIONS', 'true').lower() in ('1', 'true', 'yes', 'on') +IMAGE_SIZE = min(1024, max(512, int(os.getenv('ALFRED_WEATHER_COMFY_SIZE', '768')))) +PROMPT_VERSION = 'outfits-v4-bestof-20261025' +MAX_ATTEMPTS_PER_CHARACTER = max(1, min(3, int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS', '2')))) +QUALITY_TARGET = float(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE', '8.6')) +REVIEW_ENABLED = os.getenv('ALFRED_WEATHER_REVIEW', 'true').lower() in ('1','true','yes','on') + +# Un seul job génération météo à la fois : évite les doublons concurrents. +ILLUSTRATION_LOCK = threading.RLock() +IMAGE_DIR = CACHE_DIR / 'weather_outfit_images' +IMAGE_DIR.mkdir(parents=True, exist_ok=True) + +# Deux portraits distincts, réunis dans une seule image finale. +PORTRAIT_WIDTH = min(512, IMAGE_SIZE) +PORTRAIT_HEIGHT = IMAGE_SIZE +DISPLAY_SIZE = int(os.getenv('ALFRED_WEATHER_DISPLAY_SIZE', '350')) + + +def _load_cache() -> dict: + try: + return json.loads(CACHE_FILE.read_text(encoding='utf-8')) + except Exception: + return {} + + +def _save_cache(cache: dict) -> None: + CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding='utf-8') + + +def _pick_day(data: dict, forced_date: str | None = None) -> dict: + target = str(forced_date or data.get('focus_date') or '').strip() + try: + requested = Date.fromisoformat(target) + except ValueError as exc: + raise ValueError('Date météo invalide : format AAAA-MM-JJ attendu.') from exc + + for day in data.get('daily', []): + if str(day.get('date')) == target: + return day + + matching = [h for h in data.get('hourly', []) if h.get('date') == target] + if matching: + def available(key): + return [float(h[key]) for h in matching if h.get(key) is not None] + temps = available('temperature') + chances = available('precipitation_probability') + winds = available('wind_speed') + conditions = [str(h['condition']) for h in matching if h.get('condition')] + condition = Counter(conditions).most_common(1)[0][0] if conditions else 'unknown' + return { + 'date': target, + 'condition': condition, + 'description': ha_weather.CONDITIONS.get(condition, 'conditions non précisées'), + 'temperature': max(temps) if temps else None, + 'templow': min(temps) if temps else None, + 'precipitation_probability': max(chances) if chances else None, + 'wind_speed': max(winds) if winds else None, + } + + if requested == datetime.now(ha_weather.TZ).date(): + current = data.get('current', {}) + return { + 'date': target, + 'condition': current.get('condition', 'unknown'), + 'description': current.get('description', 'conditions non précisées'), + 'temperature': current.get('temperature'), + 'templow': current.get('temperature'), + 'precipitation_probability': None, + 'wind_speed': current.get('wind_speed'), + } + raise ValueError(f'Aucune prévision Home Assistant disponible pour le {requested:%d/%m/%Y}. Aucune image ne sera générée avec les données d’un autre jour.') + + +def _temp_band(high: float | None, low: float | None) -> str: + ref = high if high is not None else low + if ref is None: + return 'mild' + if ref < 6: + return 'cold' + if ref < 14: + return 'cool' + if ref < 23: + return 'mild' + return 'warm' + + +def _recommend(day: dict) -> dict: + high = day.get('temperature') + low = day.get('templow') + pop = day.get('precipitation_probability') + wind = day.get('wind_speed') + cond = str(day.get('condition') or '') + band = _temp_band(high, low) + rainy = cond in {'rainy', 'pouring', 'lightning-rainy', 'snowy-rainy'} or (pop is not None and pop >= 45) + sunny = cond in {'sunny', 'partlycloudy'} + windy = wind is not None and wind >= 25 + + child = [] + adult = [] + child_acc = [] + adult_acc = [] + + if band == 'cold': + child += ['doudoune chaude', 'pantalon chaud', 'chaussures fermées'] + adult += ['manteau chaud', 'pull', 'pantalon', 'chaussures fermées'] + child_acc += ['bonnet', 'écharpe'] + adult_acc += ['écharpe'] + elif band == 'cool': + child += ['veste légère', 'pantalon', 'baskets'] + adult += ['veste mi-saison', 'pull fin', 'jean', 'chaussures fermées'] + elif band == 'mild': + child += ['sweat ou gilet', 'pantalon léger', 'baskets'] + adult += ['veste légère', 'haut confortable', 'jean ou chino', 'baskets'] + else: + child += ['t-shirt', 'short ou pantalon léger', 'baskets légères'] + adult += ['t-shirt ou polo', 'tenue légère', 'chaussures légères'] + child_acc += ['casquette'] + adult_acc += ['lunettes de soleil'] + + if rainy: + child_acc += ['imperméable', 'parapluie'] + adult_acc += ['parapluie'] + if 'chaussures fermées' not in child: child.append('chaussures fermées') + if 'chaussures fermées' not in adult: adult.append('chaussures fermées') + if windy: + child_acc += ['coupe-vent'] + adult_acc += ['coupe-vent'] + if sunny and band != 'cold': + if 'casquette' not in child_acc: child_acc += ['casquette'] + if 'lunettes de soleil' not in adult_acc: adult_acc += ['lunettes de soleil'] + + def uniq(seq): + seen=[] + for x in seq: + if x not in seen: seen.append(x) + return seen + + return { + 'summary': day.get('description', 'Conditions du jour'), + 'child': { + 'title': 'Fille · 8 ans', + 'clothes': uniq(child), + 'accessories': uniq(child_acc), + }, + 'adult': { + 'title': 'Homme · 42 ans', + 'clothes': uniq(adult), + 'accessories': uniq(adult_acc), + } + } + + +def _weather_signature(day: dict, outfits: dict) -> str: + raw = json.dumps({'version': PROMPT_VERSION, 'date': day.get('date'), + 'day': day, 'outfits': outfits, 'size': IMAGE_SIZE}, + ensure_ascii=False, sort_keys=True) + return hashlib.sha256(raw.encode('utf-8')).hexdigest() + + +def _extract_images(history: dict) -> list[dict]: + images = [] + for node_id, output in history.get('outputs', {}).items(): + for image in output.get('images', []): + filename = image.get('filename') + if not filename: + continue + subfolder = image.get('subfolder', '') + image_type = image.get('type', 'output') + query = urlencode({'filename': filename, 'subfolder': subfolder, 'type': image_type}) + images.append({'filename': filename, 'url': f'{BASE}/view?{query}', 'node_id': str(node_id)}) + return images + + +NEGATIVE_PROMPT = ( + 'more than one person, group photo, two people, extra person, background crowd, ' + 'mirror reflection, human silhouette in background, duplicate heads, cloned body, ' + 'deformed face, malformed face, mutated hands, extra limbs, extra fingers, ' + 'disfigured features, asymmetrical face, photorealistic, horror, blurry, ' + 'text, captions, logos, watermark, oversized accessory, floating accessory' +) + + +def _random_seed() -> int: + return random.randint(1, 2**31 - 1) + + +def _prepare_workflow(workflow: dict, prompt: str, force_new_seed: bool = False) -> dict: + node = workflow.get(PROMPT_NODE) + if not isinstance(node, dict) or not isinstance(node.get('inputs'), dict) or 'text' not in node['inputs']: + raise ValueError(f'Nœud de prompt ComfyUI {PROMPT_NODE} introuvable ou incompatible.') + node['inputs']['text'] = prompt + + for item in workflow.values(): + inputs = item.get('inputs') or {} + if item.get('class_type') == 'CLIPTextEncode' and 'text' in inputs: + # negative prompt link target handled below when referenced by sampler. + pass + if force_new_seed: + for key in ('seed', 'noise_seed'): + if key in inputs and isinstance(inputs[key], (int, float)): + inputs[key] = _random_seed() + if item.get('class_type') in ('RandomNoise', 'Noise') and force_new_seed: + for key in ('noise_seed', 'seed'): + if key in inputs and isinstance(inputs[key], (int, float)): + inputs[key] = _random_seed() + + for sampler in workflow.values(): + if sampler.get('class_type') not in ('KSampler', 'KSamplerAdvanced'): + continue + inputs = sampler.get('inputs') or {} + if force_new_seed: + for key in ('seed', 'noise_seed'): + if key in inputs and isinstance(inputs[key], (int, float)): + inputs[key] = _random_seed() + negative_link = inputs.get('negative') + if not isinstance(negative_link, (list, tuple)) or not negative_link: + continue + neg = workflow.get(str(negative_link[0])) + if neg and neg.get('class_type') == 'CLIPTextEncode' and 'text' in neg.get('inputs', {}): + neg['inputs']['text'] = NEGATIVE_PROMPT + for latent in workflow.values(): + if latent.get('class_type') in ('EmptyLatentImage', 'EmptySD3LatentImage'): + inputs = latent.get('inputs') or {} + if 'width' in inputs and 'height' in inputs: + inputs['width'] = PORTRAIT_WIDTH + inputs['height'] = PORTRAIT_HEIGHT + return workflow + + +def _submit_workflow(prompt: str, force_new_seed: bool = False) -> str: + workflow_path = WORKFLOWS_DIR / 'text_to_image.json' + if not workflow_path.exists(): + raise RuntimeError(f'Workflow ComfyUI introuvable : {workflow_path}') + workflow = _prepare_workflow(json.loads(workflow_path.read_text(encoding='utf-8')), prompt, force_new_seed=force_new_seed) + response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30) + response.raise_for_status() + return response.json()['prompt_id'] + + +def _wait_history(prompt_id: str) -> dict: + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + response = requests.get(f'{BASE}/history/{prompt_id}', timeout=15) + response.raise_for_status() + result = response.json() + if prompt_id in result: + return result[prompt_id] + time.sleep(1) + raise RuntimeError(f'ComfyUI timeout ({prompt_id})') + + +def _primary_accessories(kind: str, outfits: dict) -> list[str]: + accessories = list(outfits[kind]['accessories']) + ordered = [] + priorities = ['parapluie', 'imperméable', 'bonnet', 'écharpe', 'coupe-vent', 'casquette', 'lunettes de soleil'] + for wanted in priorities: + if wanted in accessories and wanted not in ordered: + ordered.append(wanted) + for acc in accessories: + if acc not in ordered: + ordered.append(acc) + return ordered[:2] + + +def _single_character_prompt(kind: str, day: dict, outfits: dict) -> str: + profile = outfits[kind] + if kind == 'child': + identity = ( + 'ONE young GIRL, eight years old, clearly a primary-school child, ' + 'small body proportions of a young child, friendly and natural face, ' + 'casual appropriate haircut, no grown-up features. ' + ) + else: + identity = ( + 'ONE adult MAN, forty-two years old, unmistakably masculine adult, ' + 'short neat hair, mature male face, optionally light stubble, ' + 'adult male shoulders and body proportions, not a teenager. ' + ) + weather = str(day.get('description') or 'daily weather') + primary_acc = _primary_accessories(kind, outfits) + image_acc = ', '.join(primary_acc) if primary_acc else 'none' + return ( + 'Single character fashion illustration for a family weather dashboard. ' + 'Exactly ONE character, standing ALONE. ' + + identity + + 'Full body visible from head to shoes, centrally positioned, front three-quarter view, ' + 'generous empty space around body, relaxed neutral standing pose, smiling gently. ' + 'Soft polished 2D digital illustration, smooth clean outlines, tasteful pastel colors, ' + 'slight dimensional shading, consistent editorial children-book illustration style, ' + 'simple light neutral plain studio background, no other people, no human reflections, ' + 'no poster, no photographs, no text. ' + f'Weather for {day.get("date", "selected day")}: {weather}. ' + f'Clothing: {", ".join(profile["clothes"])}. ' + f'Visible accessories to show clearly: {image_acc}. ' + 'Show at most two accessories, and only if they are natural to wear or hold. ' + 'Correct age and correct body proportions. Clothing must be clearly visible. ' + 'High priority: one person only, clean natural face, no deformations.' + ) + + +def _render_image_from_url(url: str): + from PIL import Image + response = requests.get(url, timeout=45) + response.raise_for_status() + if len(response.content) > 18_000_000: + raise RuntimeError('Image ComfyUI trop volumineuse (limite 18 Mo).') + with Image.open(io.BytesIO(response.content)) as raw: + raw.load() + return raw.convert('RGB') + + +def _vision_review(kind: str, prompt_summary: str, image_url: str) -> dict: + """Lightweight review using the existing ComfyUI vision workflow when available.""" + default = {'score': 7.0, 'ok': True, 'issues': []} + if not REVIEW_ENABLED: + return default + workflow_path = WORKFLOWS_DIR / 'vision_analyze.json' + if not workflow_path.exists(): + return default + image_node = os.getenv('COMFY_VISION_IMAGE_NODE', '1') + prompt_node = os.getenv('COMFY_VISION_PROMPT_NODE', '2') + try: + workflow = json.loads(workflow_path.read_text(encoding='utf-8')) + workflow[str(image_node)]['inputs']['image'] = image_url + review_prompt = ( + 'Review a fashion illustration candidate for a weather dashboard. ' + f'Expected subject: {prompt_summary}. ' + 'Answer ONLY with compact JSON object with keys: ' + 'people_count (int), gender_age_ok (bool), face_ok (bool), accessories_ok (bool), ' + 'deformation (bool), score (number 0-10), issues (array of strings).' + ) + workflow[str(prompt_node)]['inputs']['text'] = review_prompt + response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30) + response.raise_for_status() + prompt_id = response.json()['prompt_id'] + history = _wait_history(prompt_id) + raw_text = None + for _, output in history.get('outputs', {}).items(): + for key in ('text', 'result', 'string'): + if key in output: + raw_text = output[key] + break + if 'texts' in output and output['texts']: + raw_text = output['texts'][0] + break + if isinstance(raw_text, list): + raw_text = raw_text[0] if raw_text else '' + if isinstance(raw_text, dict): + parsed = raw_text + else: + text = str(raw_text or '').strip() + start = text.find('{') + end = text.rfind('}') + if start >= 0 and end > start: + text = text[start:end+1] + parsed = json.loads(text) + score = float(parsed.get('score', 0) or 0) + people_count = int(parsed.get('people_count', 1) or 1) + gender_age_ok = bool(parsed.get('gender_age_ok', True)) + face_ok = bool(parsed.get('face_ok', True)) + accessories_ok = bool(parsed.get('accessories_ok', True)) + deformation = bool(parsed.get('deformation', False)) + issues = list(parsed.get('issues', [])) + ok = (people_count == 1 and gender_age_ok and face_ok and not deformation) + return {'score': score, 'ok': ok, 'issues': issues, 'people_count': people_count, + 'gender_age_ok': gender_age_ok, 'face_ok': face_ok, 'accessories_ok': accessories_ok, + 'deformation': deformation} + except Exception as exc: + return {'score': 7.0, 'ok': True, 'issues': [f'review_fallback:{exc}']} + + +def _render_best_one(kind: str, day: dict, outfits: dict, refresh: bool = False): + prompt = _single_character_prompt(kind, day, outfits) + summary = f'{kind} with clothes {", ".join(outfits[kind]["clothes"])} and accessories {", ".join(_primary_accessories(kind, outfits)) or "none"}' + best = None + best_review = None + for attempt in range(1, MAX_ATTEMPTS_PER_CHARACTER + 1): + prompt_id = _submit_workflow(prompt, force_new_seed=True if (refresh or attempt > 1) else True) + result = _wait_history(prompt_id) + images = _extract_images(result) + if not images: + raise RuntimeError(f'Aucune illustration ComfyUI générée pour {kind}.') + url = images[0]['url'] + image = _render_image_from_url(url) + review = _vision_review(kind, summary, url) + candidate = {'image': image, 'prompt_id': prompt_id, 'review': review} + if best is None or review.get('score', 0) > best_review.get('score', 0): + best = candidate + best_review = review + if review.get('ok') and review.get('score', 0) >= QUALITY_TARGET: + break + return best['image'], best['prompt_id'], best_review + + +def _compose_duo(child_img, adult_img, dest: Path): + from PIL import Image, ImageOps, ImageDraw + w = DISPLAY_SIZE * 2 + 12 + h = DISPLAY_SIZE + gap = 12 + tile_w = DISPLAY_SIZE + tile_h = DISPLAY_SIZE + canvas = Image.new('RGB', (w, h), (42, 50, 56)) + left = ImageOps.fit(child_img, (tile_w, tile_h), method=Image.Resampling.LANCZOS, centering=(.5, .5)) + right = ImageOps.fit(adult_img, (tile_w, tile_h), method=Image.Resampling.LANCZOS, centering=(.5, .5)) + canvas.paste(left, (0, 0)) + canvas.paste(right, (tile_w + gap, 0)) + draw = ImageDraw.Draw(canvas) + draw.rounded_rectangle((tile_w, 0, tile_w + gap - 1, h - 1), radius=2, fill=(42, 50, 56)) + dest.parent.mkdir(parents=True, exist_ok=True) + temp = dest.with_suffix('.tmp.jpg') + canvas.save(temp, format='JPEG', quality=90, optimize=True) + temp.replace(dest) + + +def _cache_url(file_path: Path) -> str: + return 'data:image/jpeg;base64,' + base64.b64encode(file_path.read_bytes()).decode('ascii') + + +def _generate_illustration(day: dict, outfits: dict, focus_label: str, refresh: bool = False) -> dict | None: + if not ENABLED: + return None + sig = _weather_signature(day, outfits) + image_path = IMAGE_DIR / (sig + '.jpg') + with ILLUSTRATION_LOCK: + cache = _load_cache() + entry = cache.get(sig) + now_ts = time.time() + if (entry and image_path.is_file() and not refresh + and (now_ts - float(entry.get('cached_at', 0))) < TTL_HOURS * 3600): + return { + 'url': _cache_url(image_path), + 'cached_at': entry.get('cached_at'), + 'prompt_ids': entry.get('prompt_ids', []), + 'reviews': entry.get('reviews', {}), + 'from_cache': True, + } + child, child_id, child_review = _render_best_one('child', day, outfits, refresh=refresh) + adult, adult_id, adult_review = _render_best_one('adult', day, outfits, refresh=refresh) + _compose_duo(child, adult, image_path) + entry = { + 'file': image_path.name, + 'prompt_ids': [child_id, adult_id], + 'reviews': {'child': child_review, 'adult': adult_review}, + 'cached_at': now_ts, + 'focus_label': focus_label, + } + cache[sig] = entry + _save_cache(cache) + return { + 'url': _cache_url(image_path), + 'prompt_ids': entry['prompt_ids'], + 'reviews': entry['reviews'], + 'cached_at': now_ts, + 'from_cache': False, + } + + +def build_payload(q: str = '', date: str = '', label: str = '', refresh: bool = False) -> dict: + data = ha_weather.get_weather(q) + if date: + try: + Date.fromisoformat(date) + except (ValueError, TypeError) as exc: + raise ValueError('Date invalide (AAAA-MM-JJ requis).') from exc + target = date or data.get('focus_date', '') + day = _pick_day(data, target) + focus_label = label or data.get('focus_label', 'aujourd’hui') + outfits = _recommend(day) + illustration = None + illustration_error = None + try: + illustration = _generate_illustration(day, outfits, focus_label, refresh=refresh) + except (OSError, ValueError, RuntimeError, TimeoutError, ImportError, requests.RequestException) as exc: + illustration_error = f'{type(exc).__name__} : {exc}' + print(f'[ALFRED METEO] Illustration indisponible : {illustration_error}', flush=True) + return { + 'type': 'weather_outfits', + 'focus_label': focus_label, + 'date': day.get('date'), + 'condition': day.get('condition'), + 'description': day.get('description'), + 'temperature': day.get('temperature'), + 'templow': day.get('templow'), + 'precipitation_probability': day.get('precipitation_probability'), + 'wind_speed': day.get('wind_speed'), + 'summary': outfits.get('summary'), + 'child': outfits['child'], + 'adult': outfits['adult'], + 'image': illustration, + 'illustration_error': illustration_error, + } diff --git a/alfred/requirements_calendar.txt b/alfred/requirements_calendar.txt new file mode 100644 index 0000000..315ee33 --- /dev/null +++ b/alfred/requirements_calendar.txt @@ -0,0 +1,2 @@ +requests>=2.31 +websocket-client>=1.6 diff --git a/alfred/tests/test_calendar.py b/alfred/tests/test_calendar.py new file mode 100644 index 0000000..b67c44b --- /dev/null +++ b/alfred/tests/test_calendar.py @@ -0,0 +1,88 @@ +"""Run: PYTHONPATH=app pytest -q tests/test_calendar.py""" +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo +import pytest +from services import ha_views, alfred_intents as intents + +PARIS=ZoneInfo('Europe/Paris') +NOW=datetime(2026,10,25,18,0,tzinfo=PARIS) + +@pytest.mark.parametrize('utterance,start,end',[ + ('J’ai quelque chose de prévu demain ?',date(2026,10,26),date(2026,10,27)), + ('Montre mon planning sur 3 jours',date(2026,10,25),date(2026,10,28)), + ('Que j’ai le 8 novembre ?',date(2026,11,8),date(2026,11,9)), + ('Je veux mon agenda du mois',date(2026,10,1),date(2026,11,1)), + ('Qu’est-ce que j’ai ce matin ?',date(2026,10,25),date(2026,10,25)), +]) +def test_voice_period(utterance,start,end): + a,b,view=intents.period(utterance,NOW) + assert a.date()==start + if 'matin' not in utterance:assert b.date()==end + else:assert (a.hour,b.hour)==(6,12) + +@pytest.mark.parametrize('utterance,kind',[ + ('Montre mon planning sur 7 jours','calendar'), + ('J’ai des choses de prévu le 8 novembre ?','calendar'), + ('Ajoute un rendez-vous dentiste demain à 15h','create'), + ('Supprime mon rendez-vous demain','delete'), + ('Affiche les caméras extérieures','cameras'), + ('Montre les températures','temperatures'), + ('Reviens à l’accueil','home'), + ('Génère-moi une image de poulet',None), +]) +def test_intent(utterance,kind): + result=intents.classify(utterance) + assert (result or {}).get('kind')==kind + + +def test_event_adapter(monkeypatch): + def fake_request(method,path,**kwargs): + if path=='/api/calendars':return [{'entity_id':'calendar.family','name':'Famille'}] + if path=='/api/states':return [{'entity_id':'calendar.family','attributes':{'supported_features':3}}] + if path.startswith('/api/calendars/'): + return [{'summary':'Dentiste','uid':'abc123','start':{'dateTime':'2026-10-25T10:00:00+01:00'}, + 'end':{'dateTime':'2026-10-25T11:00:00+01:00'}}] + monkeypatch.setattr(ha_views,'_request',fake_request) + data=ha_views.get_events(datetime(2026,10,25,tzinfo=PARIS),datetime(2026,10,26,tzinfo=PARIS)) + assert data['events'][0]['title']=='Dentiste' + assert data['events'][0]['id']=='abc123' + assert data['events'][0]['can_delete'] is True + + +def test_recurring_no_uid_cannot_delete(monkeypatch): + def fake_request(method,path,**kwargs): + if path=='/api/calendars':return [{'entity_id':'calendar.a','name':'A'}] + if path=='/api/states':return [{'entity_id':'calendar.a','attributes':{'supported_features':0}}] + if path.startswith('/api/calendars/'): + return [{'summary':'Test','start':'2026-10-26','end':'2026-10-27'}] + monkeypatch.setattr(ha_views,'_request',fake_request) + data=ha_views.get_events(datetime(2026,10,25,tzinfo=PARIS),datetime(2026,10,27,tzinfo=PARIS)) + assert data['events'][0]['all_day'] is True + assert data['events'][0]['can_delete'] is False + + +def test_create_event_calls_service(monkeypatch): + calls=[] + monkeypatch.setattr(ha_views,'list_calendars',lambda:[{'entity_id':'calendar.family','can_create':True,'can_delete':True}]) + monkeypatch.setattr(ha_views,'_request',lambda method,path,**kwargs:calls.append((method,path,kwargs)) or None) + start=datetime(2026,11,8,15,0,tzinfo=PARIS) + assert ha_views.create_event('calendar.family','Dentiste',start,start+timedelta(hours=1))['ok'] + assert calls[0][1]=='/api/services/calendar/create_event' + assert calls[0][2]['payload']['entity_id']=='calendar.family' + + +def test_delete_uses_websocket(monkeypatch): + import websocket + monkeypatch.setattr(ha_views,'list_calendars',lambda:[{'entity_id':'calendar.family','can_create':True,'can_delete':True}]) + monkeypatch.setattr(ha_views,'_cfg',lambda:('http://ha.example:8123','test-token')) + class FakeWS: + messages=[] + def __init__(self):self.replies=iter(['{"type":"auth_required"}','{"type":"auth_ok"}','{"id":1,"success":true}']) + def recv(self):return next(self.replies) + def send(self,m):self.messages.append(m) + def close(self):pass + ws=FakeWS() + monkeypatch.setattr(websocket,'create_connection',lambda url,timeout:ws) + ha_views.delete_event('calendar.family','event1') + assert 'calendar/event/delete' in ws.messages[1] + assert 'event1' in ws.messages[1] diff --git a/hermes/MCP_proxmox/.env b/hermes/MCP_proxmox/.env new file mode 100644 index 0000000..3233595 --- /dev/null +++ b/hermes/MCP_proxmox/.env @@ -0,0 +1,11 @@ +PROXMOX_HOST=192.168.1.20 +PROXMOX_USER=root@pam +PROXMOX_TOKEN_NAME=hermes +PROXMOX_TOKEN_VALUE=91ac0c88-c744-4b97-8a7d-a5e05c783354 +PROXMOX_VERIFY_SSL=false + +# Sécurité +ALLOW_POWER_ACTIONS=true +ALLOW_DELETE=false +ALLOW_ROLLBACK=false + diff --git a/hermes/MCP_proxmox/proxmox_mcp.py b/hermes/MCP_proxmox/proxmox_mcp.py new file mode 100755 index 0000000..98583fb --- /dev/null +++ b/hermes/MCP_proxmox/proxmox_mcp.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 + +import os +from typing import Optional + +from dotenv import load_dotenv +from proxmoxer import ProxmoxAPI +from mcp.server.fastmcp import FastMCP + +load_dotenv("/opt/hermes/proxmox-mcp/.env") + +mcp = FastMCP("proxmox-mcp") + +PROXMOX_HOST = os.getenv("PROXMOX_HOST") +PROXMOX_USER = os.getenv("PROXMOX_USER") +PROXMOX_TOKEN_NAME = os.getenv("PROXMOX_TOKEN_NAME") +PROXMOX_TOKEN_VALUE = os.getenv("PROXMOX_TOKEN_VALUE") + +VERIFY_SSL = ( + os.getenv("PROXMOX_VERIFY_SSL", "true").lower() == "true" +) + +ALLOW_POWER_ACTIONS = ( + os.getenv("ALLOW_POWER_ACTIONS", "false").lower() == "true" +) + +ALLOW_DELETE = ( + os.getenv("ALLOW_DELETE", "false").lower() == "true" +) + +ALLOW_ROLLBACK = ( + os.getenv("ALLOW_ROLLBACK", "false").lower() == "true" +) + +def get_proxmox(): + return ProxmoxAPI( + PROXMOX_HOST, + user=PROXMOX_USER, + token_name=PROXMOX_TOKEN_NAME, + token_value=PROXMOX_TOKEN_VALUE, + verify_ssl=VERIFY_SSL, + backend="https", + ) +def power_allowed(): + if not ALLOW_POWER_ACTIONS: + raise PermissionError( + "Les actions d'alimentation sont désactivées." + ) + + +@mcp.tool() +def list_nodes(): + """ + Liste tous les nodes du cluster. + """ + + proxmox = get_proxmox() + + return proxmox.nodes.get() + + +@mcp.tool() +def cluster_status(): + """ + Retourne l'état général du cluster. + """ + + proxmox = get_proxmox() + + return proxmox.cluster.status.get() + + +@mcp.tool() +def list_vms(node: Optional[str] = None): + """ + Liste les VMs QEMU. + """ + + proxmox = get_proxmox() + + result = [] + + if node: + nodes = [node] + else: + nodes = [ + n["node"] + for n in proxmox.nodes.get() + ] + + for current_node in nodes: + + try: + vms = proxmox.nodes(current_node).qemu.get() + + for vm in vms: + + result.append({ + "node": current_node, + "vmid": vm.get("vmid"), + "name": vm.get("name"), + "status": vm.get("status"), + "cpus": vm.get("cpus"), + "mem": vm.get("mem"), + "maxmem": vm.get("maxmem"), + "uptime": vm.get("uptime") + }) + + except Exception as e: + + result.append({ + "node": current_node, + "error": str(e) + }) + + return result + + +@mcp.tool() +def list_lxc(node: Optional[str] = None): + """ + Liste les conteneurs LXC. + """ + + proxmox = get_proxmox() + + result = [] + + if node: + nodes = [node] + else: + nodes = [ + n["node"] + for n in proxmox.nodes.get() + ] + + for current_node in nodes: + + try: + lxcs = proxmox.nodes(current_node).lxc.get() + + for ct in lxcs: + + result.append({ + "node": current_node, + "vmid": ct.get("vmid"), + "name": ct.get("name"), + "status": ct.get("status"), + "cpus": ct.get("cpus"), + "mem": ct.get("mem"), + "maxmem": ct.get("maxmem"), + }) + + except Exception as e: + + result.append({ + "node": current_node, + "error": str(e) + }) + + return result + + +@mcp.tool() +def vm_status(node: str, vmid: int): + """ + Statut détaillé d'une VM. + """ + + proxmox = get_proxmox() + + return proxmox.nodes(node).qemu(vmid).status.current.get() + + +@mcp.tool() +def lxc_status(node: str, vmid: int): + """ + Statut détaillé d'un LXC. + """ + + proxmox = get_proxmox() + + return proxmox.nodes(node).lxc(vmid).status.current.get() + + +@mcp.tool() +def node_resources(node: str): + """ + CPU / RAM / stockage du node. + """ + + proxmox = get_proxmox() + + return proxmox.nodes(node).status.get() + + +@mcp.tool() +def start_vm(node: str, vmid: int): + + power_allowed() + + proxmox = get_proxmox() + + task = ( + proxmox.nodes(node) + .qemu(vmid) + .status.start.post() + ) + + return { + "action": "start", + "vmid": vmid, + "task": task + } + + +@mcp.tool() +def stop_vm(node: str, vmid: int): + + power_allowed() + + proxmox = get_proxmox() + + task = ( + proxmox.nodes(node) + .qemu(vmid) + .status.stop.post() + ) + + return { + "action": "stop", + "vmid": vmid, + "task": task + } + + +@mcp.tool() +def shutdown_vm(node: str, vmid: int): + + power_allowed() + + proxmox = get_proxmox() + + task = ( + proxmox.nodes(node) + .qemu(vmid) + .status.shutdown.post() + ) + + return { + "action": "shutdown", + "vmid": vmid, + "task": task + } + + +@mcp.tool() +def reboot_vm(node: str, vmid: int): + + power_allowed() + + proxmox = get_proxmox() + + task = ( + proxmox.nodes(node) + .qemu(vmid) + .status.reboot.post() + ) + + return { + "action": "reboot", + "vmid": vmid, + "task": task + } + + +@mcp.tool() +def create_snapshot( + node: str, + vmid: int, + snapshot_name: str, + description: str = "" +): + """ + Création snapshot VM. + """ + + proxmox = get_proxmox() + + task = ( + proxmox.nodes(node) + .qemu(vmid) + .snapshot.post( + snapname=snapshot_name, + description=description + ) + ) + + return { + "action": "snapshot", + "vmid": vmid, + "snapshot": snapshot_name, + "task": task + } + + +@mcp.tool() +def list_snapshots(node: str, vmid: int): + + proxmox = get_proxmox() + + return ( + proxmox.nodes(node) + .qemu(vmid) + .snapshot.get() + ) + + +@mcp.tool() +def rollback_snapshot( + node: str, + vmid: int, + snapshot_name: str +): + """ + Rollback snapshot. + Désactivé par défaut. + """ + + if not ALLOW_ROLLBACK: + raise PermissionError( + "Rollback désactivé." + ) + + proxmox = get_proxmox() + + task = ( + proxmox.nodes(node) + .qemu(vmid) + .snapshot(snapshot_name) + .rollback.post() + ) + + return { + "action": "rollback", + "vmid": vmid, + "snapshot": snapshot_name, + "task": task + } + + +if __name__ == "__main__": + mcp.run() diff --git a/hermes/MCP_proxmox/test_api.py b/hermes/MCP_proxmox/test_api.py new file mode 100644 index 0000000..7e8f97c --- /dev/null +++ b/hermes/MCP_proxmox/test_api.py @@ -0,0 +1,16 @@ +from dotenv import load_dotenv +from proxmoxer import ProxmoxAPI +import os + +load_dotenv("/opt/mcp-proxmox/.env") + +p = ProxmoxAPI( + os.getenv("PROXMOX_HOST"), + user=os.getenv("PROXMOX_USER"), + token_name=os.getenv("PROXMOX_TOKEN_NAME"), + token_value=os.getenv("PROXMOX_TOKEN_VALUE"), + verify_ssl=False, + backend="https", +) + +print(p.nodes.get()) diff --git a/hermes/MCP_wazuh/.env b/hermes/MCP_wazuh/.env new file mode 100755 index 0000000..b59cc85 --- /dev/null +++ b/hermes/MCP_wazuh/.env @@ -0,0 +1,6 @@ +WAZUH_URL=https://192.168.1.52:55000 +WAZUH_USER=wazuh-wui +WAZUH_PASSWORD=y+tx9RA3cYiBxqpnoE2+138pV08Jhnwa +INDEXER_URL=https://192.168.1.52:9200 +INDEXER_USER=kibanaserver +INDEXER_PASSWORD=cIh?n7Y1FeACDZRb063r0+REWoI3dQAA diff --git a/hermes/MCP_wazuh/requirements.txt b/hermes/MCP_wazuh/requirements.txt new file mode 100644 index 0000000..21b3426 --- /dev/null +++ b/hermes/MCP_wazuh/requirements.txt @@ -0,0 +1,3 @@ +mcp +httpx +python-dotenv diff --git a/hermes/MCP_wazuh/vikunja_mcp.py.bak b/hermes/MCP_wazuh/vikunja_mcp.py.bak new file mode 100644 index 0000000..d4b671c --- /dev/null +++ b/hermes/MCP_wazuh/vikunja_mcp.py.bak @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 + +import os +from pathlib import Path + +import httpx +from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP + + +# ============================================================================= +# Configuration +# ============================================================================= + +BASE_DIR = Path(__file__).resolve().parent + +load_dotenv(BASE_DIR / ".env") + +VIKUNJA_URL = os.getenv("VIKUNJA_URL", "").rstrip("/") +VIKUNJA_TOKEN = os.getenv("VIKUNJA_TOKEN", "") +VIKUNJA_VERIFY_SSL = ( + os.getenv("VIKUNJA_VERIFY_SSL", "true").lower() + in ("true", "1", "yes") +) + +if not VIKUNJA_URL: + raise RuntimeError("VIKUNJA_URL n'est pas défini dans .env") + +if not VIKUNJA_TOKEN: + raise RuntimeError("VIKUNJA_TOKEN n'est pas défini dans .env") + + +API_URL = f"{VIKUNJA_URL}/api/v2" + +HEADERS = { + "Authorization": f"Bearer {VIKUNJA_TOKEN}", + "Accept": "application/json", +} + + +# ============================================================================= +# MCP +# ============================================================================= + +mcp = FastMCP("vikunja") + + +# ============================================================================= +# Client Vikunja +# ============================================================================= + +async def vikunja_request( + method: str, + endpoint: str, + params: dict | None = None, + json: dict | None = None, +): + """ + Effectue une requête vers l'API v2 de Vikunja. + """ + + url = f"{API_URL}{endpoint}" + + async with httpx.AsyncClient( + headers=HEADERS, + verify=VIKUNJA_VERIFY_SSL, + timeout=30.0, + ) as client: + + response = await client.request( + method, + url, + params=params, + json=json, + ) + + response.raise_for_status() + + if not response.content: + return None + + return response.json() + + +async def vikunja_get( + endpoint: str, + params: dict | None = None, +): + return await vikunja_request( + "GET", + endpoint, + params=params, + ) + + +async def vikunja_post( + endpoint: str, + data: dict, +): + return await vikunja_request( + "POST", + endpoint, + json=data, + ) + + +# ============================================================================= +# MCP Tools +# ============================================================================= + +@mcp.tool() +async def vikunja_get_projects() -> dict: + """ + Retourne les projets Vikunja disponibles pour l'utilisateur. + + À utiliser notamment avant de créer une tâche lorsque l'utilisateur + ne donne pas directement l'identifiant du projet. + """ + + projects = await vikunja_get( + "/projects", + params={ + "format": "markdown", + }, + ) + + result = [] + + for project in projects or []: + result.append( + { + "id": project.get("id"), + "title": project.get("title"), + "description": project.get("description"), + "parent_project_id": project.get("parent_project_id"), + "is_archived": project.get("is_archived"), + "created": project.get("created"), + "updated": project.get("updated"), + } + ) + + return { + "count": len(result), + "projects": result, + } + + +@mcp.tool() +async def vikunja_get_project( + project_id: int, +) -> dict: + """ + Retourne les informations détaillées d'un projet Vikunja. + + Args: + project_id: + ID du projet. + """ + + project = await vikunja_get( + f"/projects/{project_id}", + params={ + "format": "markdown", + }, + ) + + return { + "id": project.get("id"), + "title": project.get("title"), + "description": project.get("description"), + "parent_project_id": project.get("parent_project_id"), + "is_archived": project.get("is_archived"), + "created": project.get("created"), + "updated": project.get("updated"), + } + + +@mcp.tool() +async def vikunja_get_tasks( + project_id: int, +) -> dict: + """ + Retourne les tâches d'un projet Vikunja. + + Args: + project_id: + ID du projet Vikunja. + """ + + tasks = await vikunja_get( + f"/projects/{project_id}/tasks", + params={ + "format": "markdown", + }, + ) + + result = [] + + for task in tasks or []: + result.append( + { + "id": task.get("id"), + "title": task.get("title"), + "description": task.get("description"), + "done": task.get("done"), + "priority": task.get("priority"), + "due_date": task.get("due_date"), + "start_date": task.get("start_date"), + "end_date": task.get("end_date"), + "project_id": task.get("project_id"), + "labels": task.get("labels", []), + "created": task.get("created"), + "updated": task.get("updated"), + } + ) + + return { + "project_id": project_id, + "count": len(result), + "tasks": result, + } + + +@mcp.tool() +async def vikunja_get_task( + task_id: int, +) -> dict: + """ + Retourne le détail d'une tâche Vikunja. + + Args: + task_id: + ID de la tâche. + """ + + task = await vikunja_get( + f"/tasks/{task_id}", + params={ + "format": "markdown", + }, + ) + + return { + "id": task.get("id"), + "title": task.get("title"), + "description": task.get("description"), + "done": task.get("done"), + "priority": task.get("priority"), + "due_date": task.get("due_date"), + "start_date": task.get("start_date"), + "end_date": task.get("end_date"), + "project_id": task.get("project_id"), + "labels": task.get("labels", []), + "assignees": task.get("assignees", []), + "created": task.get("created"), + "updated": task.get("updated"), + } + + +@mcp.tool() +async def vikunja_create_task( + project_id: int, + title: str, + description: str = "", + due_date: str | None = None, + priority: int = 0, +) -> dict: + """ + Crée une nouvelle tâche dans Vikunja. + + Args: + project_id: + ID du projet dans lequel créer la tâche. + + title: + Titre de la tâche. + + description: + Description facultative. + + due_date: + Date d'échéance facultative au format ISO 8601. + Exemple : 2026-09-15T18:00:00+02:00 + + priority: + Priorité Vikunja. + 0 signifie aucune priorité. + """ + + data = { + "title": title, + "description": description, + "priority": priority, + } + + if due_date: + data["due_date"] = due_date + + task = await vikunja_request( + "POST", + f"/projects/{project_id}/tasks", + params={ + "format": "markdown", + }, + json=data, + ) + + return { + "success": True, + "task": { + "id": task.get("id"), + "title": task.get("title"), + "description": task.get("description"), + "project_id": task.get("project_id"), + "priority": task.get("priority"), + "due_date": task.get("due_date"), + "done": task.get("done"), + }, + } + + +# ============================================================================= +# Main +# ============================================================================= + +if __name__ == "__main__": + mcp.run()