"""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:]