Add rich runtime tiles and integrated ESPHome firmware management
This commit is contained in:
@@ -1,3 +1,13 @@
|
||||
# 0.2.0
|
||||
|
||||
- ESPHome CLI in de app: bestaande profielen controleren, bouwen en via USB/OTA installeren.
|
||||
- Nieuwe profielen met unieke sleutels; bestaande YAML, secrets en appdata blijven behouden.
|
||||
- Tegelopties: klikgedrag, grote waarde, mini-schuif en sensorgeschiedenis.
|
||||
- Media-, weather-, number- en select-kaarten en vernieuwde vacuumkaart op beide borden.
|
||||
- Actiefeedback, vaste tekstregels en zwarte sliderknoppen.
|
||||
- Inspector en helderheid/standby als instellingen bij het HA-apparaat.
|
||||
- Native Guition ST7701S-configuratie en lichte kaartstijl. Duuracceptatie van het fysieke beeld blijft apart van firmwaretests.
|
||||
|
||||
# 0.1.2
|
||||
|
||||
- Per scherm standbyduur, normale/standby/nachthelderheid en nachturen instellen.
|
||||
|
||||
+11
-21
@@ -1,24 +1,14 @@
|
||||
# Aan de slag
|
||||
# Installatie en dagelijks gebruik
|
||||
|
||||
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.
|
||||
Zie [de volledige 0.2.0-handleiding](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/WHATS_NEW_020.md).
|
||||
|
||||
De volledige handleiding staat op
|
||||
[GitHub: Easy Setup](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/EASY_SETUP.md).
|
||||
Open **Nieuw scherm** om een eigen profiel te bewaren. Open **Firmware & USB**
|
||||
om een profiel te controleren, bouwen of installeren. Bestaande ESPHome-profielen
|
||||
in de HA-configmap worden automatisch gevonden. Eerste flash: USB aan de Raspberry
|
||||
en expliciet de bijbehorende poort kiezen. Later: hetzelfde profiel plus het
|
||||
IP-adres van het scherm voor OTA. Bestaande secrets worden niet vervangen.
|
||||
|
||||
## 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.
|
||||
Na koppeling via de HA ESPHome-integratie kies je in ESP Screens de tegels.
|
||||
Onder **Tegelinstellingen** staan klikgedrag, grotere waarden en mini-schuiven.
|
||||
Onder **Scherminstellingen** staan helderheid en standby. **Inspector** helpt bij
|
||||
ontbrekende attributen of een offline scherm. Nieuwe kaarten vereisen firmware 0.2.0.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
FROM ghcr.io/home-assistant/base:3.22
|
||||
# Official ESPHome distribution: compiler, ESP-IDF, esptool and OTA CLI included.
|
||||
FROM ghcr.io/esphome/esphome:2026.6.2
|
||||
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
|
||||
RUN pip install --no-cache-dir 'aiohttp>=3.12,<4' PyYAML
|
||||
COPY app /app
|
||||
WORKDIR /app
|
||||
CMD ["/command/with-contenv", "python3", "/app/server.py"]
|
||||
ENTRYPOINT []
|
||||
CMD ["python3", "/app/server.py"]
|
||||
|
||||
@@ -5,10 +5,10 @@ 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())
|
||||
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player 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())
|
||||
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 battery_level fan_speed volume_level media_title options min max step temperature_unit supported_features'.split())
|
||||
|
||||
# Additive schema 1 extension. An absent object retains old firmware/YAML defaults.
|
||||
SETTING_RULES = {
|
||||
@@ -66,7 +66,26 @@ def validate_layout(data):
|
||||
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()})
|
||||
item = {'entity': tile['entity'], 'name': name.strip()}
|
||||
if 'options' in tile:
|
||||
options = tile['options']
|
||||
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours'}:
|
||||
raise ValueError('Onbekende tegelinstellingen.')
|
||||
choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': ('standard', 'watch'), 'inline': ('none', 'slider')}
|
||||
domain = tile['entity'].split('.')[0]
|
||||
for key, allowed in choices.items():
|
||||
if key in options and options[key] not in allowed:
|
||||
raise ValueError('Ongeldige tegelinstelling: ' + key)
|
||||
if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player'}:
|
||||
raise ValueError('Deze entiteit ondersteunt geen aan/uit-actie.')
|
||||
if options.get('inline') == 'slider' and domain not in {'light','fan','cover','number','input_number','media_player'}:
|
||||
raise ValueError('Deze entiteit ondersteunt geen mini-schuif.')
|
||||
if 'history_hours' in options and (type(options['history_hours']) is not int or options['history_hours'] not in (1,6,24)):
|
||||
raise ValueError('Geschiedenis: kies 1, 6 of 24 uur.')
|
||||
if options.get('display') == 'watch' and options.get('inline') == 'slider':
|
||||
raise ValueError('Kies grote waarde of mini-schuif.')
|
||||
item['options'] = dict(options)
|
||||
clean.append(item)
|
||||
result = {'title': title.strip(), 'tiles': clean}
|
||||
if 'settings' in data:
|
||||
result['settings'] = validate_settings(data['settings'])
|
||||
@@ -92,7 +111,8 @@ def state_message(index, tile, states):
|
||||
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}
|
||||
'state': short(state.get('state', 'unavailable'), 160), 'a': bounded,
|
||||
**({'o': tile['options']} if 'options' in tile else {})}
|
||||
|
||||
def packets(message, token=None):
|
||||
raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Serialised ESPHome CLI jobs. YAML/secrets stay local; no shell or arbitrary commands."""
|
||||
import asyncio
|
||||
from collections import deque
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import time
|
||||
import yaml
|
||||
from core import installation_yaml
|
||||
|
||||
class Firmware:
|
||||
def __init__(self, root, data):
|
||||
self.root, self.data = Path(root).resolve(), Path(data)
|
||||
self.job = None
|
||||
self.task = None
|
||||
self.logs = deque(maxlen=300)
|
||||
self.process = None
|
||||
|
||||
def profiles(self):
|
||||
if not self.root.exists(): return []
|
||||
return [{'file': p.name} for p in sorted(self.root.glob('*.yaml'))
|
||||
if p.name != 'secrets.yaml' and p.is_file() and not p.is_symlink()]
|
||||
|
||||
def ports(self):
|
||||
return sorted(set(glob.glob('/dev/serial/by-id/*') or glob.glob('/dev/ttyUSB*') + glob.glob('/dev/ttyACM*')))
|
||||
|
||||
def profile(self, name):
|
||||
if not isinstance(name,str) or not re.fullmatch(r'[a-zA-Z0-9_-]+\.yaml', name):
|
||||
raise ValueError('Kies een bestaand YAML-profiel.')
|
||||
p = self.root / name
|
||||
if p.is_symlink() or p.resolve().parent != self.root or not p.is_file():
|
||||
raise ValueError('Profiel bestaat niet in de ESPHome-map.')
|
||||
return p
|
||||
|
||||
def status(self):
|
||||
return {'available': bool(shutil.which('esphome')), 'profiles': self.profiles(),
|
||||
'ports': self.ports(), 'job': self.job, 'logs': list(self.logs)}
|
||||
|
||||
def create(self, data):
|
||||
content = installation_yaml(data)
|
||||
self.root.mkdir(parents=True,exist_ok=True)
|
||||
# Existing wifi secrets remain untouched. New owners can supply them once.
|
||||
secret_path = self.root / 'secrets.yaml'
|
||||
if not secret_path.exists():
|
||||
ssid, password = data.get('wifi_ssid'), data.get('wifi_password')
|
||||
if not isinstance(ssid,str) or not ssid.strip() or not isinstance(password,str):
|
||||
raise ValueError('Vul wifi in voor deze eerste installatie.')
|
||||
with secret_path.open('x') as f:
|
||||
os.chmod(secret_path,0o600)
|
||||
yaml.safe_dump({'wifi_ssid':ssid,'wifi_password':password}, f)
|
||||
profile = self.root / (data['name']+'.yaml')
|
||||
try:
|
||||
with profile.open('x') as f:
|
||||
os.chmod(profile,0o600);f.write(content)
|
||||
except FileExistsError:
|
||||
raise ValueError('Deze naam bestaat al. Gebruik het bestaande profiel voor updates.')
|
||||
return {'file':profile.name, 'yaml':content}
|
||||
|
||||
def redact(self, text):
|
||||
text = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text)
|
||||
for value in self._secret_values:
|
||||
if len(value) >= 3: text=text.replace(value,'[redacted]')
|
||||
return re.sub(r'(?i)((?:password|encryption.key|token|ssid)\s*[:=]\s*).+',r'\1[redacted]',text)[:1500]
|
||||
|
||||
def start(self, data):
|
||||
if self.task and not self.task.done(): raise ValueError('Er loopt al een build of installatie.')
|
||||
profile = self.profile(data.get('file'))
|
||||
action = data.get('action')
|
||||
if action not in ('validate','build','install'): raise ValueError('Onbekende firmwareactie.')
|
||||
if not shutil.which('esphome'): raise ValueError('ESPHome CLI ontbreekt in deze installatie.')
|
||||
target = data.get('target','')
|
||||
if action == 'install':
|
||||
if target.startswith('/dev/'):
|
||||
if target not in self.ports(): raise ValueError('Kies de aangesloten USB-poort uit de lijst.')
|
||||
elif not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}',target):
|
||||
raise ValueError('Vul het IP-adres of de hostnaam van het bedoelde scherm in.')
|
||||
self._secret_values=set()
|
||||
# Collect scalar literals, including !secret values, without executing YAML tags.
|
||||
def collect(node):
|
||||
if isinstance(node,yaml.ScalarNode):
|
||||
if node.value: self._secret_values.add(node.value)
|
||||
elif isinstance(node,yaml.SequenceNode):
|
||||
for child in node.value: collect(child)
|
||||
elif isinstance(node,yaml.MappingNode):
|
||||
for key,value in node.value:
|
||||
if key.value in ('password','key','ssid','token') or profile.name=='secrets.yaml':collect(value)
|
||||
elif isinstance(value,(yaml.MappingNode,yaml.SequenceNode)):collect(value)
|
||||
collect(yaml.compose(profile.read_text()))
|
||||
secret_file=self.root/'secrets.yaml'
|
||||
if secret_file.exists():
|
||||
node=yaml.compose(secret_file.read_text())
|
||||
if isinstance(node,yaml.MappingNode):
|
||||
for _,v in node.value:collect(v)
|
||||
self.logs.clear()
|
||||
self.job={'file':profile.name,'action':action,'target':target,'state':'running','started':time.time()}
|
||||
self.task=asyncio.create_task(self.run(profile,action,target))
|
||||
return dict(self.job)
|
||||
|
||||
async def run(self, profile, action, target):
|
||||
env={**os.environ, 'PLATFORMIO_CORE_DIR':str(self.data/'platformio'), 'ESPHOME_BUILD_PATH':str(self.data/'build'/profile.stem), 'NO_COLOR':'1'}
|
||||
try:
|
||||
stages = ['config'] if action=='validate' else ['compile'] + (['upload'] if action=='install' else [])
|
||||
for stage in stages:
|
||||
cmd=['esphome']+(['--quiet'] if stage=='config' else [])+[stage,str(profile)]
|
||||
if stage=='upload': cmd += ['--device',target]
|
||||
self.logs.append('ESPHome: '+stage)
|
||||
self.process=await asyncio.create_subprocess_exec(*cmd,cwd=self.root,env=env,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.STDOUT,limit=1024*1024,start_new_session=True)
|
||||
async with asyncio.timeout(7200):
|
||||
async for line in self.process.stdout:
|
||||
self.logs.append(self.redact(line.decode(errors='replace').rstrip()))
|
||||
code=await self.process.wait()
|
||||
if code: raise RuntimeError('ESPHome '+stage+' mislukt; bekijk het log.')
|
||||
self.job['state']='success'; self.logs.append('Geslaagd: '+action)
|
||||
except asyncio.CancelledError:
|
||||
self.job['state']='interrupted'
|
||||
raise
|
||||
except Exception as error:
|
||||
self.job['state']='failed';self.logs.append(self.redact(str(error)))
|
||||
finally:
|
||||
if self.process and self.process.returncode is None:
|
||||
os.killpg(self.process.pid,signal.SIGTERM)
|
||||
try: await asyncio.wait_for(self.process.wait(),10)
|
||||
except TimeoutError: os.killpg(self.process.pid,signal.SIGKILL);await self.process.wait()
|
||||
self.process=None
|
||||
self.job['finished']=time.time()
|
||||
@@ -7,9 +7,12 @@ import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import math
|
||||
from firmware import Firmware
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import discover, installation_yaml, packets, state_message, validate_layout
|
||||
from core import discover, installation_yaml, packets, state_message, validate_layout, validate_settings
|
||||
|
||||
LOG = logging.getLogger('screen_manager')
|
||||
|
||||
@@ -23,6 +26,7 @@ class HomeAssistant:
|
||||
self.registry, self.devices, self.areas = [], [], []
|
||||
self.online = False
|
||||
self.changed = asyncio.Event()
|
||||
self.setting_events = []
|
||||
|
||||
async def request(self, kind, **data):
|
||||
if self.ws is None or self.ws.closed:
|
||||
@@ -52,6 +56,9 @@ class HomeAssistant:
|
||||
elif data.get('type') == 'event':
|
||||
event = data.get('event', {})
|
||||
body = event.get('data', {})
|
||||
if event.get('event_type') == 'esphome.screen_setting':
|
||||
self.setting_events.append(body)
|
||||
self.changed.set()
|
||||
if event.get('event_type') == 'state_changed':
|
||||
eid = body.get('entity_id')
|
||||
if body.get('new_state'):
|
||||
@@ -79,6 +86,7 @@ class HomeAssistant:
|
||||
raise ConnectionError('Home Assistant-authenticatie mislukt.')
|
||||
reader = asyncio.create_task(self.read())
|
||||
await self.request('subscribe_events', event_type='state_changed')
|
||||
await self.request('subscribe_events', event_type='esphome.screen_setting')
|
||||
self.states = {s['entity_id']: s for s in await self.request('get_states')}
|
||||
await self.registries()
|
||||
self.online = True
|
||||
@@ -112,10 +120,41 @@ class HomeAssistant:
|
||||
await self.request('call_service', domain='text', service='set_value',
|
||||
service_data={'entity_id': inbox, 'value': packet})
|
||||
|
||||
async def history(self, entity, hours):
|
||||
start = (datetime.now(timezone.utc)-timedelta(hours=hours)).isoformat()
|
||||
async with self.session.get(self.base+'/history/period/'+start,
|
||||
params={'filter_entity_id':entity,'minimal_response':'','no_attributes':''},
|
||||
headers={'Authorization':'Bearer '+self.token}) as response:
|
||||
response.raise_for_status()
|
||||
raw = bytearray()
|
||||
async for chunk in response.content.iter_chunked(65536):
|
||||
raw.extend(chunk)
|
||||
if len(raw)>2*1024*1024: raise ValueError('Geschiedenis te groot.')
|
||||
rows=json.loads(raw)
|
||||
samples=[]; events=[]
|
||||
begin=datetime.fromisoformat(start).timestamp(); span=hours*3600
|
||||
for row in rows[0] if rows else []:
|
||||
try:
|
||||
timestamp=datetime.fromisoformat(row.get('last_changed',row.get('last_updated','')).replace('Z','+00:00')).timestamp()
|
||||
except (ValueError,TypeError): continue
|
||||
try:
|
||||
value=float(row['state']); value=round(value,3) if math.isfinite(value) else None
|
||||
except (ValueError,TypeError,KeyError): value=None
|
||||
events.append((timestamp,value))
|
||||
events.sort(key=lambda pair:pair[0]);position=0;value=None
|
||||
for bucket in range(24):
|
||||
boundary=begin+(bucket+1)*span/24
|
||||
while position<len(events) and events[position][0]<=boundary:
|
||||
value=events[position][1];position+=1
|
||||
samples.append(value)
|
||||
return samples
|
||||
|
||||
class Manager:
|
||||
def __init__(self, ha, path):
|
||||
self.ha, self.path = ha, Path(path)
|
||||
self.layouts, self.sent, self.status, self.last = {}, {}, {}, {}
|
||||
self.histories = {}
|
||||
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text())
|
||||
# Versioned persistent data. Never silently overwrite an unknown schema.
|
||||
@@ -133,6 +172,10 @@ class Manager:
|
||||
# A still-open older UI may save tiles without the new optional settings.
|
||||
if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
|
||||
layout['settings'] = self.layouts[inbox]['settings'].copy()
|
||||
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
|
||||
for tile in layout['tiles']:
|
||||
if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}):
|
||||
tile['options'] = old_tiles[tile['entity']]['options'].copy()
|
||||
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.')
|
||||
@@ -151,10 +194,21 @@ class Manager:
|
||||
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 = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}]
|
||||
if 'settings' in layout:
|
||||
messages[0]['settings'] = layout['settings']
|
||||
messages += [state_message(i, tile, self.ha.states) for i, tile in enumerate(layout['tiles'])]
|
||||
for i,tile in enumerate(layout['tiles']):
|
||||
message=state_message(i,tile,self.ha.states)
|
||||
if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
|
||||
hours=tile.get('options',{}).get('history_hours',24)
|
||||
key=(tile['entity'],hours)
|
||||
cached=self.histories.get(key)
|
||||
if not cached or time.monotonic()-cached[0]>300:
|
||||
try: values=await self.ha.history(*key)
|
||||
except Exception: values=[]
|
||||
cached=(time.monotonic(),values);self.histories[key]=cached
|
||||
message['history']={'hours':hours,'values':cached[1]}
|
||||
messages.append(message)
|
||||
previous = self.sent.get(inbox, [])
|
||||
force = force or not previous or messages[0] != previous[0]
|
||||
for i, message in enumerate(messages):
|
||||
@@ -179,6 +233,19 @@ class Manager:
|
||||
if not self.ha.online:
|
||||
self.sent.clear()
|
||||
continue
|
||||
for event in getattr(self.ha,'setting_events',[])[:]:
|
||||
try:
|
||||
inbox,key,value=event.get('inbox'),event.get('key'),event.get('value')
|
||||
if inbox in self.layouts:
|
||||
layout=dict(self.layouts[inbox]); settings=validate_settings(layout.get('settings',{}))
|
||||
if key not in settings: continue
|
||||
settings[key] = value=='1' if type(settings[key]) is bool else int(value)
|
||||
if key=='brightness':
|
||||
settings['standby_brightness']=min(settings['standby_brightness'],settings[key])
|
||||
settings['night_brightness']=min(settings['night_brightness'],settings[key])
|
||||
layout['settings']=validate_settings(settings);self.save(inbox,layout)
|
||||
except (ValueError,TypeError): pass
|
||||
if hasattr(self.ha,'setting_events'): self.ha.setting_events.clear()
|
||||
for screen in self.inventory()[0]:
|
||||
inbox = screen['id']
|
||||
if not screen['online']:
|
||||
@@ -238,6 +305,28 @@ def create_app(manager, development=False):
|
||||
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"'})
|
||||
async def inspector(request):
|
||||
inbox=request.match_info['inbox']
|
||||
if inbox not in {s['id'] for s in manager.inventory()[0]}: raise ValueError('Onbekend scherm.')
|
||||
layout=manager.layouts.get(inbox,{'tiles':[]})
|
||||
return web.json_response({'screen':next(s for s in manager.inventory()[0] if s['id']==inbox),
|
||||
'delivery':manager.status.get(inbox), 'layout':layout,
|
||||
'tiles':[{'entity':t['entity'],'state':manager.ha.states.get(t['entity'],{}).get('state'),
|
||||
'attributes':state_message(i,t,manager.ha.states)['a'],
|
||||
'options':t.get('options',{})} for i,t in enumerate(layout['tiles'])]})
|
||||
async def firmware_status(request): return web.json_response(manager.firmware.status())
|
||||
async def firmware_start(request): return web.json_response(manager.firmware.start(await request.json()))
|
||||
async def firmware_create(request): return web.json_response(manager.firmware.create(await request.json()))
|
||||
async def shutdown(app):
|
||||
task=manager.firmware.task
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError): await task
|
||||
app.on_cleanup.append(shutdown)
|
||||
app.router.add_get('/api/screens/{inbox}/inspect', inspector)
|
||||
app.router.add_get('/api/firmware', firmware_status)
|
||||
app.router.add_post('/api/firmware/jobs', firmware_start)
|
||||
app.router.add_post('/api/firmware/profiles', firmware_create)
|
||||
app.router.add_get('/', index)
|
||||
app.router.add_get('/api/inventory', inventory)
|
||||
app.router.add_put('/api/screens/{inbox}', save)
|
||||
|
||||
@@ -201,7 +201,7 @@ function renderTiles() {
|
||||
const li = node("li", undefined, "tile");
|
||||
li.draggable = true;
|
||||
li.ondragstart = (e) => {
|
||||
if (e.target instanceof HTMLInputElement) {
|
||||
if (e.target.closest("input, select, button")) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -255,6 +255,94 @@ function renderTiles() {
|
||||
b.onclick = action;
|
||||
actions.append(b);
|
||||
}
|
||||
const options = node("details", undefined, "tile-options");
|
||||
options.append(node("summary", "Tegelinstellingen"));
|
||||
const domain = tile.entity.split(".")[0];
|
||||
const fields = [
|
||||
[
|
||||
"tap",
|
||||
"Bij aantikken",
|
||||
[
|
||||
["auto", "Automatisch"],
|
||||
["detail", "Bediening openen"],
|
||||
["none", "Alleen bekijken"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"display",
|
||||
"Weergave",
|
||||
[
|
||||
["standard", "Naam en status"],
|
||||
["watch", "Grote waarde"],
|
||||
],
|
||||
],
|
||||
];
|
||||
if (
|
||||
["light", "switch", "input_boolean", "fan", "media_player"].includes(
|
||||
domain,
|
||||
)
|
||||
)
|
||||
fields[0][2].push(["toggle", "Aan / uit"]);
|
||||
if (
|
||||
[
|
||||
"light",
|
||||
"fan",
|
||||
"cover",
|
||||
"number",
|
||||
"input_number",
|
||||
"media_player",
|
||||
].includes(domain)
|
||||
)
|
||||
fields.push([
|
||||
"inline",
|
||||
"Op de tegel",
|
||||
[
|
||||
["none", "Geen extra bediening"],
|
||||
["slider", "Mini-schuif"],
|
||||
],
|
||||
]);
|
||||
if (domain === "sensor")
|
||||
fields.push([
|
||||
"history_hours",
|
||||
"Geschiedenis",
|
||||
[
|
||||
[1, "1 uur"],
|
||||
[6, "6 uur"],
|
||||
[24, "24 uur"],
|
||||
],
|
||||
]);
|
||||
for (const [key, title, choices] of fields) {
|
||||
const label = node("label", title),
|
||||
input = node("select");
|
||||
for (const [value, text] of choices) {
|
||||
const option = node("option", text);
|
||||
option.value = value;
|
||||
input.append(option);
|
||||
}
|
||||
input.value =
|
||||
tile.options?.[key] ??
|
||||
(key === "tap"
|
||||
? "auto"
|
||||
: key === "display"
|
||||
? "standard"
|
||||
: key === "history_hours"
|
||||
? 24
|
||||
: "none");
|
||||
input.onchange = () => {
|
||||
tile.options = {
|
||||
...tile.options,
|
||||
[key]: key === "history_hours" ? Number(input.value) : input.value,
|
||||
};
|
||||
if (key === "display" && input.value === "watch")
|
||||
tile.options.inline = "none";
|
||||
if (key === "inline" && input.value === "slider")
|
||||
tile.options.display = "standard";
|
||||
markDirty();
|
||||
};
|
||||
label.append(input);
|
||||
options.append(label);
|
||||
}
|
||||
content.append(options);
|
||||
li.append(node("span", "⠿", "grip"), content, actions);
|
||||
$("#tiles").append(li);
|
||||
});
|
||||
@@ -356,6 +444,10 @@ for (const [value, label] of [
|
||||
["scene", "Scènes"],
|
||||
["vacuum", "Vacuum"],
|
||||
["sensor", "Sensoren"],
|
||||
["media_player", "Media"],
|
||||
["weather", "Weer"],
|
||||
["number", "Waarden"],
|
||||
["select", "Keuzelijsten"],
|
||||
]) {
|
||||
const b = node("button", label, value === "" ? "active" : "");
|
||||
b.onclick = () => {
|
||||
@@ -430,3 +522,120 @@ window.addEventListener("beforeunload", (e) => {
|
||||
});
|
||||
refresh();
|
||||
setInterval(refresh, 10000);
|
||||
|
||||
// Shared firmware workspace; always select a concrete profile and upload target.
|
||||
let firmwarePoll;
|
||||
async function firmwareRefresh(initial = false) {
|
||||
try {
|
||||
const data = await (await api("firmware")).json();
|
||||
if (initial) {
|
||||
$("#firmware-file").replaceChildren(
|
||||
...data.profiles.map((p) => {
|
||||
const o = node("option", p.file);
|
||||
o.value = p.file;
|
||||
return o;
|
||||
}),
|
||||
);
|
||||
$("#firmware-port").replaceChildren(
|
||||
...[["ota", "Wifi / OTA"], ...data.ports.map((p) => [p, p])].map(
|
||||
([value, text]) => {
|
||||
const o = node("option", text);
|
||||
o.value = value;
|
||||
return o;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
const running = data.job?.state === "running";
|
||||
for (const id of ["validate", "build", "install"])
|
||||
$("#firmware-" + id).disabled =
|
||||
running || !data.available || !data.profiles.length;
|
||||
$("#firmware-status").textContent = !data.available
|
||||
? "ESPHome CLI ontbreekt. Werk de app bij naar 0.2.0."
|
||||
: data.job
|
||||
? `${data.job.file} · ${data.job.action} · ${data.job.state}`
|
||||
: "Kies het bedoelde profiel en een USB-poort of IP-adres.";
|
||||
$("#firmware-log").textContent = data.logs.join("\n");
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
$("#open-firmware").onclick = () => {
|
||||
$("#firmware-dialog").showModal();
|
||||
firmwareRefresh(true);
|
||||
clearInterval(firmwarePoll);
|
||||
firmwarePoll = setInterval(() => firmwareRefresh(), 3000);
|
||||
};
|
||||
$("#close-firmware").onclick = () => {
|
||||
$("#firmware-dialog").close();
|
||||
clearInterval(firmwarePoll);
|
||||
};
|
||||
$("#firmware-dialog").addEventListener("close", () =>
|
||||
clearInterval(firmwarePoll),
|
||||
);
|
||||
$("#firmware-port").onchange = () =>
|
||||
($("#firmware-host-label").hidden = $("#firmware-port").value !== "ota");
|
||||
$("#firmware-file").onchange = () => {
|
||||
$("#firmware-host").placeholder = $("#firmware-file").value.replace(
|
||||
/\.yaml$/,
|
||||
".local",
|
||||
);
|
||||
};
|
||||
for (const action of ["validate", "build", "install"])
|
||||
$("#firmware-" + action).onclick = async () => {
|
||||
try {
|
||||
await api("firmware/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
file: $("#firmware-file").value,
|
||||
action:
|
||||
action === "validate"
|
||||
? "validate"
|
||||
: action === "build"
|
||||
? "build"
|
||||
: "install",
|
||||
target:
|
||||
$("#firmware-port").value === "ota"
|
||||
? $("#firmware-host").value.trim()
|
||||
: $("#firmware-port").value,
|
||||
}),
|
||||
});
|
||||
await firmwareRefresh();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
$("#create-profile").onclick = async () => {
|
||||
const form = $("#install-form");
|
||||
if (!form.reportValidity()) return;
|
||||
try {
|
||||
const data = await (
|
||||
await api("firmware/profiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
})
|
||||
).json();
|
||||
generatedName = data.file.replace(/\.yaml$/, " ").trim();
|
||||
$("#yaml-output").value = data.yaml;
|
||||
$("#yaml-result").hidden = false;
|
||||
$("#install-status").textContent =
|
||||
`${data.file} bewaard. Open Firmware & USB om te installeren.`;
|
||||
form.elements.wifi_password.value = "";
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
$("#inspect").onclick = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
$("#inspection").textContent = JSON.stringify(
|
||||
await (
|
||||
await api(`screens/${encodeURIComponent(selected)}/inspect`)
|
||||
).json(),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<strong>ESP Screens</strong><small>Je huis. Jouw bediening.</small>
|
||||
</div>
|
||||
</div>
|
||||
<button id="open-firmware" class="quiet">Firmware & USB</button>
|
||||
<button id="new-screen">+ Nieuw scherm</button>
|
||||
</header>
|
||||
<main>
|
||||
@@ -99,6 +100,16 @@
|
||||
Gelijke begin- en eindtijd schakelen het nachtvenster uit.
|
||||
</p>
|
||||
</details>
|
||||
<details class="screen-settings">
|
||||
<summary>
|
||||
Inspector
|
||||
<span class="hint"
|
||||
>Verbinding, status en ondersteunde eigenschappen</span
|
||||
>
|
||||
</summary>
|
||||
<button id="inspect" class="quiet">Lees actuele gegevens</button>
|
||||
<pre id="inspection">Selecteer een scherm en lees de gegevens.</pre>
|
||||
</details>
|
||||
<footer class="savebar">
|
||||
<div>
|
||||
<strong id="dirty">Alles opgeslagen</strong
|
||||
@@ -111,12 +122,9 @@
|
||||
</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
|
||||
>
|
||||
Firmware bouwen en via USB of wifi installeren kan via
|
||||
<b>Firmware & USB</b> bovenaan. Je bestaande YAML en secrets blijven
|
||||
bewaard.
|
||||
</p>
|
||||
<details class="help">
|
||||
<summary>Hoe werkt het dagelijks?</summary>
|
||||
@@ -125,12 +133,13 @@
|
||||
<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.
|
||||
openen hun HA-geschiedenis met één tik. Per tegel kies je een grote
|
||||
waarde, mini-schuif of klikgedrag.
|
||||
</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.
|
||||
Nieuwe firmware bouw en installeer je hier met de ingebouwde ESPHome
|
||||
CLI. Je tegels blijven in deze app bewaard. Een offline scherm krijgt
|
||||
de wijzigingen zodra het terug is.
|
||||
</p>
|
||||
</details>
|
||||
</main>
|
||||
@@ -185,7 +194,21 @@
|
||||
>Kleine letters, cijfers en streepjes. Elk scherm een andere
|
||||
naam.</small
|
||||
></label
|
||||
><button type="submit" id="download">Maak installatie-YAML</button>
|
||||
><label
|
||||
>Wifi-naam (alleen bij eerste installatie)<input
|
||||
name="wifi_ssid"
|
||||
autocomplete="off"
|
||||
/></label>
|
||||
<label
|
||||
>Wifiwachtwoord<input
|
||||
name="wifi_password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<button type="button" id="create-profile">
|
||||
Bewaar profiel in ESP Screens
|
||||
</button>
|
||||
<button type="submit" id="download">Maak installatie-YAML</button>
|
||||
<p id="install-status" role="status"></p>
|
||||
<div id="yaml-result" hidden>
|
||||
<label
|
||||
@@ -205,36 +228,62 @@
|
||||
</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.
|
||||
<b>Bewaar je profiel.</b> Bestaande wifi-secrets worden behouden;
|
||||
bij een eerste installatie vul je wifi hierboven in.
|
||||
</li>
|
||||
<li>
|
||||
<b>Vul wifi eenmalig in bij Secrets.</b> Gebruik
|
||||
<code>wifi_ssid</code> en <code>wifi_password</code>.
|
||||
<b>Open Firmware & USB.</b> Kies dit profiel, sluit het scherm op je
|
||||
Raspberry aan en selecteer de juiste USB-poort. Kies Bouwen &
|
||||
installeren.
|
||||
</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.
|
||||
<b>Koppel in HA → Apparaten & diensten → ESPHome.</b> Gebruik de
|
||||
API-sleutel uit je eigen YAML en sta HA-acties toe.
|
||||
</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.
|
||||
<b>Kies hier je tegels.</b> CYD vraagt bij de eerste start om
|
||||
kalibratie; Guition niet.
|
||||
</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.
|
||||
<b>Later updaten?</b> Kies hetzelfde profiel en het IP-adres van je
|
||||
scherm in Firmware & USB.
|
||||
</li>
|
||||
</ol>
|
||||
<p class="hint">
|
||||
Bewaar je YAML met sleutels. Gebruik later Install → Wirelessly voor
|
||||
firmware-updates. Tegels wijzigen doe je hier.
|
||||
Bewaar je YAML met sleutels. Gebruik later Firmware & USB met het
|
||||
IP-adres voor draadloze updates. Tegels wijzigen doe je hier.
|
||||
</p>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="firmware-dialog">
|
||||
<div class="dialog-head">
|
||||
<h2>Firmware & USB</h2>
|
||||
<button id="close-firmware" class="quiet">Sluiten</button>
|
||||
</div>
|
||||
<p>
|
||||
De ingebouwde ESPHome CLI gebruikt je eigen YAML en secrets. Tegels
|
||||
blijven in ESP Screens staan. Op de Raspberry kan de eerste build enkele
|
||||
minuten duren.
|
||||
</p>
|
||||
<label>ESPHome-profiel<select id="firmware-file"></select></label>
|
||||
<label
|
||||
>Installeren naar<select id="firmware-port">
|
||||
<option value="ota">Wifi / OTA</option>
|
||||
</select></label
|
||||
>
|
||||
<label id="firmware-host-label"
|
||||
>IP-adres of hostnaam<input
|
||||
id="firmware-host"
|
||||
placeholder="scherm-woonkamer.local"
|
||||
/></label>
|
||||
<div class="yaml-actions">
|
||||
<button id="firmware-validate" class="quiet">Controleren</button
|
||||
><button id="firmware-build" class="quiet">Alleen bouwen</button
|
||||
><button id="firmware-install">Bouwen & installeren</button>
|
||||
</div>
|
||||
<p id="firmware-status" role="status"></p>
|
||||
<pre id="firmware-log"></pre>
|
||||
</dialog>
|
||||
<div id="toast" role="status" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -616,3 +616,42 @@ textarea {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.tile-options {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tile-options label {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
}
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #d8dfe5;
|
||||
border-radius: 9px;
|
||||
background: white;
|
||||
color: #152331;
|
||||
font: inherit;
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
background: #f0f3f6;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
#firmware-dialog {
|
||||
max-width: 780px;
|
||||
width: 90vw;
|
||||
}
|
||||
.tile-actions {
|
||||
align-self: start;
|
||||
}
|
||||
.tile .tile-options input,
|
||||
.tile .tile-options select {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.1.2"
|
||||
version: "0.2.0"
|
||||
slug: esp_screen_manager
|
||||
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
@@ -15,3 +15,8 @@ panel_title: ESP Screens
|
||||
panel_admin: true
|
||||
options: {}
|
||||
schema: {}
|
||||
|
||||
map:
|
||||
- homeassistant_config:rw
|
||||
uart: true
|
||||
usb: true
|
||||
|
||||
Reference in New Issue
Block a user