V1
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"""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() -> 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')
|
||||
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(),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user