Put ESP Screens in English and keep layouts when entity IDs change (0.2.40)

Everything a user sees or reads is now English: both boards' labels,
status replies and logs, the ESP Screens editor and Alerts cheatsheet,
and the README and guides (docs/TILES.md, CALIBRATING.md, ACCEPTANCE.md
and TROUBLESHOOTING.md replace the Dutch file names). Labels follow
Home Assistant's own English names (Heat/Cool modes, Heating/Cooling
actions, Away, Returning to dock, weather conditions), dates use English
abbreviations and the top bar writes numbers the English way. Docs use
HA 2026.9's menu names (Settings -> Apps, App store, Settings -> Tools).
Unused legacy files (home-like.yaml, buttons.yaml, a.out) are removed.

Firmware 0.2.34 names its entities in English. Home Assistant registers
a renamed ESPHome entity under a new entity ID, so the inbox
text.<screen>_tegelinstellingen becomes text.<screen>_tile_settings and
the layout stored under the old ID would be lost. The manager now
recognises the screen by its device, during a run or after a restart,
and moves its layout and update history along; the Update button and
the nightly round follow the screen through the rename. Screens that
still run 0.2.33 or older keep working: both languages are matched.

Main is the only release branch from now on.

Tested on a Home Assistant Yellow (HA 2026.9.1) with both bench boards:
see docs/TEST_RESULTS_0240.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-15 02:08:43 +02:00
parent f60eed3ff6
commit 3e959b3df9
87 changed files with 5049 additions and 11167 deletions
+11
View File
@@ -1,3 +1,14 @@
## 0.2.40 (firmware 0.2.34)
ESP Screens is now in English: the screens, the management page, the Home Assistant entities and the documentation.
- **English everywhere.** Every label on both boards (tiles, detail cards, the climate and vacuum cards, page navigation, calibration), the ESP Screens editor, the Alerts cheatsheet, error messages and logs, and the README and guides. The top bar writes numbers the English way (21.5 °C, 1,249 W). State labels follow Home Assistant's own English names, for example Heat and Cool for climate modes, Heating and Cooling for what the device is doing, Away, Returning to dock, and weather conditions such as Lightning, rainy. Dates use English abbreviations (Mo 14 Sep).
- **English entity names.** Firmware 0.2.34 names its entities Tile settings, Screen firmware, Device name, IP address, Guition screen type, Last boot, Normal brightness, Standby brightness, Night brightness, Standby after and Calibrate touch. Home Assistant registers a renamed ESPHome entity under a new entity id and removes the old one, so `text.kitchen_screen_tegelinstellingen` becomes `text.kitchen_screen_tile_settings`. Dashboards or automations that use one of these diagnostic entities need the new id.
- **Your layouts move along.** ESP Screen Manager recognises a screen that comes back under a new inbox id and moves its tiles, settings, top bar and update history to it, also when the app restarts in between. The **Update** button and the nightly round follow the screen through the rename and report the update as successful.
- **Older firmware keeps working.** Screens on firmware 0.2.33 or older still report the Dutch entity names and status messages; the app recognises both until they are updated.
- Docs use Home Assistant's current menu names (**Settings → Apps**, **App store**, **Settings → Tools**), and the guides are now `docs/TILES.md`, `docs/CALIBRATING.md`, `docs/ACCEPTANCE.md` and `docs/TROUBLESHOOTING.md`.
- Firmware 0.2.34 for both boards; the app offers the update. Besides the English text and entity names it changes nothing on the screen.
## 0.2.39 (firmware 0.2.33)
Zuiniger in dagelijks gebruik, ook met tientallen schermen. Niets verandert aan wat je ziet of instelt.
+19 -19
View File
@@ -1,23 +1,23 @@
# Installatie en dagelijks gebruik
# Installation and everyday use
Zie [de volledige 0.2.0-handleiding](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/WHATS_NEW_020.md).
See [the full installation guide](https://github.com/MaxGramser/homeassistant_espscreen/blob/main/docs/EASY_SETUP.md).
Open **Nieuw scherm** om een scherm te installeren: sluit het via USB aan op de
Home Assistant-machine, kies bord, naam en USB-poort en klik **Installeren**.
Het profiel met unieke sleutels komt in de ESPHome-map van de HA-configuratie,
ontbrekende wifi gaat in `secrets.yaml` (bestaande secrets blijven staan), en de
build en de flash lopen in hetzelfde venster; daarna toont het de API-sleutel
voor de koppeling. De koppeling zelf doe je in Home Assistant onder **Instellingen → Apparaten & diensten** (knop in het venster en op de kaart *nog niet in Home Assistant* onder Mijn schermen). Elk scherm heeft zijn eigen profiel. **Firmware & USB** is
voor bestaande profielen: controleren, bouwen of opnieuw installeren via USB of
het IP-adres (OTA). Bestaande ESPHome-profielen in de HA-configmap worden
automatisch gevonden.
Open **New screen** to install a screen: connect it via USB to the
Home Assistant machine, choose the board, name and USB port, then click **Install**.
The profile with unique keys goes into the ESPHome folder of the HA configuration,
any missing Wi-Fi goes into `secrets.yaml` (existing secrets are left untouched), and the
build and flash run in the same window; it then shows the API key
for pairing. Pairing itself happens in Home Assistant under **Settings → Devices & services** (a button in the window and on the *not yet in Home Assistant* card under My screens). Each screen has its own profile. **Firmware & USB** is
for existing profiles: check, build or reinstall via USB or
the IP address (OTA). Existing ESPHome profiles in the HA config folder are
found automatically.
Bovenaan de editor stel je per scherm de **Bovenbalk** in: links de naam, rechts
tot zes onderdelen (tijd, analoge klok, datum of een entiteit met icoon, zoals
temperatuur, een deur, het alarm of "laatst gewijzigd"). Sleep om te ordenen, tik
om in te stellen; firmware 0.2.32 of nieuwer tekent ze.
At the top of the editor you set the **Top bar** for each screen: the name on the left, up
to six items on the right (time, analog clock, date, or an entity with an icon, such as
temperature, a door, the alarm, or "last changed"). Drag to reorder, tap
to configure; firmware 0.2.32 or newer renders them.
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.
After pairing via the HA ESPHome integration, choose the tiles in ESP Screens.
**Tile settings** has click behavior, larger values and mini-sliders.
**Screen settings** has brightness and standby. **Inspector** helps with
missing attributes or an offline screen. New cards require firmware 0.2.0.
+5 -5
View File
@@ -1,8 +1,8 @@
# ESP Screen Manager
Zoek je Home Assistant-entiteiten, orden maximaal twintig tegels en stuur wijzigingen
naar een CYD- of Guition-scherm zonder opnieuw flashen. Met installatiehulp,
unieke apparaat-YAML en ondersteuning voor meerdere schermen.
Find your Home Assistant entities, arrange up to twenty tiles, and push changes
to a CYD or Guition screen without reflashing. Includes installation help,
a unique per-device YAML, and support for multiple screens.
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.
Open the web interface after installation. See **Documentation** for the first flash.
The app uses Home Assistant Ingress and needs no login or token of its own.
+124 -89
View File
@@ -11,31 +11,31 @@ import tile_icons
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 sun timer person screen'.split())
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
BUILTIN = {'screen.clock': 'Klok'}
BUILTIN = {'screen.clock': 'Clock'}
NEW_DOMAINS = frozenset('sun timer person screen'.split())
WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo']
WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.33'
FIRMWARE_VERSION = '0.2.34'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action 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 is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
TILE_BACKGROUNDS = {
'auto': {'label': 'Standaard', 'color': None},
'auto': {'label': 'Default', 'color': None},
# No card behind the tile: contents keep their size and place on the screen background.
'none': {'label': 'Geen', 'color': None},
'red': {'label': 'Rood', 'color': '#FADADD'},
'orange': {'label': 'Oranje', 'color': '#FFE1C6'},
'yellow': {'label': 'Geel', 'color': '#FFF0C2'},
'green': {'label': 'Groen', 'color': '#D9EEDC'},
'none': {'label': 'None', 'color': None},
'red': {'label': 'Red', 'color': '#FADADD'},
'orange': {'label': 'Orange', 'color': '#FFE1C6'},
'yellow': {'label': 'Yellow', 'color': '#FFF0C2'},
'green': {'label': 'Green', 'color': '#D9EEDC'},
'mint': {'label': 'Mint', 'color': '#D5F0EA'},
'blue': {'label': 'Blauw', 'color': '#D9EAFB'},
'purple': {'label': 'Paars', 'color': '#E9DDF5'},
'pink': {'label': 'Roze', 'color': '#F7DDEC'},
'gray': {'label': 'Grijs', 'color': '#E5E7EB'},
'blue': {'label': 'Blue', 'color': '#D9EAFB'},
'purple': {'label': 'Purple', 'color': '#E9DDF5'},
'pink': {'label': 'Pink', 'color': '#F7DDEC'},
'gray': {'label': 'Gray', 'color': '#E5E7EB'},
}
# Display modes per domain; everything else offers standard and watch (large value).
@@ -77,28 +77,28 @@ def has_gaps(tiles):
# Home Assistant's own entity rows. The first choice is what a wide card shows
# when the tile has no explicit choice; 'none' keeps the plain card.
CONTROLS = {
'climate': (('setpoint', 'Temperatuur − / +'), ('mode', 'Uit, verwarmen, koelen')),
'switch': (('toggle', 'Aan/uit-schakelaar'),),
'input_boolean': (('toggle', 'Aan/uit-schakelaar'),),
'light': (('toggle', 'Aan/uit-schakelaar'), ('brightness', 'Helderheidsschuif')),
'fan': (('toggle', 'Aan/uit-schakelaar'), ('speed', 'Snelheidsschuif')),
'vacuum': (('buttons', 'Start, stop, naar dock'),),
'cover': (('buttons', 'Open, stop, dicht'), ('position', 'Positieschuif')),
'media_player': (('volume', 'Volume en dempen'), ('playback', 'Vorige, play/pauze, volgende')),
'number': (('stepper', 'Waarde − / +'), ('slider', 'Schuif')),
'input_number': (('stepper', 'Waarde − / +'), ('slider', 'Schuif')),
'select': (('stepper', 'Vorige / volgende keuze'),),
'input_select': (('stepper', 'Vorige / volgende keuze'),),
'timer': (('buttons', 'Start/pauze en annuleren'),),
'scene': (('run', 'Knop Activeren'),),
'script': (('run', 'Knop Uitvoeren'),),
'button': (('run', 'Knop Indrukken'),),
'input_button': (('run', 'Knop Indrukken'),),
'climate': (('setpoint', 'Temperature − / +'), ('mode', 'Off, heat, cool')),
'switch': (('toggle', 'On/off switch'),),
'input_boolean': (('toggle', 'On/off switch'),),
'light': (('toggle', 'On/off switch'), ('brightness', 'Brightness slider')),
'fan': (('toggle', 'On/off switch'), ('speed', 'Speed slider')),
'vacuum': (('buttons', 'Start, stop, dock'),),
'cover': (('buttons', 'Open, stop, close'), ('position', 'Position slider')),
'media_player': (('volume', 'Volume and mute'), ('playback', 'Previous, play/pause, next')),
'number': (('stepper', 'Value − / +'), ('slider', 'Slider')),
'input_number': (('stepper', 'Value − / +'), ('slider', 'Slider')),
'select': (('stepper', 'Previous / next choice'),),
'input_select': (('stepper', 'Previous / next choice'),),
'timer': (('buttons', 'Start/pause and cancel'),),
'scene': (('run', 'Activate button'),),
'script': (('run', 'Run button'),),
'button': (('run', 'Press button'),),
'input_button': (('run', 'Press button'),),
}
def controls_catalogue():
"""Editor choices per domain: the default first, then 'none'."""
return {domain: {'default': choices[0][0], 'choices': [{'key': key, 'label': label} for key, label in choices] + [{'key': 'none', 'label': 'Geen'}]}
return {domain: {'default': choices[0][0], 'choices': [{'key': key, 'label': label} for key, label in choices] + [{'key': 'none', 'label': 'None'}]}
for domain, choices in CONTROLS.items()}
def resolve_controls(tile):
@@ -111,7 +111,42 @@ def resolve_controls(tile):
return None if choice == 'none' else choice
# Diagnostic entities every ESP Screens firmware exposes; the manager watches them for screens.
SCREEN_ENTITY_NAMES = frozenset({'Tegelinstellingen', 'Schermfirmware', 'Guition schermtype', 'Apparaatnaam', 'IP-adres'})
# Firmware built before the English translation still registers the Dutch originals, so both
# forms are recognised until every board has been reflashed with an English `name:`.
NAME_TILE_SETTINGS = ('Tile settings', 'Tegelinstellingen')
NAME_SCREEN_FIRMWARE = ('Screen firmware', 'Schermfirmware')
NAME_GUITION_TYPE = ('Guition screen type', 'Guition schermtype')
NAME_DEVICE_NAME = ('Device name', 'Apparaatnaam')
NAME_IP_ADDRESS = ('IP address', 'IP-adres')
SCREEN_ENTITY_NAMES = frozenset(NAME_TILE_SETTINGS + NAME_SCREEN_FIRMWARE + NAME_GUITION_TYPE + NAME_DEVICE_NAME + NAME_IP_ADDRESS)
def entity_slug(name):
"""The end of an entity id Home Assistant derives from an entity name (ASCII names)."""
return re.sub(r'[^a-z0-9]+', '_', str(name).lower()).strip('_')
# An inbox entity id is text.<device>_tile_settings, or text.<device>_tegelinstellingen before firmware 0.2.34.
INBOX_SUFFIXES = tuple('_' + entity_slug(name) for name in NAME_TILE_SETTINGS)
def inbox_prefix(entity):
"""The device part of an inbox entity id ('office_1' for text.office_1_tile_settings), or None."""
if isinstance(entity, str) and entity.startswith('text.'):
for suffix in INBOX_SUFFIXES:
if entity.endswith(suffix) and len(entity) > len('text.') + len(suffix):
return entity[len('text.'):-len(suffix)]
return None
def device_prefixes(registry, device_id):
"""Entity id prefixes Home Assistant gave the ESPHome entities of one device: the device name when each
entity was created. Entities whose name never changed keep the prefix an older inbox id carried."""
prefixes = set()
for item in registry:
name = item.get('original_name')
if item.get('device_id') != device_id or item.get('platform') != 'esphome' or not isinstance(name, str):
continue
object_id, suffix = item['entity_id'].split('.', 1)[-1], '_' + entity_slug(name)
if len(suffix) > 1 and object_id.endswith(suffix) and len(object_id) > len(suffix):
prefixes.add(object_id[:-len(suffix)])
return prefixes
# Additive schema 1 extension. An absent object retains old firmware/YAML defaults.
SETTING_RULES = {
@@ -132,7 +167,7 @@ SETTING_RULES = {
def validate_settings(data):
if not isinstance(data, dict) or set(data) - SETTING_RULES.keys():
raise ValueError('Onbekende scherminstellingen; vernieuw de beheerpagina.')
raise ValueError('Unknown screen settings; refresh the management page.')
clean = {}
for key, (default, minimum, maximum) in SETTING_RULES.items():
value = data.get(key, default)
@@ -142,10 +177,10 @@ def validate_settings(data):
valid = type(value) is int and minimum <= value <= maximum
if key == "rotation": valid = valid and value in (0, 90, 180, 270)
if not valid:
raise ValueError(f'Ongeldige waarde voor {key}.')
raise ValueError(f'Invalid value for {key}.')
clean[key] = value
if max(clean['standby_brightness'], clean['night_brightness']) > clean['brightness']:
raise ValueError('Helderheid in standby en nacht mag niet hoger zijn dan normaal.')
raise ValueError('Standby and night brightness may not be higher than normal.')
return clean
def entity_id(value):
@@ -163,7 +198,7 @@ TRANSPORT_MIN_FIRMWARE = (0, 2, 33)
MESSAGE_ACTION = 'screen_message'
HEADER_MAX_ITEMS = 6
# Items the screen draws on its own clock, without Home Assistant.
HEADER_BUILTIN = {'clock': 'Tijd', 'analog': 'Analoge klok', 'date': 'Datum'}
HEADER_BUILTIN = {'clock': 'Time', 'analog': 'Analog clock', 'date': 'Date'}
# Only shown, never controlled: the top bar takes these besides every tile domain.
HEADER_ONLY_DOMAINS = frozenset('device_tracker zone lock alarm_control_panel counter event input_datetime input_text water_heater humidifier'.split())
HEADER_CONTENTS = ('state', 'last_changed')
@@ -181,32 +216,32 @@ def header_items(layout):
def validate_header(data):
if not isinstance(data, dict) or set(data) - {'items'} or not isinstance(data.get('items'), list):
raise ValueError('Ongeldige bovenbalk; vernieuw de beheerpagina.')
raise ValueError('Invalid top bar; refresh the management page.')
if len(data['items']) > HEADER_MAX_ITEMS:
raise ValueError(f'De bovenbalk heeft plaats voor maximaal {HEADER_MAX_ITEMS} onderdelen.')
raise ValueError(f'The top bar has room for at most {HEADER_MAX_ITEMS} items.')
items, seen = [], set()
for item in data['items']:
kind = item.get('type') if isinstance(item, dict) else None
if kind in HEADER_BUILTIN:
if set(item) != {'type'}:
raise ValueError('Ongeldige instelling in de bovenbalk.')
raise ValueError('Invalid setting in the top bar.')
clean = {'type': kind}
elif kind == 'entity':
if set(item) - {'type', 'entity', 'content', 'icon', 'show'}:
raise ValueError('Onbekende instelling in de bovenbalk; vernieuw de beheerpagina.')
raise ValueError('Unknown setting in the top bar; refresh the management page.')
if not header_entity(item.get('entity')):
raise ValueError('Deze entiteit kan niet in de bovenbalk.')
raise ValueError("This entity can't go in the top bar.")
clean = {'type': 'entity', 'entity': item['entity'], 'content': item.get('content', 'state'),
'icon': item.get('icon', 'auto'), 'show': item.get('show', 'always')}
if clean['content'] not in HEADER_CONTENTS or clean['show'] not in HEADER_SHOWS:
raise ValueError('Ongeldige instelling in de bovenbalk.')
raise ValueError('Invalid setting in the top bar.')
if not (clean['icon'] in ('auto', 'none') or isinstance(clean['icon'], str) and clean['icon'] in tile_icons.ICONS):
raise ValueError('Kies een icoon uit de lijst.')
raise ValueError('Choose an icon from the list.')
else:
raise ValueError('Onbekend onderdeel in de bovenbalk; vernieuw de beheerpagina.')
raise ValueError('Unknown item in the top bar; refresh the management page.')
key = json.dumps(clean, sort_keys=True)
if key in seen:
raise ValueError('Dit onderdeel staat al in de bovenbalk.')
raise ValueError('This item is already in the top bar.')
seen.add(key)
items.append(clean)
return {'items': items}
@@ -226,52 +261,52 @@ def short(value, limit):
def validate_layout(data):
if not isinstance(data, dict):
raise ValueError('Ongeldige indeling.')
raise ValueError('Invalid layout.')
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.')
raise ValueError('Give the screen a title of at most 96 bytes.')
if not isinstance(tiles, list) or len(tiles) > 20:
raise ValueError('Kies maximaal 20 tegels.')
raise ValueError('Choose at most 20 tiles.')
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.')
raise ValueError("This entity isn't supported.")
if tile['entity'] in seen:
raise ValueError('Een entiteit kan maar één keer op een scherm staan.')
raise ValueError('An entity can only appear once on a screen.')
name = tile.get('name', '')
if not isinstance(name, str) or len(name.encode()) > 80:
raise ValueError('Een tegelnaam mag maximaal 80 bytes bevatten.')
raise ValueError('A tile name may contain at most 80 bytes.')
seen.add(tile['entity'])
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', 'background', 'size', 'icon', 'controls'}:
raise ValueError('Onbekende tegelinstellingen.')
raise ValueError('Unknown tile settings.')
if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS):
raise ValueError('Kies een pastel achtergrondkleur uit het palet.')
raise ValueError('Choose a pastel background color from the palette.')
if 'icon' in options and not (options['icon'] == 'auto' or isinstance(options['icon'], str) and options['icon'] in tile_icons.ICONS):
raise ValueError('Kies een icoon uit de lijst.')
raise ValueError('Choose an icon from the list.')
domain = tile['entity'].split('.')[0]
displays = DISPLAYS.get(domain, ('standard', 'watch'))
choices = {'tap': ('auto', 'detail', 'toggle', 'none'), 'display': displays, 'inline': ('none', 'slider'), 'size': ('single', 'wide')}
for key, allowed in choices.items():
if key in options and options[key] not in allowed:
raise ValueError('Ongeldige tegelinstelling: ' + key)
raise ValueError('Invalid tile setting: ' + key)
# The five-day strip and the sun path only fit a double-width card.
if options.get('display') in WIDE_ONLY:
options = {**options, 'size': 'wide'}
if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player','climate'}:
raise ValueError('Deze entiteit ondersteunt geen aan/uit-actie.')
raise ValueError("This entity doesn't support an on/off action.")
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.')
raise ValueError("This entity doesn't support a mini-slider.")
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.')
raise ValueError('History: choose 1, 6, or 24 hours.')
if options.get('display') == 'watch' and options.get('inline') == 'slider':
raise ValueError('Kies grote waarde of mini-schuif.')
raise ValueError('Choose large value or mini-slider.')
if 'controls' in options:
allowed = ('none',) + tuple(key for key, _ in CONTROLS.get(domain, ()))
if not isinstance(options['controls'], str) or options['controls'] not in allowed:
raise ValueError('Deze entiteit ondersteunt die directe bediening niet.')
raise ValueError("This entity doesn't support that direct control.")
item['options'] = dict(options)
clean.append(item)
# Positions: every tile or none (an older editor sends none and keeps its order).
@@ -280,12 +315,12 @@ def validate_layout(data):
occupied = set()
for item, slot in zip(clean, given):
if type(slot) is not int or not 0 <= slot < MAX_SLOTS:
raise ValueError('Ongeldige tegelpositie; vernieuw de beheerpagina.')
raise ValueError('Invalid tile position; refresh the management page.')
if is_wide(item) and slot % 2:
raise ValueError('Een dubbelbrede tegel begint in de linkerkolom.')
raise ValueError('A double-width tile starts in the left column.')
for cell in footprint(slot, is_wide(item)):
if cell in occupied:
raise ValueError('Twee tegels staan op dezelfde plek.')
raise ValueError('Two tiles are in the same spot.')
occupied.add(cell)
item['slot'] = slot
clean.sort(key=lambda item: item['slot'])
@@ -296,7 +331,7 @@ def validate_layout(data):
# Pages kept on purpose, empty ones included; the screen shows at least what the tiles need.
if 'pages' in data:
if type(data['pages']) is not int or not 1 <= data['pages'] <= MAX_PAGES:
raise ValueError(f'Een scherm heeft 1 tot {MAX_PAGES} pagina\'s.')
raise ValueError(f'A screen has 1 to {MAX_PAGES} pages.')
result['pages'] = data['pages']
if 'settings' in data:
result['settings'] = validate_settings(data['settings'])
@@ -451,7 +486,7 @@ def encode(message):
"""The message as the firmware parses it: compact JSON, at most 4096 bytes."""
raw = json.dumps(message, ensure_ascii=False, separators=(',', ':'), allow_nan=False)
if len(raw.encode()) > 4096:
raise ValueError('Het schermbericht is te groot.')
raise ValueError('The screen message is too large.')
return raw
def revision(message):
@@ -473,20 +508,20 @@ def message_action(node):
# keeps every value here equal to what the board profiles compile. -----
ALERT_MIN_FIRMWARE = '0.2.31'
ALERT_EVENT = 'esphome.screen_alert'
ALERT_ENDINGS = (('ok', 'De knop is ingedrukt'), ('timeout', 'De timeout is verstreken'),
('replaced', 'Een nieuwe alert kwam eroverheen'), ('remote', 'dismiss_alert vanuit Home Assistant'))
ALERT_ENDINGS = (('ok', 'The button was pressed'), ('timeout', 'The timeout ran out'),
('replaced', 'A new alert came over it'), ('remote', 'dismiss_alert from Home Assistant'))
ALERT_FALLBACK_ICON = 'alert-outline'
# (field, ESPHome type, label, explanation, example) in the order Home Assistant shows them.
ALERT_FIELDS = (
('title', 'string', 'Titel', 'Eén regel bovenaan de kaart; wat niet past krijgt puntjes. Leeg wordt "Melding".', 'Iemand belt aan'),
('subtitle', 'string', 'Subtitle', 'Toelichting onder de titel, over meerdere regels; een regeleinde mag. Leeg mag.', 'Deur 3, achterkant'),
('icon', 'string', 'Icoon', 'Een naam uit de lijst, ook als mdi:naam of als hex-codepoint (F12E6). Onbekend of leeg geeft de waarschuwingsdriehoek.', 'doorbell'),
('color', 'string', 'Kleur', 'Een van de negen pastelkleuren van de tegels. Leeg geeft de witte kaart.', 'orange'),
('button_text', 'string', 'Knoptekst', 'De tekst op de knop. Leeg is "Oké".', 'Ik kom'),
('timeout', 'int', 'Timeout', 'Seconden waarna de kaart vanzelf verdwijnt. 0 wacht op de knop, hoe lang dat ook duurt. De knop sluit altijd direct, ook met een timeout.', 0),
('flash', 'bool', 'Knipperen', 'Aan laat de backlight vier keer knipperen als de alert binnenkomt; daarna blijft het scherm gewoon aan.', True),
('title', 'string', 'Title', 'A single line at the top of the card; what doesn\'t fit gets an ellipsis. Empty becomes "Notification".', 'Someone is at the door'),
('subtitle', 'string', 'Subtitle', 'Explanation under the title, across multiple lines; a line break is fine. Empty is fine.', 'Door 3, back'),
('icon', 'string', 'Icon', 'A name from the list, also as mdi:name or as a hex codepoint (F12E6). Unknown or empty gives the warning triangle.', 'doorbell'),
('color', 'string', 'Color', 'One of the nine pastel colors of the tiles. Empty gives the white card.', 'orange'),
('button_text', 'string', 'Button text', 'The text on the button. Empty is "OK".', 'Coming'),
('timeout', 'int', 'Timeout', 'Seconds after which the card disappears on its own. 0 waits for the button, however long that takes. The button always closes it immediately, even with a timeout.', 0),
('flash', 'bool', 'Blinking', 'On makes the backlight blink four times when the alert arrives; the screen then just stays on.', True),
)
# Bytes per field the firmware keeps (the profiles' ALERT_*_MAX); a Dutch letter with accent takes two.
# Bytes per field the firmware keeps (the profiles' ALERT_*_MAX); an accented letter takes two.
ALERT_LIMITS = {'cyd': {'title': 48, 'subtitle': 160, 'button_text': 12}, 'guition': {'title': 64, 'subtitle': 240, 'button_text': 16}}
ALERT_SUGGESTED_ICONS = ('doorbell', 'bell', 'bell-ring', 'alert-outline', 'alarm-light', 'lock', 'lock-open-variant', 'door-open',
'window-closed-variant', 'motion-sensor', 'cctv', 'smoke-detector', 'water-alert', 'fire', 'mailbox', 'car',
@@ -515,35 +550,35 @@ def discover_screens(registry, states, devices, areas):
"""Paired screens: every enabled ESPHome inbox with the diagnostics of its device."""
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'}
versions = {item.get('device_id'): states.get(item['entity_id'], {}).get('state', 'unknown') for item in registry
if item.get('platform') == 'esphome' and item.get('original_name') in NAME_SCREEN_FIRMWARE}
boards = {item.get("device_id"): "guition" for item in registry
if item.get("platform") == "esphome" and item.get("original_name") == "Guition schermtype"}
def diagnostic(name, pattern):
if item.get("platform") == "esphome" and item.get("original_name") in NAME_GUITION_TYPE}
def diagnostic(names, pattern):
found = {}
for item in registry:
if item.get('platform') == 'esphome' and item.get('original_name') == name:
if item.get('platform') == 'esphome' and item.get('original_name') in names:
value = states.get(item['entity_id'], {}).get('state', '')
if isinstance(value, str) and re.fullmatch(pattern, value):
found[item.get('device_id')] = value
return found
nodes = diagnostic('Apparaatnaam', r'[a-z0-9][a-z0-9-]{0,30}')
addresses = diagnostic('IP-adres', r'\d{1,3}(\.\d{1,3}){3}')
nodes = diagnostic(NAME_DEVICE_NAME, r'[a-z0-9][a-z0-9-]{0,30}')
addresses = diagnostic(NAME_IP_ADDRESS, r'\d{1,3}(\.\d{1,3}){3}')
screens = []
for item in registry:
eid = item['entity_id']
if not (item.get('platform') == 'esphome' and eid.startswith('text.') and item.get('original_name') == 'Tegelinstellingen' and not item.get('disabled_by')):
if not (item.get('platform') == 'esphome' and eid.startswith('text.') and item.get('original_name') in NAME_TILE_SETTINGS and not item.get('disabled_by')):
continue
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'), '')
screens.append({'id': eid, 'name': device.get('name_by_user') or device.get('name') or eid,
'firmware': versions.get(item.get('device_id'), 'onbekend'),
'firmware': versions.get(item.get('device_id'), 'unknown'),
'board': boards.get(item.get('device_id'), 'unknown'),
'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')),
'device': device.get('name') or '',
'area': area, 'online': state.get('state') not in (None, 'unknown', 'unavailable'),
'status': state.get('state', 'Niet verbonden')})
'status': state.get('state', 'Not connected')})
return screens
def discover(registry, states, devices, areas):
@@ -578,13 +613,13 @@ def discover(registry, states, devices, areas):
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).')
raise ValueError('Choose a board and a unique name (lowercase letters, digits, dashes; 30 characters max).')
if not isinstance(friendly, str) or not friendly.strip() or len(friendly) > 60:
raise ValueError('Geef het scherm een herkenbare naam (maximaal 60 tekens).')
raise ValueError('Give the screen a recognizable name (60 characters max).')
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.
return f'''# Keep this file safe: it contains the unique keys for this screen.
# Wi-Fi comes from the secrets.yaml of ESPHome Device Builder.
substitutions:
DEVICE_NAME: {quote(name)}
DEVICE_FRIENDLY_NAME: {quote(friendly.strip())}
+17 -17
View File
@@ -82,10 +82,10 @@ class Firmware:
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.')
raise ValueError('Choose an existing YAML profile.')
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.')
raise ValueError("Profile doesn't exist in the ESPHome folder.")
return p
def wifi_status(self):
@@ -116,12 +116,12 @@ class Firmware:
if wifi['state'] == 'ready':
return
if wifi['state'] == 'invalid':
raise ValueError('secrets.yaml in de ESPHome-map is geen geldige YAML. Herstel het bestand eerst; het wordt niet overschreven.')
raise ValueError("secrets.yaml in the ESPHome folder isn't valid YAML. Fix the file first; it won't be overwritten.")
values = {}
for key in wifi['missing']:
value = data.get(key)
if not isinstance(value, str) or (key == 'wifi_ssid' and not value.strip()):
raise ValueError('Vul de wifi-naam en het wachtwoord in; ze worden in ESPHome secrets.yaml bewaard.')
raise ValueError("Fill in the Wi-Fi name and password; they'll be saved in ESPHome secrets.yaml.")
values[key] = value
path = self.root / 'secrets.yaml'
if wifi['state'] == 'new':
@@ -140,7 +140,7 @@ class Firmware:
text = text + ('' if not text or text.endswith('\n') else '\n') + line + '\n'
check = yaml.safe_load(text)
if not isinstance(check, dict) or any(check.get(key) != value for key, value in values.items()):
raise ValueError('Wifi kon niet in secrets.yaml worden gezet. Vul wifi_ssid en wifi_password daar zelf in.')
raise ValueError("Wi-Fi couldn't be set in secrets.yaml. Fill in wifi_ssid and wifi_password there yourself.")
path.write_text(text)
def create(self, data):
@@ -149,13 +149,13 @@ class Firmware:
self.root.mkdir(parents=True,exist_ok=True)
profile = self.root / (data['name']+'.yaml')
if profile.exists() or profile.is_symlink():
raise ValueError('Deze naam bestaat al. Gebruik het bestaande profiel voor updates.')
raise ValueError('This name already exists. Use the existing profile for updates.')
self.store_wifi(data)
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.')
raise ValueError('This name already exists. Use the existing profile for updates.')
key = yaml.load(content, Loader=LenientLoader)['api']['encryption']['key']
# The key is what Home Assistant asks for when pairing; the page shows it once.
return {'file': profile.name, 'node': data['name'], 'api_key': key}
@@ -168,11 +168,11 @@ class Firmware:
target = data.get('target') or ''
if target:
if not isinstance(target, str) or target not in self.ports():
raise ValueError('Kies de aangesloten USB-poort uit de lijst.')
raise ValueError('Choose the connected USB port from the list.')
if self.task and not self.task.done():
raise ValueError('Er loopt al een build of installatie. Wacht tot die klaar is.')
raise ValueError('A build or installation is already running. Wait for it to finish.')
if not shutil.which('esphome'):
raise ValueError('ESPHome CLI ontbreekt in deze installatie.')
raise ValueError('The ESPHome CLI is missing from this installation.')
result = self.create(data)
if target:
result['job'] = self.start({'file': result['file'], 'action': 'install', 'target': target})
@@ -185,17 +185,17 @@ class Firmware:
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.')
if self.task and not self.task.done(): raise ValueError('A build or installation is already running.')
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.')
if action not in ('validate','build','install'): raise ValueError('Unknown firmware action.')
if not shutil.which('esphome'): raise ValueError('The ESPHome CLI is missing from this installation.')
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.')
if target not in self.ports(): raise ValueError('Choose the connected USB port from the list.')
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.')
raise ValueError('Enter the IP address or hostname of the intended screen.')
self._secret_values=set()
# Collect scalar literals, including !secret values, without executing YAML tags.
def collect(node):
@@ -232,8 +232,8 @@ class Firmware:
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)
if code: raise RuntimeError('ESPHome '+stage+' failed; see the log.')
self.job['state']='success'; self.logs.append('Succeeded: '+action)
if action=='install': self.installed.add(profile.name)
except asyncio.CancelledError:
self.job['state']='interrupted'
+37 -37
View File
@@ -1,9 +1,9 @@
"""Top bar of a screen ("bovenbalk"): what the firmware draws right of the screen name.
"""Top bar of a screen: what the firmware draws right of the screen name.
Firmware 0.2.32+ only draws. This module decides everything that needs Home Assistant: an
entity's text in Dutch (number format, units, translated states), its icon, its accent colour
entity's text (number format, units, translated states), its icon, its accent colour
and whether it shows at all. Clock, analog clock and date tick on the screen itself, and a
relative time ("5 min geleden") travels as a unix time, so the screen counts the minutes
relative time ("5 min ago") travels as a unix time, so the screen counts the minutes
without any traffic.
Wire format, one message after the layout: {"v":1,"op":"header","items":[...]} with per item
@@ -21,33 +21,33 @@ from core import HEADER_BUILTIN, HEADER_CONTENTS, HEADER_MAX_ITEMS, HEADER_MIN_F
# tests/test_header_bar.py keeps them equal). Anything else folds to its base letter or goes.
GLYPHS = frozenset("<>—&@!,.?\"%()+-_:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyzäöüÄÖÜß/…·'#*=;²³µéèëêïîóôàáâçñúû–")
TEXT_BYTES = 40
MONTHS = ('jan', 'feb', 'mrt', 'apr', 'mei', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec')
MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
UNAVAILABLE = '—'
CONTENT_LABELS = {'state': 'Status', 'last_changed': 'Laatst gewijzigd'}
SHOW_LABELS = {'always': 'Altijd', 'active': 'Alleen als actief'}
CONTENT_LABELS = {'state': 'Status', 'last_changed': 'Last changed'}
SHOW_LABELS = {'always': 'Always', 'active': 'Only when active'}
# Home Assistant's own words where they fit a small bar; the screen shows the rest as they come.
STATES = {
'on': 'Aan', 'off': 'Uit', 'home': 'Thuis', 'not_home': 'Weg', 'open': 'Open', 'closed': 'Dicht',
'opening': 'Gaat open', 'closing': 'Gaat dicht', 'locked': 'Op slot', 'unlocked': 'Open', 'locking': 'Sluiten',
'unlocking': 'Openen', 'jammed': 'Vast', 'playing': 'Speelt', 'paused': 'Pauze', 'idle': 'Inactief',
'standby': 'Stand-by', 'buffering': 'Laden', 'cleaning': 'Bezig', 'docked': 'In dock', 'returning': 'Naar dock',
'error': 'Fout', 'heat': 'Verwarmen', 'cool': 'Koelen', 'heat_cool': 'Auto', 'auto': 'Auto', 'dry': 'Drogen',
'fan_only': 'Ventileren', 'disarmed': 'Uit', 'armed_home': 'Thuis', 'armed_away': 'Afwezig', 'armed_night': 'Nacht',
'armed_vacation': 'Vakantie', 'armed_custom_bypass': 'Aan', 'arming': 'Inschakelen', 'pending': 'Wachten',
'triggered': 'Alarm', 'active': 'Loopt', 'above_horizon': 'Op', 'below_horizon': 'Onder',
'on': 'On', 'off': 'Off', 'home': 'Home', 'not_home': 'Away', 'open': 'Open', 'closed': 'Closed',
'opening': 'Opening', 'closing': 'Closing', 'locked': 'Locked', 'unlocked': 'Unlocked', 'locking': 'Locking',
'unlocking': 'Unlocking', 'jammed': 'Jammed', 'playing': 'Playing', 'paused': 'Paused', 'idle': 'Idle',
'standby': 'Standby', 'buffering': 'Buffering', 'cleaning': 'Cleaning', 'docked': 'Docked', 'returning': 'Returning',
'error': 'Error', 'heat': 'Heat', 'cool': 'Cool', 'heat_cool': 'Auto', 'auto': 'Auto', 'dry': 'Dry',
'fan_only': 'Fan only', 'disarmed': 'Disarmed', 'armed_home': 'Armed home', 'armed_away': 'Armed away', 'armed_night': 'Armed night',
'armed_vacation': 'Armed vacation', 'armed_custom_bypass': 'Armed', 'arming': 'Arming', 'pending': 'Pending',
'triggered': 'Triggered', 'active': 'Active', 'above_horizon': 'Up', 'below_horizon': 'Down',
}
# device_class: (on, off)
BINARY_STATES = {
'battery': ('Laag', 'Normaal'), 'battery_charging': ('Laden', 'Niet laden'), 'carbon_monoxide': ('Gevaar', 'Veilig'),
'cold': ('Koud', 'Normaal'), 'connectivity': ('Verbonden', 'Verbroken'), 'door': ('Open', 'Dicht'),
'garage_door': ('Open', 'Dicht'), 'gas': ('Gevaar', 'Veilig'), 'heat': ('Heet', 'Normaal'), 'light': ('Licht', 'Donker'),
'lock': ('Open', 'Op slot'), 'moisture': ('Nat', 'Droog'), 'motion': ('Beweging', 'Geen beweging'),
'moving': ('Beweegt', 'Stil'), 'occupancy': ('Bezet', 'Vrij'), 'opening': ('Open', 'Dicht'), 'plug': ('Aangesloten', 'Los'),
'power': ('Aan', 'Uit'), 'presence': ('Thuis', 'Weg'), 'problem': ('Probleem', 'OK'), 'running': ('Actief', 'Inactief'),
'safety': ('Onveilig', 'Veilig'), 'smoke': ('Rook', 'Geen rook'), 'sound': ('Geluid', 'Stil'), 'tamper': ('Sabotage', 'OK'),
'update': ('Update', 'Bijgewerkt'), 'vibration': ('Trilling', 'Stil'), 'window': ('Open', 'Dicht'),
'battery': ('Low', 'Normal'), 'battery_charging': ('Charging', 'Not charging'), 'carbon_monoxide': ('Danger', 'Safe'),
'cold': ('Cold', 'Normal'), 'connectivity': ('Connected', 'Disconnected'), 'door': ('Open', 'Closed'),
'garage_door': ('Open', 'Closed'), 'gas': ('Danger', 'Safe'), 'heat': ('Hot', 'Normal'), 'light': ('Light', 'Dark'),
'lock': ('Open', 'Locked'), 'moisture': ('Wet', 'Dry'), 'motion': ('Motion', 'No motion'),
'moving': ('Moving', 'Still'), 'occupancy': ('Occupied', 'Clear'), 'opening': ('Open', 'Closed'), 'plug': ('Plugged in', 'Unplugged'),
'power': ('On', 'Off'), 'presence': ('Home', 'Away'), 'problem': ('Problem', 'OK'), 'running': ('Active', 'Inactive'),
'safety': ('Unsafe', 'Safe'), 'smoke': ('Smoke', 'No smoke'), 'sound': ('Sound', 'Silent'), 'tamper': ('Tampering', 'OK'),
'update': ('Update', 'Up to date'), 'vibration': ('Vibration', 'Still'), 'window': ('Open', 'Closed'),
}
# Icon names from tile_icons; the fonts of both boards carry every one of them.
BINARY_ICONS = { # device_class: (on, off)
@@ -101,7 +101,7 @@ def clean_text(text):
return short(''.join(out).strip(), TEXT_BYTES)
def number_text(value, precision=None):
"""A number as Home Assistant writes it in Dutch: decimal comma, dots between thousands."""
"""A number as Home Assistant writes it: decimal point, commas between thousands."""
if precision is None:
# Without a display precision HA keeps integers whole and shows at most three decimals.
digits = 0 if float(value).is_integer() else 3
@@ -112,13 +112,13 @@ def number_text(value, precision=None):
text = f'{value:.{precision}f}'
negative = text.startswith('-')
whole, _, fraction = text.lstrip('-').partition('.')
grouped = f'{int(whole):,}'.replace(',', '.')
result = grouped + (',' + fraction if fraction else '')
# "-0" and "-0,0" are just zero.
grouped = f'{int(whole):,}'
result = grouped + ('.' + fraction if fraction else '')
# "-0" and "-0.0" are just zero.
return '-' + result if negative and any(c not in '0.,' for c in result) else result
def with_unit(text, unit):
"""Home Assistant's spacing in Dutch: "21,3 °C", "65%", "18°"."""
"""Home Assistant's spacing: "21.3 °C", "65%", "18°"."""
if not unit:
return text
return text + unit if unit in ('%', '°') else f'{text} {unit}'
@@ -152,7 +152,7 @@ def value(entity, state, entry=None, units=None, tz=None):
return (None, epoch(raw)) if epoch(raw) else (UNAVAILABLE, None)
if domain == 'script':
moment = epoch(attrs.get('last_triggered'))
return ('Bezig', None) if raw == 'on' else (None, moment) if moment else ('Nog niet gestart', None)
return ('Running', None) if raw == 'on' else (None, moment) if moment else ('Never run', None)
if domain == 'sun':
# The next event: sunset while the sun is up, sunrise while it is down.
clock = local_clock(attrs.get('next_setting' if raw == 'above_horizon' else 'next_rising'), tz)
@@ -169,7 +169,7 @@ def value(entity, state, entry=None, units=None, tz=None):
if domain in ('person', 'device_tracker'):
return STATES.get(raw, raw), None
if domain == 'binary_sensor':
pair = BINARY_STATES.get(attrs.get('device_class'), ('Aan', 'Uit'))
pair = BINARY_STATES.get(attrs.get('device_class'), ('On', 'Off'))
return (pair[0] if raw == 'on' else pair[1] if raw == 'off' else raw), None
if domain == 'input_datetime':
# "2026-09-15 07:30:00", "2026-09-15" or just "07:30:00".
@@ -197,7 +197,7 @@ def value(entity, state, entry=None, units=None, tz=None):
return raw[:1].upper() + raw[1:], None
def active(entity, state):
""""Alleen als actief": on, open, home, detected, playing, heating, or a number other than zero."""
""""Only when active": on, open, home, detected, playing, heating, or a number other than zero."""
raw = state.get('state') if state else None
domain = entity.split('.')[0]
if raw in (None, '', 'unavailable', 'unknown'):
@@ -324,13 +324,13 @@ def suggestions(screen, entities, states, registry=None, limit=8):
def sensor(device_class):
return lambda e: e['id'].startswith('sensor.') and attrs(e).get('device_class') == device_class and numeric(states[e['id']]['state']) is not None
picks = []
pick('Temperatuur', sensor('temperature'))
pick('Luchtvochtigheid', sensor('humidity'))
pick('Stroomverbruik', sensor('power'))
pick('Laatste beweging', lambda e: e['id'].startswith('binary_sensor.') and attrs(e).get('device_class') in ('motion', 'occupancy', 'presence'), 'last_changed')
pick('Weer', lambda e: e['id'].startswith('weather.'))
pick('Aantal thuis', lambda e: e['id'] == 'zone.home')
pick('Zon op en onder', lambda e: e['id'] == 'sun.sun')
pick('Temperature', sensor('temperature'))
pick('Humidity', sensor('humidity'))
pick('Power usage', sensor('power'))
pick('Last motion', lambda e: e['id'].startswith('binary_sensor.') and attrs(e).get('device_class') in ('motion', 'occupancy', 'presence'), 'last_changed')
pick('Weather', lambda e: e['id'].startswith('weather.'))
pick('People home', lambda e: e['id'] == 'zone.home')
pick('Sunrise and sunset', lambda e: e['id'] == 'sun.sun')
pick('CO₂', sensor('carbon_dioxide'))
return picks[:limit]
+114 -46
View File
@@ -14,7 +14,7 @@ import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BUILTIN, HEADER_MIN_FIRMWARE, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_reference, alert_service, controls_catalogue, discover, discover_screens, encode, extras, header_items, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
from core import BUILTIN, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_reference, alert_service, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
import header_bar
from zoneinfo import ZoneInfo
@@ -31,7 +31,12 @@ FULL_REPEAT_SECONDS = 3600
HISTORY_SECONDS = 300
# Inbox states after which a screen needs the whole layout again: a restart, a ping that
# did not match, or a tile state that never arrived. Guarded so a slow batch cannot loop.
RESEND_STATES = frozenset({'Klaar voor tegelconfiguratie', 'Indeling opnieuw nodig', 'Tegels laden'})
# Firmware built before the English translation still reports the Dutch originals, so both
# forms are recognised until every board has been reflashed.
RESEND_STATES = frozenset({
'Ready for tile configuration', 'Resend needed', 'Loading tiles',
'Klaar voor tegelconfiguratie', 'Indeling opnieuw nodig', 'Tegels laden',
})
RESEND_GUARD_SECONDS = 120
FORECAST_SECONDS = 1800
@@ -78,7 +83,7 @@ class HomeAssistant:
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
raise ConnectionError('Home Assistant is niet verbonden.')
raise ConnectionError("Home Assistant isn't connected.")
self.next_id += 1
key = self.next_id
future = asyncio.get_running_loop().create_future()
@@ -100,7 +105,7 @@ class HomeAssistant:
if data.get('success'):
future.set_result(data.get('result'))
else:
future.set_exception(ConnectionError('Home Assistant heeft de opdracht geweigerd.'))
future.set_exception(ConnectionError('Home Assistant refused the command.'))
elif data.get('type') == 'event':
event = data.get('event', {})
body = event.get('data', {})
@@ -118,7 +123,7 @@ class HomeAssistant:
self.changed.set()
elif event.get('event_type') in REGISTRY_EVENTS:
self.registry_changed.set()
raise ConnectionError('Home Assistant-verbinding verbroken.')
raise ConnectionError('Home Assistant connection lost.')
def describe_close(self, connected):
"""Why and after how long the websocket ended; helps explain unexpected reconnects in the log."""
@@ -126,7 +131,7 @@ class HomeAssistant:
return ''
ws = self.ws
reason = ws.exception() if ws is not None else None
return ' na %d s, close-code %s%s' % (time.monotonic() - connected, ws.close_code if ws is not None else None,
return ' after %d s, close code %s%s' % (time.monotonic() - connected, ws.close_code if ws is not None else None,
f', {type(reason).__name__}' if reason else '')
async def registries(self):
@@ -144,7 +149,7 @@ class HomeAssistant:
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.')
raise ConnectionError('Home Assistant authentication failed.')
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')
@@ -161,7 +166,7 @@ class HomeAssistant:
self.online = True
self.changed.set()
connected = time.monotonic()
LOG.info('Home Assistant verbonden')
LOG.info('Home Assistant connected')
# Refresh the registry when HA reports a change (debounced), with a slow
# fallback; a full fetch is about 1 MB of JSON and used to run every 30 s.
fetched = time.monotonic()
@@ -182,12 +187,12 @@ class HomeAssistant:
fetched = time.monotonic()
self.changed.set()
except (ConnectionError, TimeoutError, OSError, ValueError) as error:
LOG.warning('Home Assistant tijdelijk niet beschikbaar (%s)%s', type(error).__name__, self.describe_close(connected))
LOG.warning('Home Assistant temporarily unavailable (%s)%s', type(error).__name__, self.describe_close(connected))
except Exception as error:
LOG.warning('Verbinding opnieuw starten (%s)%s', type(error).__name__, self.describe_close(connected))
LOG.warning('Restarting connection (%s)%s', type(error).__name__, self.describe_close(connected))
finally:
if self.online:
LOG.info('Home Assistant-verbinding gesloten%s', self.describe_close(connected))
LOG.info('Home Assistant connection closed%s', self.describe_close(connected))
self.online = False
self.ws = None
if reader:
@@ -196,7 +201,7 @@ class HomeAssistant:
await reader
for future in self.pending.values():
if not future.done():
future.set_exception(ConnectionError('Verbinding verbroken.'))
future.set_exception(ConnectionError('Connection lost.'))
await asyncio.sleep(5)
async def send(self, inbox, message, action=None):
@@ -249,7 +254,7 @@ class HomeAssistant:
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.')
if len(raw)>2*1024*1024: raise ValueError('History too large.')
rows=json.loads(raw)
events=[]
begin=datetime.fromisoformat(start).timestamp(); span=hours*3600
@@ -278,11 +283,15 @@ class Manager:
self._screens_key, self._screens = None, []
self._watched_key, self._watched = None, set()
self._seen_registry = None
# Inbox entity ids seen this run with their Home Assistant device, and old inbox ids that a screen
# now reports under a new id (see follow_renamed_inboxes); the updater follows a screen through them.
self._inbox_devices, self.aliases = {}, {}
self._prefix_source, self._prefixes = None, {}
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.')
raise ValueError('Unknown storage version; data stays unchanged.')
self.layouts = {key: validate_layout(value) for key, value in raw['screens'].items()}
def inventory(self):
@@ -306,10 +315,74 @@ class Manager:
key = (id(ha.registry), id(ha.devices), id(ha.areas), tuple(ha.states.get(item['entity_id'], {}).get('state') for item in items))
if key != self._screens_key:
self._screens_key, self._screens = key, discover_screens(items, ha.states, ha.devices, ha.areas)
self.follow_renamed_inboxes(items)
return [dict(screen) for screen in self._screens]
def screen(self, inbox):
return next((s for s in self.screens() if s['id'] == inbox), None)
screens = self.screens()
inbox = self.aliases.get(inbox, inbox)
return next((s for s in screens if s['id'] == inbox), None)
def device_prefixes(self, device):
registry = getattr(self.ha, 'registry', [])
if self._prefix_source is not registry:
self._prefix_source, self._prefixes = registry, {}
if device not in self._prefixes:
self._prefixes[device] = device_prefixes(registry, device)
return self._prefixes[device]
def follow_renamed_inboxes(self, items):
"""Keep a screen's layout and update history when its inbox entity gets a new entity id.
Firmware 0.2.34 renamed the inbox from "Tegelinstellingen" to "Tile settings". Home Assistant then
removes the old entity and registers the renamed one under a new id, which would orphan the layout
stored under the old id. The screen is recognised by its device: an inbox this run saw on the same
device, or (after a restart) a stored inbox id that carries one of the device's entity id prefixes."""
current = {item['entity_id']: item.get('device_id') for item in items
if item.get('platform') == 'esphome' and item['entity_id'].startswith('text.') and item.get('device_id')
and item.get('original_name') in NAME_TILE_SETTINGS and not item.get('disabled_by')}
stored = (set(self.layouts) | set(self.updates.hosts) | set(self.updates.results)) - set(current) - set(self.aliases)
for new, device in current.items():
olds = {old for old, seen in self._inbox_devices.items() if seen == device and old not in current}
candidates = [old for old in stored if inbox_prefix(old) is not None]
if candidates:
prefixes = self.device_prefixes(device)
olds |= {old for old in candidates if inbox_prefix(old) in prefixes}
for old in sorted(olds - set(self.aliases)):
self.rename_inbox(old, new)
self._inbox_devices.update(current)
def write_layouts(self, layouts):
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': layouts}, handle, ensure_ascii=False)
handle.flush()
os.fsync(handle.fileno())
temp.replace(self.path)
def rename_inbox(self, old, new):
"""Move everything kept under an old inbox id to the id the same screen reports under now."""
moved = []
if old in self.layouts and new not in self.layouts:
layouts = {(new if key == old else key): value for key, value in self.layouts.items()}
self.write_layouts(layouts)
self.layouts = layouts
moved.append('layout')
elif old in self.layouts:
LOG.warning('Screen %s already has a layout; the one stored under %s stays untouched', new, old)
if self.updates.renamed(old, new):
moved.append('update history')
for store in (self.sent, self.status, self.last, self.pinged):
store.pop(old, None)
self.aliases = {**{key: (new if value == old else value) for key, value in self.aliases.items()}, old: new}
self._inbox_devices.pop(old, None)
LOG.info('Screen %s now reports as %s%s', old, new, f"; moved its {' and '.join(moved)}" if moved else '')
if moved:
self.history_wake.set()
self.ha.changed.set()
self.notify()
def firmware_version(self, inbox, screen=None):
if screen is None:
@@ -356,13 +429,15 @@ class Manager:
def save(self, inbox, data):
screens, entities = self.inventory()
# A page opened before a screen's inbox got a new id still saves to the right screen.
inbox = self.aliases.get(inbox, inbox)
screen=next((s for s in screens if s['id']==inbox), None)
if screen is None:
raise ValueError('Dit is geen gekoppeld ESP-scherm. Vernieuw het overzicht.')
raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
layout = validate_layout(data)
needed = self.needs_firmware(inbox, layout, screen)
if needed:
raise ValueError(f"Installeer eerst schermfirmware {needed} of nieuwer voor deze tegels.")
raise ValueError(f"Install screen firmware {needed} or newer first for these tiles.")
# A still-open older UI may save tiles without the new optional settings or top bar.
if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
layout['settings'] = self.layouts[inbox]['settings'].copy()
@@ -376,7 +451,7 @@ class Manager:
if 'settings' in layout and 'rotation' not in data.get('settings',{}):
layout['settings']['rotation']=self.layouts.get(inbox,{}).get('settings',{}).get('rotation',0)
if layout.get('settings',{}).get('rotation',0) and screen.get('board')!='guition':
raise ValueError('Rotatie vereist een Guition met firmware 0.2.9 of nieuwer.')
raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
for tile in layout['tiles']:
old_options=old_tiles.get(tile['entity'],{}).get('options',{})
@@ -393,21 +468,14 @@ class Manager:
layout = validate_layout(layout)
known = {e['id'] for e in entities} | set(BUILTIN)
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.')
raise ValueError('A chosen entity no longer exists. Look up the new entity.')
if any(item['type'] == 'entity' and item['entity'] not in known and item['entity'] not in self.ha.states for item in header_items(layout)):
raise ValueError('Een entiteit in de bovenbalk bestaat niet meer. Kies een andere.')
raise ValueError('An entity in the top bar no longer exists. Choose a different one.')
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.write_layouts(updated)
self.layouts = updated
self.sent.pop(inbox, None)
self.status[inbox] = 'Opgeslagen; wacht op synchronisatie'
self.status[inbox] = 'Saved; waiting for sync'
self.history_wake.set()
self.ha.changed.set()
self.notify()
@@ -497,7 +565,7 @@ class Manager:
screen = self.screen(inbox) or {}
needed = self.needs_firmware(inbox, layout, screen)
if needed:
self.status[inbox]=f"Indeling bewaard; firmware {needed}+ nodig voor deze tegels"
self.status[inbox]=f"Layout saved; firmware {needed}+ needed for these tiles"
return False
tiles = layout['tiles']
layout_msg = self.layout_message(inbox, layout, screen)
@@ -540,7 +608,7 @@ class Manager:
self.last[inbox] = time.monotonic()
if outgoing:
self.pinged[inbox] = time.monotonic()
self.status[inbox] = 'Verzonden naar Home Assistant'
self.status[inbox] = 'Sent to Home Assistant'
return bool(outgoing)
async def ping(self, inbox, screen):
@@ -599,7 +667,7 @@ class Manager:
before = self.status.get(inbox)
if not screen['online']:
self.sent.pop(inbox, None)
self.status[inbox] = 'Scherm offline; wijzigingen bewaard'
self.status[inbox] = 'Screen offline; changes saved'
changed = changed or self.status[inbox] != before
continue
if inbox not in self.layouts:
@@ -617,8 +685,8 @@ class Manager:
await self.ping(inbox, screen)
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__)
self.status[inbox] = 'Sending failed; retrying automatically'
LOG.warning('Retrying screen sync (%s)', type(error).__name__)
await asyncio.sleep(1)
changed = changed or self.status.get(inbox) != before
if changed:
@@ -647,7 +715,7 @@ class Manager:
try:
found.update({(entity, hours): values for entity, values in (await self.ha.statistics(entities, hours)).items()})
except Exception as error:
LOG.warning('Statistieken ophalen mislukt (%s); geschiedenis per sensor', type(error).__name__)
LOG.warning('Fetching statistics failed (%s); falling back to per-sensor history', type(error).__name__)
changed = set()
for key in due:
values = found.get(key)
@@ -677,7 +745,7 @@ class Manager:
try:
await self.refresh_histories()
except Exception as error:
LOG.warning('Geschiedenis vernieuwen mislukt (%s)', type(error).__name__)
LOG.warning('Refreshing history failed (%s)', type(error).__name__)
def create_app(manager, development=False):
csrf = secrets.token_urlsafe(32)
@@ -685,15 +753,15 @@ def create_app(manager, development=False):
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.')
raise web.HTTPForbidden(text='Open this page through 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.')
raise web.HTTPForbidden(text='Refresh this page and try again.')
try:
response = await handler(request)
except ValueError as error:
return web.json_response({'error': str(error)}, status=400)
except (TypeError, KeyError):
return web.json_response({'error': 'Ongeldige invoer. Controleer naam, bord en gekozen tegels.'}, status=400)
return web.json_response({'error': 'Invalid input. Check the name, board, and chosen tiles.'}, status=400)
if response.prepared:
return response # streamed (SSE) responses set their headers before prepare()
response.headers['Cache-Control'] = 'no-store'
@@ -712,8 +780,8 @@ def create_app(manager, development=False):
screens = manager.screens()
profiles = manager.firmware.profile_names()
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')
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Home', 'tiles': []})
screen['delivery'] = manager.status.get(screen['id'], 'Choose your first tiles')
screen['update'] = manager.updates.state_for(screen, profiles)
screen['alert_action'] = alert_service(screen.get('node'))
screen['dismiss_action'] = alert_service(screen.get('node'), 'dismiss_alert')
@@ -731,7 +799,7 @@ def create_app(manager, development=False):
payload['controls'] = controls_catalogue()
payload['icons'] = tile_icons.editor()
payload['alerts'] = alert_reference()
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Ingebouwd op het scherm', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
payload['builtin'] = [{'id': key, 'name': name, 'device': 'Built into the screen', 'area': '', 'state': 'ok'} for key, name in BUILTIN.items()]
payload['header'] = {**header_bar.catalogue(), 'suggestions': {
screen['id']: header_bar.suggestions(screen, entities, manager.ha.states, manager.registry_index()) for screen in payload['screens']}}
return web.json_response(payload)
@@ -778,9 +846,9 @@ def create_app(manager, development=False):
manager.updates.set_auto((await request.json()).get('auto'))
return web.json_response(manager.updates.summary())
async def inspector(request):
inbox=request.match_info['inbox']
screen=manager.screen(inbox)
if screen is None: raise ValueError('Onbekend scherm.')
screen=manager.screen(request.match_info['inbox'])
inbox=manager.aliases.get(request.match_info['inbox'], request.match_info['inbox'])
if screen is None: raise ValueError('Unknown screen.')
layout=manager.layouts.get(inbox,{'tiles':[]})
return web.json_response({'screen':screen,
'delivery':manager.status.get(inbox), 'layout':layout,
@@ -819,7 +887,7 @@ async def main():
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.')
raise SystemExit('No Home Assistant access. Start the 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')
File diff suppressed because it is too large Load Diff
+163 -163
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="nl">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
@@ -13,28 +13,28 @@
<span class="mark">▦</span>
<div>
<strong>ESP Screens</strong
><small>Tegels voor je CYD en Guition, zonder opnieuw flashen</small>
><small>Tiles for your CYD and Guition, without reflashing</small>
</div>
</div>
<nav class="toolbar" aria-label="Acties">
<nav class="toolbar" aria-label="Actions">
<button id="new-screen" class="tool tool-primary">
<span class="tool-icon" aria-hidden="true">+</span>
<span class="tool-text"
><b>Nieuw scherm</b><small>Aansluiten en installeren</small></span
><b>New screen</b><small>Connect and install</small></span
>
</button>
<button id="open-firmware" class="tool">
<span class="tool-icon" aria-hidden="true">⇪</span>
<span class="tool-text"
><b>Firmware & USB</b
><small>Bouwen en installeren via USB of wifi</small></span
><small>Build and install via USB or Wi-Fi</small></span
>
</button>
<button id="open-alerts" class="tool">
<span class="tool-icon" aria-hidden="true">!</span>
<span class="tool-text"
><b>Alerts</b
><small>Meldingen over het hele scherm, vanuit een automatisering</small></span
><small>Full-screen notifications, from an automation</small></span
>
</button>
</nav>
@@ -43,26 +43,26 @@
<div class="intro">
<div>
<p class="eyebrow">HOME ASSISTANT</p>
<h1>Beheer je remote screens hier.</h1>
<h1>Manage your remote screens here.</h1>
</div>
<span id="connection" role="status" class="badge">Verbinden…</span>
<span id="connection" role="status" class="badge">Connecting…</span>
</div>
<div class="workspace">
<aside>
<h2>Mijn schermen</h2>
<h2>My screens</h2>
<div id="screens"></div>
<div id="pending"></div>
<button class="quiet" id="refresh">↻ Vernieuwen</button>
<button class="quiet" id="refresh">↻ Refresh</button>
<div class="updates" id="updates" hidden>
<h2>Firmware-updates</h2>
<h2>Firmware updates</h2>
<p class="hint" id="updates-hint"></p>
<button id="update-all" hidden>Alle schermen bijwerken</button>
<button id="update-all" hidden>Update all screens</button>
<label class="check">
<input type="checkbox" id="auto-update" />
<span
>Elke nacht automatisch bijwerken<small
>Tussen 03:00 en 06:00, één scherm tegelijk. Stopt bij een
fout en meldt dat in Home Assistant.</small
>Update automatically every night<small
>Between 03:00 and 06:00, one screen at a time. Stops on an
error and reports it in Home Assistant.</small
></span
>
</label>
@@ -70,35 +70,35 @@
</aside>
<section id="empty" class="empty">
<span class="empty-icon">▦</span>
<h2>Begin met een scherm</h2>
<h2>Start with a screen</h2>
<p>
Sluit een CYD of Guition via USB aan op je Home Assistant-machine.
Profiel, firmware en de eerste flash gebeuren in één venster.
Connect a CYD or Guition via USB to your Home Assistant machine.
Profile, firmware, and the first flash happen in one window.
</p>
<button id="start">Nieuw scherm installeren</button>
<button id="start">Install new screen</button>
</section>
<section id="editor" hidden>
<div class="editor-head">
<div>
<p class="eyebrow" id="screen-name"></p>
<h2>Maak het jouw scherm</h2>
<h2>Make it your screen</h2>
</div>
<span id="delivery" class="badge"></span>
</div>
<section class="topbar-editor" id="topbar" aria-labelledby="topbar-title">
<div class="section-title">
<h3 id="topbar-title">Bovenbalk</h3>
<h3 id="topbar-title">Top bar</h3>
<span id="topbar-count"></span>
</div>
<div class="topbar-fields">
<label class="topbar-name"
>Links: naam<input
>Left: name<input
id="title"
maxlength="60"
placeholder="Bijvoorbeeld: Woonkamer"
placeholder="For example: Living room"
/></label>
<div class="topbar-right">
<span class="topbar-caption" id="topbar-caption">Rechts: tijd, sensoren of een klok</span>
<span class="topbar-caption" id="topbar-caption">Right: time, sensors, or a clock</span>
<div
id="topbar-chips"
class="topbar-chips"
@@ -112,24 +112,24 @@
<div class="columns" id="tile-section">
<div class="tile-editor">
<div class="section-title">
<h3>Je tegels</h3>
<h3>Your tiles</h3>
<span id="count">0 / 10</span>
</div>
<div id="layout-preview" aria-label="Schermindeling"></div>
<div id="layout-preview" aria-label="Screen layout"></div>
<button id="add-page" class="quiet add-page" type="button">
+ Pagina toevoegen
+ Add page
</button>
<p id="no-tiles" class="hint">
Voeg je eerste lamp, scène of apparaat toe via de kiezer.
Add your first light, scene, or device via the picker.
</p>
<p id="positions-hint" class="hint" hidden></p>
</div>
<div class="picker">
<label for="search">Voeg een entiteit toe</label
<label for="search">Add an entity</label
><input
id="search"
type="search"
placeholder="Zoek op naam, apparaat of ruimte…"
placeholder="Search by name, device, or room…"
autocomplete="off"
/>
<div class="filters" id="filters"></div>
@@ -138,36 +138,36 @@
</div>
<details class="screen-settings" id="general-settings" open>
<summary>
Scherminstellingen
<span class="hint">Helderheid, rustmomenten en weergave</span>
Screen settings
<span class="hint">Brightness, quiet hours, and display</span>
</summary>
<p class="hint" id="settings-support"></p>
<div class="settings-grid" id="settings-fields"></div>
<p class="hint">
Nachturen gelden voor de helderheid tijdens standby. De eerste
aanraking maakt het scherm wakker; de volgende bedient je huis.
Gelijke begin- en eindtijd schakelen het nachtvenster uit.
Night hours apply to the brightness during standby. The first
touch wakes the screen; the next one controls your home.
Matching start and end times turn the night window off.
</p>
</details>
<details class="screen-settings" id="inspector-section">
<summary>
Inspector
<span class="hint"
>Verbinding, status en ondersteunde eigenschappen</span
>Connection, status, and supported properties</span
>
</summary>
<button id="inspect" class="quiet">Lees actuele gegevens</button>
<button id="inspect" class="quiet">Read current data</button>
<div id="inspection-summary"></div>
<pre id="inspection">Selecteer een scherm en lees de gegevens.</pre>
<pre id="inspection">Select a screen and read the data.</pre>
</details>
<footer class="savebar">
<div>
<strong id="dirty">Alles opgeslagen</strong
<strong id="dirty">All saved</strong
><small id="save-detail"
>Aanpassen kan zonder opnieuw flashen.</small
>Changes apply without reflashing.</small
>
</div>
<button id="save">Opslaan & naar scherm</button>
<button id="save">Save & send to screen</button>
</footer>
</section>
</div>
@@ -176,29 +176,29 @@
<form id="install-form">
<div class="dialog-head">
<div>
<p class="eyebrow">NIEUW SCHERM</p>
<h2 id="install-title">Aansluiten en installeren.</h2>
<p class="eyebrow">NEW SCREEN</p>
<h2 id="install-title">Connect and install.</h2>
</div>
<button
type="button"
class="quiet"
id="close-install"
aria-label="Sluiten"
aria-label="Close"
>
✕
</button>
</div>
<div id="install-setup">
<p>
Sluit het scherm met een USB-datakabel aan op de machine waarop Home
Assistant draait. ESP Screens maakt een eigen profiel met unieke
sleutels, bouwt de firmware en zet die direct op het scherm.
Connect the screen with a USB data cable to the machine running Home
Assistant. ESP Screens creates its own profile with unique
keys, builds the firmware, and puts it straight on the screen.
</p>
<fieldset>
<legend>Welk scherm heb je?</legend>
<legend>Which screen do you have?</legend>
<label class="board"
><input type="radio" name="board" value="cyd" checked /><span
><b>CYD · 2,8 inch</b
><b>CYD · 2.8 inch</b
><small>ESP32-2432S028 · 320 × 240</small></span
></label
><label class="board"
@@ -209,51 +209,51 @@
>
</fieldset>
<label
>Naam<input
>Name<input
name="friendly_name"
required
maxlength="60"
placeholder="Scherm woonkamer"
placeholder="Living room screen"
autocomplete="off" /><small class="node-line"
>Apparaatnaam <code id="node-preview">scherm</code
>Device name <code id="node-preview">screen</code
><button type="button" class="link" id="edit-node">
aanpassen
customize
</button></small
><small
>Elk scherm krijgt een eigen profiel met eigen sleutels. Vier
schermen? Doorloop dit vier keer met een andere naam.</small
>Every screen gets its own profile with its own keys. Four
screens? Go through this four times with a different name.</small
></label
>
<label id="node-label" hidden
>Apparaatnaam<input
>Device name<input
name="name"
pattern="[a-z][a-z0-9-]{0,29}"
maxlength="30"
autocomplete="off" /><small
>Kleine letters, cijfers en streepjes. Elk scherm een andere
naam.</small
>Lowercase letters, digits, and dashes. A different name for
each screen.</small
></label
>
<fieldset id="wifi-fields" class="wifi" hidden disabled>
<legend>Wifi</legend>
<legend>Wi-Fi</legend>
<p class="hint" id="wifi-status"></p>
<label id="wifi-ssid-label"
>Wifi-naam (2,4 GHz)<input name="wifi_ssid" autocomplete="off"
>Wi-Fi name (2.4 GHz)<input name="wifi_ssid" autocomplete="off"
/></label>
<label id="wifi-password-label"
>Wifiwachtwoord<input
>Wi-Fi password<input
name="wifi_password"
type="password"
autocomplete="new-password"
/></label>
</fieldset>
<label
>Installeren via<select name="target" id="install-target"></select
>Install via<select name="target" id="install-target"></select
><small id="target-hint"></small
></label>
<p class="hint" id="install-note"></p>
<div class="install-actions">
<button type="submit" id="install-go">Installeren</button>
<button type="submit" id="install-go">Install</button>
<span id="install-status" role="status"></span>
</div>
</div>
@@ -266,21 +266,21 @@
<p id="progress-detail"></p>
<div id="install-result" hidden>
<div class="key-box">
<span>API-sleutel</span><code id="api-key"></code>
<button type="button" class="mini" id="copy-key">Kopieer</button>
<span>API key</span><code id="api-key"></code>
<button type="button" class="mini" id="copy-key">Copy</button>
</div>
<ol class="steps" id="install-steps"></ol>
</div>
<details id="install-log-wrap">
<summary>Log van ESPHome</summary>
<summary>ESPHome log</summary>
<pre id="install-log"></pre>
</details>
<div class="install-actions">
<button type="button" id="install-retry" hidden>
Opnieuw proberen
Retry
</button>
<button type="button" class="quiet" id="install-close">
Sluiten
Close
</button>
</div>
</div>
@@ -290,187 +290,187 @@
<div class="dialog-head">
<div>
<p class="eyebrow">FIRMWARE & USB</p>
<h2>Schermfirmware bouwen en installeren</h2>
<h2>Build and install screen firmware</h2>
</div>
<button id="close-firmware" class="quiet" aria-label="Sluiten">✕</button>
<button id="close-firmware" class="quiet" aria-label="Close">✕</button>
</div>
<p>
De ingebouwde ESPHome CLI gebruikt je eigen profiel (YAML met sleutels)
en secrets. Kies het profiel van het scherm, dan een USB-poort (scherm
aan de Home Assistant-machine) of <b>Wifi / OTA</b> met de hostnaam.
<b>Controleren</b> valideert alleen, <b>Alleen bouwen</b> compileert,
<b>Bouwen & installeren</b> zet de firmware op het scherm. Je tegels
blijven in ESP Screens staan. De eerste build duurt enkele minuten.
The built-in ESPHome CLI uses your own profile (YAML with keys)
and secrets. Choose the screen's profile, then a USB port (screen
connected to the Home Assistant machine) or <b>Wi-Fi / OTA</b> with the hostname.
<b>Check</b> only validates, <b>Build only</b> compiles,
<b>Build & install</b> puts the firmware on the screen. Your tiles
stay as they are in ESP Screens. The first build takes a few minutes.
</p>
<label>ESPHome-profiel<select id="firmware-file"></select></label>
<label>ESPHome profile<select id="firmware-file"></select></label>
<label
>Installeren naar<select id="firmware-port">
<option value="ota">Wifi / OTA</option>
>Install to<select id="firmware-port">
<option value="ota">Wi-Fi / OTA</option>
</select></label
>
<label id="firmware-host-label"
>IP-adres of hostnaam<input
>IP address or hostname<input
id="firmware-host"
placeholder="scherm-woonkamer.local"
placeholder="screen-livingroom.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>
<button id="firmware-validate" class="quiet">Check</button
><button id="firmware-build" class="quiet">Build only</button
><button id="firmware-install">Build & install</button>
</div>
<p id="firmware-status" role="status"></p>
<pre id="firmware-log"></pre>
</dialog>
<dialog id="tile-sheet" class="tile-sheet" aria-label="Tegel instellen"></dialog>
<dialog id="topbar-sheet" class="tile-sheet" aria-label="Bovenbalk"></dialog>
<dialog id="tile-sheet" class="tile-sheet" aria-label="Configure tile"></dialog>
<dialog id="topbar-sheet" class="tile-sheet" aria-label="Top bar"></dialog>
<dialog id="alerts-dialog" class="alerts" aria-label="Alerts">
<div class="dialog-head">
<div>
<p class="eyebrow">ALERTS · CHEATSHEET</p>
<h2>Een melding over het hele scherm, vanuit een automatisering.</h2>
<h2>A full-screen notification, from an automation.</h2>
</div>
<button id="close-alerts" class="quiet" aria-label="Sluiten">✕</button>
<button id="close-alerts" class="quiet" aria-label="Close">✕</button>
</div>
<p>
Elk scherm heeft de actie <code>show_alert</code>. Die legt een kaart over alles
heen, wekt het scherm en houdt de backlight op de normale helderheid tot iemand op
de knop tikt of de timeout verstrijkt. Home Assistant toont bij ESPHome-acties
zelf geen uitleg of keuzelijsten; alles wat je kunt invullen staat hier.
Every screen has the <code>show_alert</code> action. It places a card over everything,
wakes the screen, and keeps the backlight at normal brightness until someone taps
the button or the timeout runs out. Home Assistant itself shows no explanation or
dropdowns for ESPHome actions; everything you can fill in is listed here.
</p>
<div class="alert-hero">
<div class="alert-mock" id="alert-mock" aria-hidden="true">
<div class="alert-mock-card">
<span class="mdi alert-mock-icon" id="alert-mock-icon"></span>
<strong id="alert-mock-title">Iemand belt aan</strong>
<span id="alert-mock-subtitle">Deur 3, achterkant</span>
<button type="button" id="alert-mock-button" tabindex="-1">Ik kom</button>
<strong id="alert-mock-title">Someone is at the door</strong>
<span id="alert-mock-subtitle">Door 3, back</span>
<button type="button" id="alert-mock-button" tabindex="-1">Coming</button>
</div>
</div>
<nav class="alerts-nav" aria-label="Onderdelen">
<button type="button" data-jump="alerts-screens">Jouw schermen</button>
<button type="button" data-jump="alerts-howto">In een automatisering</button>
<button type="button" data-jump="alerts-fields">De velden</button>
<button type="button" data-jump="alerts-icons">Iconen</button>
<button type="button" data-jump="alerts-colors">Kleuren</button>
<button type="button" data-jump="alerts-behaviour">Gedrag</button>
<nav class="alerts-nav" aria-label="Sections">
<button type="button" data-jump="alerts-screens">Your screens</button>
<button type="button" data-jump="alerts-howto">In an automation</button>
<button type="button" data-jump="alerts-fields">The fields</button>
<button type="button" data-jump="alerts-icons">Icons</button>
<button type="button" data-jump="alerts-colors">Colors</button>
<button type="button" data-jump="alerts-behaviour">Behavior</button>
<button type="button" data-jump="alerts-events">Events</button>
<button type="button" data-jump="alerts-tips">Tips</button>
</nav>
</div>
<section id="alerts-screens">
<h3>Jouw schermen</h3>
<h3>Your screens</h3>
<p>
Elke scherm heeft zijn eigen actie, met de apparaatnaam erin. Vanaf firmware
<code id="alerts-min-firmware">0.2.31</code>; een ouder scherm werk je bij onder
Firmware-updates.
Every screen has its own action, with the device name in it. From firmware
<code id="alerts-min-firmware">0.2.31</code>; update an older screen under
Firmware updates.
</p>
<div id="alerts-screen-list"></div>
</section>
<section id="alerts-howto">
<h3>In een automatisering</h3>
<h3>In an automation</h3>
<ol class="steps">
<li><b>Actie toevoegen</b> en zoek op <b>ESPHome</b>. Kies de actie van je scherm:
Home Assistant noemt hem "Performs the action show_alert of the node
<i>apparaatnaam</i>".</li>
<li><b>Vul de velden in.</b> Home Assistant vraagt alle zeven; laat wat je niet
gebruikt leeg (<code>""</code>, <code>0</code>, uit).</li>
<li>Liever YAML? Kies <b>Bewerken in YAML</b> bij de actie en plak het voorbeeld
hieronder.</li>
<li><b>Add action</b> and search for <b>ESPHome</b>. Choose your screen's action:
Home Assistant calls it "Performs the action show_alert of the node
<i>device name</i>".</li>
<li><b>Fill in the fields.</b> Home Assistant asks for all seven; leave what you don't
use empty (<code>""</code>, <code>0</code>, off).</li>
<li>Prefer YAML? Choose <b>Edit in YAML</b> on the action and paste the example
below.</li>
</ol>
<div class="alert-example">
<label
>Voorbeeld voor<select id="alerts-example-screen"></select
>Example for<select id="alerts-example-screen"></select
></label>
<div class="copy-line">
<pre id="alerts-example"></pre>
<button type="button" class="mini" id="alerts-example-copy">Kopieer YAML</button>
<button type="button" class="mini" id="alerts-example-copy">Copy YAML</button>
</div>
</div>
</section>
<section id="alerts-fields">
<h3>De velden</h3>
<h3>The fields</h3>
<p>
Teksten zijn vrij; de limiet is in bytes en een letter met accent telt voor twee.
Wat niet past krijgt puntjes (titel) of valt weg (subtitle).
Text is free-form; the limit is in bytes, and an accented letter counts as two.
What doesn't fit gets an ellipsis (title) or is dropped (subtitle).
</p>
<div class="table-scroll"><table id="alerts-field-table"></table></div>
</section>
<section id="alerts-icons">
<h3>Iconen</h3>
<h3>Icons</h3>
<p>
Vul de naam in bij <code>icon</code>, bijvoorbeeld <code>doorbell</code>.
<code>mdi:doorbell</code> en de hex-codepoint (<code>F12E6</code>) mogen ook.
Alleen deze iconen zitten in de firmware; een andere naam geeft de
waarschuwingsdriehoek. Tik op een icoon om de naam te kopiëren.
Fill in the name in <code>icon</code>, for example <code>doorbell</code>.
<code>mdi:doorbell</code> and the hex codepoint (<code>F12E6</code>) also work.
Only these icons are included in the firmware; any other name shows the
warning triangle. Tap an icon to copy its name.
</p>
<p class="alerts-subhead">Handig voor meldingen</p>
<p class="alerts-subhead">Handy for notifications</p>
<div class="chips" id="alerts-suggested"></div>
<label class="alerts-search"
>Zoek een icoon<input
>Search for an icon<input
id="alerts-icon-search"
type="search"
placeholder="Zoek op naam of omschrijving…"
placeholder="Search by name or description…"
autocomplete="off"
/></label>
<div id="alerts-icon-groups"></div>
</section>
<section id="alerts-colors">
<h3>Kleuren</h3>
<h3>Colors</h3>
<p>
Vul de naam in bij <code>color</code>. Dit zijn de pasteltinten van de tegels,
met donkere tekst. Leeg of onbekend geeft de witte kaart. Tik om te kopiëren.
Fill in the name in <code>color</code>. These are the pastel shades of the tiles,
with dark text. Empty or unknown gives the white card. Tap to copy.
</p>
<div class="swatches" id="alerts-swatches"></div>
</section>
<section id="alerts-behaviour">
<h3>Gedrag</h3>
<h3>Behavior</h3>
<ul class="alerts-list">
<li><b>Wekken.</b> Een alert maakt een gedimd of donker scherm meteen wakker en
komt boven elke pagina, elke open lichtkaart en de standby-laag.</li>
<li><b>Aan tot de knop.</b> Zolang de kaart staat blijft de backlight op de
normale helderheid. Standby en de nachturen wachten. Na de knop start de gewone
standby-tijd opnieuw.</li>
<li><b>Timeout.</b> <code>timeout: 0</code> wacht op de knop, hoe lang ook. Met
<code>timeout: 60</code> verdwijnt de kaart na een minuut, maar de knop sluit
hem eerder.</li>
<li><b>Knipperen.</b> <code>flash: true</code> knippert de backlight vier keer
bij binnenkomst (ongeveer een seconde) en laat het scherm daarna aan.</li>
<li><b>Vervangen.</b> Een nieuwe alert vervangt de huidige; er is geen stapel.
De vervangen alert meldt zich als <code>replaced</code>.</li>
<li><b>Op afstand sluiten.</b> <code>dismiss_alert</code> haalt de kaart weg,
bijvoorbeeld als de deur al open is. Zonder alert doet die actie niets.</li>
<li><b>Tikken naast de kaart</b> doet niets; alleen de knop sluit. Zo drukt een
wek-tik de alert nooit per ongeluk weg.</li>
<li><b>Wakes the screen.</b> An alert immediately wakes a dimmed or dark screen and
appears above every page, every open light card, and the standby layer.</li>
<li><b>On until the button.</b> As long as the card is showing, the backlight stays at
normal brightness. Standby and night hours wait. After the button, the regular
standby time starts again.</li>
<li><b>Timeout.</b> <code>timeout: 0</code> waits for the button, however long that
takes. With <code>timeout: 60</code> the card disappears after a minute, but the
button closes it sooner.</li>
<li><b>Blinking.</b> <code>flash: true</code> blinks the backlight four times
when the alert arrives (about a second) and leaves the screen on afterward.</li>
<li><b>Replacing.</b> A new alert replaces the current one; there's no stack.
The replaced alert reports itself as <code>replaced</code>.</li>
<li><b>Closing remotely.</b> <code>dismiss_alert</code> clears the card,
for example if the door is already open. Without an alert, that action does nothing.</li>
<li><b>Tapping beside the card</b> does nothing; only the button closes it. That way a
wake-up tap never accidentally dismisses the alert.</li>
</ul>
</section>
<section id="alerts-events">
<h3>Events</h3>
<p>
Elk einde vuurt het event <code id="alerts-event-name">esphome.screen_alert</code>
met <code>action</code>, <code>title</code>, <code>screen</code> (de apparaatnaam)
en het <code>device_id</code> dat Home Assistant toevoegt. Te zien onder
Ontwikkelhulpmiddelen → Events.
Every end fires the event <code id="alerts-event-name">esphome.screen_alert</code>
with <code>action</code>, <code>title</code>, <code>screen</code> (the device name)
and the <code>device_id</code> that Home Assistant adds. Visible under
Settings → Tools → Events.
</p>
<div class="table-scroll"><table id="alerts-ending-table"></table></div>
<p class="alerts-subhead">Wachten tot iemand op de knop drukt</p>
<p class="alerts-subhead">Waiting until someone presses the button</p>
<div class="copy-line">
<pre id="alerts-wait-example"></pre>
<button type="button" class="mini" id="alerts-wait-copy">Kopieer YAML</button>
<button type="button" class="mini" id="alerts-wait-copy">Copy YAML</button>
</div>
</section>
<section id="alerts-tips">
<h3>Tips</h3>
<ul class="alerts-list">
<li>Gebruik een <b>kleur per soort melding</b>: oranje voor de deur, rood voor
rook of water, groen voor "klaar". De tekst blijft altijd donker en leesbaar.</li>
<li>Houd de titel <b>kort</b>; de CYD toont ongeveer veertig tekens op één regel.
Zet details in de subtitle.</li>
<li>Een <b>eigen knoptekst</b> maakt de melding een vraag: "Ik kom", "Gezien",
"Uitzetten". Het event zegt dan wie reageerde.</li>
<li>Een alert die zichzelf oplost (de deur gaat open, de was is eruit gehaald)
sluit je in dezelfde automatisering met <code>dismiss_alert</code>.</li>
<li>Meerdere schermen? Roep de actie van elk scherm aan; ze werken los van
elkaar en melden zich elk met hun eigen <code>screen</code>.</li>
<li>Use a <b>color per kind of notification</b>: orange for the door, red for
smoke or water, green for "done". The text always stays dark and readable.</li>
<li>Keep the title <b>short</b>; the CYD shows about forty characters on one line.
Put details in the subtitle.</li>
<li>A <b>custom button text</b> turns the notification into a question: "Coming", "Seen",
"Turn off". The event then tells you who responded.</li>
<li>For an alert that resolves itself (the door opens, the laundry gets taken out)
close it in the same automation with <code>dismiss_alert</code>.</li>
<li>Multiple screens? Call each screen's action; they work independently and
each reports with its own <code>screen</code>.</li>
</ul>
</section>
</dialog>
+1 -1
View File
@@ -929,7 +929,7 @@ pre {
.icon-picker .hint { margin: 0; }
.icon-hint { font-weight: 400; }
/* Nieuw scherm: profiel, wifi en de eerste flash in één venster. */
/* New screen: profile, Wi-Fi, and the first flash in one window. */
.link {
background: none;
color: var(--accent);
+135 -135
View File
@@ -8,179 +8,179 @@ fonts/materialdesignicons-webfont.ttf. Firmware 0.2.18+ carries every glyph here
# (group, ((MDI name, codepoint, label), ...)) in picker order.
GROUPS = (
('Verlichting', (
('lightbulb', 'F0335', 'Lamp'),
('lightbulb-group', 'F1253', 'Lampen'),
('ceiling-light', 'F0769', 'Plafondlamp'),
('chandelier', 'F1793', 'Kroonluchter'),
('lamp', 'F06B5', 'Tafellamp'),
('floor-lamp', 'F08DD', 'Vloerlamp'),
('desk-lamp', 'F095F', 'Bureaulamp'),
('wall-sconce-flat', 'F091D', 'Wandlamp'),
('light-recessed', 'F179B', 'Inbouwspot'),
('spotlight-beam', 'F04C9', 'Spot'),
('led-strip-variant', 'F1051', 'Ledstrip'),
('outdoor-lamp', 'F1054', 'Buitenlamp'),
('Lighting', (
('lightbulb', 'F0335', 'Light bulb'),
('lightbulb-group', 'F1253', 'Lights'),
('ceiling-light', 'F0769', 'Ceiling light'),
('chandelier', 'F1793', 'Chandelier'),
('lamp', 'F06B5', 'Table lamp'),
('floor-lamp', 'F08DD', 'Floor lamp'),
('desk-lamp', 'F095F', 'Desk lamp'),
('wall-sconce-flat', 'F091D', 'Wall light'),
('light-recessed', 'F179B', 'Recessed light'),
('spotlight-beam', 'F04C9', 'Spotlight'),
('led-strip-variant', 'F1051', 'LED strip'),
('outdoor-lamp', 'F1054', 'Outdoor light'),
)),
('Ruimtes', (
('home', 'F02DC', 'Huis'),
('sofa', 'F04B9', 'Woonkamer'),
('bed', 'F02E3', 'Slaapkamer'),
('silverware-fork-knife', 'F0A70', 'Eetkamer'),
('chef-hat', 'F0B7C', 'Keuken'),
('shower', 'F09A0', 'Douche'),
('bathtub', 'F1818', 'Badkamer'),
('Rooms', (
('home', 'F02DC', 'House'),
('sofa', 'F04B9', 'Living room'),
('bed', 'F02E3', 'Bedroom'),
('silverware-fork-knife', 'F0A70', 'Dining room'),
('chef-hat', 'F0B7C', 'Kitchen'),
('shower', 'F09A0', 'Shower'),
('bathtub', 'F1818', 'Bathroom'),
('toilet', 'F09AB', 'Toilet'),
('desk', 'F1239', 'Werkkamer'),
('desk', 'F1239', 'Office'),
('garage', 'F06D9', 'Garage'),
('fireplace', 'F0E2E', 'Open haard'),
('fireplace', 'F0E2E', 'Fireplace'),
)),
('Klimaat', (
('thermometer', 'F050F', 'Temperatuur'),
('thermostat', 'F0393', 'Thermostaat'),
('air-conditioner', 'F001B', 'Airco'),
('Climate', (
('thermometer', 'F050F', 'Temperature'),
('thermostat', 'F0393', 'Thermostat'),
('air-conditioner', 'F001B', 'AC'),
('radiator', 'F0438', 'Radiator'),
('heat-pump', 'F1A43', 'Warmtepomp'),
('fire', 'F0238', 'Verwarming'),
('snowflake', 'F0717', 'Koelen'),
('water-percent', 'F058E', 'Luchtvochtigheid'),
('fan', 'F0210', 'Ventilator'),
('ceiling-fan', 'F1797', 'Plafondventilator'),
('air-purifier', 'F0D44', 'Luchtreiniger'),
('heat-pump', 'F1A43', 'Heat pump'),
('fire', 'F0238', 'Heating'),
('snowflake', 'F0717', 'Cooling'),
('water-percent', 'F058E', 'Humidity'),
('fan', 'F0210', 'Fan'),
('ceiling-fan', 'F1797', 'Ceiling fan'),
('air-purifier', 'F0D44', 'Air purifier'),
('molecule-co2', 'F07E4', 'CO₂'),
)),
('Weer', (
('weather-sunny', 'F0599', 'Zon'),
('weather-partly-cloudy', 'F0595', 'Half bewolkt'),
('weather-cloudy', 'F0590', 'Bewolkt'),
('weather-rainy', 'F0597', 'Regen'),
('weather-snowy', 'F0598', 'Sneeuw'),
('Weather', (
('weather-sunny', 'F0599', 'Sun'),
('weather-partly-cloudy', 'F0595', 'Partly cloudy'),
('weather-cloudy', 'F0590', 'Cloudy'),
('weather-rainy', 'F0597', 'Rain'),
('weather-snowy', 'F0598', 'Snow'),
('weather-windy', 'F059D', 'Wind'),
('weather-night', 'F0594', 'Nacht'),
('weather-sunset-up', 'F059C', 'Zonsopkomst'),
('weather-night', 'F0594', 'Night'),
('weather-sunset-up', 'F059C', 'Sunrise'),
)),
('Media en muziek', (
('music-note', 'F0387', 'Muziek'),
('music', 'F075A', 'Muzieknoten'),
('playlist-music', 'F0CB8', 'Afspeellijst'),
('Media and music', (
('music-note', 'F0387', 'Music'),
('music', 'F075A', 'Music notes'),
('playlist-music', 'F0CB8', 'Playlist'),
('album', 'F0025', 'Album'),
('record-player', 'F099A', 'Platenspeler'),
('record-player', 'F099A', 'Record player'),
('spotify', 'F04C7', 'Spotify'),
('radio', 'F0439', 'Radio'),
('podcast', 'F0994', 'Podcast'),
('speaker', 'F04C3', 'Speaker'),
('speaker-multiple', 'F0D38', 'Speakers'),
('soundbar', 'F17DB', 'Soundbar'),
('headphones', 'F02CB', 'Koptelefoon'),
('headphones', 'F02CB', 'Headphones'),
('piano', 'F067D', 'Piano'),
('guitar-acoustic', 'F0771', 'Gitaar'),
('television', 'F0502', 'Televisie'),
('projector', 'F042E', 'Beamer'),
('movie-open', 'F0FCE', 'Film'),
('cast', 'F0118', 'Casten'),
('gamepad-variant', 'F0297', 'Gameconsole'),
('remote-tv', 'F0EC5', 'Afstandsbediening'),
('guitar-acoustic', 'F0771', 'Guitar'),
('television', 'F0502', 'TV'),
('projector', 'F042E', 'Projector'),
('movie-open', 'F0FCE', 'Movie'),
('cast', 'F0118', 'Cast'),
('gamepad-variant', 'F0297', 'Game console'),
('remote-tv', 'F0EC5', 'Remote control'),
)),
('Beveiliging', (
('lock', 'F033E', 'Slot'),
('lock-open-variant', 'F0FC6', 'Slot open'),
('Security', (
('lock', 'F033E', 'Lock'),
('lock-open-variant', 'F0FC6', 'Lock open'),
('shield-home', 'F068A', 'Alarm'),
('alarm-light', 'F078F', 'Alarmlicht'),
('bell', 'F009A', 'Bel'),
('doorbell', 'F12E6', 'Deurbel'),
('alarm-light', 'F078F', 'Alarm light'),
('bell', 'F009A', 'Bell'),
('doorbell', 'F12E6', 'Doorbell'),
('cctv', 'F07AE', 'Camera'),
('motion-sensor', 'F0D91', 'Bewegingsmelder'),
('door-closed', 'F081B', 'Deur'),
('door-open', 'F081C', 'Deur open'),
('window-closed-variant', 'F11DB', 'Raam'),
('smoke-detector', 'F0392', 'Rookmelder'),
('water-alert', 'F1502', 'Waterlek'),
('motion-sensor', 'F0D91', 'Motion sensor'),
('door-closed', 'F081B', 'Door'),
('door-open', 'F081C', 'Door open'),
('window-closed-variant', 'F11DB', 'Window'),
('smoke-detector', 'F0392', 'Smoke detector'),
('water-alert', 'F1502', 'Water leak'),
)),
('Apparaten', (
('washing-machine', 'F072A', 'Wasmachine'),
('tumble-dryer', 'F0917', 'Droger'),
('dishwasher', 'F0AAC', 'Vaatwasser'),
('fridge', 'F0290', 'Koelkast'),
('stove', 'F04DE', 'Fornuis'),
('microwave', 'F0C99', 'Magnetron'),
('Appliances', (
('washing-machine', 'F072A', 'Washing machine'),
('tumble-dryer', 'F0917', 'Dryer'),
('dishwasher', 'F0AAC', 'Dishwasher'),
('fridge', 'F0290', 'Fridge'),
('stove', 'F04DE', 'Stove'),
('microwave', 'F0C99', 'Microwave'),
('toaster-oven', 'F0CD3', 'Oven'),
('coffee-maker', 'F109F', 'Koffie'),
('kettle', 'F05FA', 'Waterkoker'),
('robot-vacuum', 'F070D', 'Robotstofzuiger'),
('robot-mower', 'F11F7', 'Robotmaaier'),
('printer-3d', 'F042B', '3D-printer'),
('power-plug', 'F06A5', 'Stekker'),
('power-socket-eu', 'F07E7', 'Stopcontact'),
('coffee-maker', 'F109F', 'Coffee'),
('kettle', 'F05FA', 'Kettle'),
('robot-vacuum', 'F070D', 'Robot vacuum'),
('robot-mower', 'F11F7', 'Robot mower'),
('printer-3d', 'F042B', '3D printer'),
('power-plug', 'F06A5', 'Plug'),
('power-socket-eu', 'F07E7', 'Socket'),
('laptop', 'F0322', 'Laptop'),
('router-wireless', 'F0469', 'Router'),
)),
('Energie', (
('flash', 'F0241', 'Stroom'),
('solar-power', 'F0A72', 'Zonne-energie'),
('solar-panel', 'F0D9B', 'Zonnepanelen'),
('home-battery', 'F1901', 'Thuisbatterij'),
('battery-high', 'F12A3', 'Batterij'),
('ev-station', 'F05F1', 'Laadpaal'),
('car-electric', 'F0B6C', 'Elektrische auto'),
('meter-electric', 'F1A57', 'Stroommeter'),
('meter-gas', 'F1A59', 'Gasmeter'),
('Energy', (
('flash', 'F0241', 'Power'),
('solar-power', 'F0A72', 'Solar power'),
('solar-panel', 'F0D9B', 'Solar panels'),
('home-battery', 'F1901', 'Home battery'),
('battery-high', 'F12A3', 'Battery'),
('ev-station', 'F05F1', 'Charging station'),
('car-electric', 'F0B6C', 'Electric car'),
('meter-electric', 'F1A57', 'Power meter'),
('meter-gas', 'F1A59', 'Gas meter'),
('water', 'F058C', 'Water'),
('water-boiler', 'F0F92', 'Boiler'),
('gauge', 'F029A', 'Meter'),
)),
('Zonwering', (
('window-shutter', 'F111C', 'Rolluik'),
('window-shutter-open', 'F111E', 'Rolluik open'),
('blinds', 'F00AC', 'Jaloezie'),
('blinds-open', 'F1011', 'Jaloezie open'),
('roller-shade-closed', 'F1A6C', 'Rolgordijn'),
('curtains', 'F1846', 'Gordijnen'),
('curtains-closed', 'F1847', 'Gordijnen dicht'),
('awning-outline', 'F1B88', 'Zonnescherm'),
('Window coverings', (
('window-shutter', 'F111C', 'Shutter'),
('window-shutter-open', 'F111E', 'Shutter open'),
('blinds', 'F00AC', 'Blinds'),
('blinds-open', 'F1011', 'Blinds open'),
('roller-shade-closed', 'F1A6C', 'Roller blind'),
('curtains', 'F1846', 'Curtains'),
('curtains-closed', 'F1847', 'Curtains closed'),
('awning-outline', 'F1B88', 'Awning'),
)),
('Tuin en huisdieren', (
('flower', 'F024A', 'Bloem'),
('tree', 'F0531', 'Boom'),
('Garden and pets', (
('flower', 'F024A', 'Flower'),
('tree', 'F0531', 'Tree'),
('sprout', 'F0E66', 'Plant'),
('grass', 'F1510', 'Gazon'),
('sprinkler', 'F105F', 'Sproeier'),
('pool', 'F0606', 'Zwembad'),
('grass', 'F1510', 'Lawn'),
('sprinkler', 'F105F', 'Sprinkler'),
('pool', 'F0606', 'Pool'),
('grill', 'F0E45', 'Barbecue'),
('mailbox', 'F06EE', 'Brievenbus'),
('trash-can', 'F0A79', 'Afval'),
('mailbox', 'F06EE', 'Mailbox'),
('trash-can', 'F0A79', 'Trash'),
('recycle', 'F044C', 'Recycling'),
('dog', 'F0A43', 'Hond'),
('cat', 'F011B', 'Kat'),
('dog', 'F0A43', 'Dog'),
('cat', 'F011B', 'Cat'),
)),
('Mensen en onderweg', (
('account', 'F0004', 'Persoon'),
('account-group', 'F0849', 'Gezin'),
('account-child', 'F0A89', 'Kind'),
('home-account', 'F0826', 'Thuis'),
('sleep', 'F04B2', 'Slapen'),
('run', 'F070E', 'Sporten'),
('briefcase', 'F00D6', 'Werk'),
('car', 'F010B', 'Auto'),
('bike', 'F00A3', 'Fiets'),
('airplane', 'F001D', 'Vakantie'),
('map-marker', 'F034E', 'Locatie'),
('People and travel', (
('account', 'F0004', 'Person'),
('account-group', 'F0849', 'Family'),
('account-child', 'F0A89', 'Child'),
('home-account', 'F0826', 'Home'),
('sleep', 'F04B2', 'Sleeping'),
('run', 'F070E', 'Exercising'),
('briefcase', 'F00D6', 'Work'),
('car', 'F010B', 'Car'),
('bike', 'F00A3', 'Bike'),
('airplane', 'F001D', 'Vacation'),
('map-marker', 'F034E', 'Location'),
)),
('Overig', (
('power', 'F0425', 'Aan/uit'),
('toggle-switch', 'F0521', 'Schakelaar'),
('gesture-tap-button', 'F12A8', 'Knop'),
('Other', (
('power', 'F0425', 'On/off'),
('toggle-switch', 'F0521', 'Switch'),
('gesture-tap-button', 'F12A8', 'Button'),
('play', 'F040A', 'Start'),
('script-text', 'F0BC2', 'Script'),
('palette', 'F03D8', 'Scène'),
('alarm', 'F0020', 'Wekker'),
('clock-outline', 'F0150', 'Klok'),
('palette', 'F03D8', 'Scene'),
('alarm', 'F0020', 'Alarm clock'),
('clock-outline', 'F0150', 'Clock'),
('timer-outline', 'F051B', 'Timer'),
('calendar', 'F00ED', 'Agenda'),
('bell-ring', 'F009E', 'Melding'),
('heart', 'F02D1', 'Favoriet'),
('alert-outline', 'F002A', 'Waarschuwing'),
('cog', 'F0493', 'Instellingen'),
('broom', 'F00E2', 'Schoonmaken'),
('calendar', 'F00ED', 'Calendar'),
('bell-ring', 'F009E', 'Notification'),
('heart', 'F02D1', 'Favorite'),
('alert-outline', 'F002A', 'Warning'),
('cog', 'F0493', 'Settings'),
('broom', 'F00E2', 'Cleaning'),
)),
)
+46 -24
View File
@@ -34,7 +34,7 @@ class Updater:
if self.path.exists():
raw = json.loads(self.path.read_text())
if raw.get('version') != 1:
raise ValueError('Onbekende opslagversie voor updates; gegevens blijven ongewijzigd.')
raise ValueError('Unknown storage version for updates; data stays unchanged.')
self.auto = raw.get('auto') is True
self.hosts = {k: v for k, v in raw.get('hosts', {}).items() if isinstance(v, str)}
self.results = {k: v for k, v in raw.get('results', {}).items() if isinstance(v, dict)}
@@ -57,6 +57,26 @@ class Updater:
def screen(self, inbox):
return self.manager.screen(inbox)
def current_id(self, inbox):
"""The id a screen reports under now; firmware 0.2.34 gave the inbox entity a new id."""
return getattr(self.manager, 'aliases', {}).get(inbox, inbox)
def renamed(self, old, new):
"""A screen's inbox entity got a new id: its address, last result and place in a running round follow."""
changed = False
for store in (self.hosts, self.results):
if old in store:
if new not in store:
store[new] = store[old]
del store[old]
changed = True
if changed:
self.save()
if self.current == old:
self.current = new
self.queue = [new if inbox == old else inbox for inbox in self.queue]
return changed
def resolve(self, screen, profiles=None):
"""Profile file and OTA address for a screen; None when the add-on cannot tell."""
if profiles is None:
@@ -97,39 +117,40 @@ class Updater:
def set_auto(self, enabled):
if not isinstance(enabled, bool):
raise ValueError('Kies aan of uit voor automatisch bijwerken.')
raise ValueError('Choose on or off for automatic updates.')
self.auto = enabled
self.save()
def start(self, inbox, host=None):
if self.busy():
raise ValueError('Er loopt al een update. Wacht tot die klaar is.')
raise ValueError('An update is already running. Wait for it to finish.')
screen = self.screen(inbox)
inbox = self.current_id(inbox)
if not screen:
raise ValueError('Onbekend scherm. Vernieuw het overzicht.')
raise ValueError('Unknown screen. Refresh the overview.')
if not screen['online']:
raise ValueError('Dit scherm is offline; bijwerken kan zodra het terug is.')
raise ValueError("This screen is offline; updating is possible once it's back.")
if not self.state_for(screen)['available']:
raise ValueError('Dit scherm heeft al de nieuwste firmware.')
raise ValueError('This screen already has the latest firmware.')
if host is not None:
if not isinstance(host, str) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}', host.strip()):
raise ValueError('Vul het IP-adres van dit scherm in.')
raise ValueError("Enter this screen's IP address.")
self.hosts[inbox] = host.strip()
self.save()
profile, host = self.resolve(screen)
if not profile:
raise ValueError('Geen ESPHome-profiel gevonden voor dit scherm. Controleer de naam in de ESPHome-map.')
raise ValueError('No ESPHome profile found for this screen. Check the name in the ESPHome folder.')
if not host:
raise ValueError('IP-adres onbekend. Vul het eenmalig in; nieuwe firmware meldt het daarna zelf.')
raise ValueError('IP address unknown. Enter it once; new firmware reports it itself after that.')
self.launch([inbox])
return self.state_for(screen)
def start_all(self):
if self.busy():
raise ValueError('Er loopt al een update. Wacht tot die klaar is.')
raise ValueError('An update is already running. Wait for it to finish.')
pending = self.pending()
if not pending:
raise ValueError('Alle bereikbare schermen zijn al bijgewerkt.')
raise ValueError('All reachable screens are already updated.')
self.launch(pending)
return pending
@@ -139,19 +160,20 @@ class Updater:
self.task = asyncio.create_task(self.run_round(inboxes, automatic))
def record(self, inbox, state, message):
self.results[inbox] = {'time': time.time(), 'state': state, 'message': message, 'version': FIRMWARE_VERSION}
self.results[self.current_id(inbox)] = {'time': time.time(), 'state': state, 'message': message, 'version': FIRMWARE_VERSION}
self.save()
async def run_round(self, inboxes, automatic=False):
"""Update screens one by one; a failure ends the round so a bad build never reaches the next screen."""
try:
for index, inbox in enumerate(inboxes):
self.current, self.queue = inbox, inboxes[index+1:]
inbox = self.current_id(inbox)
self.current, self.queue = inbox, [self.current_id(i) for i in inboxes[index+1:]]
outcome = await self.update_one(inbox)
if outcome == 'failed':
if automatic:
await self.notify(f"Automatisch bijwerken is gestopt bij {self.name(inbox)}: "
f"{self.results[inbox]['message']} De overige schermen zijn niet aangeraakt.")
await self.notify(f"Automatic update stopped at {self.name(inbox)}: "
f"{self.results[self.current_id(inbox)]['message']} The remaining screens were not touched.")
break
if self.queue and outcome == 'success':
await asyncio.sleep(self.pause_seconds)
@@ -165,11 +187,11 @@ class Updater:
async def update_one(self, inbox):
screen = self.screen(inbox)
if not screen or not screen['online']:
self.record(inbox, 'skipped', 'Scherm was offline; volgende keer opnieuw.')
self.record(inbox, 'skipped', 'Screen was offline; will retry next time.')
return 'skipped'
profile, host = self.resolve(screen)
if not profile or not host:
self.record(inbox, 'skipped', 'Profiel of IP-adres onbekend; werk dit scherm handmatig bij.')
self.record(inbox, 'skipped', 'Profile or IP address unknown; update this screen manually.')
return 'skipped'
self.phase = 'install'
try:
@@ -179,19 +201,19 @@ class Updater:
self.record(inbox, 'failed', str(error))
return 'failed'
if self.manager.firmware.job.get('state') != 'success':
self.record(inbox, 'failed', 'Bouwen of installeren mislukt; bekijk het log onder Firmware & USB.')
self.record(inbox, 'failed', 'Build or install failed; see the log under Firmware & USB.')
return 'failed'
self.phase = 'verify'
if not await self.wait_for_target(inbox):
self.record(inbox, 'failed', f'Scherm meldde zich niet met firmware {FIRMWARE_VERSION}; controleer het scherm.')
self.record(inbox, 'failed', f"Screen didn't report back with firmware {FIRMWARE_VERSION}; check the screen.")
return 'failed'
self.phase = 'settle'
await asyncio.sleep(self.settle_seconds)
current = self.screen(inbox)
if not current or not current['online']:
self.record(inbox, 'failed', 'Scherm viel weg na de update; controleer het scherm.')
self.record(inbox, 'failed', 'Screen dropped off after the update; check the screen.')
return 'failed'
self.record(inbox, 'success', f'Bijgewerkt naar firmware {FIRMWARE_VERSION}.')
self.record(inbox, 'success', f'Updated to firmware {FIRMWARE_VERSION}.')
return 'success'
async def wait_for_target(self, inbox):
@@ -209,7 +231,7 @@ class Updater:
await self.manager.ha.request('call_service', domain='persistent_notification', service='create',
service_data={'notification_id': 'esp_screens_update', 'title': 'ESP Screens', 'message': message})
except Exception as error:
LOG.warning('Melding naar Home Assistant mislukt (%s)', type(error).__name__)
LOG.warning('Notifying Home Assistant failed (%s)', type(error).__name__)
def due(self, now):
return self.auto and now.hour in NIGHT_HOURS and self.last_round != now.date().isoformat()
@@ -227,7 +249,7 @@ class Updater:
self.save()
pending = self.pending()
if pending:
LOG.info('Nachtelijke firmwareronde: %d scherm(en)', len(pending))
LOG.info('Nightly firmware round: %d screen(s)', len(pending))
self.launch(pending, automatic=True)
except Exception as error:
LOG.warning('Nachtelijke updatecontrole overgeslagen (%s)', type(error).__name__)
LOG.warning('Nightly update check skipped (%s)', type(error).__name__)
+2 -2
View File
@@ -1,7 +1,7 @@
name: ESP Screen Manager
version: "0.2.39"
version: "0.2.40"
slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
arch: [aarch64, amd64]
startup: application