237 lines
10 KiB
Python
237 lines
10 KiB
Python
"""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:]
|