Files
Alfredv2/alfred/app/services/weather_outfits.py.bak.20261025_234759
T
2026-10-26 00:06:24 +01:00

420 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
}