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
+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())}