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