"""Options de carte Maison de l'accueil Glass, en conservant l'auth Admin. Aucune URL arbitraire, commande, ou credentiel stocke dans la configuration. """ from __future__ import annotations import json import os from pathlib import Path import re import secrets import threading import time from urllib.error import URLError, HTTPError from urllib.request import Request, urlopen from flask import Blueprint, jsonify, request from services import admin_console ROOT = Path(__file__).resolve().parents[1] SETTINGS = ROOT / 'config' / 'home_card.json' LOCK = threading.RLock() _CACHE = {'expires': 0.0, 'states': None} _CACHE_LOCK = threading.Lock() DOMAINS = {'light', 'cover', 'sensor', 'binary_sensor', 'switch', 'climate', 'input_boolean', 'person', 'device_tracker', 'lock', 'weather', 'fan'} DEFAULT_ROWS = [ {'kind': 'lights_on', 'label': 'Lumières allumées'}, {'kind': 'covers_closed', 'label': 'Volets fermés'}, {'kind': 'indoor_temperature', 'label': 'Température intérieure'}, ] PRESETS = {'lights_on', 'covers_closed', 'indoor_temperature'} def validate_rows(rows): if not isinstance(rows, list) or not 1 <= len(rows) <= 3: raise ValueError('Choisir entre 1 et 3 lignes pour le cadre Maison.') out = [] for row in rows: if not isinstance(row, dict) or set(row) - {'kind','label','entity_id'}: raise ValueError('Ligne de configuration invalide.') kind = row.get('kind') if kind not in (*PRESETS, 'entity'): raise ValueError('Type de ligne non autorisé.') label = row.get('label', '') if not isinstance(label, str) or not 1 <= len(label.strip()) <= 38 or any(ord(c) < 32 for c in label): raise ValueError('Chaque intitulé doit contenir 1 à 38 caractères.') item = {'kind': kind, 'label': label.strip()} if kind == 'entity': eid = row.get('entity_id', '') if not isinstance(eid, str) or len(eid) > 120 or not re.fullmatch(r'[a-z][a-z0-9_]*\.[a-z0-9_]+', eid): raise ValueError('Identifiant Home Assistant invalide.') if eid.split('.')[0] not in DOMAINS: raise ValueError('Domaine Home Assistant non autorisé pour le cadre Maison.') item['entity_id'] = eid out.append(item) return out def read_rows(): try: loaded = json.loads(SETTINGS.read_text(encoding='utf-8')) return validate_rows(loaded['rows']) except (OSError, ValueError, KeyError, TypeError): return [dict(row) for row in DEFAULT_ROWS] def save_rows(rows): valid = validate_rows(rows) SETTINGS.parent.mkdir(parents=True, exist_ok=True) with LOCK: tmp = SETTINGS.with_name(SETTINGS.name + '.' + secrets.token_hex(6) + '.tmp') try: with tmp.open('w', encoding='utf-8') as out: json.dump({'version': 1, 'rows': valid}, out, ensure_ascii=False, indent=2) out.write('\n'); out.flush(); os.fsync(out.fileno()) os.chmod(tmp, 0o600) tmp.replace(SETTINGS) finally: tmp.unlink(missing_ok=True) return valid def states_from_ha(): now = time.monotonic() with _CACHE_LOCK: if _CACHE['states'] is not None and now < _CACHE['expires']: return _CACHE['states'] base = os.getenv('HA_URL', '').strip().rstrip('/') token = os.getenv('HA_TOKEN', '').strip() if not base or not token or not (base.startswith('http://') or base.startswith('https://')): raise ValueError('Connexion Home Assistant non configurée pour le tableau de bord.') from services.alfred_dashboard import _json_get data = _json_get(base + '/api/states', token=token, timeout=3) if not isinstance(data, list): raise ValueError('Réponse Home Assistant invalide.') with _CACHE_LOCK: _CACHE.update(states=data, expires=time.monotonic() + 20) return data def _pretty_state(state, attrs): if state in ('unknown','unavailable',''): return 'Indisponible' value = {'on':'Allumé', 'off':'Éteint', 'open':'Ouvert', 'closed':'Fermé', 'home':'Présent', 'not_home':'Absent', 'locked':'Verrouillé', 'unlocked':'Déverrouillé', 'heat':'Chauffage', 'cool':'Climatisation'}.get(state, state) unit = str(attrs.get('unit_of_measurement') or '') if unit and len(unit) <= 12: return str(value) + ' ' + unit return str(value)[:42] def resolve_rows(rows, states, dashboard_home=None): lookup = {str(s.get('entity_id')): s for s in states if isinstance(s, dict)} output = [] for item in rows: kind, label = item['kind'], item['label'] value = 'Indisponible' if kind == 'lights_on': value = str(sum(e.get('state') == 'on' for eid,e in lookup.items() if eid.startswith('light.'))) elif kind == 'covers_closed': value = str(sum(e.get('state') == 'closed' for eid,e in lookup.items() if eid.startswith('cover.'))) elif kind == 'indoor_temperature': candidates=[] for eid,e in lookup.items(): attr=e.get('attributes') or {} try: if eid.startswith('climate.'): t=float(attr.get('current_temperature')) elif eid.startswith('sensor.') and attr.get('device_class') == 'temperature': t=float(e.get('state')) else: continue if abs(t) < 150: candidates.append((0 if 'salon' in eid else 1,t)) except (TypeError,ValueError): pass if candidates: candidates.sort(key=lambda x:x[0]); value=f'{candidates[0][1]:g} °C' elif kind == 'entity': e=lookup.get(item['entity_id']) if e: value=_pretty_state(str(e.get('state') or ''), e.get('attributes') or {}) output.append({'label':label,'value':value,'available':value!='Indisponible'}) return output def register_home_glass(app): bp = Blueprint('alfred_home_glass_v1', __name__) @bp.get('/api/control/home-card') @admin_console._require_admin def get_config(): return jsonify(rows=read_rows()) @bp.post('/api/control/home-card') @admin_console._require_admin def set_config(): data=request.get_json(silent=True) if not isinstance(data,dict) or set(data)!={'rows'}: return jsonify(error='JSON invalide.'), 400 try: result=save_rows(data['rows']) except (ValueError, OSError) as exc: return jsonify(error=str(exc)), 400 return jsonify(ok=True,rows=result,applied_immediately=True) @bp.get('/api/control/home-entities') @admin_console._require_admin def list_entities(): try: states=states_from_ha() except (OSError, ValueError, HTTPError, URLError) as exc: return jsonify(error='Home Assistant inaccessible ou non configuré.', detail=str(exc)[:150]), 503 options=[] for entity in states: eid=entity.get('entity_id','') if not isinstance(eid,str) or eid.split('.')[0] not in DOMAINS: continue attrs=entity.get('attributes') or {} friendly=str(attrs.get('friendly_name') or eid)[:80] options.append({'id':eid,'name':friendly}) options.sort(key=lambda x:(x['name'].casefold(),x['id'])) return jsonify(entities=options[:1500]) @bp.get('/api/alfred/home-card') def live_card(): rows=read_rows() try: states=states_from_ha() values=resolve_rows(rows,states) except (OSError, ValueError, HTTPError, URLError): values=[{'label':r['label'],'value':'Indisponible','available':False} for r in rows] return jsonify(rows=values,available=any(x['available'] for x in values)) app.register_blueprint(bp)