Files
homeassistant_espscreen/screen_manager/app/server.py
T
Max Gramser 1a6308aba7 Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host
harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and
Vue) found these, fixed here with Max's choices for the product questions:

- Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the
  waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap
  it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has
  no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built.
- App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its
  firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the
  live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes.
- Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone
  dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build.
- tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:25:23 +02:00

1855 lines
110 KiB
Python

"""HA Ingress app. HA writes: text.set_value on discovered inboxes (or the screen_message action on firmware 0.2.33+), each screen's alert actions when an alert event for every screen fires, and one persistent notification when a nightly update stops."""
import asyncio
from collections import Counter
import contextlib
import json
import logging
import os
from pathlib import Path
import secrets
import time
from datetime import datetime, timedelta, timezone
import math
import camera_feed
import claude_skill
from firmware import Firmware
import ha_catalogue
import tile_icons
from updates import Updater
from aiohttp import ClientError, ClientSession, ClientTimeout, WSMsgType, web
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state, state_word
from core import PAGE_TILE_REPEAT_MIN_FIRMWARE, SLOTS_PER_PAGE, firmware_features, packed_slots, run_tile_event, screen_firmware, version_text
import header_bar
import history_card
from zoneinfo import ZoneInfo
LOG = logging.getLogger('screen_manager')
REGISTRY_EVENTS = ('entity_registry_updated', 'device_registry_updated', 'area_registry_updated')
# Keepalive cadence. Firmware 0.2.33+ gets a small ping carrying the layout revision; every
# layout message declares this number, so the firmware sizes its feed watchdog from it
# (firmware 0.2.22+). Firmware before 0.2.33 still gets the whole layout every round.
KEEPALIVE_SECONDS = 120
# Safety net for screens that answer every ping: everything again once an hour.
FULL_REPEAT_SECONDS = 3600
# Sensor history is refreshed in a background task, never inside the sync loop.
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.
# 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
# A screen that answers its ping (firmware 0.2.49+) is asked with this timeout, so a busy screen never holds
# up the others. An answer that the screen lacks something repeats everything, after this many seconds right
# after a full send, doubling up to RESEND_GUARD_SECONDS while it keeps failing.
ANSWER_TIMEOUT_SECONDS = 5
ANSWER_RETRY_SECONDS = 30
SERVICE_EVENTS = ('service_registered', 'service_removed')
def samples(events, begin, span):
"""24 values, one per bucket: the last known value at the end of each bucket (None until the first)."""
events.sort(key=lambda pair: pair[0])
position, value, out = 0, None, []
for bucket in range(24):
boundary = begin + (bucket + 1) * span / 24
while position < len(events) and events[position][0] <= boundary:
value = events[position][1]
position += 1
out.append(value)
return out
class Refused(ConnectionError):
"""Home Assistant answered a command with an error; `detail` is its own message."""
def __init__(self, detail=''):
super().__init__('Home Assistant refused the command.')
self.detail = detail
def rounded(value):
try:
value = float(value)
except (TypeError, ValueError):
return None
return round(value, 3) if math.isfinite(value) else None
class HomeAssistant:
registry_interval = 600
def __init__(self, session, base, token):
self.session, self.base, self.token = session, base.rstrip('/'), token
self.ws = None
self.next_id = 0
self.pending = {}
self.states = {}
self.registry, self.devices, self.areas = [], [], []
self.online = False
self.changed = asyncio.Event()
# Entity ids the manager cares about; None wakes it for every state change.
self.relevant = None
# Entity ids whose state changed since the manager last looked; it only rebuilds those tiles.
self.dirty = set()
self.registry_changed = asyncio.Event()
self.setting_events = []
self.time_zone = None
# Home Assistant's unit system; a climate entity's temperature carries no unit of its own.
self.units = {}
# (event type, data) of alert events for every screen, for Manager.alert_loop.
self.broadcasts = asyncio.Queue()
# (event type, data) of tile events (app 0.2.51+), for Manager.tile_loop.
self.tile_events = asyncio.Queue()
# ESPHome actions that can answer (`esphome.<node>_screen_message` of firmware 0.2.49+), from Home
# Assistant's own list; refreshed when a device registers its actions again.
self.responses = set()
self.services_changed = asyncio.Event()
# Every action Home Assistant describes (get_services), which the editor's choices per entity follow (app
# 0.2.67); `services_rev` counts the refreshes. `targets` keeps Home Assistant's answer per entity, and
# `target_lookup` stays None until it is known whether Home Assistant answers get_services_for_target (2025.12+).
self.services, self.services_rev, self.targets, self.target_lookup = {}, 0, {}, None
# Home Assistant's own names and descriptions of actions and their fields (frontend/get_translations, English
# like the editor), for Perform action (app 0.2.67). `get_services` has carried no translated names since 2025.10.
self.service_names = {}
# Home Assistant's words for states (frontend/get_translations `entity_component` and `entity`, English), which a
# tile shows where it would show the raw state (app 0.2.67).
self.state_words = {}
self.esphome_services = False
self._platforms_source, self._platforms = None, {}
# History a detail card asks for when it opens (firmware 0.2.51+): {inbox, entity, hours}, for Manager.card_history_loop.
self.history_requests = asyncio.Queue()
# A camera a screen opens full screen (firmware 0.2.57+): {inbox, entity}, for Manager.camera_loop.
self.camera_requests = asyncio.Queue()
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
raise ConnectionError("Home Assistant isn't connected.")
self.next_id += 1
key = self.next_id
future = asyncio.get_running_loop().create_future()
self.pending[key] = future
try:
await self.ws.send_json({'id': key, 'type': kind, **data})
return await asyncio.wait_for(future, 15)
finally:
self.pending.pop(key, None)
async def read(self):
async for msg in self.ws:
if msg.type != WSMsgType.TEXT:
continue
data = msg.json()
if data.get('type') == 'result':
future = self.pending.get(data.get('id'))
if future and not future.done():
if data.get('success'):
future.set_result(data.get('result'))
else:
error = data.get('error') if isinstance(data.get('error'), dict) else {}
future.set_exception(Refused(str(error.get('message') or '')))
elif data.get('type') == 'event':
event = data.get('event', {})
body = event.get('data', {})
if event.get('event_type') == 'esphome.screen_setting':
self.setting_events.append(body)
self.changed.set()
elif event.get('event_type') == 'esphome.screen_history':
self.history_requests.put_nowait(body)
elif event.get('event_type') == 'esphome.screen_camera':
self.camera_requests.put_nowait(body)
elif event.get('event_type') == 'state_changed':
eid = body.get('entity_id')
if body.get('new_state'):
self.states[eid] = body['new_state']
else:
self.states.pop(eid, None)
if self.relevant is None or eid in self.relevant:
self.dirty.add(eid)
self.changed.set()
elif event.get('event_type') in REGISTRY_EVENTS:
self.registry_changed.set()
elif event.get('event_type') in SERVICE_EVENTS:
# Every action counts for what the editor offers (app 0.2.67); a screen's own actions also decide
# how the app talks to it.
self.esphome_services |= body.get('domain') == 'esphome'
self.services_changed.set()
elif event.get('event_type') in BROADCAST_EVENTS:
# Queued, not handled here: the calls wait for results this reader has to deliver.
self.broadcasts.put_nowait((event['event_type'], body))
elif event.get('event_type') in TILE_EVENTS:
self.tile_events.put_nowait((event['event_type'], body))
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."""
if connected is None:
return ''
ws = self.ws
reason = ws.exception() 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):
self.registry, self.devices, self.areas = await asyncio.gather(
self.request('config/entity_registry/list'), self.request('config/device_registry/list'), self.request('config/area_registry/list'))
async def fetch_services(self):
"""Every action Home Assistant describes: which ESPHome actions answer (Home Assistant lists `response` for an
action that can return one), and the descriptions the editor's choices per entity follow (app 0.2.67)."""
services = await self.request('get_services') or {}
esphome = services.get('esphome', {})
self.responses = {f'esphome.{name}' for name, spec in esphome.items() if isinstance(spec, dict) and spec.get('response')}
if isinstance(services, dict):
self.services, self.services_rev, self.targets = services, self.services_rev + 1, {}
try:
names = await self.request('frontend/get_translations', language='en', category='services')
if isinstance((names or {}).get('resources'), dict):
self.service_names = names['resources']
words = {}
for category in ('entity_component', 'entity'):
found = await self.request('frontend/get_translations', language='en', category=category)
if isinstance((found or {}).get('resources'), dict):
words.update(found['resources'])
if words:
self.state_words = words
icons = {}
for category in ('entity_component', 'entity'):
found = await self.request('frontend/get_icons', category=category)
if isinstance((found or {}).get('resources'), dict):
icons[category] = found['resources']
if icons:
tile_icons.use_ha_icons(icons)
except (Refused, TimeoutError) as error:
# The editor then shows the names services.yaml still carries, and tiles keep their raw states.
LOG.info('No action names or state words from Home Assistant (%s)', type(error).__name__)
async def refresh_services(self):
"""fetch_services for the connection loop: a list that cannot be read only means no answers are asked for."""
try:
await self.fetch_services()
except (ConnectionError, TimeoutError, OSError, ValueError, TypeError, AttributeError) as error:
LOG.warning('Reading the actions from Home Assistant failed (%s); screens are not asked for answers', type(error).__name__)
self.responses = set()
def platform_of(self, entity_id):
"""The integration behind an entity, from the entity registry (an action can be meant for one integration)."""
registry = self.registry
if self._platforms_source is not registry:
self._platforms_source = registry
self._platforms = {item.get('entity_id'): item.get('platform') for item in registry if isinstance(item, dict)}
return self._platforms.get(entity_id)
async def entity_actions(self, entity_id):
"""The actions Home Assistant offers for one entity, or None while that is unknown (no action list yet, or no
state). Home Assistant 2025.12+ answers get_services_for_target itself; an older one gets the same answer from the
action descriptions."""
state = self.states.get(entity_id)
if state is None or not self.services:
return None
attributes = state.get('attributes') or {}
key = (self.services_rev, id(self.registry), attributes.get('supported_features'), attributes.get('device_class'))
cached = self.targets.get(entity_id)
if cached and cached[0] == key:
return cached[1]
actions = None
if self.target_lookup is not False:
try:
answer = await self.request('get_services_for_target', target={'entity_id': [entity_id]}, expand_group=False)
if isinstance(answer, list):
actions, self.target_lookup = frozenset(answer), True
except Refused as error:
if 'unknown command' in (error.detail or '').lower():
self.target_lookup = False
except (ConnectionError, TimeoutError):
pass
if actions is None:
actions = frozenset(ha_catalogue.local_actions(self.services, entity_id, attributes, self.platform_of(entity_id)))
self.targets[entity_id] = (key, actions)
return actions
async def capabilities(self, entity_id):
"""What the editor may offer for one entity (ha_catalogue.capabilities), or None while Home Assistant can't say."""
actions = await self.entity_actions(entity_id)
if actions is None:
return None
return ha_catalogue.capabilities(entity_id, actions, self.states.get(entity_id), self.services)
async def run(self):
url = ('ws://supervisor/core/websocket' if self.base == 'http://supervisor/core/api'
else self.base.replace('http://', 'ws://').replace('https://', 'wss://') + '/websocket')
while True:
reader, connected = None, None
try:
async with self.session.ws_connect(url, heartbeat=30, max_msg_size=16*1024*1024) as ws:
self.ws = ws
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 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')
await self.request('subscribe_events', event_type='esphome.screen_history')
await self.request('subscribe_events', event_type='esphome.screen_camera')
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS, *SERVICE_EVENTS):
await self.request('subscribe_events', event_type=event_type)
self.states = {s['entity_id']: s for s in await self.request('get_states')}
await self.registries()
await self.refresh_services()
try:
config = await self.request('get_config')
self.units = config.get('unit_system') or {}
self.time_zone = ZoneInfo(config.get('time_zone') or 'UTC')
except Exception:
self.time_zone = timezone.utc
self.online = True
self.changed.set()
connected = time.monotonic()
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()
while True:
waiters = [asyncio.ensure_future(self.registry_changed.wait()), asyncio.ensure_future(self.services_changed.wait())]
try:
done, _ = await asyncio.wait([reader, *waiters], timeout=30, return_when=asyncio.FIRST_COMPLETED)
finally:
for waiter in waiters:
waiter.cancel()
if reader in done:
await reader
if self.services_changed.is_set():
# A screen that reconnects registers its actions one after the other.
await asyncio.sleep(1)
self.services_changed.clear()
await self.refresh_services()
if self.esphome_services:
self.esphome_services = False
self.changed.set()
if self.registry_changed.is_set():
await asyncio.sleep(1)
self.registry_changed.clear()
elif time.monotonic() - fetched < self.registry_interval:
continue
await self.registries()
fetched = time.monotonic()
self.changed.set()
except (ConnectionError, TimeoutError, OSError, ValueError) as error:
LOG.warning('Home Assistant temporarily unavailable (%s)%s', type(error).__name__, self.describe_close(connected))
except Exception as error:
LOG.warning('Restarting connection (%s)%s', type(error).__name__, self.describe_close(connected))
finally:
if self.online:
LOG.info('Home Assistant connection closed%s', self.describe_close(connected))
self.online = False
self.ws = None
if reader:
reader.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await reader
for future in self.pending.values():
if not future.done():
future.set_exception(ConnectionError('Connection lost.'))
await asyncio.sleep(5)
async def send(self, inbox, message, action=None, respond=False):
"""One message to a screen: as one ESPHome action call (firmware 0.2.33+), else as text chunks.
`respond`: wait for the screen's answer (firmware 0.2.49+), at most ANSWER_TIMEOUT_SECONDS, and return it
(`status`, `rev`), or None when the answer carries nothing usable."""
if action:
domain, service = action.split('.', 1)
data = {'domain': domain, 'service': service, 'service_data': {'message': encode(message)}}
if not respond:
await self.request('call_service', **data)
return None
result = await asyncio.wait_for(self.request('call_service', **data, return_response=True), ANSWER_TIMEOUT_SECONDS)
response = (result or {}).get('response') if isinstance(result, dict) else None
return response if isinstance(response, dict) else None
for packet in packets(message):
await self.request('call_service', domain='text', service='set_value',
service_data={'entity_id': inbox, 'value': packet})
async def call(self, action, data):
"""One Home Assistant action, such as a screen's esphome.<node>_show_alert."""
domain, service = action.split('.', 1)
await self.request('call_service', domain=domain, service=service, service_data=data)
async def camera_image(self, entity):
"""The picture of a camera or image entity as Home Assistant hands it to its own frontend."""
path = 'camera_proxy' if entity.startswith('camera.') else 'image_proxy'
async with self.session.get(f'{self.base}/{path}/{entity}', headers={'Authorization': 'Bearer ' + self.token},
timeout=ClientTimeout(total=camera_feed.FETCH_SECONDS)) as response:
response.raise_for_status()
if (response.content_length or 0) > camera_feed.MAX_SNAPSHOT_BYTES:
raise ValueError('image too large')
raw = bytearray()
async for chunk in response.content.iter_chunked(65536):
raw += chunk
if len(raw) > camera_feed.MAX_SNAPSHOT_BYTES:
raise ValueError('image too large')
return bytes(raw)
def media_picture(self, entity):
"""The address of a media player's cover as its state carries it right now, or '' without one."""
attrs = self.states.get(entity, {}).get('attributes', {})
picture = attrs.get('entity_picture') or attrs.get('entity_picture_local')
return picture if isinstance(picture, str) and picture else ''
async def media_image(self, entity):
"""The cover of a media player (app 0.2.77), fetched where its state points: Home Assistant's own proxy
with this app's token (`/api/media_player_proxy/...`), or a picture on the internet as it is."""
picture = self.media_picture(entity)
if not picture:
raise ValueError('no picture')
root = self.base[:-4] if self.base.endswith('/api') else self.base
if picture.startswith('/'):
url, headers = f'{root}{picture}', {'Authorization': 'Bearer ' + self.token}
elif picture.startswith(('http://', 'https://')):
url, headers = picture, {}
else:
raise ValueError('unknown picture address')
async with self.session.get(url, headers=headers, timeout=ClientTimeout(total=camera_feed.FETCH_SECONDS)) as response:
response.raise_for_status()
if (response.content_length or 0) > camera_feed.MAX_SNAPSHOT_BYTES:
raise ValueError('picture too large')
raw = bytearray()
async for chunk in response.content.iter_chunked(65536):
raw += chunk
if len(raw) > camera_feed.MAX_SNAPSHOT_BYTES:
raise ValueError('picture too large')
return bytes(raw)
async def fire(self, event_type, data):
"""One Home Assistant event of our own, such as the answer to a tile event."""
await self.request('fire_event', event_type=event_type, event_data=data)
async def set_state(self, entity_id, state, attributes):
"""A state this app publishes itself (the layout sensors). Home Assistant's websocket has no
command for that, so this goes over the REST API with the same token."""
async with self.session.post(f'{self.base}/states/{entity_id}',
headers={'Authorization': 'Bearer ' + self.token},
json={'state': state, 'attributes': attributes}) as response:
response.raise_for_status()
async def forecast(self, entity, kind='daily'):
# Forecasts left the weather attributes in HA 2024.4; ask the service instead.
result = await self.request('call_service', domain='weather', service='get_forecasts',
service_data={'type': kind}, target={'entity_id': entity}, return_response=True)
forecast = ((result or {}).get('response') or {}).get(entity, {}).get('forecast', [])
return forecast if isinstance(forecast, list) else []
async def statistics(self, entities, hours):
"""24 samples per entity from the recorder's statistics, all entities in one request.
Hourly means for a day, five-minute means below that; a sum-only sensor (energy) gives its
state. Entities without statistics (no state class) are absent and fall back to `history`."""
period = 'hour' if hours >= 24 else '5minute'
start = datetime.now(timezone.utc) - timedelta(hours=hours)
result = await self.request('recorder/statistics_during_period', start_time=start.isoformat(),
statistic_ids=sorted(entities), period=period, types=['mean', 'state'])
begin, span, found = start.timestamp(), hours * 3600, {}
for entity, rows in (result or {}).items():
if entity not in entities or not isinstance(rows, list):
continue
events = []
for row in rows:
moment = row.get('start') if isinstance(row, dict) else None
if not isinstance(moment, (int, float)):
continue
if moment > 1e11: # milliseconds since HA 2023.9
moment /= 1000
events.append((moment, rounded(row.get('mean') if row.get('mean') is not None else row.get('state'))))
found[entity] = samples(events, begin, span)
return found
async def state_changes(self, entity, hours):
"""(unix time, state) of every change in the last `hours`, beginning with the state at the start (REST history)."""
start = (datetime.now(timezone.utc)-timedelta(hours=hours)).isoformat()
async with self.session.get(self.base+'/history/period/'+start,
params={'filter_entity_id':entity,'minimal_response':'','no_attributes':''},
headers={'Authorization':'Bearer '+self.token}) as response:
response.raise_for_status()
raw = bytearray()
async for chunk in response.content.iter_chunked(65536):
raw.extend(chunk)
if len(raw)>2*1024*1024: raise ValueError('History too large.')
rows=json.loads(raw)
changes=[]
for row in rows[0] if rows else []:
try:
timestamp=datetime.fromisoformat(row.get('last_changed',row.get('last_updated','')).replace('Z','+00:00')).timestamp()
except (ValueError,TypeError,AttributeError): continue
changes.append((timestamp,row.get('state')))
return changes
async def history(self, entity, hours):
begin=(datetime.now(timezone.utc)-timedelta(hours=hours)).timestamp()
return samples([(timestamp,rounded(state)) for timestamp,state in await self.state_changes(entity,hours)], begin, hours*3600)
async def statistic_rows(self, entity, hours):
"""The recorder's hourly statistics rows of one entity over the last `hours` (mean, min, max, state), from the
hour the range begins in; empty for an entity without statistics."""
start = datetime.now(timezone.utc) - timedelta(hours=hours + 1)
result = await self.request('recorder/statistics_during_period', start_time=start.isoformat(), statistic_ids=[entity],
period='hour', types=['mean', 'min', 'max', 'state'])
rows = (result or {}).get(entity) if isinstance(result, dict) else None
return rows if isinstance(rows, list) else []
class Manager:
def __init__(self, ha, path):
self.ha, self.path = ha, Path(path)
# sent: per inbox what the screen holds ({'layout', 'header', 'states', 'rev'}); last: the
# last full send; pinged: the last keepalive ping.
self.layouts, self.sent, self.status, self.last, self.pinged = {}, {}, {}, {}, {}
# (entity, hours) -> (monotonic, 24 samples); filled by history_loop, read by sync_one.
self.histories = {}
self.history_wake = asyncio.Event()
self.forecasts = {}
self.listeners = set() # asyncio.Event per open /api/events stream
self.firmware = Firmware(os.environ.get("ESPHOME_CONFIG", "/homeassistant/esphome"), self.path.parent)
self.updates = Updater(self, self.path.parent / 'updates.json')
self.skill_dir = claude_skill.skill_dir()
self._registry_source, self._registry_index = None, {}
self._items_source, self._items = None, []
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 = {}, {}
# The layout snapshot each screen's sensor already carries, so it is only written when it changes.
self.published = {}
self._prefix_source, self._prefixes = None, {}
# Screens that own their settings (firmware 0.2.49+): {device_id: {key: entity_id}}, per registry.
self._settings_source, self._settings_index, self._settings_key = None, {}, None
# Answers (firmware 0.2.49+): when to send everything again after one said the screen lacks something,
# how often that failed in a row, and actions Home Assistant refused to answer for.
self.retry_at, self.retries, self.no_answers = {}, {}, set()
# History for detail cards (firmware 0.2.51+): (entity, hours) -> (monotonic, message), and the fetches under way.
self.card_histories, self.card_history_fetches = {}, {}
# Camera images (firmware 0.2.57+): the feed behind the camera port, and the cameras of recent alerts
# (entity -> monotonic time) that a screen may open full screen without a tile.
self.camera = camera_feed.CameraFeed(lambda entity: self.ha.camera_image(entity),
fetch_cover=lambda entity: self.ha.media_image(entity),
picture=lambda entity: self.ha.media_picture(entity))
self.alert_cameras = {}
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('Unknown storage version; data stays unchanged.')
# Loaded leniently: a tile setting this version doesn't know (saved by a newer one) never stops the app.
self.layouts = {key: validate_layout(value, stored=True) for key, value in raw['screens'].items()}
def inventory(self):
"""(screens, every tile/top-bar entity): the full walk over the registry, for the editor and saving."""
return discover(self.ha.registry, self.ha.states, self.ha.devices, self.ha.areas)
def screen_registry(self):
"""The few registry entries that describe screens; rebuilt only when HA delivers a new registry."""
registry = getattr(self.ha, 'registry', [])
if self._items_source is not registry:
self._items_source, self._items = registry, screen_items(registry)
return self._items
def screens(self):
"""The paired screens, cached until the registry or one of the screens' own diagnostics changes.
Costs a handful of dictionary lookups per screen instead of a walk over every entity in
Home Assistant, so the sync loop can run it on every wake."""
ha = self.ha
items = self.screen_registry()
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):
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):
"""The firmware a screen's features go by: its sensor, or while that has no version (offline, restarting) the
one Home Assistant's device registry kept (app 0.2.78); None when neither says (core.screen_firmware)."""
if screen is None:
screen = self.screen(inbox) or {}
return screen_firmware(screen)
def supports_twenty(self, inbox):
version=self.firmware_version(inbox)
return bool(version) and version >= (0,2,7)
def supports_header(self, inbox, screen=None):
return (self.firmware_version(inbox, screen) or (0, 0, 0)) >= HEADER_MIN_FIRMWARE
def supports_ping(self, inbox, screen=None):
"""Firmware 0.2.33+ answers a keepalive ping with its layout revision."""
return (self.firmware_version(inbox, screen) or (0, 0, 0)) >= TRANSPORT_MIN_FIRMWARE
def transport(self, inbox, screen=None):
"""The ESPHome action that takes a whole message (firmware 0.2.33+), or None for the text inbox."""
if screen is None:
screen = self.screen(inbox) or {}
return message_action(screen.get('node')) if self.supports_ping(inbox, screen) else None
# ----- Screen settings: owned by the screen (firmware 0.2.49+), else stored with the layout -----
def setting_index(self):
"""{device_id: {key: entity_id}} for every screen that owns its settings; rebuilt per registry."""
registry = getattr(self.ha, 'registry', [])
if self._settings_source is not registry:
by_device = {}
for item in registry:
if item.get('platform') == 'esphome' and item.get('device_id'):
by_device.setdefault(item['device_id'], []).append(item)
index = {}
for device, items in by_device.items():
entities = setting_entities(items)
if entities is not None:
index[device] = entities
self._settings_source, self._settings_index = registry, index
return self._settings_index
def setting_entities(self, screen):
"""{key: entity_id} when this screen owns its settings, else None (they travel in the layout message)."""
device = (screen or {}).get('device_id')
return self.setting_index().get(device) if device else None
def settings_view(self, screen):
"""What the editor shows under Screen settings.
`owner` is 'screen' when the screen's own entities hold the settings, else 'layout'. `keys` are the
settings this screen has, `unavailable` the ones Home Assistant cannot read or change right now (the
screen is offline, or the entity is disabled); their value is None, not a default that could differ
from what the screen has."""
inbox = self.aliases.get(screen['id'], screen['id'])
keys = [key for key in SETTING_RULES if key != 'show_clock' and (key != 'rotation' or screen.get('board') == 'guition')]
entities = self.setting_entities(screen)
if entities is None:
try:
values = validate_settings(self.layouts.get(inbox, {}).get('settings', {}))
except ValueError:
values = validate_settings({})
if (self.firmware_version(inbox, screen) or (0, 0, 0)) < (0, 2, 44):
keys = [key for key in keys if key not in ('auto_home', 'auto_home_seconds')]
# Dark mode came after the screens took over their settings: firmware that gets them with the layout lacks it.
keys = [key for key in keys if key != 'dark_mode']
return {'owner': 'layout', 'values': values, 'keys': keys, 'unavailable': []}
# Only the settings this screen has an entity for: one added in later firmware stays out of the panel.
keys = [key for key in keys if key in entities]
values = {key: setting_from_state(key, self.ha.states.get(entities[key])) for key in keys}
return {'owner': 'screen', 'values': values, 'keys': keys, 'unavailable': [key for key in keys if values[key] is None]}
def settings_states_key(self):
"""The states of every setting entity, so the sync loop notices a change the editor should show."""
states = self.ha.states
return tuple((entity, states.get(entity, {}).get('state')) for device in self.setting_index().values()
for entity in device.values())
def store_settings(self, inbox, settings, screen=None, on_screen=False):
"""Settings of a screen that does not own them, kept with its layout.
`on_screen`: the screen reported them itself. The layout message it holds is then brought in step
without sending it back, which could undo a change it made after reporting this one; its revision
stays, so the next ping still matches."""
base = self.layouts.get(inbox) or {'title': (screen or {}).get('name') or 'Home', 'tiles': []}
layout = validate_layout({**base, 'settings': settings})
updated = {**self.layouts, inbox: layout}
self.write_layouts(updated)
self.layouts = updated
if on_screen and inbox in self.sent:
self.sent[inbox] = {**self.sent[inbox], 'layout': self.layout_message(inbox, layout, screen or self.screen(inbox) or {})}
self.notify()
def screen_setting_event(self, event):
"""A screen reported a setting that changed on it (its settings page, one of its entities).
A screen that owns its settings needs nothing here: its entities carry the change. Older firmware gets
it kept with the layout, without the layout being sent back."""
inbox = self.aliases.get(event.get('inbox'), event.get('inbox'))
if inbox not in self.layouts:
return
screen = self.screen(inbox)
if screen is not None and self.setting_entities(screen) is not None:
return
key, value = event.get('key'), event.get('value')
stored = validate_settings(self.layouts[inbox].get('settings', {}))
if key not in stored or key == 'show_clock':
return
settings = dict(stored)
settings[key] = value == '1' if type(stored[key]) is bool else int(value)
if key == 'brightness':
for dim in ('standby_brightness', 'night_brightness'):
settings[dim] = min(settings[dim], settings[key])
settings = validate_settings(settings)
# A screen reporting what it already has (an automation setting the same value on every light change).
if settings != stored:
self.store_settings(inbox, settings, screen, on_screen=True)
async def change_settings(self, inbox, changes):
"""Settings from the editor: on the screen itself when it owns them, else kept with its layout, which the
sync loop then sends. Returns the settings as the editor shows them afterwards."""
inbox = self.aliases.get(inbox, inbox)
screen = self.screen(inbox)
if screen is None:
raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
view = self.settings_view(screen)
if not isinstance(changes, dict) or not changes or set(changes) - set(view['keys']):
raise ValueError('Unknown screen settings; refresh the management page.')
if view['owner'] == 'screen' and not screen.get('online'):
raise ValueError('This screen is offline. You can change its settings once it is back.')
missing = [SETTING_ENTITIES[key][1] for key in changes if key in view['unavailable']]
if missing:
raise ValueError(f"{', '.join(missing)} can't be changed now: the entity is off in Home Assistant, or the screen is restarting.")
# A setting Home Assistant cannot read (its entity is off) is checked at its default.
wanted = {**{key: value for key, value in view['values'].items() if value is not None}, **changes}
if 'brightness' in changes and type(changes['brightness']) is int:
# A lower brightness pulls both dim levels down with it, as on the screen.
for dim in ('standby_brightness', 'night_brightness'):
if dim not in changes:
wanted[dim] = min(wanted.get(dim, SETTING_RULES[dim][0]), changes['brightness'])
merged = validate_settings(wanted)
if merged['rotation'] and screen.get('board') != 'guition':
raise ValueError('Rotation requires a Guition with firmware 0.2.9 or newer.')
if view['owner'] == 'layout':
self.store_settings(inbox, merged, screen)
self.ha.changed.set()
return self.settings_view(screen)
entities = self.setting_entities(screen)
# Brightness first: the screen keeps both dim levels at or below it.
for key in sorted(changes, key=lambda key: (key != 'brightness', list(SETTING_RULES).index(key))):
if merged[key] != view['values'][key]:
await self.ha.call(*setting_action(key, entities[key], merged[key]))
return self.settings_view(screen)
# ----- Answers from the screen (firmware 0.2.49+) -----
def answers(self, inbox, screen):
"""True when the screen's message action can answer, so a ping learns at once what the screen holds."""
action = self.transport(inbox, screen)
return bool(action) and action in getattr(self.ha, 'responses', ()) and action not in self.no_answers
def answered(self, inbox, screen, answer):
"""Act on a screen's answer to a ping: everything again when it lacks the layout or a tile, or refused one."""
status = answer.get('status') if isinstance(answer, dict) else None
if not isinstance(status, str):
return
if status not in RESEND_STATES and not status.startswith('Error'):
self.retries.pop(inbox, None)
return
failures = self.retries.get(inbox, 0)
due = self.last.get(inbox, 0) + min(RESEND_GUARD_SECONDS, ANSWER_RETRY_SECONDS * 2 ** failures)
name = (screen or {}).get('name') or inbox
if time.monotonic() >= due:
LOG.info('%s answered "%s"; sending everything again', name, status)
self.retries[inbox] = failures + 1
self.retry_at.pop(inbox, None)
self.sent.pop(inbox, None)
self.ha.changed.set()
elif inbox not in self.retry_at:
LOG.info('%s answered "%s"; sending everything again in %d s', name, status, max(1, round(due - time.monotonic())))
self.retries[inbox] = failures + 1
self.retry_at[inbox] = due
# ----- History on a detail card (firmware 0.2.51+) -----
async def card_history_loop(self):
"""Answer the history a screen asks for when a card opens, a few at a time."""
limit = asyncio.Semaphore(3)
async def answer(request):
async with limit:
try:
await self.answer_history(request)
except (ClientError, ConnectionError, TimeoutError, OSError, ValueError) as error:
# Nothing is sent or kept: the card says it has no history and asks again.
LOG.info('No history for %s (%s)', request.get('entity'), type(error).__name__)
except Exception as error:
LOG.warning('History for %s was not sent (%s)', request.get('entity') if isinstance(request, dict) else '?', type(error).__name__)
while True:
request = await self.ha.history_requests.get()
asyncio.ensure_future(answer(request))
async def answer_history(self, request):
"""One screen's request: an entity on its own layout, a range the card offers, a screen that takes whole messages."""
if not isinstance(request, dict):
return
inbox = self.aliases.get(request.get('inbox'), request.get('inbox'))
entity = request.get('entity')
try:
hours = int(request.get('hours'))
except (TypeError, ValueError):
return
layout, screen = self.layouts.get(inbox), self.screen(inbox) if isinstance(inbox, str) else None
if hours not in history_card.RANGES or not layout or not screen or not screen.get('online'):
return
if entity not in {tile['entity'] for tile in layout['tiles']}:
return
what = history_card.kind(entity, self.ha.states.get(entity))
action = self.transport(inbox, screen)
if what is None or not action:
return
await self.ha.send(inbox, await self.card_history(entity, hours, what), action)
async def card_history(self, entity, hours, what):
"""The history message, from a short cache; screens asking at the same time share one fetch."""
key = (entity, hours)
cached = self.card_histories.get(key)
if cached and time.monotonic() - cached[0] < history_card.CACHE_SECONDS[hours]:
return cached[1]
fetch = self.card_history_fetches.get(key)
if fetch is None:
fetch = asyncio.ensure_future(self.build_card_history(entity, hours, what))
self.card_history_fetches[key] = fetch
fetch.add_done_callback(lambda _: self.card_history_fetches.pop(key, None))
message = await asyncio.shield(fetch)
self.card_histories[key] = (time.monotonic(), message)
for old in [k for k, (moment, _) in self.card_histories.items() if time.monotonic() - moment > 3600]:
del self.card_histories[old]
return message
async def build_card_history(self, entity, hours, what):
"""Numbers from the recorder's hourly statistics for a day or a week (the exact changes for an hour, or for an
entity without statistics); states from their changes. A fetch that fails raises, so nothing is kept."""
start, end = history_card.window(hours)
tz = getattr(self.ha, 'time_zone', None)
attrs = (self.ha.states.get(entity) or {}).get('attributes') or {}
if what == 'timeline':
return history_card.timeline(entity, hours, await self.ha.state_changes(entity, hours), start, end, tz, attrs,
entry=self.registry_index().get(entity), translations=getattr(self.ha, 'state_words', None))
entry, unit = self.registry_index().get(entity), attrs.get('unit_of_measurement') or ''
rows = await self.ha.statistic_rows(entity, hours) if hours > 1 else []
if rows:
means, extreme = history_card.statistic_changes(rows, 3600)
return history_card.line(entity, hours, means, start, end, tz, entry, unit, extreme)
changes = [(moment, header_bar.numeric(value)) for moment, value in await self.ha.state_changes(entity, hours)]
return history_card.line(entity, hours, changes, start, end, tz, entry, unit)
def registry_index(self):
"""Entity registry by id (display precision, entity category); rebuilt only when HA delivers a new registry."""
registry = getattr(self.ha, 'registry', [])
if self._registry_source is not registry:
self._registry_source, self._registry_index = registry, {item['entity_id']: item for item in registry}
return self._registry_index
def device_entries(self, entity):
"""Registry entries on the device of `entity` (itself included); the index follows the registry object."""
registry = getattr(self.ha, 'registry', [])
if getattr(self, '_devices_source', None) is not registry:
by_device = {}
for item in registry:
if item.get('device_id'):
by_device.setdefault(item['device_id'], []).append(item)
self._devices_source, self._by_device = registry, by_device
device = self.registry_index().get(entity, {}).get('device_id')
return self._by_device.get(device, []) if device else []
def related_entities(self, tile):
"""Entities a card reads besides its own: a vacuum's cleaning mode and water selects and its battery sensor, a
cover's battery sensor."""
from core import cover_related, vacuum_related
if tile['entity'].startswith('vacuum.'):
return tuple(vacuum_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
if tile['entity'].startswith('cover.'):
return tuple(cover_related(tile['entity'], self.device_entries(tile['entity']), self.ha.states).values())
return ()
def header_message(self, layout):
return header_bar.message(layout, self.ha.states, self.registry_index(), getattr(self.ha, 'units', {}), getattr(self.ha, 'time_zone', None),
getattr(self.ha, 'state_words', None))
def needs_firmware(self, inbox, layout, screen=None):
"""Version string the screen must run first, or None when the layout can be sent."""
needed=min_firmware(layout)
if needed and not ((self.firmware_version(inbox, screen) or (0,0,0)) >= needed):
return '.'.join(str(part) for part in needed)
return None
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("This isn't a paired ESP screen. Refresh the overview.")
layout = validate_layout(data)
# A CYD has no memory for camera images, whatever its firmware; say so before asking for an update.
if any(t['entity'].split('.')[0] in CAMERA_DOMAINS for t in layout['tiles']) and screen.get('board') not in camera_feed.BOXES:
raise ValueError('Camera images need a Guition screen.')
needed = self.needs_firmware(inbox, layout, screen)
if needed:
raise ValueError(f"Install screen firmware {needed} or newer first for these tiles.")
# Settings change through their own call (change_settings). A page from before app 0.2.57 still sends them
# with the tiles: a screen that owns its settings ignores them, so a stale form never undoes a change
# made on the screen; other screens keep taking them, as before.
if self.setting_entities(screen) is not None:
layout.pop('settings', None)
# 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()
if 'header' not in layout and 'header' in self.layouts.get(inbox, {}):
layout['header'] = self.layouts[inbox]['header']
# Firmware before the top bar only knows show_clock; it follows the clock item.
if 'header' in layout and 'settings' in layout:
layout['settings']['show_clock'] = any(item['type'] == 'clock' for item in layout['header']['items'])
if 'settings' in layout and 'swipe_pages' not in data.get('settings',{}):
layout['settings']['swipe_pages']=self.layouts.get(inbox,{}).get('settings',{}).get('swipe_pages',False)
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('Rotation requires a Guition with firmware 0.2.9 or newer.')
# A page from before an option existed sends its tiles without it. It never sends a navigation tile twice
# (firmware 0.2.65+), so a copy keeps exactly what it was sent with.
old_list = self.layouts.get(inbox,{}).get('tiles',[])
old_counts, new_counts = Counter(t['entity'] for t in old_list), Counter(t['entity'] for t in layout['tiles'])
old_tiles = {t['entity']:t for t in old_list if old_counts[t['entity']] == 1 and new_counts[t['entity']] == 1}
for tile in layout['tiles']:
old_options=old_tiles.get(tile['entity'],{}).get('options',{})
for key in ('background', 'icon', 'controls'):
if 'options' in tile and key not in tile['options'] and key in old_options:
tile['options'][key]=old_options[key]
if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}):
tile['options'] = old_tiles[tile['entity']]['options'].copy()
# Restored options can widen a tile: an editor without positions packs again with
# the real widths, and explicit positions are checked once more for overlap.
if not any(isinstance(t, dict) and 'slot' in t for t in data.get('tiles', [])):
for tile, slot in zip(layout['tiles'], packed_slots(layout['tiles'])):
tile['slot'] = slot
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('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('An entity in the top bar no longer exists. Choose a different one.')
# The screen takes the layout in one message of at most 4096 bytes, which lists every tile's entity id: 48 tiles
# with long ids can pass every rule above and still never reach the screen (app 0.2.78).
try:
encode(self.layout_message(inbox, layout, screen))
except ValueError:
raise ValueError("These tiles' entity IDs are too long together to fit in one message to the screen; "
"remove a few tiles.") from None
updated = {**self.layouts, inbox: layout}
self.write_layouts(updated)
self.layouts = updated
self.sent.pop(inbox, None)
self.status[inbox] = 'Saved; waiting for sync'
self.history_wake.set()
self.ha.changed.set()
self.notify()
async def check_supported(self, inbox, data):
"""Refuse a tile setting Home Assistant doesn't support for its entity, before saving (app 0.2.67): On / off on a
speaker that can't turn on and off, a small slider on a light without brightness. A setting a tile already has
stays, and nothing is refused while Home Assistant can't say."""
capabilities = getattr(self.ha, 'capabilities', None)
if capabilities is None:
return
layout = validate_layout(data)
inbox = self.aliases.get(inbox, inbox)
before = {tile['entity']: tile for tile in self.layouts.get(inbox, {}).get('tiles', [])}
for tile in layout['tiles']:
previous = before.get(tile['entity'])
if tile['entity'] in BUILTIN or not tile.get('options') or (previous or {}).get('options') == tile['options']:
continue
name = tile.get('name') or self.ha.states.get(tile['entity'], {}).get('attributes', {}).get('friendly_name') or tile['entity']
found = ha_catalogue.unsupported(tile, previous, await capabilities(tile['entity']))
if found:
raise ValueError(ha_catalogue.refusal(tile['entity'], name, *found))
# Perform action (app 0.2.67): an action Home Assistant offers for the entity, with its required fields.
action = tile['options'].get('action')
if tile['options'].get('tap') == 'action' and action != ((previous or {}).get('options') or {}).get('action'):
problem = ha_catalogue.action_problem(tile['entity'], name, action, self.ha.states.get(tile['entity']),
await self.ha.entity_actions(tile['entity']), self.ha.services)
if problem:
raise ValueError(problem)
def notify(self):
for listener in self.listeners:
listener.set()
def pending_profiles(self, screens, profiles):
"""ESP Screens profiles without a paired screen: flashed but not yet added in Home Assistant, or not flashed yet.
Pairing happens in Home Assistant itself, outside this page; the sidebar shows these so nobody wonders
where the freshly flashed screen went."""
nodes = {s.get('node') for s in screens}
devices = {s.get('device') for s in screens}
installed = getattr(self.firmware, 'installed', set())
downloaded = getattr(self.firmware, 'downloaded', set())
return [{'file': file, 'node': meta['node'], 'friendly': meta.get('friendly') or meta['node'] or file,
'installed': file in installed, 'downloaded': file in downloaded, 'api_key': meta.get('api_key')}
for file, meta in profiles.items()
if meta.get('screen') and meta.get('node') not in nodes and (meta.get('friendly') or None) not in devices]
def watched_entities(self):
"""Entities whose state changes matter: tiles on any layout plus the screens' own diagnostics.
Cached per (registry, layouts): both are replaced as whole objects when they change."""
key = (id(getattr(self.ha, 'registry', [])), id(self.layouts))
if key != self._watched_key:
watched = {tile['entity'] for layout in self.layouts.values() for tile in layout['tiles']}
watched |= {item['entity'] for layout in self.layouts.values() for item in header_items(layout) if item['type'] == 'entity'}
watched |= {item['entity_id'] for item in self.screen_registry()}
watched |= {eid for layout in self.layouts.values() for tile in layout['tiles'] for eid in self.related_entities(tile)}
watched |= {eid for device in self.setting_index().values() for eid in device.values()}
self._watched_key, self._watched = key, watched
return set(self._watched)
async def cached(self, store, key, ttl, fetch):
entry=store.get(key)
if not entry or time.monotonic()-entry[0]>ttl:
try: value=await fetch()
except Exception: value=[]
entry=(time.monotonic(),value);store[key]=entry
return entry[1]
def forecast_due(self, entity):
entry = self.forecasts.get(entity)
hourly = self.forecasts.get((entity, 'hourly'))
return not entry or not hourly or time.monotonic() - min(entry[0], hourly[0]) > FORECAST_SECONDS
async def tile_message(self, index, tile):
"""The state message of one tile: state, options, extras, and the history the background task holds."""
forecast=hourly=None
if tile['entity'].startswith('weather.') and hasattr(self.ha,'forecast'):
entity = tile['entity']
# Only the forecasts the entity offers: asking Buienradar for hourly ones logged an error in Home
# Assistant every half hour. What it lacks is kept as empty, so the cache still counts its age.
kinds = forecast_kinds(self.ha.states.get(entity, {}).get('attributes', {}))
async def nothing():
return []
forecast=await self.cached(self.forecasts, entity, FORECAST_SECONDS, (lambda: self.ha.forecast(entity)) if 'daily' in kinds else nothing)
# Hourly forecasts feed the weather card's next-hours strip (0.2.23+); refreshed every half hour.
hourly=await self.cached(self.forecasts, (entity,'hourly'), FORECAST_SECONDS, (lambda: self.ha.forecast(entity,'hourly')) if 'hourly' in kinds else nothing)
# A vacuum's card also reads selects and the battery sensor of its device (app 0.2.46), a cover's card its battery (0.2.58).
device=self.device_entries(tile['entity']) if tile['entity'].startswith(('vacuum.', 'cover.')) else None
entry=self.registry_index().get(tile['entity'])
extra=extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device)
if tile['entity'].startswith('vacuum.'):
ha_catalogue.chip_words(extra,tile['entity'],self.ha.states,device,getattr(self.ha,'state_words',None))
message=state_message(index,tile,self.ha.states,extra,
precision=header_bar.precision_of(entry) if tile['entity'].startswith('sensor.') else None,entry=entry)
# Home Assistant's word where the screen would show the raw state (firmware 0.2.58+ shows it).
state=self.ha.states.get(tile['entity'],{})
word=ha_catalogue.screen_word(tile['entity'],message['state'],state.get('attributes'),entry,getattr(self.ha,'state_words',None))
if word:
message.setdefault('x',{})['w']=word
if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
hours=tile.get('options',{}).get('history_hours',24)
entry=self.histories.get((tile['entity'],hours))
if entry is not None:
message['history']={'hours':hours,'values':entry[1]}
else:
self.history_wake.set()
return message
def layout_message(self, inbox, layout, screen):
tiles = layout['tiles']
# Grid positions (firmware 0.2.26+; older firmware ignores them and packs the entities in order).
message = {'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in tiles],
'slots': [t['slot'] for t in tiles], 'keepalive': KEEPALIVE_SECONDS}
if 'pages' in layout:
message['pages'] = layout['pages']
# A screen that owns its settings (firmware 0.2.49+) gets none: they would overwrite what changed on it.
if 'settings' in layout and self.setting_entities(screen) is None:
# `settings` is the fixed eleven-key block older firmware insists on; everything added
# later travels as its own key, which firmware that predates it simply ignores.
message['settings'] = {k:v for k,v in layout['settings'].items() if k not in SETTINGS_BESIDE_BLOCK}
message['swipe_pages'] = layout['settings'].get('swipe_pages',False)
message['auto_home'] = layout['settings'].get('auto_home',True)
message['auto_home_seconds'] = layout['settings'].get('auto_home_seconds',120)
if screen.get('board')=='guition':
message['rotation'] = layout['settings'].get('rotation',0)
# The revision the screen echoes on every ping (firmware 0.2.33+; older firmware ignores it).
message['rev'] = revision(message)
return message
async def sync_one(self, inbox, layout, force=False, screen=None, dirty=None):
"""Send the screen what it lacks; True when something went out.
`dirty` is the set of entity ids whose state changed since the last pass: only their tiles
(and the top bar, when it shows one of them) are rebuilt, the other messages are reused as
sent. None rebuilds every message and sends the differences; `force` sends everything."""
if screen is None:
screen = self.screen(inbox) or {}
needed = self.needs_firmware(inbox, layout, screen)
if needed:
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)
try:
encode(layout_msg)
except ValueError:
# Saved by an app before 0.2.78, which didn't check this: nothing to retry until the layout changes.
self.status[inbox] = 'Layout saved; too large for one message to the screen, remove a few tiles'
return False
previous = self.sent.get(inbox)
full = force or not previous or previous['layout'] != layout_msg or dirty is None
# The top bar right after the layout (firmware 0.2.32+; older firmware draws the clock of show_clock).
header_msg = None
if self.supports_header(inbox, screen):
bar = {item['entity'] for item in header_items(layout) if item['type'] == 'entity'}
if full or previous['header'] is None or bar & dirty:
header_msg = self.header_message(layout)
else:
header_msg = previous['header']
states = []
for i, tile in enumerate(tiles):
reuse = not full and i < len(previous['states']) and tile['entity'] not in dirty and dirty.isdisjoint(self.related_entities(tile))
if reuse and tile['entity'].startswith('weather.') and self.forecast_due(tile['entity']):
reuse = False
states.append(previous['states'][i] if reuse else await self.tile_message(i, tile))
outgoing = []
if force or not previous or layout_msg != previous['layout']:
outgoing.append(layout_msg)
if header_msg is not None and (force or not previous or header_msg != previous['header']):
outgoing.append(header_msg)
for i, message in enumerate(states):
if force or not previous or i >= len(previous['states']) or message != previous['states'][i]:
outgoing.append(message)
action = self.transport(inbox, screen)
for message in outgoing:
# Save during transmission aborts the old batch, then starts a full new layout.
if self.layouts.get(inbox) != layout:
self.sent.pop(inbox, None)
return True
await self.ha.send(inbox, message, action)
# `rev` is the revision of the layout message the screen holds: a layout brought in step without being
# sent (settings the screen reported itself, see store_settings) keeps the one it had.
sent_layout = any(message is layout_msg for message in outgoing)
rev = layout_msg['rev'] if sent_layout or not previous else previous.get('rev', layout_msg['rev'])
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': rev}
# The hourly timer follows a real full transmission; any message refreshes the screen's
# feed window, so the ping timer follows whatever went out (a rebuild without differences,
# after a registry refresh, must not postpone the ping).
if force or not previous:
self.last[inbox] = time.monotonic()
if outgoing:
self.pinged[inbox] = time.monotonic()
self.status[inbox] = 'Sent to Home Assistant'
# A screen that answers confirms a new layout at once: a ping after the batch says whether it holds
# the layout and every tile, or which part is missing (firmware 0.2.49+).
if sent_layout and self.answers(inbox, screen):
await self.ping(inbox, screen)
return bool(outgoing)
async def ping(self, inbox, screen):
"""Keepalive for firmware 0.2.33+: the layout revision only; the screen asks for the rest itself. A screen
that can answer (firmware 0.2.49+) is asked to, so a missing layout or tile is sent again right away."""
sent = self.sent.get(inbox)
if not sent:
return
message = {'v': 1, 'op': 'ping', 'rev': sent['rev'], 'keepalive': KEEPALIVE_SECONDS}
action = self.transport(inbox, screen)
if not self.answers(inbox, screen):
await self.ha.send(inbox, message, action)
self.pinged[inbox] = time.monotonic()
return
try:
answer = await self.ha.send(inbox, message, action, respond=True)
except TimeoutError:
# Busy or gone: the text inbox and the next ping still tell.
LOG.info('%s did not answer its ping within %d s', (screen or {}).get('name') or inbox, ANSWER_TIMEOUT_SECONDS)
answer = None
except Refused as error:
if 'response' not in error.detail.lower():
raise
# Home Assistant listed an answer but will not give one: ask this screen no more, ping it as before.
LOG.warning('Home Assistant gives no answer for %s (%s); pinging it without', action, error.detail)
self.no_answers.add(action)
await self.ha.send(inbox, message, action)
answer = None
self.pinged[inbox] = time.monotonic()
self.answered(inbox, screen, answer)
def take_dirty(self):
"""Entity ids that changed since the last pass, or None when everything must be rebuilt."""
dirty = getattr(self.ha, 'dirty', None)
if dirty is None:
return None
self.ha.dirty = set()
registry = getattr(self.ha, 'registry', None)
if registry is not self._seen_registry:
# A new registry can change names and display precision: rebuild everything once.
self._seen_registry = registry
return None
return dirty
async def run(self):
while True:
try:
await asyncio.wait_for(self.ha.changed.wait(), timeout=20)
except TimeoutError:
pass
self.ha.changed.clear()
await asyncio.sleep(0.25)
if not self.ha.online:
self.sent.clear()
# States made over the REST API are gone once Home Assistant restarts: publish them again.
self.published.clear()
self.notify()
continue
for event in getattr(self.ha,'setting_events',[])[:]:
try:
self.screen_setting_event(event)
except (ValueError, TypeError, AttributeError) as error:
LOG.warning('A setting %s reported by %s was not kept (%s)', event.get('key') if isinstance(event, dict) else '?',
event.get('inbox') if isinstance(event, dict) else '?', error)
if hasattr(self.ha,'setting_events'): self.ha.setting_events.clear()
dirty = self.take_dirty()
screens_key = self._screens_key
screens = self.screens()
changed = self._screens_key != screens_key
self.ha.relevant = self.watched_entities()
# A setting changed on a screen that owns them: the editor shows it.
settings_key = self.settings_states_key()
if settings_key != self._settings_key:
self._settings_key, changed = settings_key, True
for screen in screens:
inbox = screen['id']
before = self.status.get(inbox)
if not screen['online']:
self.sent.pop(inbox, None)
self.status[inbox] = 'Screen offline; changes saved'
changed = changed or self.status[inbox] != before
continue
if inbox not in self.layouts:
continue
try:
now = time.monotonic()
pings = self.supports_ping(inbox, screen)
# The screen says it lost the layout (restart, mismatched ping, a state that never came).
if inbox in self.sent and screen['status'] in RESEND_STATES and now - self.last.get(inbox, 0) >= RESEND_GUARD_SECONDS:
self.sent.pop(inbox)
# An answer said so right after a full send; its wait is over (firmware 0.2.49+).
if inbox in self.retry_at and now >= self.retry_at[inbox]:
del self.retry_at[inbox]
self.sent.pop(inbox, None)
force = now - self.last.get(inbox, 0) >= (FULL_REPEAT_SECONDS if pings else KEEPALIVE_SECONDS)
if await self.sync_one(inbox, self.layouts[inbox], force, screen, dirty):
changed = True
if pings and inbox in self.sent and now - self.pinged.get(inbox, 0) >= KEEPALIVE_SECONDS:
await self.ping(inbox, screen)
except Exception as error:
self.sent.pop(inbox, None)
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
await self.publish_layouts()
if changed:
self.notify()
def history_keys(self):
"""(entity, hours) of every sensor tile on any screen."""
return {(tile['entity'], tile.get('options', {}).get('history_hours', 24))
for layout in self.layouts.values() for tile in layout['tiles'] if tile['entity'].startswith('sensor.')}
async def refresh_histories(self):
"""Fetch the histories that are missing or older than HISTORY_SECONDS: one statistics request per
window for all sensors at once, the REST history only for sensors without statistics. Changed
values mark their entity dirty so the sync loop resends just those tiles."""
wanted = self.history_keys()
for key in [key for key in self.histories if key not in wanted]:
del self.histories[key]
now = time.monotonic()
due = [key for key in wanted if key not in self.histories or now - self.histories[key][0] >= HISTORY_SECONDS]
if not due:
return
found = {}
if hasattr(self.ha, 'statistics'):
for hours in sorted({hours for _, hours in due}):
entities = {entity for entity, h in due if h == hours}
try:
found.update({(entity, hours): values for entity, values in (await self.ha.statistics(entities, hours)).items()})
except Exception as error:
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)
if values is None:
try:
values = await self.ha.history(*key)
except Exception:
values = []
old = self.histories.get(key)
self.histories[key] = (time.monotonic(), values)
if old is None or old[1] != values:
changed.add(key[0])
if changed:
dirty = getattr(self.ha, 'dirty', None)
if dirty is not None:
dirty.update(changed)
self.ha.changed.set()
async def history_loop(self):
"""Background refresh of sensor histories, so the sync loop never waits for Home Assistant's database."""
while True:
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self.history_wake.wait(), 30)
self.history_wake.clear()
if not self.ha.online or not hasattr(self.ha, 'history'):
continue
try:
await self.refresh_histories()
except Exception as error:
LOG.warning('Refreshing history failed (%s)', type(error).__name__)
# ----- Camera images (firmware 0.2.57+) -----
async def camera_loop(self):
"""Answer the cameras screens open full screen, a few at a time."""
limit = asyncio.Semaphore(4)
async def answer(request):
async with limit:
try:
await self.answer_camera(request)
except Exception as error:
LOG.warning('Camera for %s was not sent (%s)', request.get('entity') if isinstance(request, dict) else '?', type(error).__name__)
queue = getattr(self.ha, 'camera_requests', None)
while queue is not None:
request = await queue.get()
asyncio.ensure_future(answer(request))
def camera_allowed(self, inbox, entity):
"""A camera on the screen's own layout, or one a recent alert showed."""
seen = self.alert_cameras.get(entity)
if seen is not None and time.monotonic() - seen < camera_feed.STILL_SECONDS:
return True
return entity in {tile['entity'] for tile in self.layouts.get(inbox, {}).get('tiles', [])}
async def camera_message(self, entity, view, board, still=None):
"""The screen message for one camera view: a link to its image, or an empty link when there is none."""
url = ''
base = await camera_feed.base_url(self.ha.request)
if base:
box = camera_feed.BOXES[board][view]
if view == 'thumb':
token = self.camera.link(entity, box, still) if still else None
else:
token = self.camera.link(entity, box) if await self.camera.frame(entity, box) else None
url = f'{base}/camera/{token}.bmp' if token else ''
else:
LOG.warning('Camera images: no address for this app on the LAN; set SCREEN_CAMERA_URL')
return {'v': 1, 'op': 'camera', 't': 'alert' if view == 'thumb' else 'full', 'e': entity, 'u': url}
async def answer_camera(self, request):
"""One screen's request: a camera it may show, on a screen that draws camera images."""
if not isinstance(request, dict):
return
inbox = self.aliases.get(request.get('inbox'), request.get('inbox'))
entity = request.get('entity')
screen = self.screen(inbox) if isinstance(inbox, str) else None
# A media player's cover (app 0.2.77, firmware 0.2.64+) comes the same way, at the size the card asks for.
cover = camera_feed.cover_request(request) if camera_feed.cover_supported(entity) else None
if cover:
if not camera_feed.can_show_cover(screen) or not screen.get('online'):
return
elif not camera_feed.supported(entity) or not camera_feed.can_show(screen) or not screen.get('online'):
return
action = self.transport(inbox, screen)
if not action or not self.camera_allowed(inbox, entity):
return
message = await self.cover_message(entity, *cover) if cover else await self.camera_message(entity, 'full', screen['board'])
await self.ha.send(inbox, message, action)
LOG.info('%s %s on %s%s', 'Cover of' if cover else 'Camera', entity, screen['name'], '' if message['u'] else ': no image')
async def cover_message(self, entity, size, background):
"""The screen message with a link to a media player's cover at `size` with `background` behind the corners,
or an empty link when the player shows no picture (the card keeps its placeholder)."""
url = ''
base = await camera_feed.base_url(self.ha.request)
if base:
token = self.camera.link(entity, (size, size), cover=(size, background)) if await self.camera.cover(entity, size, background) else None
url = f'{base}/camera/{token}.bmp' if token else ''
else:
LOG.warning('Covers: no address for this app on the LAN; set SCREEN_CAMERA_URL')
return {'v': 1, 'op': 'camera', 't': 'cover', 'e': entity, 'u': url}
async def alert_images(self, camera, screens):
"""The picture of an alert's camera at that moment, on every screen that draws it."""
self.alert_cameras[camera] = time.monotonic()
for old in [entity for entity, moment in self.alert_cameras.items() if time.monotonic() - moment > camera_feed.STILL_SECONDS]:
del self.alert_cameras[old]
boards = {}
for screen in screens:
boards.setdefault(screen['board'], []).append(screen)
for board, group in boards.items():
found = await self.camera.frame(camera, camera_feed.BOXES[board]['thumb'], fresh=False, now=True)
message = await self.camera_message(camera, 'thumb', board, found[1] if found else None)
results = await asyncio.gather(*(self.ha.send(screen['id'], message, self.transport(screen['id'], screen)) for screen in group),
return_exceptions=True)
failed = sum(isinstance(result, BaseException) for result in results)
LOG.info('Alert image of %s: %d of %d screens%s', camera, len(group) - failed, len(group), '' if message['u'] else ' (no image)')
async def broadcast(self, event_type, data):
"""An alert event for every screen: the matching action on each screen that can show it, all at once."""
action = BROADCAST_EVENTS[event_type]
service_data, unusable = alert_data(data) if action == 'show_alert' else ({}, [])
camera, usable = alert_camera(data) if event_type == BROADCAST_SHOW else ('', True)
if not usable:
unusable.append('camera')
if unusable:
LOG.warning('%s: unusable %s left empty', event_type, ', '.join(unusable))
ready, skipped = alert_targets(self.screens())
# A screen that draws the image hears about it before the alert, so the card opens with room for it.
viewers = [screen for screen in ready if camera and camera_feed.can_show(screen) and self.transport(screen['id'], screen)]
if viewers:
pending = {'v': 1, 'op': 'camera', 't': 'alert', 'e': camera, 'u': ''}
await asyncio.gather(*(self.ha.send(screen['id'], pending, self.transport(screen['id'], screen)) for screen in viewers),
return_exceptions=True)
results = await asyncio.gather(*(self.ha.call(alert_service(screen['node'], action), service_data) for screen in ready),
return_exceptions=True)
failed = [(screen, type(result).__name__) for screen, result in zip(ready, results) if isinstance(result, BaseException)]
notes = [f"{screen['name']} {reason}" for screen, reason in skipped + failed]
LOG.info('%s: %d of %d screens%s', event_type, len(ready) - len(failed), len(ready) + len(skipped),
f" (not: {'; '.join(notes)})" if notes else '')
shown = [screen for screen, result in zip(ready, results) if not isinstance(result, BaseException)]
if viewers and any(screen in shown for screen in viewers):
await self.alert_images(camera, [screen for screen in viewers if screen in shown])
return {'sent': len(ready) - len(failed), 'skipped': len(skipped), 'failed': len(failed)}
async def tile_event(self, event_type, data):
"""One tile event: the screen it names, the changed layout, saved and pushed like the editor does. Returns the
screen and the tile the event acted on (None for an order)."""
screen = match_screen(self.screens(), data.get('screen'), self.layouts)
inbox = self.aliases.get(screen['id'], screen['id'])
# Firmware 0.2.65+ takes a navigation tile on several pages; an older screen keeps one per page it goes to.
repeat = (self.firmware_version(inbox, screen) or (0, 0, 0)) >= PAGE_TILE_REPEAT_MIN_FIRMWARE
layout, tile = run_tile_event(self.layouts.get(inbox) or {'title': screen['name'], 'tiles': []}, TILE_EVENTS[event_type], data, repeat)
await self.check_supported(inbox, layout)
self.save(inbox, layout)
return screen, tile
async def tile_loop(self):
"""Tile events (app 0.2.51+) in arrival order, apart from the sync loop, which can be busy for a
while. Every event gets an answer, so whoever fired it knows what happened."""
queue = getattr(self.ha, 'tile_events', None)
while queue is not None:
event_type, data = await queue.get()
answer = {'event': event_type, 'screen': data.get('screen') or '', 'entity': data.get('entity') or ''}
try:
screen, tile = await self.tile_event(event_type, data)
answer.update(ok=True, screen=screen['name'])
if tile is not None and 'slot' in tile:
# Which tile it was, which matters for a navigation tile that is on several pages (app 0.2.78).
answer.update(page=tile['slot'] // SLOTS_PER_PAGE + 1, slot=tile['slot'])
LOG.info('%s: %s on %s', event_type, answer['entity'] or 'order', screen['name'])
await self.publish_layouts()
except Exception as error:
answer.update(ok=False, error=str(error) if isinstance(error, ValueError) else type(error).__name__)
LOG.warning('%s refused: %s', event_type, answer['error'])
try:
await self.ha.fire(TILE_RESULT_EVENT, answer)
except Exception as error:
LOG.warning('%s: no answer sent (%s)', event_type, type(error).__name__)
async def publish_layouts(self):
"""Each screen's layout as a sensor in Home Assistant, so an assistant can read what is where.
Published again after every change and after a reconnect; a state made this way is gone once
Home Assistant restarts."""
for screen in self.screens():
inbox = self.aliases.get(screen['id'], screen['id'])
layout, node = self.layouts.get(inbox), screen.get('node')
if not layout or not node:
continue
snapshot = layout_snapshot(screen, layout)
if self.published.get(inbox) == snapshot:
continue
try:
await self.ha.set_state('sensor.esp_screens_' + node.replace('-', '_'), len(snapshot['tiles']),
{'friendly_name': f"{screen['name']} tiles", 'icon': 'mdi:view-dashboard-outline', **snapshot})
self.published[inbox] = snapshot
except Exception as error:
LOG.warning('Publishing the layout of %s failed (%s)', screen['name'], type(error).__name__)
async def alert_loop(self):
"""Alert events for every screen, in arrival order and apart from the sync loop, which can be busy for a while."""
queue = getattr(self.ha, 'broadcasts', None)
while queue is not None:
event_type, data = await queue.get()
try:
await self.broadcast(event_type, data)
except Exception as error:
LOG.warning('%s failed (%s)', event_type, type(error).__name__)
def create_app(manager, development=False):
csrf = secrets.token_urlsafe(32)
@web.middleware
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 this page through Home Assistant.')
if request.method not in {'GET', 'HEAD'} and request.headers.get('X-Screen-CSRF') != csrf:
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)
# Home Assistant said no, or can't be reached (Identify, Try it, a setting): a sentence the page shows, never a
# bare 500 with a traceback in the log (app 0.2.78). Refused is a ConnectionError, so it comes first.
except Refused as error:
return web.json_response({'error': f"Home Assistant didn't take it: {error.detail or 'no reason given'}."}, status=400)
except (ConnectionError, TimeoutError):
return web.json_response({'error': "Home Assistant isn't reachable right now. Try again in a moment."}, status=503)
except web.HTTPRequestEntityTooLarge:
return web.json_response({'error': 'That request is too large.'}, status=413)
except (TypeError, KeyError):
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'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'"
return response
# A layout of 48 tiles with actions on their taps passes 16 KB, the limit from the twenty-tile days; the largest the
# rules allow stays under about 75 KB (app 0.2.78). The YAML override keeps its own 12 KB limit (firmware.py).
app = web.Application(middlewares=[guard], client_max_size=128*1024)
static = Path(__file__).parent / 'static'
async def cache_assets(request, response):
"""The built script, styles and fonts under /assets/ never change under their name (Vite names each file after a
hash of its content), so a browser keeps them instead of fetching about 310 KB on every open (app 0.2.78).
`private`: behind ingress the request carries Home Assistant's session. index.html and api/ stay no-store."""
if request.path.startswith('/assets/') and response.status in (200, 304):
response.headers['Cache-Control'] = 'private, max-age=31536000, immutable'
app.on_response_prepare.append(cache_assets)
# The page is the Vite build of web/ (npm run build writes it here): index.html names its script and styles
# by a hash of their content, so a browser that keeps old copies anyway (Safari did after an update) never
# runs an old script against a new page.
page = (static / 'index.html').read_text()
async def index(request):
# SCREEN_DEV reads the file every time, so a fresh `npm run build` shows up without a restart.
return web.Response(text=(static / 'index.html').read_text() if development else page, content_type='text/html')
def light_payload(screens=None):
"""Screens and update status: everything that changes while the page is open."""
if screens is None:
screens = manager.screens()
profiles = manager.firmware.profile_names()
for screen in screens:
screen['layout'] = manager.layouts.get(screen['id'], {'title': 'Home', 'tiles': []})
screen['settings'] = manager.settings_view(screen)
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')
# What the editor may offer this screen, by the firmware the app's own checks go by (app 0.2.78): an offline
# screen keeps its 48 tiles, and the editor needn't know the version rules.
version = manager.firmware_version(screen['id'], screen)
screen['firmware_known'] = version_text(version)
screen.update(firmware_features(version))
return {'csrf': csrf, 'connected': manager.ha.online, 'screens': screens,
'pending': manager.pending_profiles(screens, profiles),
'updates': manager.updates.summary(screens, profiles)}
async def inventory(request):
if request.query.get('light') == '1':
# The page polls the light form; entities, backgrounds and icons (~100 KB) only on demand.
return web.json_response(light_payload())
screens, entities = manager.inventory()
payload = light_payload(screens)
payload['entities'] = entities
payload['backgrounds'] = TILE_BACKGROUNDS
payload['controls'] = controls_catalogue()
payload['icons'] = tile_icons.editor()
payload['alerts'] = alert_reference()
# What's new for the Update badge: here only, not in every live update (app 0.2.78).
payload['changelog'] = manager.updates.changelog
payload['claude_skill'] = claude_skill.status(manager.skill_dir)
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)
async def header_preview(request):
"""The top bar as a screen would draw it right now, so the editor shows unsaved changes live."""
data = await request.json()
header = validate_header(data.get('header') if isinstance(data, dict) else None)
return web.json_response({'items': header_bar.preview(header, manager.ha.states, manager.registry_index(),
getattr(manager.ha, 'units', {}), getattr(manager.ha, 'time_zone', None),
getattr(manager.ha, 'state_words', None))})
async def events(request):
"""Server-sent events: pushes the light inventory whenever it changes, so the page need not poll."""
response = web.StreamResponse(headers={'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store',
'X-Accel-Buffering': 'no', 'X-Content-Type-Options': 'nosniff'})
await response.prepare(request)
wake, sent = asyncio.Event(), None
wake.set() # first event goes out right away
manager.listeners.add(wake)
try:
while True:
# Sync results are pushed at once; updater phases are picked up by the 3 s check.
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(wake.wait(), 3)
wake.clear()
body = json.dumps(light_payload(), ensure_ascii=False)
if body != sent:
await response.write(f'data: {body}\n\n'.encode())
sent = body
else:
await response.write(b': keepalive\n\n')
except (ConnectionResetError, asyncio.CancelledError):
pass
finally:
manager.listeners.discard(wake)
return response
async def save(request):
data = await request.json()
await manager.check_supported(request.match_info['inbox'], data)
manager.save(request.match_info['inbox'], data)
return web.json_response({'saved': True})
async def capabilities(request):
"""What Home Assistant says each entity can do, for the tile settings (app 0.2.67). Unknown is null: the editor
then offers what it always offered."""
entities = list(dict.fromkeys(request.query.getall('entity', [])))[:40]
lookup = getattr(manager.ha, 'capabilities', None)
async def one(entity):
try:
return await lookup(entity) if lookup else None
except Exception as error:
LOG.info('No capabilities for %s (%s)', entity, type(error).__name__)
return None
found = await asyncio.gather(*(one(entity) for entity in entities))
return web.json_response({'capabilities': dict(zip(entities, found))})
async def entity_actions(request):
"""Perform action (app 0.2.67): the actions Home Assistant offers for one entity, with its names and the fields it
offers for that entity. Null while Home Assistant can't say."""
entity = request.query.get('entity', '')
ha = manager.ha
actions = await ha.entity_actions(entity) if entity_id(entity) and hasattr(ha, 'entity_actions') else None
if actions is None:
return web.json_response({'actions': None})
return web.json_response({'actions': ha_catalogue.action_choices(entity, actions, ha.states.get(entity), ha.services,
getattr(ha, 'service_names', {}), ha.platform_of(entity))})
async def change_settings(request):
"""Screen settings apply one change at a time, like the settings page on the screen (app 0.2.57)."""
data = await request.json()
try:
view = await manager.change_settings(request.match_info['inbox'], data.get('settings') if isinstance(data, dict) else None)
except Refused as error:
raise ValueError(f"Home Assistant didn't take the change: {error.detail or 'no reason given'}.") from error
except (ConnectionError, TimeoutError) as error:
raise ValueError("Home Assistant isn't reachable right now. Try again in a moment.") from error
return web.json_response(view)
async def update_screen(request):
data = await request.json() if request.can_read_body else {}
return web.json_response(manager.updates.start(request.match_info['inbox'], data.get('host')))
async def update_all(request):
return web.json_response({'started': manager.updates.start_all()})
async def update_settings(request):
manager.updates.set_auto((await request.json()).get('auto'))
return web.json_response(manager.updates.summary())
async def inspector(request):
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,
'tiles':[{'entity':t['entity'],'state':manager.ha.states.get(t['entity'],{}).get('state'),
# Home Assistant's word for the state, as its own pages show it ("Heat/Cool", app 0.2.67).
'word':state_word(t['entity'],manager.ha.states.get(t['entity'],{}).get('state'),manager.ha.states.get(t['entity'],{}).get('attributes'),
manager.registry_index().get(t['entity']),getattr(manager.ha,'state_words',None)),
'attributes':state_message(i,t,manager.ha.states)['a'],
'options':t.get('options',{})} for i,t in enumerate(layout['tiles'])]})
async def states(request):
"""Live values for the editor's mockup (app 0.2.73): the state, Home Assistant's word and the attributes a
card shows, for the tiles on the page, saved or not. At most sixty entities per request."""
index = manager.registry_index()
result = {}
for eid in request.query.getall('entity', [])[:60]:
if not isinstance(eid, str) or eid not in manager.ha.states:
continue
state = manager.ha.states.get(eid, {})
entry = index.get(eid)
message = state_message(0, {'entity': eid, 'name': ''}, manager.ha.states,
precision=header_bar.precision_of(entry) if eid.startswith('sensor.') else None)
result[eid] = {'state': message['state'], 'a': message['a'],
'word': state_word(eid, state.get('state'), state.get('attributes'), entry, getattr(manager.ha, 'state_words', None))}
return web.json_response({'states': result})
def one_alert_target(inbox):
screen = manager.screen(inbox)
if screen is None:
raise ValueError('Unknown screen.')
ready, skipped = alert_targets([screen])
if not ready:
raise ValueError(f"{screen['name']} can't show an alert: {skipped[0][1] if skipped else 'not ready'}.")
return ready[0]
async def identify(request):
"""Identify (app 0.2.73): the screen shows a short card and blinks its backlight, so you know which one it is."""
screen = one_alert_target(request.match_info['inbox'])
data, _ = alert_data({'title': f"This is {screen['name']}", 'subtitle': 'Identify, from ESP Screens', 'icon': 'bell-ring',
'color': 'blue', 'button_text': 'OK', 'timeout': 8, 'flash': True})
await manager.ha.call(alert_service(screen['node']), data)
return web.json_response({'ok': True})
async def test_alert(request):
"""Alerts → Try it (app 0.2.73): one alert to one screen or to every screen, with the fields an automation sends."""
body = await request.json()
if not isinstance(body, dict):
raise ValueError('Invalid alert.')
data = body.get('data') if isinstance(body.get('data'), dict) else {}
if body.get('screen') == 'all':
return web.json_response(await manager.broadcast(BROADCAST_SHOW, data))
screen = one_alert_target(str(body.get('screen', '')))
service, unusable = alert_data(data)
await manager.ha.call(alert_service(screen['node']), service)
return web.json_response({'sent': 1, 'failed': 0, 'skipped': 0, 'unusable': unusable})
async def install_claude_skill(request):
"""Settings → Claude → Install: writes the skill into Home Assistant's configuration folder, only on request."""
return web.json_response(claude_skill.install(manager.skill_dir))
async def download_claude_skill(request):
"""Settings → Claude → Download: the same skill as a zip for claude.ai; writes nothing."""
return web.Response(body=claude_skill.archive(), content_type='application/zip',
headers={'Content-Disposition': f'attachment; filename="{claude_skill.NAME}.zip"'})
async def firmware_status(request): return web.json_response(manager.firmware.status())
async def firmware_start(request): return web.json_response(manager.firmware.start(await request.json()))
async def firmware_override(request):
return web.json_response(manager.firmware.override(request.match_info['file']))
async def firmware_override_save(request):
data = await request.json()
if not isinstance(data, dict):
raise ValueError('Invalid override data.')
return web.json_response(manager.firmware.save_override(request.match_info['file'], data.get('content')))
async def firmware_create(request):
# Profile, missing wifi secrets and (with a USB port or the download) the build and flash in one request.
return web.json_response(manager.firmware.install(await request.json()))
async def firmware_download(request):
"""New screen and Firmware & USB → Download: the factory image this app just built, for ESPHome Web on
the owner's own computer. Like the profile it came from, it holds the Wi-Fi password and the screen's keys."""
path, name = manager.firmware.image(request.match_info['file'])
return web.FileResponse(path, headers={'Content-Type': 'application/octet-stream',
'Content-Disposition': f'attachment; filename="{name}"'})
async def shutdown(app):
for task in (manager.updates.task, manager.firmware.task):
if task and not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError): await task
app.on_cleanup.append(shutdown)
app.router.add_get('/api/screens/{inbox}/inspect', inspector)
app.router.add_post('/api/screens/{inbox}/update', update_screen)
app.router.add_post('/api/updates/run', update_all)
app.router.add_put('/api/updates', update_settings)
app.router.add_get('/api/firmware', firmware_status)
app.router.add_post('/api/firmware/jobs', firmware_start)
app.router.add_get('/api/firmware/profiles/{file}/override', firmware_override)
app.router.add_put('/api/firmware/profiles/{file}/override', firmware_override_save)
app.router.add_get('/api/firmware/profiles/{file}/download', firmware_download)
app.router.add_post('/api/firmware/profiles', firmware_create)
app.router.add_get('/', index)
app.router.add_get('/api/inventory', inventory)
app.router.add_get('/api/capabilities', capabilities)
app.router.add_get('/api/states', states)
app.router.add_post('/api/screens/{inbox}/identify', identify)
app.router.add_post('/api/alerts/test', test_alert)
app.router.add_get('/api/entity-actions', entity_actions)
app.router.add_post('/api/header-preview', header_preview)
app.router.add_post('/api/claude-skill', install_claude_skill)
app.router.add_get('/api/claude-skill.zip', download_claude_skill)
app.router.add_get('/api/events', events)
app.router.add_put('/api/screens/{inbox}', save)
app.router.add_put('/api/screens/{inbox}/settings', change_settings)
app.router.add_static('/assets/', static / 'assets')
return app
async def main():
development = os.environ.get('SCREEN_DEV') == '1'
token = os.environ.get('SUPERVISOR_TOKEN', '')
if development and os.environ.get('HA_TOKEN_FILE'):
token = Path(os.environ['HA_TOKEN_FILE']).read_text().strip()
if not token:
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')
# handle_signals: SIGTERM (the Supervisor stopping the app, `docker stop`) and SIGINT end the app through the
# cleanup below, which also stops a running build, instead of waiting ten seconds for SIGKILL (app 0.2.78).
runner = web.AppRunner(create_app(manager, development), access_log=None, handle_signals=True)
await runner.setup()
await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start()
# Camera images for the screens: their own port on the LAN, not the ingress page (docs/CAMERA.md).
cameras = web.AppRunner(camera_feed.web_app(manager.camera), access_log=None)
await cameras.setup()
try:
await web.TCPSite(cameras, '0.0.0.0', camera_feed.port()).start()
except OSError as error:
# Everything else still works; only camera images stay away.
LOG.error('Camera images are off: port %d is not available (%s)', camera_feed.port(), error)
try:
await asyncio.gather(ha.run(), manager.run(), manager.history_loop(), manager.updates.run(),
manager.alert_loop(), manager.tile_loop(), manager.card_history_loop(), manager.camera_loop())
finally:
await cameras.cleanup()
await runner.cleanup()
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s')
# The signal arrives as GracefulExit once everything is cleaned up, as in aiohttp's own run_app: a normal stop.
with contextlib.suppress(web.GracefulExit):
asyncio.run(main())