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:
@@ -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')),
|
||||
|
||||
Reference in New Issue
Block a user