V1
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
"""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 hashlib
|
||||
import json
|
||||
import os
|
||||
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-v2-20261025'
|
||||
|
||||
|
||||
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 = (
|
||||
'deformed face, distorted face, disfigured features, asymmetric eyes, extra eyes, '
|
||||
'extra fingers, missing fingers, malformed hands, bad anatomy, extra limbs, '
|
||||
'extra people, multiple faces on one person, two adults, two teenage girls, '
|
||||
'adult woman, teenage woman, teenager, old child, blurry, out of focus, '
|
||||
'photorealistic uncanny face, text, watermark, logos'
|
||||
)
|
||||
|
||||
|
||||
def _prepare_workflow(workflow: dict, prompt: str) -> dict:
|
||||
"""Modifie seulement les noeuds reconnus du workflow API existant d'Hermes."""
|
||||
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'Noeud 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 len(negative_link) < 1:
|
||||
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
|
||||
# Taille carrée: uniquement les noeuds latents natifs compatibles.
|
||||
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'] = IMAGE_SIZE
|
||||
inputs['height'] = IMAGE_SIZE
|
||||
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 _illustration_prompt(day: dict, outfits: dict, focus_label: str) -> str:
|
||||
"""Descriptions en anglais pour les checkpoints d'illustration les plus courants."""
|
||||
high, low = day.get('temperature'), day.get('templow')
|
||||
pop, wind = day.get('precipitation_probability'), day.get('wind_speed')
|
||||
bits = [str(day.get('description') or 'daily weather')]
|
||||
if high is not None: bits.append(f'maximum temperature {high} Celsius')
|
||||
if low is not None: bits.append(f'minimum temperature {low} Celsius')
|
||||
if pop is not None: bits.append(f'precipitation probability {pop}%')
|
||||
if wind is not None: bits.append(f'wind speed {wind} km/h')
|
||||
return (
|
||||
'Professional clean flat 2D digital illustration, like a high quality family clothing lookbook, '
|
||||
'square composition, TWO human characters EXACTLY, side by side, full body, comfortable spacing, '
|
||||
'simple plain studio background, no text, no captions, no logos. '
|
||||
'LEFT CHARACTER: one little GIRL, 8 YEARS OLD, unmistakably a primary school CHILD, '
|
||||
'shorter and much smaller than the adult, innocent age-appropriate appearance, child body proportions, '
|
||||
'simple pleasant face, standing naturally. '
|
||||
'RIGHT CHARACTER: one ADULT MAN, AGE 42, unmistakably a mature masculine man, '
|
||||
'adult height and mature adult male body proportions, short hair, subtly defined adult male jaw, '
|
||||
'neatly trimmed beard optional, clearly NOT a teenager, NOT a woman. '
|
||||
'Precisely two people: ONE small child girl and ONE adult man. '
|
||||
'Symmetrical well-defined faces, two natural eyes per face, normal hands, five fingers per hand, '
|
||||
'no distorted features, tidy linework, modest friendly cartoon styling instead of photorealism. '
|
||||
f'Weather context for {day.get("date", focus_label)}: ' + ', '.join(bits) + '. '
|
||||
'Outfits must match the following French clothing specifications. '
|
||||
f'LEFT child GIRL (8): clothing: {", ".join(outfits["child"]["clothes"])}; '
|
||||
f'accessories: {", ".join(outfits["child"]["accessories"]) or "none"}. '
|
||||
f'RIGHT ADULT MAN (42): clothing: {", ".join(outfits["adult"]["clothes"])}; '
|
||||
f'accessories: {", ".join(outfits["adult"]["accessories"]) or "none"}. '
|
||||
'Show clothing clearly, accurate head-to-toe outfits. Exactly one child and one adult male.'
|
||||
)
|
||||
|
||||
|
||||
def _generate_illustration(day: dict, outfits: dict, focus_label: str, refresh: bool = False) -> dict | None:
|
||||
if not ENABLED:
|
||||
return None
|
||||
sig = _weather_signature(day, outfits)
|
||||
cache = _load_cache()
|
||||
entry = cache.get(sig)
|
||||
now_ts = time.time()
|
||||
if entry and not refresh and (now_ts - float(entry.get('cached_at', 0))) < TTL_HOURS * 3600:
|
||||
entry['from_cache'] = True
|
||||
return entry
|
||||
|
||||
prompt = _illustration_prompt(day, outfits, focus_label)
|
||||
prompt_id = _submit_workflow(prompt)
|
||||
history = _wait_history(prompt_id)
|
||||
images = _extract_images(history)
|
||||
if not images:
|
||||
raise RuntimeError('ComfyUI a terminé sans image de sortie pour l’illustration météo.')
|
||||
entry = {
|
||||
'url': images[0]['url'],
|
||||
'filename': images[0]['filename'],
|
||||
'prompt_id': prompt_id,
|
||||
'prompt': prompt,
|
||||
'cached_at': now_ts,
|
||||
'from_cache': False,
|
||||
}
|
||||
cache[sig] = entry
|
||||
_save_cache(cache)
|
||||
return entry
|
||||
|
||||
|
||||
def build_payload(q: str = '', 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, 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