v2 - new interface, many new page for fine tuning
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user