3e959b3df9
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>
653 lines
35 KiB
Python
653 lines
35 KiB
Python
"""Pure validation, firmware generation and bounded display protocol."""
|
||
import base64
|
||
from datetime import datetime, timedelta, timezone
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import re
|
||
import secrets
|
||
|
||
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': 'Clock'}
|
||
NEW_DOMAINS = frozenset('sun timer person screen'.split())
|
||
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.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': 'Default', 'color': None},
|
||
# No card behind the tile: contents keep their size and place on the screen background.
|
||
'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': '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).
|
||
DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard', 'watch', 'graph'), 'screen': ('digital', 'analog'), 'sun': ('standard', 'watch', 'sunpath')}
|
||
# Displays that only work on a double-width card.
|
||
WIDE_ONLY = ('forecast', 'sunpath')
|
||
|
||
# Grid positions: two columns, three rows per page, at most eight pages. A tile's
|
||
# `slot` is its absolute cell (page * 6 + row * 2 + column); a wide tile starts in
|
||
# the left column and also covers the cell to its right. Empty cells are allowed.
|
||
SLOTS_PER_PAGE = 6
|
||
MAX_PAGES = 8
|
||
MAX_SLOTS = MAX_PAGES * SLOTS_PER_PAGE
|
||
|
||
def is_wide(tile):
|
||
return tile.get('options', {}).get('size') == 'wide'
|
||
|
||
def footprint(slot, wide):
|
||
return (slot, slot + 1) if wide else (slot,)
|
||
|
||
def pack_slots(tiles):
|
||
"""In-order packing, the rule before explicit positions and what firmware without
|
||
`slots` still does: fill left to right, a wide card starts a new row."""
|
||
position, slots = 0, []
|
||
for tile in tiles:
|
||
wide = is_wide(tile)
|
||
if wide and position % 2:
|
||
position += 1
|
||
slots.append(position)
|
||
position += 2 if wide else 1
|
||
return slots
|
||
|
||
def has_gaps(tiles):
|
||
"""True when the stored positions differ from the in-order packing, so firmware
|
||
before 0.2.26 (which ignores `slots`) would show another arrangement."""
|
||
return [t.get('slot') for t in tiles] != pack_slots(tiles)
|
||
|
||
# Direct controls on the right half of a double-width card (firmware 0.2.19+), like
|
||
# 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', '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': 'None'}]}
|
||
for domain, choices in CONTROLS.items()}
|
||
|
||
def resolve_controls(tile):
|
||
"""Control set a card shows on the screen, or None: only wide cards in the standard layout have room for one."""
|
||
options = tile.get('options', {})
|
||
domain = tile['entity'].split('.')[0]
|
||
if domain not in CONTROLS or options.get('size') != 'wide' or options.get('display', 'standard') != 'standard' or options.get('inline') == 'slider':
|
||
return None
|
||
choice = options.get('controls', CONTROLS[domain][0][0])
|
||
return None if choice == 'none' else choice
|
||
|
||
# Diagnostic entities every ESP Screens firmware exposes; the manager watches them for screens.
|
||
# 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 = {
|
||
'standby_enabled': (True, None, None),
|
||
'standby_seconds': (600, 60, 86400),
|
||
'brightness': (100, 5, 100),
|
||
'standby_brightness': (20, 0, 100),
|
||
'night_enabled': (True, None, None),
|
||
'night_start': (1320, 0, 1439),
|
||
'night_end': (420, 0, 1439),
|
||
'night_brightness': (10, 0, 100),
|
||
'show_clock': (True, None, None),
|
||
'clock_24h': (True, None, None),
|
||
'home_on_standby': (False, None, None),
|
||
'swipe_pages': (False, None, None),
|
||
'rotation': (0, 0, 270),
|
||
}
|
||
|
||
def validate_settings(data):
|
||
if not isinstance(data, dict) or set(data) - SETTING_RULES.keys():
|
||
raise ValueError('Unknown screen settings; refresh the management page.')
|
||
clean = {}
|
||
for key, (default, minimum, maximum) in SETTING_RULES.items():
|
||
value = data.get(key, default)
|
||
if minimum is None:
|
||
valid = type(value) is bool
|
||
else:
|
||
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'Invalid value for {key}.')
|
||
clean[key] = value
|
||
if max(clean['standby_brightness'], clean['night_brightness']) > clean['brightness']:
|
||
raise ValueError('Standby and night brightness may not be higher than normal.')
|
||
return clean
|
||
|
||
def entity_id(value):
|
||
if not (isinstance(value, str) and len(value) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value)):
|
||
return False
|
||
return value in BUILTIN if value.startswith('screen.') else value.split('.')[0] in DOMAINS
|
||
|
||
# ----- Top bar (firmware 0.2.32+): the screen name on the left, up to six items on the right.
|
||
# Without a `header` the screen keeps its name and the clock of `show_clock`. -----
|
||
HEADER_MIN_FIRMWARE = (0, 2, 32)
|
||
# Firmware 0.2.33+ takes a whole message in one API action (esphome.<node>_screen_message)
|
||
# and answers a keepalive ping with its layout revision; older firmware gets base64
|
||
# chunks in the text inbox and a full repeat every keepalive.
|
||
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': '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')
|
||
HEADER_SHOWS = ('always', 'active')
|
||
|
||
def header_entity(value):
|
||
return (isinstance(value, str) and len(value) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value) is not None
|
||
and value.split('.')[0] in (DOMAINS - {'screen'}) | HEADER_ONLY_DOMAINS)
|
||
|
||
def header_items(layout):
|
||
"""Items the top bar shows: the stored ones, else what firmware before the top bar drew (the clock)."""
|
||
if 'header' in layout:
|
||
return layout['header']['items']
|
||
return [{'type': 'clock'}] if layout.get('settings', {}).get('show_clock', True) else []
|
||
|
||
def validate_header(data):
|
||
if not isinstance(data, dict) or set(data) - {'items'} or not isinstance(data.get('items'), list):
|
||
raise ValueError('Invalid top bar; refresh the management page.')
|
||
if len(data['items']) > HEADER_MAX_ITEMS:
|
||
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('Invalid setting in the top bar.')
|
||
clean = {'type': kind}
|
||
elif kind == 'entity':
|
||
if set(item) - {'type', 'entity', 'content', 'icon', 'show'}:
|
||
raise ValueError('Unknown setting in the top bar; refresh the management page.')
|
||
if not header_entity(item.get('entity')):
|
||
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('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('Choose an icon from the list.')
|
||
else:
|
||
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('This item is already in the top bar.')
|
||
seen.add(key)
|
||
items.append(clean)
|
||
return {'items': items}
|
||
|
||
def min_firmware(layout):
|
||
"""Oldest firmware that still accepts this layout; None when any version works."""
|
||
if any(t.get('options', {}).get('background') == 'none' for t in layout['tiles']):
|
||
return (0, 2, 16)
|
||
if any(t['entity'].split('.')[0] in NEW_DOMAINS for t in layout['tiles']):
|
||
return (0, 2, 14)
|
||
if len(layout['tiles']) > 10:
|
||
return (0, 2, 7)
|
||
return None
|
||
|
||
def short(value, limit):
|
||
return str(value).encode('utf-8')[:limit].decode('utf-8', errors='ignore')
|
||
|
||
def validate_layout(data):
|
||
if not isinstance(data, dict):
|
||
raise ValueError('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('Give the screen a title of at most 96 bytes.')
|
||
if not isinstance(tiles, list) or len(tiles) > 20:
|
||
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("This entity isn't supported.")
|
||
if tile['entity'] in seen:
|
||
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('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('Unknown tile settings.')
|
||
if 'background' in options and (not isinstance(options['background'],str) or options['background'] not in TILE_BACKGROUNDS):
|
||
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('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('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("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("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('History: choose 1, 6, or 24 hours.')
|
||
if options.get('display') == 'watch' and options.get('inline') == 'slider':
|
||
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("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).
|
||
given = [tile.get('slot') for tile in tiles]
|
||
if any(slot is not None for slot in given):
|
||
occupied = set()
|
||
for item, slot in zip(clean, given):
|
||
if type(slot) is not int or not 0 <= slot < MAX_SLOTS:
|
||
raise ValueError('Invalid tile position; refresh the management page.')
|
||
if is_wide(item) and slot % 2:
|
||
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('Two tiles are in the same spot.')
|
||
occupied.add(cell)
|
||
item['slot'] = slot
|
||
clean.sort(key=lambda item: item['slot'])
|
||
else:
|
||
for item, slot in zip(clean, pack_slots(clean)):
|
||
item['slot'] = slot
|
||
result = {'title': title.strip(), 'tiles': clean}
|
||
# 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'A screen has 1 to {MAX_PAGES} pages.')
|
||
result['pages'] = data['pages']
|
||
if 'settings' in data:
|
||
result['settings'] = validate_settings(data['settings'])
|
||
if 'header' in data:
|
||
result['header'] = validate_header(data['header'])
|
||
return result
|
||
|
||
def local_clock(value, tz):
|
||
"""HH:MM in the home's time zone for an ISO timestamp; '' when unusable."""
|
||
try:
|
||
moment = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
|
||
except (ValueError, TypeError):
|
||
return ''
|
||
if moment.tzinfo is None:
|
||
moment = moment.replace(tzinfo=timezone.utc)
|
||
return moment.astimezone(tz or timezone.utc).strftime('%H:%M')
|
||
|
||
def forecast_number(entry, name):
|
||
value = entry.get(name)
|
||
return round(value, 1) if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
|
||
|
||
def forecast_time(entry, tz):
|
||
try:
|
||
return datetime.fromisoformat(str(entry.get('datetime')).replace('Z', '+00:00')).astimezone(tz or timezone.utc)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
def epoch(value):
|
||
"""Unix time of an ISO timestamp (a scene's state, a script's last_triggered); None when unusable."""
|
||
try:
|
||
moment = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
|
||
except (ValueError, TypeError):
|
||
return None
|
||
if moment.tzinfo is None:
|
||
moment = moment.replace(tzinfo=timezone.utc)
|
||
return int(moment.timestamp())
|
||
|
||
def extras(tile, states, forecast=None, tz=None, hourly=None, now=None):
|
||
"""Small, pre-computed values the firmware cannot derive itself (time zones, forecasts)."""
|
||
domain = tile['entity'].split('.')[0]
|
||
attrs = states.get(tile['entity'], {}).get('attributes', {})
|
||
if domain == 'weather' and (forecast or hourly):
|
||
result = {}
|
||
days = []
|
||
for entry in forecast or []:
|
||
if not isinstance(entry, dict) or len(days) == 5:
|
||
continue
|
||
day = forecast_time(entry, tz)
|
||
if day is None:
|
||
continue
|
||
item = {'d': WEEKDAYS[day.weekday()], 'c': short(entry.get('condition') or '', 20)}
|
||
# h/l: high and low; p: chance of rain in %; r: rain in the entity's unit (mm).
|
||
for key, name in (('h', 'temperature'), ('l', 'templow'), ('p', 'precipitation_probability'), ('r', 'precipitation')):
|
||
value = forecast_number(entry, name)
|
||
if value is not None:
|
||
item[key] = value
|
||
days.append(item)
|
||
if days:
|
||
result['days'] = days
|
||
# The next eight hours from now, for the weather card's hourly strip.
|
||
hours = []
|
||
# The running hour still counts: an entry stays until its hour has passed.
|
||
start = (now or datetime.now(timezone.utc)) - timedelta(minutes=59)
|
||
for entry in hourly or []:
|
||
if not isinstance(entry, dict) or len(hours) == 8:
|
||
continue
|
||
moment = forecast_time(entry, tz)
|
||
if moment is None or moment < start:
|
||
continue
|
||
item = {'t': moment.strftime('%H:%M'), 'c': short(entry.get('condition') or '', 20)}
|
||
for key, name in (('h', 'temperature'), ('p', 'precipitation_probability'), ('r', 'precipitation')):
|
||
value = forecast_number(entry, name)
|
||
if value is not None:
|
||
item[key] = value
|
||
hours.append(item)
|
||
if hours:
|
||
result['hours'] = hours
|
||
return result or None
|
||
if domain in ('scene', 'script', 'button', 'input_button'):
|
||
# When it last ran: scripts report last_triggered; scenes and buttons carry the time as their state.
|
||
last = epoch(attrs.get('last_triggered') if domain == 'script' else states.get(tile['entity'], {}).get('state'))
|
||
return {'last': last} if last else None
|
||
if domain == 'sun':
|
||
rise, down = local_clock(attrs.get('next_rising'), tz), local_clock(attrs.get('next_setting'), tz)
|
||
return {'rise': rise, 'set': down} if rise or down else None
|
||
if domain == 'timer':
|
||
result = {}
|
||
try:
|
||
end = datetime.fromisoformat(str(attrs.get('finishes_at')).replace('Z', '+00:00'))
|
||
result['end'] = int(end.timestamp())
|
||
except (ValueError, TypeError):
|
||
pass
|
||
for key, name in (('dur', 'duration'), ('rem', 'remaining')):
|
||
if isinstance(attrs.get(name), str):
|
||
result[key] = short(attrs[name], 16)
|
||
return result or None
|
||
return None
|
||
|
||
def tile_icon(tile, attrs):
|
||
"""Codepoint the screen shows: the chosen icon, else HA's own mdi icon; None keeps the firmware default."""
|
||
choice = tile.get('options', {}).get('icon', 'auto')
|
||
return tile_icons.ICONS[choice][0] if choice in tile_icons.ICONS else tile_icons.ha_icon(attrs)
|
||
|
||
def screen_options(tile, attrs):
|
||
"""Stored options on the wire; `icon` travels as the resolved codepoint (firmware 0.2.18+, ignored before)
|
||
and `controls` only as the set the card really shows (firmware 0.2.19+, ignored before)."""
|
||
options = {k: v for k, v in tile.get('options', {}).items() if k not in ('icon', 'controls')}
|
||
icon = tile_icon(tile, attrs)
|
||
if icon:
|
||
options['icon'] = icon
|
||
controls = resolve_controls(tile)
|
||
if controls:
|
||
options['controls'] = controls
|
||
return options if options or 'options' in tile else None
|
||
|
||
def state_message(index, tile, states, extra=None):
|
||
if tile['entity'] in BUILTIN:
|
||
message = {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
|
||
'name': short(tile['name'] or BUILTIN[tile['entity']], 80), 'state': 'ok', 'a': {}}
|
||
if 'options' in tile:
|
||
message['o'] = tile['options']
|
||
return message
|
||
state = states.get(tile['entity'], {})
|
||
attrs = state.get('attributes', {})
|
||
bounded = {}
|
||
for key in ATTRS:
|
||
value = attrs.get(key)
|
||
if isinstance(value, bool):
|
||
if key in BOOL_ATTRS:
|
||
bounded[key] = value
|
||
continue
|
||
if value is None:
|
||
continue
|
||
if isinstance(value, (int, float)):
|
||
# supported_features is a bit field (media players exceed 8 million); other numbers are display values.
|
||
if math.isfinite(value) and abs(value) <= (2**31 if key == 'supported_features' else 1000000):
|
||
bounded[key] = value
|
||
elif isinstance(value, str):
|
||
bounded[key] = short(value, 48)
|
||
elif isinstance(value, list):
|
||
# Attribute lists have bounded lengths, strings and numeric ranges.
|
||
limit = 2 if key == 'hs_color' else 4 if key == 'fan_speed_list' else 8
|
||
bounded[key] = [short(v, 48) if isinstance(v, str) else v for v in value[:limit]
|
||
if isinstance(v, str) or isinstance(v, (float, int)) and math.isfinite(v) and abs(v) <= 1000000]
|
||
options = screen_options(tile, attrs)
|
||
return {'v': 1, 'op': 'state', 'i': index, 'entity': tile['entity'],
|
||
'name': short(tile['name'] or attrs.get('friendly_name') or tile['entity'], 80),
|
||
'state': short(state.get('state', 'unavailable'), 160), 'a': bounded,
|
||
**({'o': options} if options is not None else {}), **({'x': extra} if extra else {})}
|
||
|
||
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('The screen message is too large.')
|
||
return raw
|
||
|
||
def revision(message):
|
||
"""Short fingerprint of a layout message; the screen echoes it back on every ping."""
|
||
return hashlib.sha256(json.dumps(message, sort_keys=True, separators=(',', ':')).encode()).hexdigest()[:12]
|
||
|
||
def packets(message, token=None):
|
||
"""The message as base64 chunks for the 255-character text inbox (firmware before 0.2.33)."""
|
||
encoded = base64.b64encode(encode(message).encode()).decode('ascii')
|
||
token = token or secrets.token_hex(8)
|
||
chunks = [encoded[i:i+200] for i in range(0, len(encoded), 200)]
|
||
return [f'{token}|{i}|{int(i == len(chunks)-1)}|{part}' for i, part in enumerate(chunks)]
|
||
|
||
def message_action(node):
|
||
"""Home Assistant action that hands a screen one whole message, or None without a node name."""
|
||
return alert_service(node, MESSAGE_ACTION)
|
||
|
||
# ----- Alerts (firmware 0.2.31+): the reference the cheatsheet shows. tests/test_alerts_reference.py
|
||
# 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', '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', '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); 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',
|
||
'account', 'account-group', 'washing-machine', 'robot-vacuum', 'timer-outline', 'check')
|
||
|
||
def alert_service(node, action='show_alert'):
|
||
"""Home Assistant registers a device's actions as esphome.<node>_<action>, dashes as underscores."""
|
||
return f"esphome.{node.replace('-', '_')}_{action}" if isinstance(node, str) and node else None
|
||
|
||
def alert_reference():
|
||
"""Everything the Alerts cheatsheet shows besides the screens themselves."""
|
||
return {'min_firmware': ALERT_MIN_FIRMWARE, 'event': ALERT_EVENT,
|
||
'endings': [{'action': action, 'label': label} for action, label in ALERT_ENDINGS],
|
||
'fallback_icon': ALERT_FALLBACK_ICON, 'fallback_cp': tile_icons.GLYPHS[ALERT_FALLBACK_ICON],
|
||
'fields': [{'name': name, 'type': kind, 'label': label, 'help': help_, 'example': example} for name, kind, label, help_, example in ALERT_FIELDS],
|
||
'limits': ALERT_LIMITS,
|
||
'colors': [{'name': name, 'label': item['label'], 'color': item['color']} for name, item in TILE_BACKGROUNDS.items() if item['color']],
|
||
'suggested_icons': [{'name': name, 'cp': tile_icons.GLYPHS[name]} for name in ALERT_SUGGESTED_ICONS],
|
||
'extra_icons': [{'name': name, 'cp': cp} for name, cp in tile_icons.FIXED]}
|
||
|
||
def screen_items(registry):
|
||
"""The ESPHome entities that describe a screen; the subset `discover_screens` needs."""
|
||
return [item for item in registry if item.get('platform') == 'esphome' and item.get('original_name') in SCREEN_ENTITY_NAMES]
|
||
|
||
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', '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") in NAME_GUITION_TYPE}
|
||
def diagnostic(names, pattern):
|
||
found = {}
|
||
for item in registry:
|
||
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(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') 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'), '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', 'Not connected')})
|
||
return screens
|
||
|
||
def discover(registry, states, devices, areas):
|
||
"""(screens, entities): the paired screens and every entity a tile or the top bar can show."""
|
||
device_map = {d['id']: d for d in devices}
|
||
area_map = {a['area_id']: a['name'] for a in areas}
|
||
screens = discover_screens(registry, states, devices, areas)
|
||
entities = []
|
||
for item in registry:
|
||
eid = item['entity_id']
|
||
tile = entity_id(eid)
|
||
if not (tile or header_entity(eid)) or 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'), '')
|
||
entities.append({'id': eid, 'name': state.get('attributes', {}).get('friendly_name') or item.get('name') or item.get('original_name') or eid,
|
||
'device': device.get('name_by_user') or device.get('name') or '', 'area': area,
|
||
'state': state.get('state', 'unavailable'), 'icon': tile_icons.ha_icon(state.get('attributes')),
|
||
# Top-bar-only domains (a phone's tracker, a lock) stay out of the tile picker.
|
||
**({} if tile else {'tile': False})})
|
||
# YAML entities may not have an entity-registry entry.
|
||
registered = {e['id'] for e in entities}
|
||
in_registry = {r['entity_id'] for r in registry}
|
||
for eid, state in states.items():
|
||
tile = entity_id(eid)
|
||
if (tile or header_entity(eid)) and eid not in registered and eid not in in_registry:
|
||
entities.append({'id': eid, 'name': state.get('attributes', {}).get('friendly_name', eid), 'device': '', 'area': '', 'state': state['state'],
|
||
'icon': tile_icons.ha_icon(state.get('attributes')), **({} if tile else {'tile': False})})
|
||
return screens, sorted(entities, key=lambda e: e['name'].casefold())
|
||
|
||
def installation_yaml(data):
|
||
board, name, friendly = data.get('board'), data.get('name'), data.get('friendly_name')
|
||
if board not in REFS or not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,29}', name):
|
||
raise ValueError('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('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'''# 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())}
|
||
|
||
esphome:
|
||
name: {quote(name)}
|
||
friendly_name: {quote(friendly.strip())}
|
||
|
||
packages:
|
||
display:
|
||
url: {REPO}
|
||
ref: {REFS[board]}
|
||
files: [packages/{board}.yaml]
|
||
refresh: 0s
|
||
|
||
api:
|
||
encryption:
|
||
key: {quote(key)}
|
||
ota:
|
||
- platform: esphome
|
||
password: {quote(ota)}
|
||
wifi:
|
||
ssid: !secret wifi_ssid
|
||
password: !secret wifi_password
|
||
power_save_mode: none
|
||
ap:
|
||
ssid: {quote(name + ' Setup')}
|
||
password: {quote(ap)}
|
||
captive_portal:
|
||
'''
|