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
+236
View File
@@ -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:]