V1
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
import os, threading, hmac
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Flask, render_template, request, jsonify, Response
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
from services.audio import listen_once, speak_async, tts_backend_detect
|
||||
from services.wakeword import WakeWordListener
|
||||
from services.hermes_client import ask_hermes
|
||||
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
||||
from services.camera import video_stream
|
||||
from services import ha_views, alfred_views_controller
|
||||
from services import camera_settings, ha_weather, weather_outfits
|
||||
|
||||
load_dotenv()
|
||||
|
||||
##
|
||||
from services.audio import get_vosk_model
|
||||
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
|
||||
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
|
||||
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
|
||||
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||
##
|
||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
||||
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||
STATE_LOCK = threading.RLock()
|
||||
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
|
||||
conversation_lock=threading.Lock()
|
||||
mic_stop_event=threading.Event()
|
||||
audio_settings.initialize()
|
||||
audio_settings.update_settings({"mic_gain": 30})
|
||||
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
|
||||
camera_settings.restore_saved_settings()
|
||||
|
||||
def emit_state(mode=None, **extra):
|
||||
with STATE_LOCK:
|
||||
if mode: STATE["mode"] = mode
|
||||
STATE.update(extra)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return
|
||||
|
||||
try:
|
||||
emit_state("listening", partial="", transcript="", wake_score=score)
|
||||
|
||||
print("[ALFRED] Début écoute STT", flush=True)
|
||||
|
||||
r = listen_once(
|
||||
stop_event=mic_stop_event,
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
|
||||
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
|
||||
|
||||
if audio_settings.mic_muted():
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
if r.error:
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": r.error}
|
||||
)
|
||||
return
|
||||
|
||||
if not r.text:
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
emit_state(
|
||||
"thinking",
|
||||
partial="",
|
||||
transcript=r.text,
|
||||
content={"type": "text", "text": r.text}
|
||||
)
|
||||
|
||||
# Vue dynamique : commandes déterministes exécutées sans faire
|
||||
# dépendre l'affichage des capacités ou sessions Hermes.
|
||||
visual = None
|
||||
try:
|
||||
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
|
||||
visual = ({"type":"text", "text":str(exc)}, str(exc))
|
||||
|
||||
if visual is not None:
|
||||
content, speech = visual
|
||||
with STATE_LOCK:
|
||||
STATE["content"] = content
|
||||
socketio.emit("alfred_render", content)
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=done.set)
|
||||
done.wait(timeout=120)
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
print("[ALFRED] Envoi à Hermes", flush=True)
|
||||
# ComfyUI publie l'image pendant cet appel Hermes.
|
||||
with STATE_LOCK:
|
||||
image_revision_before = IMAGE_REVISION
|
||||
answer = ask_hermes(r.text)
|
||||
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
|
||||
if isinstance(answer, dict):
|
||||
speech = str(answer.get("speech") or answer.get("text") or "")
|
||||
display = answer.get("display") or {
|
||||
"type": "text",
|
||||
"text": speech
|
||||
}
|
||||
else:
|
||||
speech = str(answer or "")
|
||||
display = {"type": "text", "text": speech}
|
||||
|
||||
# Si le MCP ComfyUI a affiché une image pendant la requête,
|
||||
# ne pas l'écraser par la réponse textuelle d'Hermes.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == image_revision_before:
|
||||
STATE["content"] = display
|
||||
socketio.emit("alfred_render", display)
|
||||
else:
|
||||
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
|
||||
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
speak_async(speech, on_end=done.set)
|
||||
done.wait(timeout=120)
|
||||
|
||||
emit_state("idle", partial="")
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": str(exc)}
|
||||
)
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
print("[ALFRED] Wake word réactivé", flush=True)
|
||||
|
||||
|
||||
|
||||
def on_wake(score):
|
||||
if audio_settings.mic_muted():
|
||||
return
|
||||
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
|
||||
threading.Thread(target=conversation,args=(score,),daemon=True).start()
|
||||
|
||||
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
|
||||
|
||||
@app.get('/api/audio/settings')
|
||||
def get_audio_settings():
|
||||
return jsonify(audio_settings.get_settings())
|
||||
|
||||
@app.post('/api/audio/settings')
|
||||
def set_audio_settings():
|
||||
data = request.get_json(silent=True)
|
||||
try:
|
||||
old = audio_settings.get_settings()
|
||||
new = audio_settings.update_settings(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'ALSA : {exc}'}), 500
|
||||
if new['mic_muted'] != old['mic_muted']:
|
||||
if new['mic_muted']:
|
||||
mic_stop_event.set()
|
||||
wake.pause()
|
||||
else:
|
||||
mic_stop_event.clear()
|
||||
if not conversation_lock.locked():
|
||||
wake.resume()
|
||||
socketio.emit('audio_settings', new)
|
||||
return jsonify(new)
|
||||
|
||||
@app.get('/api/camera/settings')
|
||||
def get_camera_settings():
|
||||
try:
|
||||
return jsonify(camera_settings.get_settings())
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/settings')
|
||||
def set_camera_settings():
|
||||
# Même garde-fou que les commandes calendrier existantes.
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'JSON invalide.'}), 400
|
||||
try:
|
||||
result = camera_settings.update_setting(data.get('control'), data.get('value'))
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/reset')
|
||||
def reset_camera_settings():
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
try:
|
||||
result = camera_settings.reset_defaults()
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.get('/api/camera/stream')
|
||||
def camera_stream():
|
||||
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
|
||||
try:
|
||||
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
|
||||
except (ImportError, ValueError, RuntimeError, OSError) as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
return Response(
|
||||
frames,
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame',
|
||||
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
|
||||
@app.get("/api/state")
|
||||
def state():
|
||||
with STATE_LOCK:
|
||||
snapshot = dict(STATE)
|
||||
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
|
||||
|
||||
@app.post("/api/state")
|
||||
def set_state():
|
||||
data = request.get_json(force=True)
|
||||
with STATE_LOCK:
|
||||
STATE.update(data)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
return jsonify({"ok": True})
|
||||
@app.post("/api/render")
|
||||
def render_payload():
|
||||
global IMAGE_REVISION
|
||||
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||
if token and not hmac.compare_digest(
|
||||
request.headers.get("X-Alfred-Render-Token", ""), token
|
||||
):
|
||||
return jsonify({"ok": False, "error": "Non autorisé"}), 401
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
|
||||
if data.get("type") == "image":
|
||||
image_url = data.get("url")
|
||||
if not isinstance(image_url, str) or not (
|
||||
image_url.startswith(("http://", "https://", "/"))
|
||||
and not image_url.startswith("//")
|
||||
):
|
||||
return jsonify({"ok": False, "error": "URL image invalide"}), 400
|
||||
|
||||
with STATE_LOCK:
|
||||
if data.get("type") == "image":
|
||||
IMAGE_REVISION += 1
|
||||
STATE["content"] = data
|
||||
socketio.emit("alfred_render", data)
|
||||
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
|
||||
# --- Agenda / capteurs / caméras Home Assistant ---
|
||||
def _calendar_range():
|
||||
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
|
||||
now = datetime.now(tz)
|
||||
def parse(name, default):
|
||||
raw = request.args.get(name)
|
||||
if not raw:return default
|
||||
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
|
||||
if value.tzinfo is None: value = value.replace(tzinfo=tz)
|
||||
return value.astimezone(tz)
|
||||
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
|
||||
end = parse('end',start+timedelta(days=7))
|
||||
return start,end
|
||||
|
||||
def _json_error(exc):
|
||||
code = 400 if isinstance(exc,ValueError) else 502
|
||||
return jsonify({'error':str(exc)}),code
|
||||
|
||||
def _writes_enabled():
|
||||
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
|
||||
|
||||
def _same_origin_json():
|
||||
# POSTs require a custom header and same-origin browser requests.
|
||||
if request.headers.get('X-Alfred-Request') != '1':return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
|
||||
return True
|
||||
|
||||
@app.get('/api/calendar/calendars')
|
||||
def calendars_list():
|
||||
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/calendar/events')
|
||||
def calendar_events():
|
||||
try:
|
||||
start,end=_calendar_range()
|
||||
selected=request.args.getlist('calendar_id')
|
||||
return jsonify(ha_views.get_events(start,end,selected or None))
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/create')
|
||||
def calendar_create():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
def read_dt(key):
|
||||
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
|
||||
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
||||
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
|
||||
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
|
||||
str(data.get('location') or ''),bool(data.get('all_day',False)))
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_create()
|
||||
socketio.emit('calendar_changed', {'type':'created'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/delete')
|
||||
def calendar_delete():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
|
||||
start=datetime.fromisoformat(str(data['start']))
|
||||
end=datetime.fromisoformat(str(data['end']))
|
||||
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
|
||||
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
|
||||
rid=str(data.get('recurrence_id') or '')
|
||||
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
|
||||
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
|
||||
result=ha_views.delete_event(eid,uid,rid or None)
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_delete()
|
||||
socketio.emit('calendar_changed',{'type':'deleted'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather')
|
||||
def ha_weather_data():
|
||||
# Read-only route, token always stays on the Alfred server.
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
return jsonify(ha_weather.get_weather(q))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather/outfits')
|
||||
def ha_weather_outfits_data():
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
|
||||
return jsonify(weather_outfits.build_payload(q=q, refresh=refresh))
|
||||
except (ha_views.HAError, ValueError, RuntimeError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperatures')
|
||||
def ha_temperatures():
|
||||
try:return jsonify({'items':ha_views.get_temperatures()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperature/history')
|
||||
def ha_temperature_history():
|
||||
try:
|
||||
eid=request.args.get('entity_id','')
|
||||
hours=int(request.args.get('hours','24'))
|
||||
return jsonify({'items':ha_views.temperature_history(eid,hours)})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/cameras')
|
||||
def ha_cameras():
|
||||
try:return jsonify({'items':ha_views.list_cameras()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/camera/<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")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Fast voice routing for visual actions; Hermes still handles general tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, time
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from . import ha_views as ha
|
||||
from . import alfred_intents as intents
|
||||
from . import ha_weather
|
||||
|
||||
TZ = ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
|
||||
|
||||
def _label(s,e):
|
||||
return f"du {s:%d/%m} au {(e-timedelta(days=1)):%d/%m}"
|
||||
|
||||
|
||||
def _event_summary(events, question):
|
||||
count=len(events)
|
||||
if count == 0:return "Non, je n'ai trouvé aucun événement pour cette période." if question else 'Aucun événement prévu sur cette période.'
|
||||
names=', '.join(ev['title'] for ev in events[:3])
|
||||
if question:
|
||||
return f"Oui, {count} événement{'s' if count>1 else ''} prévu{'s' if count>1 else ''}. {names}." if count<=3 else f"Oui, {count} événements sont prévus. Notamment : {names}."
|
||||
return f"Voici ton planning avec {count} événement{'s' if count>1 else ''}."
|
||||
|
||||
|
||||
def _calendar(text, question=False):
|
||||
start,end,view=intents.period(text)
|
||||
payload=ha.get_events(start,end)
|
||||
content={'type':'calendar','view':view,'title':'Planning','start':start.isoformat(),'end':end.isoformat(),
|
||||
'events':payload['events'],'calendars':payload['calendars'],'errors':payload['errors']}
|
||||
speech=_event_summary(payload['events'],question)
|
||||
if payload['errors']: speech+=' Certains calendriers sont indisponibles.'
|
||||
return content,speech
|
||||
|
||||
|
||||
def _create(text):
|
||||
calendars=ha.list_calendars()
|
||||
details, complete=intents.extract_create(text,calendars)
|
||||
if not complete:
|
||||
return {'type':'calendar_form','values':details,'calendars':calendars},'Je prépare le rendez-vous. Complète ou vérifie les informations à l’écran avant de l’enregistrer.'
|
||||
# No calendar mutation until a second explicit confirmation.
|
||||
intents.set_create(details)
|
||||
pretty=datetime.fromisoformat(details['date']).strftime('%d/%m')
|
||||
speech=f"Je vais ajouter {details['title']} le {pretty} à {details['time']}. Dis confirme pour enregistrer, ou annule."
|
||||
return {'type':'calendar_form','values':details,'calendars':calendars,'awaiting_confirmation':True},speech
|
||||
|
||||
|
||||
def _delete(text):
|
||||
now=datetime.now(TZ)
|
||||
day=intents.parse_day(text,now)
|
||||
if day:
|
||||
start=datetime.combine(day,time.min,TZ); end=start+timedelta(days=1)
|
||||
else:
|
||||
start=datetime.combine(now.date(),time.min,TZ);end=start+timedelta(days=45)
|
||||
data=ha.get_events(start,end)
|
||||
needle=intents.extract_search(text)
|
||||
events=[e for e in data['events'] if intents.simplify(needle) in intents.simplify(e['title'])] if needle else data['events']
|
||||
if not events:
|
||||
return {'type':'calendar','view':'days','start':start.isoformat(),'end':end.isoformat(),**data},'Je ne trouve pas cet événement. Aucun événement n’a été supprimé.'
|
||||
if len(events)!=1:
|
||||
return {'type':'delete_candidates','events':events[:30]},f"J'ai trouvé {len(events)} événements correspondants. Choisis celui à supprimer sur l'écran."
|
||||
ev=events[0]
|
||||
if not ev['can_delete']:
|
||||
return {'type':'delete_candidates','events':[ev]},'Ce calendrier ne permet pas la suppression de cet événement depuis Alfred.'
|
||||
intents.set_delete(ev)
|
||||
warning=' Attention, si cet événement est récurrent, cela peut supprimer toute la série.' if not ev['recurrence_id'] else ''
|
||||
return {'type':'delete_candidates','events':[ev], 'awaiting_confirmation':True},f"Confirme la suppression de {ev['title']}, ou dis annule.{warning}"
|
||||
|
||||
|
||||
def _confirm_delete():
|
||||
e=intents.current_delete()
|
||||
if not e:return {'type':'text','text':'Confirmation expirée.'},'La demande de suppression a expiré.'
|
||||
# Recheck existence in HA to avoid deleting an event that changed meanwhile.
|
||||
start=datetime.fromisoformat(e['start'])-timedelta(days=1)
|
||||
end=datetime.fromisoformat(e['end'])+timedelta(days=1)
|
||||
found=ha.get_events(start,end,[e['calendar_id']])['events']
|
||||
if not any(x['id']==e['id'] and x['start']==e['start'] and x['recurrence_id']==e['recurrence_id'] for x in found):
|
||||
intents.clear_delete()
|
||||
return {'type':'text','text':'Cet événement a changé. Suppression annulée.'},'Cet événement a changé. Rien n’a été supprimé.'
|
||||
ha.delete_event(e['calendar_id'],e['id'],e['recurrence_id'] or None)
|
||||
intents.clear_delete()
|
||||
content,speech=_calendar('planning sur 7 jours')
|
||||
return content,'Événement supprimé. '+speech
|
||||
|
||||
|
||||
def _confirm_create():
|
||||
data=intents.current_create()
|
||||
if not data:return {'type':'text','text':'Confirmation expirée.'},'La demande de création a expiré.'
|
||||
if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'):
|
||||
return {'type':'calendar_form','values':data,'calendars':ha.list_calendars()},'L’écriture est désactivée. Active ALFRED_ALLOW_CALENDAR_WRITES dans la configuration.'
|
||||
start=datetime.fromisoformat(data['date']+'T'+data['time']).replace(tzinfo=TZ)
|
||||
end=start+timedelta(minutes=int(data.get('duration_minutes',60)))
|
||||
ha.create_event(data['calendar_id'],data['title'],start,end)
|
||||
intents.clear_create()
|
||||
content,speech=_calendar('planning sur 7 jours')
|
||||
return content,'Événement ajouté. '+speech
|
||||
|
||||
|
||||
def route(text, last_view=None):
|
||||
# Weather is a read-only dynamic view; avoid sending routine forecasts to Hermes.
|
||||
if ha_weather.is_weather_request(text, last_view):
|
||||
data = ha_weather.get_weather()
|
||||
return data, ha_weather.describe(data, text)
|
||||
intent=intents.classify(text)
|
||||
# Short temporal follow-ups to a displayed calendar: 'Et demain ?', 'Ce matin ?'
|
||||
if intent is None and last_view == 'calendar' and len(text.strip()) < 45:
|
||||
t=intents.simplify(text)
|
||||
if any(word in t for word in ('demain','matin','apres-midi','soir','journee','semaine','mois','jours','novembre','decembre','janvier','fevrier','mars','avril','mai','juin','juillet','aout','septembre','octobre')):
|
||||
intent={'kind':'calendar','question':True}
|
||||
if intent is None:return None
|
||||
kind=intent['kind']
|
||||
if kind=='home':return {'type':'home','title':'Alfred','text':'À ton écoute.'},'Je reviens à l’accueil.'
|
||||
if kind=='calendar':return _calendar(text, intent.get('question',False))
|
||||
if kind=='create':return _create(text)
|
||||
if kind=='delete':return _delete(text)
|
||||
if kind=='confirm_delete':
|
||||
if os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() not in ('1','true','yes'):
|
||||
return {'type':'text','text':'Écriture calendrier désactivée.'},'La suppression est désactivée dans la configuration.'
|
||||
return _confirm_delete()
|
||||
if kind=='confirm_create':return _confirm_create()
|
||||
if kind=='cancel_pending':
|
||||
intents.cancel_pending()
|
||||
return {'type':'home','text':'Demande annulée.'},'D’accord, aucune modification effectuée.'
|
||||
if kind=='temperatures':
|
||||
temps=ha.get_temperatures()
|
||||
return {'type':'temperatures','items':temps},f"Voici {len(temps)} mesures de température provenant de Home Assistant."
|
||||
if kind=='cameras':
|
||||
cameras=ha.list_cameras()
|
||||
return {'type':'cameras','items':cameras},f"Voici {len(cameras)} caméras Home Assistant disponibles."
|
||||
return None
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Outfit recommendations and ComfyUI illustration for Alfred weather view.
|
||||
|
||||
This service reuses the same ComfyUI instance as Hermes, but calls ComfyUI
|
||||
HTTP directly to avoid replacing Alfred's central view while generating the
|
||||
illustration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dotenv import load_dotenv
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
from . import ha_weather
|
||||
|
||||
load_dotenv("/opt/hermes/MCP_comfyui/.env")
|
||||
|
||||
BASE = os.getenv(
|
||||
"COMFYUI_URL",
|
||||
"http://192.168.1.13:8188"
|
||||
).rstrip("/")
|
||||
|
||||
WORKFLOWS_DIR = Path(
|
||||
os.getenv(
|
||||
"WORKFLOWS_DIR",
|
||||
"/opt/hermes/MCP_comfyui/workflows"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
CACHE_DIR = Path(os.getenv('ALFRED_CACHE_DIR', str(Path(__file__).resolve().parents[1] / 'cache')))
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CACHE_FILE = CACHE_DIR / 'weather_outfit_cache.json'
|
||||
BASE = os.getenv('COMFYUI_URL', 'http://127.0.0.1:8188').rstrip('/')
|
||||
WORKFLOWS_DIR = Path(os.getenv('WORKFLOWS_DIR', '/opt/hermes/MCP_comfyui/workflows'))
|
||||
#WORKFLOWS_DIR = Path(os.getenv('WORKFLOWS_DIR', str(Path(__file__).parent / 'workflows')))
|
||||
PROMPT_NODE = os.getenv('COMFY_PROMPT_NODE', '6')
|
||||
TIMEOUT = int(os.getenv('COMFYUI_TIMEOUT', '300'))
|
||||
TTL_HOURS = int(os.getenv('ALFRED_WEATHER_ILLUSTRATION_TTL_HOURS', '12'))
|
||||
ENABLED = os.getenv('ALFRED_WEATHER_ILLUSTRATIONS', 'true').lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def _load_cache() -> dict:
|
||||
try:
|
||||
return json.loads(CACHE_FILE.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_cache(cache: dict) -> None:
|
||||
CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def _pick_day(data: dict) -> dict:
|
||||
target = str(data.get('focus_date') or '')
|
||||
for day in data.get('daily', []):
|
||||
if str(day.get('date')) == target:
|
||||
return day
|
||||
if data.get('daily'):
|
||||
return data['daily'][0]
|
||||
current = data.get('current', {})
|
||||
return {
|
||||
'date': target or datetime.now().date().isoformat(),
|
||||
'condition': current.get('condition', 'unknown'),
|
||||
'description': current.get('description', 'conditions non précisées'),
|
||||
'temperature': current.get('temperature'),
|
||||
'templow': current.get('temperature'),
|
||||
'precipitation_probability': None,
|
||||
'wind_speed': current.get('wind_speed'),
|
||||
}
|
||||
|
||||
|
||||
def _temp_band(high: float | None, low: float | None) -> str:
|
||||
ref = high if high is not None else low
|
||||
if ref is None:
|
||||
return 'mild'
|
||||
if ref < 6:
|
||||
return 'cold'
|
||||
if ref < 14:
|
||||
return 'cool'
|
||||
if ref < 23:
|
||||
return 'mild'
|
||||
return 'warm'
|
||||
|
||||
|
||||
def _recommend(day: dict) -> dict:
|
||||
high = day.get('temperature')
|
||||
low = day.get('templow')
|
||||
pop = day.get('precipitation_probability')
|
||||
wind = day.get('wind_speed')
|
||||
cond = str(day.get('condition') or '')
|
||||
band = _temp_band(high, low)
|
||||
rainy = cond in {'rainy', 'pouring', 'lightning-rainy', 'snowy-rainy'} or (pop is not None and pop >= 45)
|
||||
sunny = cond in {'sunny', 'partlycloudy'}
|
||||
windy = wind is not None and wind >= 25
|
||||
cold = band in {'cold', 'cool'}
|
||||
|
||||
child = []
|
||||
adult = []
|
||||
child_acc = []
|
||||
adult_acc = []
|
||||
|
||||
if band == 'cold':
|
||||
child += ['doudoune chaude', 'pantalon chaud', 'chaussures fermées']
|
||||
adult += ['manteau chaud', 'pull', 'pantalon', 'chaussures fermées']
|
||||
child_acc += ['bonnet', 'écharpe']
|
||||
adult_acc += ['écharpe']
|
||||
elif band == 'cool':
|
||||
child += ['veste légère', 'pantalon', 'baskets']
|
||||
adult += ['veste mi-saison', 'pull fin', 'jean', 'chaussures fermées']
|
||||
elif band == 'mild':
|
||||
child += ['sweat ou gilet', 'pantalon léger', 'baskets']
|
||||
adult += ['veste légère', 'haut confortable', 'jean ou chino', 'baskets']
|
||||
else:
|
||||
child += ['t-shirt', 'short ou pantalon léger', 'baskets légères']
|
||||
adult += ['t-shirt ou polo', 'tenue légère', 'chaussures légères']
|
||||
child_acc += ['casquette']
|
||||
adult_acc += ['lunettes de soleil']
|
||||
|
||||
if rainy:
|
||||
child_acc += ['imperméable', 'parapluie']
|
||||
adult_acc += ['parapluie']
|
||||
if 'chaussures fermées' not in child: child.append('chaussures fermées')
|
||||
if 'chaussures fermées' not in adult: adult.append('chaussures fermées')
|
||||
if windy:
|
||||
child_acc += ['coupe-vent']
|
||||
adult_acc += ['coupe-vent']
|
||||
if sunny and band != 'cold':
|
||||
if 'casquette' not in child_acc: child_acc += ['casquette']
|
||||
if 'lunettes de soleil' not in adult_acc: adult_acc += ['lunettes de soleil']
|
||||
|
||||
def uniq(seq):
|
||||
seen=[]
|
||||
for x in seq:
|
||||
if x not in seen: seen.append(x)
|
||||
return seen
|
||||
|
||||
return {
|
||||
'summary': day.get('description', 'Conditions du jour'),
|
||||
'child': {
|
||||
'title': 'Fille · 8 ans',
|
||||
'clothes': uniq(child),
|
||||
'accessories': uniq(child_acc),
|
||||
},
|
||||
'adult': {
|
||||
'title': 'Homme · 42 ans',
|
||||
'clothes': uniq(adult),
|
||||
'accessories': uniq(adult_acc),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _weather_signature(day: dict, outfits: dict) -> str:
|
||||
raw = json.dumps({'day': day, 'outfits': outfits}, ensure_ascii=False, sort_keys=True)
|
||||
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _extract_images(history: dict) -> list[dict]:
|
||||
images = []
|
||||
for node_id, output in history.get('outputs', {}).items():
|
||||
for image in output.get('images', []):
|
||||
filename = image.get('filename')
|
||||
if not filename:
|
||||
continue
|
||||
subfolder = image.get('subfolder', '')
|
||||
image_type = image.get('type', 'output')
|
||||
query = urlencode({'filename': filename, 'subfolder': subfolder, 'type': image_type})
|
||||
images.append({'filename': filename, 'url': f'{BASE}/view?{query}', 'node_id': str(node_id)})
|
||||
return images
|
||||
|
||||
|
||||
def _submit_workflow(prompt: str) -> str:
|
||||
workflow_path = WORKFLOWS_DIR / 'text_to_image.json'
|
||||
if not workflow_path.exists():
|
||||
raise RuntimeError(f'Workflow ComfyUI introuvable : {workflow_path}')
|
||||
workflow = json.loads(workflow_path.read_text(encoding='utf-8'))
|
||||
workflow[PROMPT_NODE]['inputs']['text'] = prompt
|
||||
response = requests.post(f'{BASE}/prompt', json={'prompt': workflow, 'client_id': str(uuid.uuid4())}, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json()['prompt_id']
|
||||
|
||||
|
||||
def _wait_history(prompt_id: str) -> dict:
|
||||
deadline = time.monotonic() + TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
response = requests.get(f'{BASE}/history/{prompt_id}', timeout=15)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if prompt_id in result:
|
||||
return result[prompt_id]
|
||||
time.sleep(1)
|
||||
raise RuntimeError(f'ComfyUI timeout ({prompt_id})')
|
||||
|
||||
|
||||
def _illustration_prompt(day: dict, outfits: dict, focus_label: str) -> str:
|
||||
high = day.get('temperature')
|
||||
low = day.get('templow')
|
||||
pop = day.get('precipitation_probability')
|
||||
wind = day.get('wind_speed')
|
||||
weather_bits = [day.get('description', 'conditions du jour')]
|
||||
if high is not None:
|
||||
weather_bits.append(f'température maximale {high}°C')
|
||||
if low is not None:
|
||||
weather_bits.append(f'température minimale {low}°C')
|
||||
if pop is not None:
|
||||
weather_bits.append(f'probabilité de pluie {pop}%')
|
||||
if wind is not None:
|
||||
weather_bits.append(f'vent {wind} km/h')
|
||||
return (
|
||||
'Crée une illustration verticale propre pour un tableau météo d’un assistant domestique. '
|
||||
'Montre en pied deux personnages côte à côte, style illustration moderne semi-réaliste douce, lisible, familiale, fond simple et discret. '
|
||||
'À gauche : une fille de 8 ans. À droite : un homme adulte de 42 ans. '
|
||||
f'Contexte météo pour {focus_label}: ' + ', '.join(weather_bits) + '. '
|
||||
'Habille les personnages selon les recommandations suivantes. '
|
||||
f"Fille 8 ans: vêtements {', '.join(outfits['child']['clothes'])}; accessoires {', '.join(outfits['child']['accessories']) or 'aucun accessoire particulier'}. "
|
||||
f"Homme 42 ans: vêtements {', '.join(outfits['adult']['clothes'])}; accessoires {', '.join(outfits['adult']['accessories']) or 'aucun accessoire particulier'}. "
|
||||
'Aucun texte dans l’image. Montrer clairement les tenues et accessoires conseillés. '
|
||||
'Composition propre, adaptée à une interface d’assistant, haute qualité.'
|
||||
)
|
||||
|
||||
|
||||
def _generate_illustration(day: dict, outfits: dict, focus_label: str, refresh: bool = False) -> dict | None:
|
||||
if not ENABLED:
|
||||
return None
|
||||
sig = _weather_signature(day, outfits)
|
||||
cache = _load_cache()
|
||||
entry = cache.get(sig)
|
||||
now_ts = time.time()
|
||||
if entry and not refresh and (now_ts - float(entry.get('cached_at', 0))) < TTL_HOURS * 3600:
|
||||
entry['from_cache'] = True
|
||||
return entry
|
||||
|
||||
prompt = _illustration_prompt(day, outfits, focus_label)
|
||||
prompt_id = _submit_workflow(prompt)
|
||||
history = _wait_history(prompt_id)
|
||||
images = _extract_images(history)
|
||||
if not images:
|
||||
raise RuntimeError('ComfyUI a terminé sans image de sortie pour l’illustration météo.')
|
||||
entry = {
|
||||
'url': images[0]['url'],
|
||||
'filename': images[0]['filename'],
|
||||
'prompt_id': prompt_id,
|
||||
'prompt': prompt,
|
||||
'cached_at': now_ts,
|
||||
'from_cache': False,
|
||||
}
|
||||
cache[sig] = entry
|
||||
_save_cache(cache)
|
||||
return entry
|
||||
|
||||
|
||||
def build_payload(q: str = '', refresh: bool = False) -> dict:
|
||||
data = ha_weather.get_weather(q)
|
||||
day = _pick_day(data)
|
||||
outfits = _recommend(day)
|
||||
illustration = _generate_illustration(day, outfits, data.get('focus_label', 'aujourd’hui'), refresh=refresh)
|
||||
return {
|
||||
'type': 'weather_outfits',
|
||||
'focus_label': data.get('focus_label', 'aujourd’hui'),
|
||||
'date': day.get('date'),
|
||||
'condition': day.get('condition'),
|
||||
'description': day.get('description'),
|
||||
'temperature': day.get('temperature'),
|
||||
'templow': day.get('templow'),
|
||||
'precipitation_probability': day.get('precipitation_probability'),
|
||||
'wind_speed': day.get('wind_speed'),
|
||||
'summary': outfits.get('summary'),
|
||||
'child': outfits['child'],
|
||||
'adult': outfits['adult'],
|
||||
'image': illustration,
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* Calendar and Home Assistant views — preserves original dark Alfred layout */
|
||||
#stage:has(.alfred-view) {padding:0 0 6px;justify-content:stretch;align-items:stretch}
|
||||
#content.alfred-view {width:100%;height:100%;text-align:left;min-height:0;}
|
||||
.view-pane {display:flex;flex-direction:column;gap:12px;max-height:100%;height:100%;min-height:0;padding:8px 1px 14px;}
|
||||
.view-head {display:flex;justify-content:space-between;align-items:center;gap:12px;}
|
||||
#content .view-title {font-size:clamp(1.2rem,1.7vw,1.85rem);margin:0;font-weight:500}
|
||||
.view-close,.view-button,.view-pill {background:#363d42;color:#f3f7fa;border:1px solid #ffffff24;border-radius:10px;padding:9px 14px;cursor:pointer;font:inherit}
|
||||
.view-close {font-size:1.4rem;min-width:40px;height:40px;padding:0}
|
||||
.view-button:hover,.view-pill:hover,.view-close:hover {background:#455057}
|
||||
.view-pill.active,.view-button.primary {background:#427789;border-color:#619aad}
|
||||
.view-toolbar {display:flex;gap:9px;align-items:center;flex-wrap:wrap;}
|
||||
.view-nav,.view-modes {display:flex;gap:5px;align-items:center}
|
||||
.view-period {font-size:.85rem;min-width:215px;text-align:center;color:#d8e0e4}
|
||||
.view-select {padding:9px;max-width:220px;border-radius:9px;background:#333a3e;color:#eff3f5;border:1px solid #ffffff2b}
|
||||
.agenda-grid {display:grid;gap:5px;min-height:0;flex:1;overflow:auto;padding:2px;align-content:stretch;}
|
||||
.agenda-seven,.agenda-month {grid-template-columns:repeat(7,minmax(0,1fr))}
|
||||
.agenda-month {grid-template-rows:repeat(6,minmax(110px,1fr));}
|
||||
.agenda-three {grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.agenda-one {grid-template-columns:1fr}
|
||||
.agenda-day {border:1px solid #ffffff1c;border-radius:11px;min-width:0;min-height:120px;background:#ffffff08;padding:9px;display:flex;flex-direction:column;gap:6px;overflow-y:auto}
|
||||
.agenda-day.is-today {border-color:#86bfda7a;background:#57839923}
|
||||
.agenda-day.out-month {opacity:.42}
|
||||
.agenda-date {font-size:.83rem;font-weight:600;color:#dce2e6;letter-spacing:.01em;padding-bottom:7px;border-bottom:1px solid #ffffff14}
|
||||
.agenda-event {width:100%;text-align:left;background:color-mix(in srgb,var(--event-color) 17%,#2a2e32);color:#f1f3f5;border:0;border-left:3px solid var(--event-color);border-radius:5px;padding:7px 5px;font-size:clamp(.72rem,.85vw,.95rem);line-height:1.3;cursor:pointer;overflow-wrap:anywhere}
|
||||
.agenda-event:hover {background:color-mix(in srgb,var(--event-color) 30%,#2a2e32)}
|
||||
.agenda-empty {font-size:.8rem;color:#667781;margin:auto}
|
||||
#content .view-footer {color:#9aa8b3;font-size:.76rem;margin:0}
|
||||
#content .view-error {color:#f7adad;font-size:.85rem;margin:0}
|
||||
#content .view-empty {font-size:1rem;color:#b8c2ca}
|
||||
.agenda-form {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;gap:14px;max-width:850px;width:100%;overflow-y:auto;margin:12px auto;}
|
||||
.form-field {display:flex;flex-direction:column;gap:7px;color:#cfd6dd;font-size:.83rem}
|
||||
.form-field input,.form-field select,.form-field textarea {padding:12px;border-radius:10px;color:#eff3f5;background:#2e3439;border:1px solid #ffffff32;font:inherit;min-width:0}
|
||||
.form-field textarea {resize:vertical}
|
||||
.form-check {display:flex;align-items:center;gap:10px}
|
||||
.view-actions {display:flex;align-items:center;gap:10px;justify-content:flex-end;flex-wrap:wrap;}
|
||||
.form-message {grid-column:1 / -1;}
|
||||
.agenda-form .view-actions {grid-column:1 / -1}
|
||||
.view-danger {color:#ffc4c4;background:#8f383850;border-color:#ed929255}
|
||||
.detail-modal {position:fixed;inset:0;z-index:30;display:grid;place-items:center;background:#000b;padding:20px}
|
||||
.detail-box {min-width:min(92vw,300px);max-width:540px;width:100%;border:1px solid #ffffff36;border-radius:19px;background:#292e33;box-shadow:0 25px 90px #000a;padding:24px;}
|
||||
.detail-box h2 {font-size:1.4rem;margin:0 0 20px}.detail-box p {font-size:1rem;color:#d7dfe2;overflow-wrap:anywhere}
|
||||
.delete-list {display:grid;gap:8px;overflow:auto}.delete-row {display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid #ffffff1c;border-radius:9px;padding:12px}
|
||||
.temperature-grid {display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:4px;max-height:53%;}
|
||||
.temperature-card {border:1px solid #ffffff22;background:#ffffff0a;border-radius:12px;padding:22px;color:#eaf1f3;white-space:pre-line;text-align:left;font:inherit;cursor:pointer;line-height:1.9}
|
||||
.temperature-card:hover {background:#ffffff18}
|
||||
.temperature-history {max-width:700px;text-align:center;margin:auto;width:100%;}
|
||||
.temperature-history svg {width:100%;max-height:155px}
|
||||
.temperature-history h2 {font-size:1.1rem;font-weight:450}
|
||||
.ha-cameras {display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;overflow:auto;}
|
||||
.ha-camera-card {background:#ffffff0a;border-radius:13px;overflow:hidden;}
|
||||
.ha-camera-card h2 {font-size:.95rem;padding:10px;font-weight:500;margin:0}
|
||||
.ha-camera-card img {display:block;width:100%;aspect-ratio:16/9;object-fit:contain;background:#121619}
|
||||
@media(max-width:1100px){.view-toolbar{gap:6px}.view-button,.view-pill {padding:7px;font-size:.77rem}.view-period{min-width:150px}.agenda-day{padding:5px}.agenda-event{padding:4px;font-size:.74rem}}
|
||||
@media(max-width:760px){#stage:has(.alfred-view){min-height:460px}.agenda-grid{min-height:280px}.agenda-seven,.agenda-month{min-width:630px}.agenda-day{min-height:105px}.view-pane{max-height:600px}.view-head{padding:0 4px}.agenda-form{grid-template-columns:1fr}.header-actions{gap:3px}.header-actions .icon-button {width:38px;height:38px}.view-toolbar{overflow:auto}}
|
||||
|
||||
/* Weather dynamic view — same dark UI as calendar. */
|
||||
.weather-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.weather-now{display:flex;align-items:center;gap:22px;background:#293238;border:1px solid #ffffff16;border-radius:16px;padding:18px 24px;}
|
||||
.weather-current-icon{font-size:clamp(44px,6vw,80px);line-height:1;color:#edcb82}
|
||||
.weather-now-details h2{margin:0 0 8px;font-size:clamp(20px,2.1vw,32px);font-weight:500;color:#f5f6f7}
|
||||
.weather-now-details p{margin:0;color:#b6c4ce;font-size:clamp(12px,1vw,17px)}
|
||||
.weather-section-title{font-size:15px;letter-spacing:.02em;color:#bfd0da;margin:7px 0 -2px;font-weight:550}
|
||||
.weather-days{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:7px}
|
||||
.weather-day{min-width:0;display:flex;flex-direction:column;align-items:center;text-align:center;gap:5px;background:#293035;border-radius:12px;border:1px solid #ffffff12;padding:12px 5px 10px}
|
||||
.weather-day p{margin:0}
|
||||
.weather-day-name{font-size:12px;color:#d4e0e6}
|
||||
.weather-day-icon{font-size:clamp(22px,2.8vw,40px);color:#eaca85}
|
||||
.weather-day-condition{font-size:11px;color:#adbcc5;min-height:30px;line-height:1.3}
|
||||
.weather-day-temp{font-size:21px;color:#f5f7f8;font-weight:600}
|
||||
.weather-day-low,.weather-rain{font-size:11px;color:#a7b6c0}
|
||||
.weather-rain{color:#8dc8e4 !important}
|
||||
.weather-hours{display:flex;gap:7px;overflow-x:auto;padding:3px 0 10px;scrollbar-color:#637480 #2a3237}
|
||||
.weather-hour{display:flex;flex:0 0 80px;align-items:center;flex-direction:column;gap:9px;padding:10px 6px;background:#293035;border:1px solid #ffffff16;border-radius:12px;color:#cbd7df;font-size:12px}
|
||||
.weather-hour-icon{font-size:27px;color:#edd298}
|
||||
@media(max-width:840px){.weather-days{grid-template-columns:repeat(4,minmax(0,1fr))}.weather-now{padding:14px;gap:12px}.weather-current-icon{font-size:48px}}
|
||||
@media(max-width:470px){.weather-days{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||||
|
||||
|
||||
/* Weather outfits + ComfyUI illustration */
|
||||
.weather-outfit-host{margin:8px 0 4px}
|
||||
.weather-outfit-section{display:flex;flex-direction:column;gap:12px;background:#252d32;border:1px solid #ffffff16;border-radius:16px;padding:16px}
|
||||
.weather-outfit-head{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.weather-outfit-grid{display:grid;grid-template-columns:minmax(260px,360px) minmax(320px,1fr);gap:14px;align-items:stretch}
|
||||
.weather-outfit-guide{display:grid;gap:12px}
|
||||
.weather-person-card{background:#ffffff08;border:1px solid #ffffff16;border-radius:14px;padding:14px}
|
||||
.weather-person-title{margin:0 0 6px;font-size:1.05rem;color:#f2f5f7}
|
||||
.weather-person-subtitle{margin:10px 0 4px;color:#b8c7d1;font-size:.86rem}
|
||||
.weather-outfit-list{margin:0;padding-left:18px;color:#e0e6ea;font-size:.95rem;line-height:1.55}
|
||||
.weather-illustration-card{display:flex;flex-direction:column;justify-content:center;align-items:center;background:#ffffff07;border:1px solid #ffffff16;border-radius:14px;padding:12px;min-height:320px}
|
||||
.weather-illustration-image{display:block;max-width:100%;max-height:430px;border-radius:12px;box-shadow:0 16px 40px #0005;object-fit:contain}
|
||||
@media(max-width:980px){.weather-outfit-grid{grid-template-columns:1fr}.weather-illustration-card{min-height:220px}}
|
||||
@@ -0,0 +1,353 @@
|
||||
/* Alfred • visual views, powered by first-party Flask endpoints. No extra CDN. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const root = document.getElementById('content');
|
||||
let current = null, camerasRefresh = null;
|
||||
const DAYS = ['lun.','mar.','mer.','jeu.','ven.','sam.','dim.'];
|
||||
const PALETTE = ['#77bcdf','#e5b677','#9dc99a','#d3a2d5','#8cb7c9','#ec9f95','#b6b5e1','#b9d78d'];
|
||||
const el = (tag, cls, text) => { const x=document.createElement(tag); if(cls)x.className=cls; if(text!==undefined)x.textContent=String(text); return x; };
|
||||
const btn = (label, fn, cls='view-button') => { const b=el('button',cls,label); b.type='button'; b.addEventListener('click',fn);return b; };
|
||||
const api = async (url, options) => {
|
||||
const r=await fetch(url,{cache:'no-store', ...options});
|
||||
const data=await r.json().catch(()=>({error:`Erreur HTTP ${r.status}`}));
|
||||
if(!r.ok)throw new Error(data.error || `HTTP ${r.status}`);
|
||||
return data;
|
||||
};
|
||||
const post = (url,data) => api(url,{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify(data)});
|
||||
const dateLabel = iso => new Date(iso).toLocaleDateString('fr-FR',{day:'numeric',month:'long',year:'numeric'});
|
||||
const timeLabel = iso => new Date(iso).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
|
||||
const localKey = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
const atDay = (d,h=0) => new Date(d.getFullYear(),d.getMonth(),d.getDate(),h);
|
||||
const dayShift = (d,n) => new Date(d.getFullYear(),d.getMonth(),d.getDate()+n,d.getHours(),d.getMinutes());
|
||||
const eventOnDay=(ev,day)=> {const start=new Date(ev.start),end=new Date(ev.end);return 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;
|
||||
}
|
||||
async function loadWeatherOutfits(container, query='', refresh=false){
|
||||
container.replaceChildren();
|
||||
const loading=el('p','view-footer', refresh ? 'Régénération de l’illustration…' : 'Préparation des tenues conseillées…');
|
||||
container.append(loading);
|
||||
try{
|
||||
const q = new URLSearchParams();
|
||||
if(query)q.set('q',query);
|
||||
if(refresh)q.set('refresh_illustration','1');
|
||||
const data = await api('/api/ha/weather/outfits' + (q.toString() ? '?' + q.toString() : ''));
|
||||
const section=el('section','weather-outfit-section');
|
||||
const head=el('div','weather-outfit-head');
|
||||
head.append(el('h2','weather-section-title',`Tenues conseillées · ${data.focus_label || 'aujourd’hui'}`));
|
||||
head.append(btn('Régénérer l’illustration',()=>loadWeatherOutfits(container, query, true),'view-pill'));
|
||||
section.append(head);
|
||||
|
||||
const grid=el('div','weather-outfit-grid');
|
||||
const guide=el('div','weather-outfit-guide');
|
||||
[data.child, data.adult].forEach(person=>{
|
||||
const card=el('article','weather-person-card');
|
||||
card.append(el('h3','weather-person-title',person.title));
|
||||
card.append(el('p','weather-person-subtitle','Tenue recommandée'));
|
||||
card.append(weatherList(person.clothes));
|
||||
card.append(el('p','weather-person-subtitle','Accessoires'));
|
||||
card.append(weatherList(person.accessories));
|
||||
guide.append(card);
|
||||
});
|
||||
grid.append(guide);
|
||||
const art=el('div','weather-illustration-card');
|
||||
if(data.image?.url){
|
||||
const img=el('img','weather-illustration-image');
|
||||
img.src=data.image.url;
|
||||
img.alt='Illustration des tenues conseillées selon la météo';
|
||||
art.append(img);
|
||||
const foot=el('p','view-footer', data.image.from_cache ? 'Illustration mise en cache.' : 'Illustration générée par ComfyUI.');
|
||||
art.append(foot);
|
||||
} else {
|
||||
art.append(el('p','view-empty','Illustration indisponible pour le moment.'));
|
||||
}
|
||||
grid.append(art);
|
||||
section.append(grid);
|
||||
if(data.summary){ section.append(el('p','view-footer',`Conseil général : ${data.summary}.`)); }
|
||||
container.replaceChildren(section);
|
||||
}catch(err){
|
||||
container.replaceChildren(el('p','view-error',`Tenues conseillées indisponibles : ${err.message || err}`));
|
||||
}
|
||||
}
|
||||
function drawWeather(data){
|
||||
const {pane}=wrap('Météo · '+(data.name||'Home Assistant'));
|
||||
const nav=el('div','weather-toolbar');
|
||||
nav.append(btn('Actualiser',openWeather,'view-pill'));
|
||||
nav.append(el('span','view-footer','Source : Home Assistant'));
|
||||
pane.append(nav);
|
||||
const c=data.current||{},now=el('section','weather-now');
|
||||
const badge=el('div','weather-current-icon',weatherIcon(c.condition));badge.setAttribute('aria-hidden','true');
|
||||
const details=el('div','weather-now-details');
|
||||
details.append(el('h2','',`${tempText(c.temperature,data.temperature_unit)} · ${c.description||'Conditions inconnues'}`));
|
||||
const metrics=[];
|
||||
if(c.apparent_temperature!==null && c.apparent_temperature!==undefined)metrics.push(`Ressenti ${tempText(c.apparent_temperature,data.temperature_unit)}`);
|
||||
if(c.humidity!==null && c.humidity!==undefined)metrics.push(`Humidité ${c.humidity}%`);
|
||||
if(c.wind_speed!==null && c.wind_speed!==undefined)metrics.push(`Vent ${c.wind_speed} ${data.wind_speed_unit||'km/h'}`);
|
||||
details.append(el('p','',metrics.join(' · ')||'Conditions actuelles'));now.append(badge,details);pane.append(now);
|
||||
|
||||
const outfitHost=el('div','weather-outfit-host');
|
||||
pane.append(outfitHost);
|
||||
loadWeatherOutfits(outfitHost, data.focus_label || '');
|
||||
|
||||
const heading = text => pane.append(el('h2','weather-section-title',text));
|
||||
heading('Prévisions pour les prochains jours');
|
||||
if(data.daily?.length){
|
||||
const cards=el('div','weather-days');
|
||||
data.daily.slice(0,7).forEach(day=>{
|
||||
const d=new Date(day.datetime);
|
||||
const box=el('article','weather-day');
|
||||
box.append(el('p','weather-day-name',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:'short'})));
|
||||
box.append(el('div','weather-day-icon',weatherIcon(day.condition)));
|
||||
box.append(el('p','weather-day-condition',day.description));
|
||||
box.append(el('p','weather-day-temp',tempText(day.temperature,data.temperature_unit)));
|
||||
if(day.templow!==null)box.append(el('p','weather-day-low','Min '+tempText(day.templow,data.temperature_unit)));
|
||||
if(day.precipitation_probability!==null)box.append(el('p','weather-rain','Pluie '+day.precipitation_probability+' %'));
|
||||
cards.append(box);
|
||||
});pane.append(cards);
|
||||
}else pane.append(el('p','view-empty','Prévisions journalières non disponibles.'));
|
||||
heading('Prochaines heures');
|
||||
if(data.hourly?.length){
|
||||
const list=el('div','weather-hours'), nowMs=Date.now();
|
||||
data.hourly.filter(x=>new Date(x.datetime).getTime()>=nowMs-3600000).slice(0,12).forEach(h=>{
|
||||
const box=el('div','weather-hour');
|
||||
box.append(el('span','',new Date(h.datetime).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'})));
|
||||
box.append(el('span','weather-hour-icon',weatherIcon(h.condition)));
|
||||
box.append(el('strong','',tempText(h.temperature,data.temperature_unit)));
|
||||
if(h.precipitation_probability!==null)box.append(el('span','weather-rain',h.precipitation_probability+' %'));
|
||||
list.append(box);
|
||||
});pane.append(list);
|
||||
}else pane.append(el('p','view-empty','Prévisions horaires non disponibles.'));
|
||||
if(data.errors?.length)pane.append(el('p','view-footer','Certaines prévisions ne sont pas fournies par cette source météo.'));
|
||||
}
|
||||
async function openWeather(){
|
||||
try{render(await api('/api/ha/weather'))}
|
||||
catch(err){const {pane}=wrap('Météo');showError(err,pane)}
|
||||
}
|
||||
|
||||
async function goHome(){
|
||||
try{await post('/api/ui/home',{})}catch(err){render({type:'home'})}
|
||||
}
|
||||
async function openCalendar(){
|
||||
const d=atDay(new Date());try{await loadCalendar(d,dayShift(d,7),'days')}catch(err){const {pane}=wrap('Agenda');showError(err,pane)}
|
||||
}
|
||||
async function openTemperatures(){
|
||||
try{render({type:'temperatures',...await api('/api/ha/temperatures')})}catch(err){const {pane}=wrap('Températures');showError(err,pane)}
|
||||
}
|
||||
async function openCameras(){
|
||||
try{render({type:'cameras',...await api('/api/ha/cameras')})}catch(err){const {pane}=wrap('Caméras');showError(err,pane)}
|
||||
}
|
||||
document.getElementById('viewHome')?.addEventListener('click',goHome);
|
||||
document.getElementById('viewCalendar')?.addEventListener('click',openCalendar);
|
||||
document.getElementById('viewTemperatures')?.addEventListener('click',openTemperatures);
|
||||
document.getElementById('viewWeather')?.addEventListener('click',openWeather);
|
||||
document.getElementById('viewCameras')?.addEventListener('click',openCameras);
|
||||
const socket=window.alfredSocket;
|
||||
socket?.on('calendar_changed',()=>{if(current?.type==='calendar') loadCalendar(current.start,current.end,current.view,getSelected()).catch(console.warn)});
|
||||
window.AlfredViews={render,canRender,openCalendar,openTemperatures,openCameras,openWeather,goHome};
|
||||
})();
|
||||
Reference in New Issue
Block a user