V1
This commit is contained in:
@@ -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:]
|
||||
Reference in New Issue
Block a user