V1
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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:]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 217 KiB |
@@ -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); } }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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};
|
||||
})();
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||