This commit is contained in:
pporcheret
2026-10-26 00:06:24 +01:00
parent c9c3d5f223
commit cd37ac3c0a
207 changed files with 19205 additions and 54 deletions
@@ -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,
}