This commit is contained in:
pporcheret
2026-10-26 00:06:24 +01:00
parent c9c3d5f223
commit cd37ac3c0a
207 changed files with 19205 additions and 54 deletions
Binary file not shown.
+196
View File
@@ -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/<path:entity_id>/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")
+195
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+16
View File
@@ -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
}
}
+22
View File
@@ -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
+189
View File
@@ -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()
@@ -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
+187
View File
@@ -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)
+236
View File
@@ -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:]
+230
View File
@@ -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
+688
View File
@@ -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,
}
@@ -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,
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

+27
View File
@@ -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); } }
+102
View File
@@ -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}}
@@ -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);
});
})();
+3 -3
View File
@@ -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) {
+368
View File
@@ -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 start<dayShift(atDay(day),1) && end>atDay(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<end && limit++<42){days.push(cursor);cursor=dayShift(cursor,1)}
if(month) while(days.length%7)days.push(cursor),cursor=dayShift(cursor,1);
grid.classList.add(month?'agenda-month':days.length===1?'agenda-one':days.length<=3?'agenda-three':'agenda-seven');
const colors=new Map((data.calendars||[]).map((c,i)=>[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};
})();
+5
View File
@@ -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.
+39 -9
View File
@@ -7,6 +7,7 @@
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
@@ -18,14 +19,30 @@
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Commandes audio">
<div class="header-actions" aria-label="Navigation et commandes audio">
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Volume et microphone">
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -40,11 +57,16 @@
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio" aria-modal="false" hidden>
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages audio</strong>
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
@@ -55,11 +77,17 @@
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<div class="setting-toggle">
<span>Microphone</span>
<button id="micMute" type="button" class="quiet-button" aria-pressed="false">Actif</button>
</div>
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat dans l’aperçu « CAMÉRA » en bas à gauche.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
@@ -97,7 +125,9 @@
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
</body>
</html>