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
+92 -1
View File
@@ -17,13 +17,15 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.48'
FIRMWARE_VERSION = '0.2.49'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
SETTINGS_PAGE_MIN_FIRMWARE = '0.2.44'
# The Wake and Sleep buttons a screen offers Home Assistant automations.
WAKE_SLEEP_MIN_FIRMWARE = '0.2.45'
# Every screen setting as an entity of the screen, which owns them (see SETTING_ENTITIES).
SETTING_ENTITIES_MIN_FIRMWARE = '0.2.49'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
@@ -180,6 +182,85 @@ SETTING_RULES = {
# docs/SETTINGS.md walks through adding one.
SETTINGS_BESIDE_BLOCK = ('swipe_pages', 'rotation', 'auto_home', 'auto_home_seconds')
# ----- The screen owns its settings (firmware 0.2.49+) -----
# A screen offers every setting as an entity of its own device, and the settings page on the screen, Home
# Assistant and ESP Screens all change them there. ESP Screens reads them from those entities, changes them
# with the entity's own action, and leaves them out of the layout message, so it never overwrites what
# someone changed on the screen or in an automation. A screen without these entities (older firmware) gets
# its settings in the layout message as before. `show_clock` has no entity: the top bar decides the clock.
# key: (Home Assistant domain, the entity's name in the board profiles)
SETTING_ENTITIES = {
'brightness': ('number', 'Normal brightness'),
'standby_enabled': ('switch', 'Auto standby'),
'standby_seconds': ('number', 'Standby after'),
'standby_brightness': ('number', 'Standby brightness'),
'night_enabled': ('switch', 'Night mode'),
'night_start': ('time', 'Night starts'),
'night_end': ('time', 'Night ends'),
'night_brightness': ('number', 'Night brightness'),
'clock_24h': ('switch', '24-hour clock'),
'auto_home': ('switch', 'Back to page 1'),
'auto_home_seconds': ('number', 'Back to page 1 after'),
'home_on_standby': ('switch', 'Back to page 1 on standby'),
'swipe_pages': ('switch', 'Swipe between pages'),
'rotation': ('select', 'Rotation'),
}
# Entities firmware 0.2.49 added; one of them on a device means the screen owns its settings. The first five
# existed before, so they cannot tell.
OWNED_SETTINGS_MARKERS = frozenset(('Night mode', 'Night starts', 'Night ends', '24-hour clock', 'Back to page 1',
'Back to page 1 after', 'Back to page 1 on standby', 'Swipe between pages'))
ROTATION_OPTIONS = ('0°', '90°', '180°', '270°')
def setting_entities(items):
"""{key: entity_id} of a screen's setting entities, from the registry entries of its device (disabled ones
included), or None when the screen does not own its settings yet (firmware before 0.2.49)."""
found, owned = {}, False
for item in items:
if item.get('platform') != 'esphome':
continue
name, domain = item.get('original_name'), item['entity_id'].split('.', 1)[0]
owned = owned or name in OWNED_SETTINGS_MARKERS
for key, (wanted_domain, wanted_name) in SETTING_ENTITIES.items():
if name == wanted_name and domain == wanted_domain and key not in found:
found[key] = item['entity_id']
return found if owned else None
def setting_from_state(key, state):
"""The value of one setting from its entity's state, or None while Home Assistant has none."""
value = (state or {}).get('state')
if not isinstance(value, str) or value in ('', 'unknown', 'unavailable'):
return None
domain = SETTING_ENTITIES[key][0]
try:
if domain == 'switch':
return {'on': True, 'off': False}.get(value)
if domain == 'number':
number = float(value)
return int(round(number)) if math.isfinite(number) else None
if domain == 'time':
hour, minute = (int(part) for part in value.split(':')[:2])
return hour * 60 + minute if 0 <= hour < 24 and 0 <= minute < 60 else None
if domain == 'select':
return int(value.rstrip('°')) if value in ROTATION_OPTIONS else None
except ValueError:
return None
return None
def setting_action(key, entity, value):
"""(action, data) that gives one setting entity `value`."""
domain = SETTING_ENTITIES[key][0]
if domain == 'switch':
return ('switch.turn_on' if value else 'switch.turn_off'), {'entity_id': entity}
if domain == 'number':
return 'number.set_value', {'entity_id': entity, 'value': value}
if domain == 'time':
return 'time.set_value', {'entity_id': entity, 'time': f'{value // 60:02d}:{value % 60:02d}:00'}
return 'select.select_option', {'entity_id': entity, 'option': f'{value}°'}
def validate_settings(data):
if not isinstance(data, dict) or set(data) - SETTING_RULES.keys():
raise ValueError('Unknown screen settings; refresh the management page.')
@@ -590,6 +671,15 @@ def local_clock(value, tz):
moment = moment.replace(tzinfo=timezone.utc)
return moment.astimezone(tz or timezone.utc).strftime('%H:%M')
def forecast_kinds(attributes):
"""The forecasts a weather entity offers, from its supported_features (WeatherEntityFeature: 1 daily,
2 hourly). Asking for one it lacks makes Home Assistant log an error; an entity that reports no features
(unavailable) is asked for both, as before."""
features = (attributes or {}).get('supported_features')
if not isinstance(features, int) or isinstance(features, bool):
return frozenset(('daily', 'hourly'))
return frozenset(kind for kind, bit in (('daily', 1), ('hourly', 2)) if features & bit)
def forecast_number(entry, name):
value = entry.get(name)
return round(value, 1) if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
@@ -996,6 +1086,7 @@ def discover_screens(registry, states, devices, areas):
state = states.get(eid, {})
area = area_map.get(item.get('area_id') or device.get('area_id'), '')
screens.append({'id': eid, 'name': device.get('name_by_user') or device.get('name') or eid,
'device_id': item.get('device_id'),
'firmware': versions.get(item.get('device_id'), 'unknown'),
'board': boards.get(item.get('device_id'), 'unknown'),
'node': nodes.get(item.get('device_id')), 'ip': addresses.get(item.get('device_id')),