Files
Alfredv2/alfred/app/services/alfred_home_glass.py
T

195 lines
7.8 KiB
Python

"""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)