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:
+124
-89
@@ -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())}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
+268
-268
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
Reference in New Issue
Block a user