195 lines
7.8 KiB
Python
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)
|