Add Home Assistant screen manager and runtime tile configuration
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# 0.1.0
|
||||
|
||||
- Eerste ESP Screen Manager met Home Assistant Ingress.
|
||||
- Zoek op entiteit, apparaat of ruimte; tien geordende tegels per scherm.
|
||||
- Tegelwijzigingen en actuele waarden zonder firmwareflash.
|
||||
- Installatie-YAML met unieke sleutels voor CYD en Guition.
|
||||
- Permanente indelingen, automatisch herstel na HA-/schermherstart.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Aan de slag
|
||||
|
||||
Start de app en open de webinterface. Klik **Nieuw scherm**, kies het exacte bord
|
||||
en download je eigen YAML. De wizard legt de eerste USB-flash via ESPHome Device
|
||||
Builder, wifi, HA-koppeling en kalibratie uit. Kies daarna je tegels in deze app.
|
||||
|
||||
De volledige handleiding staat op
|
||||
[GitHub: Easy Setup](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/EASY_SETUP.md).
|
||||
|
||||
## Updates
|
||||
|
||||
Update de app via de appwinkel. Indelingen staan in de permanente `/data`-map en
|
||||
blijven bij een update behouden. Verwijder de app niet om een update te installeren.
|
||||
Voor nieuwe schermfuncties kies je in ESPHome bij je **bestaande apparaat**:
|
||||
Install → Wirelessly. Houd je eigen YAML, apparaatnaam en sleutels gelijk.
|
||||
|
||||
Maak vóór updates een HA-back-up inclusief deze app en ESPHome Device Builder.
|
||||
|
||||
## Werking
|
||||
|
||||
De app leest entiteiten, apparaten, ruimtes en status uit jouw HA en schrijft
|
||||
uitsluitend configuratieberichten naar de ESPHome-tekstentiteit Tegelinstellingen.
|
||||
Het scherm voert de gekozen HA-acties uit. Geef het daarvoor toestemming in de
|
||||
ESPHome-integratie. De app moet draaien om actuele gegevens te blijven versturen.
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM ghcr.io/home-assistant/base:3.22
|
||||
ARG BUILD_VERSION
|
||||
ARG BUILD_ARCH
|
||||
LABEL io.hass.version="${BUILD_VERSION}" io.hass.type="app" io.hass.arch="${BUILD_ARCH}"
|
||||
RUN apk add --no-cache python3 py3-aiohttp py3-yaml
|
||||
COPY app /app
|
||||
WORKDIR /app
|
||||
CMD ["/command/with-contenv", "python3", "/app/server.py"]
|
||||
@@ -0,0 +1,8 @@
|
||||
# ESP Screen Manager
|
||||
|
||||
Zoek je Home Assistant-entiteiten, orden maximaal tien tegels en stuur wijzigingen
|
||||
naar een CYD- of Guition-scherm zonder opnieuw flashen. Met installatiehulp,
|
||||
unieke apparaat-YAML en ondersteuning voor meerdere schermen.
|
||||
|
||||
Open de webinterface na installatie. Zie **Documentatie** voor de eerste flash.
|
||||
De app gebruikt Home Assistant Ingress en heeft geen eigen login of token nodig.
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Pure validation, firmware generation and bounded display protocol."""
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import secrets
|
||||
|
||||
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select button input_button'.split())
|
||||
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
|
||||
REFS = {'cyd': 'main', 'guition': 'main'}
|
||||
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement'.split())
|
||||
|
||||
def entity_id(value):
|
||||
return isinstance(value, str) and len(value) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value) and value.split('.')[0] in DOMAINS
|
||||
|
||||
def short(value, limit):
|
||||
return str(value).encode('utf-8')[:limit].decode('utf-8', errors='ignore')
|
||||
|
||||
def validate_layout(data):
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('Ongeldige indeling.')
|
||||
title, tiles = data.get('title'), data.get('tiles')
|
||||
if not isinstance(title, str) or not title.strip() or len(title.encode()) > 96:
|
||||
raise ValueError('Geef het scherm een titel van maximaal 96 bytes.')
|
||||
if not isinstance(tiles, list) or len(tiles) > 10:
|
||||
raise ValueError('Kies maximaal 10 tegels.')
|
||||
clean, seen = [], set()
|
||||
for tile in tiles:
|
||||
if not isinstance(tile, dict) or not entity_id(tile.get('entity')):
|
||||
raise ValueError('Deze entiteit wordt niet ondersteund.')
|
||||
if tile['entity'] in seen:
|
||||
raise ValueError('Een entiteit kan maar één keer op een scherm staan.')
|
||||
name = tile.get('name', '')
|
||||
if not isinstance(name, str) or len(name.encode()) > 80:
|
||||
raise ValueError('Een tegelnaam mag maximaal 80 bytes bevatten.')
|
||||
seen.add(tile['entity'])
|
||||
clean.append({'entity': tile['entity'], 'name': name.strip()})
|
||||
return {'title': title.strip(), 'tiles': clean}
|
||||
|
||||
def state_message(index, tile, states):
|
||||
state = states.get(tile['entity'], {})
|
||||
attrs = state.get('attributes', {})
|
||||
bounded = {}
|
||||
for key in ATTRS:
|
||||
value = attrs.get(key)
|
||||
if isinstance(value, bool) or value is None:
|
||||
continue
|
||||
if isinstance(value, (int, float)):
|
||||
if math.isfinite(value) and abs(value) <= 1000000:
|
||||
bounded[key] = value
|
||||
elif isinstance(value, str):
|
||||
bounded[key] = short(value, 48)
|
||||
elif isinstance(value, list):
|
||||
# Attribute lists have bounded lengths, strings and numeric ranges.
|
||||
limit = 2 if key == 'hs_color' else 4 if key == 'fan_speed_list' else 8
|
||||
bounded[key] = [short(v, 48) if isinstance(v, str) else v for v in value[:limit]
|
||||
if isinstance(v, str) or isinstance(v, (float, int)) and math.isfinite(v) and abs(v) <= 1000000]
|
||||
return {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
|
||||
'name': short(tile['name'] or attrs.get('friendly_name') or tile['entity'], 80),
|
||||
'state': short(state.get('state', 'unavailable'), 160), 'a': bounded}
|
||||
|
||||
def packets(message, token=None):
|
||||
raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode()
|
||||
if len(raw) > 4096:
|
||||
raise ValueError('Het schermbericht is te groot.')
|
||||
encoded = base64.b64encode(raw).decode('ascii')
|
||||
token = token or secrets.token_hex(8)
|
||||
chunks = [encoded[i:i+200] for i in range(0, len(encoded), 200)]
|
||||
return [f'{token}|{i}|{int(i == len(chunks)-1)}|{part}' for i, part in enumerate(chunks)]
|
||||
|
||||
def discover(registry, states, devices, areas):
|
||||
device_map = {d['id']: d for d in devices}
|
||||
area_map = {a['area_id']: a['name'] for a in areas}
|
||||
versions = {item.get('device_id'): states.get(item['entity_id'], {}).get('state', 'onbekend') for item in registry
|
||||
if item.get('platform') == 'esphome' and item.get('original_name') == 'Schermfirmware'}
|
||||
screens, entities = [], []
|
||||
for item in registry:
|
||||
eid = item['entity_id']
|
||||
device = device_map.get(item.get('device_id'), {})
|
||||
state = states.get(eid, {})
|
||||
area = area_map.get(item.get('area_id') or device.get('area_id'), '')
|
||||
if item.get('platform') == 'esphome' and eid.startswith('text.') and item.get('original_name') == 'Tegelinstellingen' and not item.get('disabled_by'):
|
||||
screens.append({'id': eid, 'name': device.get('name_by_user') or device.get('name') or eid,
|
||||
'firmware': versions.get(item.get('device_id'), 'onbekend'),
|
||||
'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'),
|
||||
'status': state.get('state', 'Niet verbonden')})
|
||||
if not entity_id(eid) or item.get('disabled_by'):
|
||||
continue
|
||||
entities.append({'id': eid, 'name': state.get('attributes', {}).get('friendly_name') or item.get('name') or item.get('original_name') or eid,
|
||||
'device': device.get('name_by_user') or device.get('name') or '', 'area': area,
|
||||
'state': state.get('state', 'unavailable')})
|
||||
# YAML entities may not have an entity-registry entry.
|
||||
registered = {e['id'] for e in entities}
|
||||
for eid, state in states.items():
|
||||
if entity_id(eid) and eid not in registered and not any(r['entity_id'] == eid for r in registry):
|
||||
entities.append({'id': eid, 'name': state.get('attributes', {}).get('friendly_name', eid), 'device': '', 'area': '', 'state': state['state']})
|
||||
return screens, sorted(entities, key=lambda e: e['name'].casefold())
|
||||
|
||||
def installation_yaml(data):
|
||||
board, name, friendly = data.get('board'), data.get('name'), data.get('friendly_name')
|
||||
if board not in REFS or not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,29}', name):
|
||||
raise ValueError('Kies een bord en een unieke naam (kleine letters, cijfers, streepjes; maximaal 30 tekens).')
|
||||
if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60:
|
||||
raise ValueError('Geef het scherm een herkenbare naam (maximaal 60 tekens).')
|
||||
quote = lambda s: json.dumps(s, ensure_ascii=False)
|
||||
key, ota, ap = base64.b64encode(secrets.token_bytes(32)).decode(), secrets.token_urlsafe(24), secrets.token_urlsafe(12)
|
||||
return f'''# Bewaar dit bestand: het bevat de unieke sleutels voor dit scherm.
|
||||
# Wifi komt uit de secrets.yaml van ESPHome Device Builder.
|
||||
substitutions:
|
||||
DEVICE_NAME: {quote(name)}
|
||||
DEVICE_FRIENDLY_NAME: {quote(friendly.strip())}
|
||||
|
||||
esphome:
|
||||
name: {quote(name)}
|
||||
friendly_name: {quote(friendly.strip())}
|
||||
|
||||
packages:
|
||||
display:
|
||||
url: {REPO}
|
||||
ref: {REFS[board]}
|
||||
files: [packages/{board}.yaml]
|
||||
refresh: 0s
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: {quote(key)}
|
||||
ota:
|
||||
- platform: esphome
|
||||
password: {quote(ota)}
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
ap:
|
||||
ssid: {quote(name + ' Setup')}
|
||||
password: {quote(ap)}
|
||||
captive_portal:
|
||||
'''
|
||||
@@ -0,0 +1,261 @@
|
||||
"""HA Ingress app. The only HA write is text.set_value on discovered inboxes."""
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import discover, installation_yaml, packets, state_message, validate_layout
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
|
||||
class HomeAssistant:
|
||||
def __init__(self, session, base, token):
|
||||
self.session, self.base, self.token = session, base.rstrip('/'), token
|
||||
self.ws = None
|
||||
self.next_id = 0
|
||||
self.pending = {}
|
||||
self.states = {}
|
||||
self.registry, self.devices, self.areas = [], [], []
|
||||
self.online = False
|
||||
self.changed = asyncio.Event()
|
||||
|
||||
async def request(self, kind, **data):
|
||||
if self.ws is None or self.ws.closed:
|
||||
raise ConnectionError('Home Assistant is niet verbonden.')
|
||||
self.next_id += 1
|
||||
key = self.next_id
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
self.pending[key] = future
|
||||
try:
|
||||
await self.ws.send_json({'id': key, 'type': kind, **data})
|
||||
return await asyncio.wait_for(future, 15)
|
||||
finally:
|
||||
self.pending.pop(key, None)
|
||||
|
||||
async def read(self):
|
||||
async for msg in self.ws:
|
||||
if msg.type != WSMsgType.TEXT:
|
||||
continue
|
||||
data = msg.json()
|
||||
if data.get('type') == 'result':
|
||||
future = self.pending.get(data.get('id'))
|
||||
if future and not future.done():
|
||||
if data.get('success'):
|
||||
future.set_result(data.get('result'))
|
||||
else:
|
||||
future.set_exception(ConnectionError('Home Assistant heeft de opdracht geweigerd.'))
|
||||
elif data.get('type') == 'event':
|
||||
event = data.get('event', {})
|
||||
body = event.get('data', {})
|
||||
if event.get('event_type') == 'state_changed':
|
||||
eid = body.get('entity_id')
|
||||
if body.get('new_state'):
|
||||
self.states[eid] = body['new_state']
|
||||
else:
|
||||
self.states.pop(eid, None)
|
||||
self.changed.set()
|
||||
raise ConnectionError('Home Assistant-verbinding verbroken.')
|
||||
|
||||
async def registries(self):
|
||||
self.registry, self.devices, self.areas = await asyncio.gather(
|
||||
self.request('config/entity_registry/list'), self.request('config/device_registry/list'), self.request('config/area_registry/list'))
|
||||
|
||||
async def run(self):
|
||||
url = ('ws://supervisor/core/websocket' if self.base == 'http://supervisor/core/api'
|
||||
else self.base.replace('http://', 'ws://').replace('https://', 'wss://') + '/websocket')
|
||||
while True:
|
||||
reader = None
|
||||
try:
|
||||
async with self.session.ws_connect(url, heartbeat=30, max_msg_size=16*1024*1024) as ws:
|
||||
self.ws = ws
|
||||
await ws.receive_json(timeout=15)
|
||||
await ws.send_json({'type': 'auth', 'access_token': self.token})
|
||||
if (await ws.receive_json(timeout=15)).get('type') != 'auth_ok':
|
||||
raise ConnectionError('Home Assistant-authenticatie mislukt.')
|
||||
reader = asyncio.create_task(self.read())
|
||||
await self.request('subscribe_events', event_type='state_changed')
|
||||
self.states = {s['entity_id']: s for s in await self.request('get_states')}
|
||||
await self.registries()
|
||||
self.online = True
|
||||
self.changed.set()
|
||||
LOG.info('Home Assistant verbonden')
|
||||
# Refresh registry for newly paired screens and renamed entities.
|
||||
while True:
|
||||
done, _ = await asyncio.wait([reader], timeout=30)
|
||||
if done:
|
||||
await reader
|
||||
await self.registries()
|
||||
self.changed.set()
|
||||
except (ConnectionError, TimeoutError, OSError, ValueError) as error:
|
||||
LOG.warning('Home Assistant tijdelijk niet beschikbaar (%s)', type(error).__name__)
|
||||
except Exception as error:
|
||||
LOG.warning('Verbinding opnieuw starten (%s)', type(error).__name__)
|
||||
finally:
|
||||
self.online = False
|
||||
self.ws = None
|
||||
if reader:
|
||||
reader.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await reader
|
||||
for future in self.pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(ConnectionError('Verbinding verbroken.'))
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def send(self, inbox, message):
|
||||
for packet in packets(message):
|
||||
await self.request('call_service', domain='text', service='set_value',
|
||||
service_data={'entity_id': inbox, 'value': packet})
|
||||
|
||||
class Manager:
|
||||
def __init__(self, ha, path):
|
||||
self.ha, self.path = ha, Path(path)
|
||||
self.layouts, self.sent, self.status, self.last = {}, {}, {}, {}
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text())
|
||||
# Versioned persistent data. Never silently overwrite an unknown schema.
|
||||
if raw.get('version') != 1 or not isinstance(raw.get('screens'), dict):
|
||||
raise ValueError('Onbekende opslagversie; gegevens blijven ongewijzigd.')
|
||||
self.layouts = {key: validate_layout(value) for key, value in raw['screens'].items()}
|
||||
|
||||
def inventory(self):
|
||||
return discover(self.ha.registry, self.ha.states, self.ha.devices, self.ha.areas)
|
||||
|
||||
def save(self, inbox, data):
|
||||
if inbox not in {s['id'] for s in self.inventory()[0]}:
|
||||
raise ValueError('Dit is geen gekoppeld ESP-scherm. Vernieuw het overzicht.')
|
||||
layout = validate_layout(data)
|
||||
known = {e['id'] for e in self.inventory()[1]}
|
||||
if any(t['entity'] not in known for t in layout['tiles']):
|
||||
raise ValueError('Een gekozen entiteit bestaat niet meer. Zoek de nieuwe entiteit op.')
|
||||
updated = {**self.layouts, inbox: layout}
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_suffix('.tmp')
|
||||
with open(temp, 'w', encoding='utf8') as handle:
|
||||
os.chmod(temp, 0o600)
|
||||
json.dump({'version': 1, 'screens': updated}, handle, ensure_ascii=False)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temp.replace(self.path)
|
||||
self.layouts = updated
|
||||
self.sent.pop(inbox, None)
|
||||
self.status[inbox] = 'Opgeslagen; wacht op synchronisatie'
|
||||
self.ha.changed.set()
|
||||
|
||||
async def sync_one(self, inbox, layout, force=False):
|
||||
messages = [{'v': 1, 'op': 'layout', 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}]
|
||||
messages += [state_message(i, tile, self.ha.states) for i, tile in enumerate(layout['tiles'])]
|
||||
previous = self.sent.get(inbox, [])
|
||||
force = force or not previous or messages[0] != previous[0]
|
||||
for i, message in enumerate(messages):
|
||||
# Save during transmission aborts the old batch, then starts a full new layout.
|
||||
if self.layouts.get(inbox) != layout:
|
||||
self.sent.pop(inbox, None)
|
||||
return
|
||||
if force or i >= len(previous) or message != previous[i]:
|
||||
await self.ha.send(inbox, message)
|
||||
self.sent[inbox] = messages
|
||||
self.last[inbox] = time.monotonic()
|
||||
self.status[inbox] = 'Verzonden naar Home Assistant'
|
||||
|
||||
async def run(self):
|
||||
while True:
|
||||
try:
|
||||
await asyncio.wait_for(self.ha.changed.wait(), timeout=20)
|
||||
except TimeoutError:
|
||||
pass
|
||||
self.ha.changed.clear()
|
||||
await asyncio.sleep(0.25)
|
||||
if not self.ha.online:
|
||||
self.sent.clear()
|
||||
continue
|
||||
for screen in self.inventory()[0]:
|
||||
inbox = screen['id']
|
||||
if not screen['online']:
|
||||
self.sent.pop(inbox, None)
|
||||
self.status[inbox] = 'Scherm offline; wijzigingen bewaard'
|
||||
continue
|
||||
if inbox not in self.layouts:
|
||||
continue
|
||||
try:
|
||||
force = time.monotonic() - self.last.get(inbox, 0) >= 25
|
||||
# last records full keepalive, not unrelated HA state events.
|
||||
before = self.last.get(inbox, 0)
|
||||
await self.sync_one(inbox, self.layouts[inbox], force)
|
||||
if not force:
|
||||
self.last[inbox] = before
|
||||
except Exception as error:
|
||||
self.sent.pop(inbox, None)
|
||||
self.status[inbox] = 'Verzenden mislukt; automatisch opnieuw proberen'
|
||||
LOG.warning('Schermsynchronisatie opnieuw proberen (%s)', type(error).__name__)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
def create_app(manager, development=False):
|
||||
csrf = secrets.token_urlsafe(32)
|
||||
@web.middleware
|
||||
async def guard(request, handler):
|
||||
allowed = {'127.0.0.1', '::1'} if development else {'172.30.32.2'}
|
||||
if request.remote not in allowed:
|
||||
raise web.HTTPForbidden(text='Open deze pagina via Home Assistant.')
|
||||
if request.method not in {'GET', 'HEAD'} and request.headers.get('X-Screen-CSRF') != csrf:
|
||||
raise web.HTTPForbidden(text='Vernieuw deze pagina en probeer opnieuw.')
|
||||
try:
|
||||
response = await handler(request)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
return web.json_response({'error': 'Ongeldige invoer. Controleer naam, bord en gekozen tegels.'}, status=400)
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'"
|
||||
return response
|
||||
|
||||
app = web.Application(middlewares=[guard], client_max_size=16*1024)
|
||||
static = Path(__file__).parent / 'static'
|
||||
|
||||
async def index(request):
|
||||
return web.FileResponse(static / 'index.html')
|
||||
async def inventory(request):
|
||||
screens, entities = manager.inventory()
|
||||
for screen in screens:
|
||||
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Thuis', 'tiles': []})
|
||||
screen['delivery'] = manager.status.get(screen['id'], 'Kies je eerste tegels')
|
||||
return web.json_response({'csrf': csrf, 'connected': manager.ha.online, 'screens': screens, 'entities': entities})
|
||||
async def save(request):
|
||||
manager.save(request.match_info['inbox'], await request.json())
|
||||
return web.json_response({'saved': True})
|
||||
async def download(request):
|
||||
data = await request.json()
|
||||
content = installation_yaml(data)
|
||||
return web.Response(text=content, content_type='text/yaml', headers={'Content-Disposition': f'attachment; filename="{data["name"]}.yaml"'})
|
||||
app.router.add_get('/', index)
|
||||
app.router.add_get('/api/inventory', inventory)
|
||||
app.router.add_put('/api/screens/{inbox}', save)
|
||||
app.router.add_post('/api/install', download)
|
||||
app.router.add_static('/static/', static)
|
||||
return app
|
||||
|
||||
async def main():
|
||||
development = os.environ.get('SCREEN_DEV') == '1'
|
||||
token = os.environ.get('SUPERVISOR_TOKEN', '')
|
||||
if development and os.environ.get('HA_TOKEN_FILE'):
|
||||
token = Path(os.environ['HA_TOKEN_FILE']).read_text().strip()
|
||||
if not token:
|
||||
raise SystemExit('Geen Home Assistant-toegang. Start de app via Supervisor.')
|
||||
async with ClientSession(timeout=ClientTimeout(total=20)) as session:
|
||||
ha = HomeAssistant(session, os.environ.get('HA_API', 'http://supervisor/core/api'), token)
|
||||
manager = Manager(ha, Path(os.environ.get('SCREEN_DATA', '/data')) / 'screens.json')
|
||||
runner = web.AppRunner(create_app(manager, development), access_log=None)
|
||||
await runner.setup()
|
||||
await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start()
|
||||
try:
|
||||
await asyncio.gather(ha.run(), manager.run())
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s')
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
const $ = s => document.querySelector(s);
|
||||
let inventory = {screens: [], entities: []}, selected = null, layout = null, dirty = false, filter = '', dragIndex = -1, busy = false;
|
||||
const node = (tag, text, cls) => { const n = document.createElement(tag); if (text !== undefined) n.textContent = text; if (cls) n.className = cls; return n; };
|
||||
function toast(message) { $('#toast').textContent = message; $('#toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('#toast').hidden = true, 5000); }
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(`api/${path}`, {...options, headers: {'Content-Type': 'application/json', 'X-Screen-CSRF': inventory.csrf || '', ...options.headers}});
|
||||
if (!response.ok) { let message = 'Dit lukte niet. Vernieuw de pagina en probeer opnieuw.'; try { message = (await response.json()).error || message; } catch {} throw new Error(message); }
|
||||
return response;
|
||||
}
|
||||
function markDirty() { dirty = true; $('#dirty').textContent = 'Niet-opgeslagen wijzigingen'; $('#save-detail').textContent = 'Klik op opslaan om je scherm bij te werken.'; }
|
||||
function select(id) {
|
||||
if (id !== selected && dirty && !confirm('Je hebt niet-opgeslagen wijzigingen. Toch een ander scherm openen?')) return;
|
||||
selected = id; const screen = inventory.screens.find(s => s.id === id); if (!screen) return;
|
||||
layout = structuredClone(screen.layout); dirty = false; $('#title').value = layout.title; $('#screen-name').textContent = screen.name;
|
||||
$('#editor').hidden = false; $('#empty').hidden = true; $('#dirty').textContent = 'Alles opgeslagen'; $('#save-detail').textContent = 'Aanpassen kan zonder opnieuw flashen.';
|
||||
renderScreens(); renderTiles(); renderResults();
|
||||
}
|
||||
function renderScreens() {
|
||||
$('#screens').replaceChildren();
|
||||
for (const screen of inventory.screens) { const b = node('button', undefined, `screen ${screen.id === selected ? 'selected' : ''}`); b.append(node('strong', screen.name), node('small', `${screen.online ? '● Online' : '○ Offline'}${screen.area ? ' · '+screen.area : ''} · firmware ${screen.firmware || 'onbekend'}`)); b.onclick = () => select(screen.id); $('#screens').append(b); }
|
||||
const screen = inventory.screens.find(s => s.id === selected);
|
||||
if (screen) { $('#delivery').textContent = screen.online ? `${screen.delivery} · ${screen.status}` : 'Offline · wijzigingen worden bewaard'; $('#delivery').classList.toggle('online', screen.online); }
|
||||
}
|
||||
function move(from, to) { if (to < 0 || to >= layout.tiles.length) return; const [tile] = layout.tiles.splice(from, 1); layout.tiles.splice(to, 0, tile); markDirty(); renderTiles(); }
|
||||
function renderTiles() {
|
||||
$('#tiles').replaceChildren(); $('#count').textContent = `${layout.tiles.length} / 10`; $('#no-tiles').hidden = layout.tiles.length > 0;
|
||||
layout.tiles.forEach((tile, i) => {
|
||||
if (i === 6) $('#tiles').append(node('li', 'Pagina 2', 'page-break'));
|
||||
const li = node('li', undefined, 'tile'); li.draggable = true;
|
||||
li.ondragstart = e => { if (e.target instanceof HTMLInputElement) { e.preventDefault(); return; } dragIndex = i; e.dataTransfer.setData('text/plain', String(i)); li.classList.add('dragging'); };
|
||||
li.ondragend = () => { dragIndex = -1; li.classList.remove('dragging'); };
|
||||
li.ondragover = e => { if (dragIndex >= 0) e.preventDefault(); };
|
||||
li.ondrop = e => { e.preventDefault(); if (dragIndex >= 0) move(dragIndex, i); };
|
||||
const content = node('div'), input = node('input'); input.value = tile.name; input.placeholder = inventory.entities.find(e => e.id === tile.entity)?.name || tile.entity; input.maxLength = 60; input.setAttribute('aria-label', `Naam voor tegel ${i+1}`); input.oninput = () => { tile.name = input.value; markDirty(); }; content.append(input, node('small', tile.entity));
|
||||
const actions = node('div', undefined, 'tile-actions'); for (const [label, title, action, disabled] of [['↑','Omhoog',()=>move(i,i-1),i===0],['↓','Omlaag',()=>move(i,i+1),i===layout.tiles.length-1],['✕','Verwijderen',()=>{layout.tiles.splice(i,1);markDirty();renderTiles();renderResults();},false]]) { const b = node('button',label); b.title = title; b.setAttribute('aria-label',title); b.disabled = disabled; b.onclick = action; actions.append(b); }
|
||||
li.append(node('span','⠿','grip'), content, actions); $('#tiles').append(li);
|
||||
});
|
||||
}
|
||||
function renderResults() {
|
||||
if (!layout) return;
|
||||
const query = $('#search').value.toLocaleLowerCase(); const chosen = new Set(layout.tiles.map(t => t.entity));
|
||||
const matches = inventory.entities.filter(e => (!filter || e.id.startsWith(filter+'.')) && `${e.name} ${e.id} ${e.device} ${e.area}`.toLocaleLowerCase().includes(query));
|
||||
$('#results').replaceChildren();
|
||||
for (const entity of matches.slice(0, 80)) {
|
||||
const b = node('button', undefined, 'result'), description = node('span'); description.append(node('span',entity.name), node('small',[entity.area,entity.device].filter(Boolean).join(' · ')), node('small',entity.id)); b.append(description,node('span',chosen.has(entity.id)?'✓':'+','plus')); b.disabled = chosen.has(entity.id) || layout.tiles.length >= 10; b.onclick = () => { layout.tiles.push({entity:entity.id,name:''}); markDirty();renderTiles();renderResults(); }; $('#results').append(b);
|
||||
}
|
||||
if (!matches.length) $('#results').append(node('p','Geen entiteiten gevonden. Probeer een andere naam.','hint'));
|
||||
if (matches.length > 80) $('#results').append(node('p',`${matches.length} resultaten. Typ verder om te verfijnen.`,'hint'));
|
||||
}
|
||||
async function refresh() {
|
||||
try { inventory = await (await api('inventory')).json(); $('#connection').textContent = inventory.connected ? '● Home Assistant verbonden' : 'Verbinding met Home Assistant herstellen…'; $('#connection').classList.toggle('online',inventory.connected); renderScreens(); if (!selected && inventory.screens.length) select(inventory.screens[0].id); }
|
||||
catch { $('#connection').textContent = 'Beheerpagina niet bereikbaar · opnieuw proberen…'; }
|
||||
}
|
||||
$('#save').onclick = async () => { if (busy) return; busy = true; $('#save').disabled = true; try { layout.title = $('#title').value; await api(`screens/${encodeURIComponent(selected)}`, {method:'PUT',body:JSON.stringify(layout)}); dirty = false; $('#dirty').textContent = 'Opgeslagen'; $('#save-detail').textContent = 'Synchronisatie volgt automatisch, ook na opnieuw verbinden.'; toast('Opgeslagen. Je scherm wordt bijgewerkt.'); await refresh(); } catch(e) { toast(e.message); } finally { busy = false; $('#save').disabled = false; } };
|
||||
$('#title').oninput = markDirty; $('#search').oninput = renderResults; $('#refresh').onclick = refresh;
|
||||
for (const [value,label] of [['','Alles'],['light','Lampen'],['climate','Klimaat'],['scene','Scènes'],['vacuum','Vacuum'],['sensor','Sensoren']]) { const b = node('button',label,value===''?'active':''); b.onclick=()=>{filter=value;$('#filters').querySelectorAll('button').forEach(n=>n.classList.remove('active'));b.classList.add('active');renderResults();};$('#filters').append(b); }
|
||||
$('#new-screen').onclick = $('#start').onclick = () => $('#installer').showModal(); $('#close-install').onclick = () => $('#installer').close();
|
||||
let generatedName = '';
|
||||
$('#install-form').onsubmit = async e => { e.preventDefault(); $('#download').disabled = true; try { const data = Object.fromEntries(new FormData(e.target)); const response = await api('install',{method:'POST',body:JSON.stringify(data)}); $('#yaml-output').value = await response.text(); generatedName = data.name; $('#yaml-result').hidden = false; $('#install-status').textContent='Je YAML is klaar. Kopieer hem naar ESPHome en bewaar het bestand met sleutels.'; } catch(e) { $('#install-status').textContent=e.message; } finally { $('#download').disabled=false; } };
|
||||
$('#copy-yaml').onclick = async () => { try { if (navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText($('#yaml-output').value); else { $('#yaml-output').select(); if (!document.execCommand('copy')) throw new Error(); } $('#install-status').textContent='Gekopieerd. Plak dit bij Edit in ESPHome Device Builder.'; } catch { $('#yaml-output').select(); $('#install-status').textContent='De tekst is geselecteerd. Kopieer met Ctrl+C of Command+C.'; } };
|
||||
$('#download-yaml').onclick = () => { const url = URL.createObjectURL(new Blob([$('#yaml-output').value],{type:'text/plain'})); const a=node('a');a.href=url;a.download=`${generatedName}.yaml`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);$('#install-status').textContent='Download gestart. Blokkeert je browser deze? Gebruik Kopieer YAML.'; };
|
||||
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
|
||||
refresh(); setInterval(refresh, 10000);
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="nl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ESP Screens</title><link rel="stylesheet" href="static/style.css"><script src="static/app.js" defer></script></head>
|
||||
<body>
|
||||
<header><div class="brand"><span class="mark">▦</span><div><strong>ESP Screens</strong><small>Je huis. Jouw bediening.</small></div></div><button id="new-screen">+ Nieuw scherm</button></header>
|
||||
<main>
|
||||
<div class="intro"><div><p class="eyebrow">HOME ASSISTANT</p><h1>Een plek voor je favorieten.</h1><p>Kies wat je wilt bedienen. Je scherm verandert met je mee.</p></div><span id="connection" role="status" class="badge">Verbinden…</span></div>
|
||||
<div class="workspace">
|
||||
<aside><h2>Mijn schermen</h2><div id="screens"></div><p class="hint">Nieuw scherm gekoppeld? Het verschijnt hier automatisch binnen 30 seconden.</p><button class="quiet" id="refresh">↻ Vernieuwen</button></aside>
|
||||
<section id="empty" class="empty"><span class="empty-icon">▦</span><h2>Begin met een scherm</h2><p>Sluit een CYD of Guition aan. We maken de installatie-YAML voor je en helpen je met de eerste flash.</p><button id="start">Nieuw scherm installeren</button></section>
|
||||
<section id="editor" hidden>
|
||||
<div class="editor-head"><div><p class="eyebrow" id="screen-name"></p><h2>Maak het jouw scherm</h2></div><span id="delivery" class="badge"></span></div>
|
||||
<label>Naam bovenaan het scherm<input id="title" maxlength="60" placeholder="Bijvoorbeeld: Woonkamer"></label>
|
||||
<div class="columns"><div class="tile-editor"><div class="section-title"><h3>Je tegels</h3><span id="count">0 / 10</span></div><p class="hint">Sleep om te ordenen of gebruik de pijltjes. Zes tegels per pagina.</p><ol id="tiles"></ol><p id="no-tiles" class="hint">Voeg rechts je eerste lamp, scène of apparaat toe.</p></div>
|
||||
<div class="picker"><label for="search">Voeg een entiteit toe</label><input id="search" type="search" placeholder="Zoek op naam, apparaat of ruimte…" autocomplete="off"><div class="filters" id="filters"></div><div id="results" aria-live="polite"></div></div></div>
|
||||
<footer class="savebar"><div><strong id="dirty">Alles opgeslagen</strong><small id="save-detail">Aanpassen kan zonder opnieuw flashen.</small></div><button id="save">Opslaan & naar scherm</button></footer>
|
||||
</section></div>
|
||||
<p class="firmware-link"><a href="/5c53de3b_esphome" target="_top">Firmware bijwerken in ESPHome ↗</a><small>Open je bestaande apparaat → Install → Wirelessly. Je tegels en sleutels blijven behouden.</small></p><details class="help"><summary>Hoe werkt het dagelijks?</summary><p>Zoek een entiteit, voeg deze toe en klik op <b>Opslaan & naar scherm</b>. De app houdt de status bij. Voor een kleurenlamp: houd de tegel ingedrukt voor kleur, temperatuur en helderheid. Climate, vacuum en cover openen hun kaart direct. Sensoren tonen alleen hun waarde.</p><p>Alleen nieuwe firmware vraagt om een installatie via ESPHome Device Builder. Je tegels blijven in deze app bewaard. Een offline scherm krijgt de wijzigingen zodra het terug is.</p></details>
|
||||
</main>
|
||||
<dialog id="installer"><form id="install-form"><div class="dialog-head"><div><p class="eyebrow">NIEUW SCHERM</p><h2>Van USB naar aan de muur.</h2></div><button type="button" class="quiet" id="close-install" aria-label="Sluiten">✕</button></div><p>Kies je scherm. Elk bestand krijgt eigen sleutels voor een veilige koppeling en draadloze updates.</p><fieldset><legend>Welk scherm heb je?</legend><label class="board"><input type="radio" name="board" value="cyd" checked><span><b>CYD · 2,8 inch</b><small>ESP32-2432S028 · 320 × 240</small></span></label><label class="board"><input type="radio" name="board" value="guition"><span><b>Guition · 4 inch</b><small>ESP32-S3-4848S040 · 480 × 480 · GT911</small></span></label></fieldset><label>Herkenbare naam<input name="friendly_name" required maxlength="60" placeholder="Scherm woonkamer"></label><label>Unieke apparaatnaam<input name="name" required pattern="[a-z][a-z0-9-]{0,29}" maxlength="30" placeholder="scherm-woonkamer"><small>Kleine letters, cijfers en streepjes. Elk scherm een andere naam.</small></label><button type="submit" id="download">Maak installatie-YAML</button><p id="install-status" role="status"></p><div id="yaml-result" hidden><label>Je eigen ESPHome-YAML<textarea id="yaml-output" readonly rows="9" spellcheck="false"></textarea></label><div class="yaml-actions"><button type="button" id="copy-yaml">Kopieer YAML</button><button type="button" class="quiet" id="download-yaml">Download bestand</button></div></div><ol class="steps"><li><b>Open ESPHome Device Builder.</b> Voeg een nieuw apparaat toe, sla de eerste installatie over en vervang de inhoud via Edit door het gedownloade bestand.</li><li><b>Vul wifi eenmalig in bij Secrets.</b> Gebruik <code>wifi_ssid</code> en <code>wifi_password</code>.</li><li><b>Sluit USB aan op je Home Assistant Raspberry.</b> Klik Install → Plug into the computer running ESPHome Device Builder en kies de USB-poort.</li><li><b>Koppel in Instellingen → Apparaten & diensten → ESPHome.</b> Gebruik de <code>api.encryption.key</code> uit je bestand en sta Home Assistant-acties toe bij Configureren.</li><li><b>Open deze pagina en kies je tegels.</b> CYD toont bij de eerste start een kalibratie: tik ieder kruisje drie keer. Guition hoeft niet gekalibreerd.</li></ol><p class="hint">Bewaar je YAML met sleutels. Gebruik later Install → Wirelessly voor firmware-updates. Tegels wijzigen doe je hier.</p></form></dialog>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
</body></html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.1.0"
|
||||
slug: esp_screen_manager
|
||||
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
arch: [aarch64, amd64]
|
||||
startup: application
|
||||
boot: auto
|
||||
init: false
|
||||
homeassistant_api: true
|
||||
ingress: true
|
||||
ingress_port: 8099
|
||||
panel_icon: mdi:monitor-dashboard
|
||||
panel_title: ESP Screens
|
||||
panel_admin: true
|
||||
options: {}
|
||||
schema: {}
|
||||
Reference in New Issue
Block a user