Let the screen own its settings, answer its pings, and show them as cards (0.2.57)

A code check found that a setting changed on a screen could jump back.
ESP Screen Manager kept its own copy and sent it with every full update:
after a restart of the app or Home Assistant, every hour and after every
save. A change it missed while it or Home Assistant restarted came back
to the old value, a reported setting was lost when save() refused a
layout with a removed entity, and every step of a held - or + on the
screen resent the whole screen.

Firmware 0.2.49 offers every setting as an entity over the existing
preference records (no migration): Night mode, Night starts/ends,
24-hour clock, Back to page 1 and after how long, Back to page 1 on
standby, Swipe between pages and, on the Guition, Rotation, next to the
four numbers and Auto standby. The settings page and every entity write
through settings_screen::set(), which clamps like the page and stores,
applies and reports only a real change. screen_message answers a caller
that asks with its status and layout revision (api.respond). The
settings tile opens without Home Assistant.

The app reads and changes the settings of such a screen through its
entities and leaves them out of the layout message. Older firmware keeps
them with the layout; a setting it reports is stored without the tile
check and not sent back. Pings to screens that can answer learn at once
whether a tile is missing and resend after 30 s instead of 120 s. Layout
sensors come back after a Home Assistant restart, and weather tiles only
ask for the forecasts the entity supports. The editor's settings form is
now three cards with the rows of the screen's own page; a change applies
at once, and an offline screen shows its values as unknown.

Checked with 245 Python and 14 C++ tests, generate_packages --check,
ESP32 builds of both profiles, host-build API tests on both boards, and
a live run against Home Assistant 2026.9.1 with a temporary host device.
Firmware 0.2.49.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-16 15:50:41 +02:00
parent 93fa7e2712
commit b58f0cde25
23 changed files with 2419 additions and 534 deletions
+295 -30
View File
@@ -15,7 +15,8 @@ import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BROADCAST_EVENTS, BUILTIN, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
from core import BROADCAST_EVENTS, BUILTIN, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, 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, pack_slots, 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
import header_bar
from zoneinfo import ZoneInfo
@@ -40,6 +41,12 @@ RESEND_STATES = frozenset({
})
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)."""
@@ -53,6 +60,13 @@ def samples(events, begin, span):
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)
@@ -85,6 +99,10 @@ class HomeAssistant:
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()
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
@@ -110,7 +128,8 @@ class HomeAssistant:
if data.get('success'):
future.set_result(data.get('result'))
else:
future.set_exception(ConnectionError('Home Assistant refused the command.'))
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', {})
@@ -128,6 +147,9 @@ class HomeAssistant:
self.changed.set()
elif event.get('event_type') in REGISTRY_EVENTS:
self.registry_changed.set()
elif event.get('event_type') in SERVICE_EVENTS:
if 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))
@@ -148,6 +170,19 @@ class HomeAssistant:
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):
"""Which ESPHome actions answer: Home Assistant lists `response` for an action that can return one."""
esphome = (await self.request('get_services') or {}).get('esphome', {})
self.responses = {f'esphome.{name}' for name, spec in esphome.items() if isinstance(spec, dict) and spec.get('response')}
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()
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')
@@ -163,10 +198,11 @@ class HomeAssistant:
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')
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS):
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 {}
@@ -181,13 +217,20 @@ class HomeAssistant:
# fallback; a full fetch is about 1 MB of JSON and used to run every 30 s.
fetched = time.monotonic()
while True:
waiter = asyncio.ensure_future(self.registry_changed.wait())
waiters = [asyncio.ensure_future(self.registry_changed.wait()), asyncio.ensure_future(self.services_changed.wait())]
try:
done, _ = await asyncio.wait([reader, waiter], timeout=30, return_when=asyncio.FIRST_COMPLETED)
done, _ = await asyncio.wait([reader, *waiters], timeout=30, return_when=asyncio.FIRST_COMPLETED)
finally:
waiter.cancel()
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()
self.changed.set()
if self.registry_changed.is_set():
await asyncio.sleep(1)
self.registry_changed.clear()
@@ -214,12 +257,20 @@ class HomeAssistant:
future.set_exception(ConnectionError('Connection lost.'))
await asyncio.sleep(5)
async def send(self, inbox, message, action=None):
"""One message to a screen: as one ESPHome action call (firmware 0.2.33+), else as text chunks."""
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)
await self.request('call_service', domain=domain, service=service, service_data={'message': encode(message)})
return
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})
@@ -317,6 +368,11 @@ class Manager:
# 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()
if self.path.exists():
raw = json.loads(self.path.read_text())
# Versioned persistent data. Never silently overwrite an unknown schema.
@@ -440,6 +496,161 @@ class Manager:
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')]
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
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', [])
@@ -487,6 +698,11 @@ class Manager:
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()
@@ -556,6 +772,7 @@ class Manager:
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)
@@ -576,9 +793,15 @@ class Manager:
"""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'):
forecast=await self.cached(self.forecasts, tile['entity'], FORECAST_SECONDS, lambda: self.ha.forecast(tile['entity']))
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, (tile['entity'],'hourly'), FORECAST_SECONDS, lambda: self.ha.forecast(tile['entity'],'hourly'))
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).
device=self.device_entries(tile['entity']) if tile['entity'].startswith('vacuum.') else None
message=state_message(index,tile,self.ha.states,extras(tile,self.ha.states,forecast,getattr(self.ha,'time_zone',None),hourly,device=device))
@@ -598,7 +821,8 @@ class Manager:
'slots': [t['slot'] for t in tiles], 'keepalive': KEEPALIVE_SECONDS}
if 'pages' in layout:
message['pages'] = layout['pages']
if 'settings' in layout:
# 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}
@@ -656,7 +880,11 @@ class Manager:
self.sent.pop(inbox, None)
return True
await self.ha.send(inbox, message, action)
self.sent[inbox] = {'layout': layout_msg, 'header': header_msg, 'states': states, 'rev': layout_msg['rev']}
# `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).
@@ -665,15 +893,40 @@ class Manager:
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."""
"""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
await self.ha.send(inbox, {'v': 1, 'op': 'ping', 'rev': sent['rev'], 'keepalive': KEEPALIVE_SECONDS}, self.transport(inbox, screen))
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."""
@@ -698,30 +951,26 @@ class Manager:
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:
inbox,key,value=event.get('inbox'),event.get('key'),event.get('value')
if inbox in self.layouts:
layout=dict(self.layouts[inbox]); stored=validate_settings(layout.get('settings',{})); settings=dict(stored)
if key not in settings: continue
settings[key] = value=='1' if type(settings[key]) is bool else int(value)
if key=='brightness':
settings['standby_brightness']=min(settings['standby_brightness'],settings[key])
settings['night_brightness']=min(settings['night_brightness'],settings[key])
settings=validate_settings(settings)
# A screen reporting what it already has (an automation setting the same value on
# every light change): saving would resend the whole screen while someone uses it.
if settings==stored: continue
layout['settings']=settings;self.save(inbox,layout)
except (ValueError,TypeError): pass
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)
@@ -738,6 +987,10 @@ class Manager:
# 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
@@ -914,6 +1167,7 @@ def create_app(manager, development=False):
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'))
@@ -971,6 +1225,16 @@ def create_app(manager, development=False):
async def save(request):
manager.save(request.match_info['inbox'], await request.json())
return web.json_response({'saved': True})
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')))
@@ -1021,6 +1285,7 @@ def create_app(manager, development=False):
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('/static/', static)
return app