V1
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user