Change a screen's settings on the screen itself (0.2.52)

Holding the top bar for about a second and a half opens a settings page on the
screen, with a blue line filling along the top edge while you hold. A built-in
screen.settings tile and the esphome.<screen>_open_settings action open it too.

The page is one constexpr table of rows in settings_screen.h: Brightness,
Night, Screen (clock, back to page 1, swiping, rotation) and This screen (name,
address, firmware, Home Assistant, Restart). Toggles flip on a tap, numbers and
times step with -/+ (a held time walks whole hours), a choice cycles in a chip,
and a row that depends on a switch above it is greyed out. The LVGL objects exist
only while the page is open: +608 bytes of RAM and +10.7 KB of flash on the CYD.
A change is saved, applied and reported through the existing screen_setting
event, so ESP Screens shows the same value within a second.

New setting auto_home (default on, 2 minutes): an open card, the settings page
or a later page goes back to page 1 after that long without a touch, not only on
standby. It travels beside the frozen eleven-key settings block with its own
preference, so older firmware and older apps keep working.

docs/SETTINGS.md is the recipe for adding a setting; the Claude skill explains
the page. Firmware 0.2.44.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-16 10:05:53 +02:00
parent 93c789dc48
commit b83164d61a
28 changed files with 1645 additions and 27 deletions
+19 -1
View File
@@ -14,7 +14,8 @@ import zipfile
import tile_icons
from core import (ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
ALERT_MIN_FIRMWARE, ALERT_SUGGESTED_ICONS, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW,
CONTROLS, DISPLAYS, MAX_PAGES, SLOTS_PER_PAGE, TILE_BACKGROUNDS, TILE_EVENTS, TILE_RESULT_EVENT)
CONTROLS, DISPLAYS, MAX_PAGES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS,
TILE_EVENTS, TILE_RESULT_EVENT)
NAME = 'esp-screens'
# claude.ai accepts at most 200 characters; Claude Code picks the skill by this sentence.
@@ -298,6 +299,23 @@ actions:
```
Replace the person, light, window and screen with the real entity IDs (ask which screens when there are several), and leave out conditions the user did not ask for.
## The settings page on the screen itself
Firmware {SETTINGS_PAGE_MIN_FIRMWARE} or newer carries a settings page the user can open at the panel: hold the top bar (the strip with the screen's name and the clock) for about a second and a half, until the blue line along the top edge is full. A screen can also carry a tile for it: put the entity `screen.settings` on a screen like any other tile.
It holds Brightness, Night, Screen (clock, going back to page 1, swiping, rotation) and This screen (name, address, firmware, whether Home Assistant is connected, and Restart). A change there is saved on the screen and shows up in ESP Screens within a second, exactly like a change made from Home Assistant.
You can open it for someone who is standing at the panel:
```yaml
actions:
- action: esphome.kitchen_screen_open_settings
data:
page: 1
```
`page` 0 is the menu, 1 Brightness, 2 Night, 3 Screen, 4 This screen, and -1 closes the page and puts the screen back on page 1. Use it to walk someone through a setting ("I opened Brightness on the kitchen screen"), not to change something yourself: for that, use the entities above, which do the same thing without anyone at the panel.
'''
def status(directory):
+15 -2
View File
@@ -11,15 +11,17 @@ import tile_icons
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen'.split())
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
BUILTIN = {'screen.clock': 'Clock'}
BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings'}
NEW_DOMAINS = frozenset('sun timer person screen'.split())
WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.43'
FIRMWARE_VERSION = '0.2.44'
# 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'
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'])
@@ -165,7 +167,16 @@ SETTING_RULES = {
'home_on_standby': (False, None, None),
'swipe_pages': (False, None, None),
'rotation': (0, 0, 270),
# Back to page 1 by itself (firmware 0.2.44+): closes an open card and any page but the first
# after this many seconds without a touch. Older firmware ignores both keys.
'auto_home': (True, None, None),
'auto_home_seconds': (120, 30, 3600),
}
# Firmware before 0.2.44 accepts a `settings` object with exactly its own eleven keys and refuses any
# other size, so everything added after it travels as its own key in the layout message. Old firmware
# ignores a key it does not know; a new screen with an old add-on keeps what it saved itself.
# docs/SETTINGS.md walks through adding one.
SETTINGS_BESIDE_BLOCK = ('swipe_pages', 'rotation', 'auto_home', 'auto_home_seconds')
def validate_settings(data):
if not isinstance(data, dict) or set(data) - SETTING_RULES.keys():
@@ -250,6 +261,8 @@ def validate_header(data):
def min_firmware(layout):
"""Oldest firmware that still accepts this layout; None when any version works."""
if any(t['entity'] == 'screen.settings' for t in layout['tiles']):
return (0, 2, 44)
if any(t.get('options', {}).get('background') == 'none' for t in layout['tiles']):
return (0, 2, 16)
if any(t['entity'].split('.')[0] in NEW_DOMAINS for t in layout['tiles']):
+6 -2
View File
@@ -15,7 +15,7 @@ import tile_icons
from updates import Updater
from aiohttp import ClientSession, ClientTimeout, WSMsgType, web
from core import BROADCAST_EVENTS, BUILTIN, 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, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
import header_bar
from zoneinfo import ZoneInfo
@@ -599,8 +599,12 @@ class Manager:
if 'pages' in layout:
message['pages'] = layout['pages']
if 'settings' in layout:
message['settings'] = {k:v for k,v in layout['settings'].items() if k not in ('swipe_pages','rotation')}
# `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).
+3
View File
@@ -92,6 +92,8 @@ const settingDefinitions = [
// Whether the clock shows is up to the top bar now; the add-on keeps show_clock in step for older firmware.
["clock_24h", "24-hour clock (off = 12 hour)", "check", true],
["home_on_standby", "Return to page 1 after standby", "check", false],
["auto_home", "Close cards and return to page 1 by itself (firmware 0.2.44+)", "check", true],
["auto_home_seconds", "Return to page 1 after", "minutes", 120, 1, 60],
["rotation", "Rotate screen (clockwise)", "rotation", 0],
["swipe_pages", "Swipe between pages (Guition: from the edge, firmware 0.2.24+; CYD: quick swipe, 0.2.7+)", "check", false],
];
@@ -647,6 +649,7 @@ function iconNamed(name) {
// What the firmware draws without a choice: Home Assistant's own icon, else the domain icon.
function automaticIcon(id) {
const icons = inventory.icons, entity = inventory.entities.find((e) => e.id === id), domain = id.split(".")[0];
if (icons.builtin?.[id]) return icons.builtin[id];
if (entity?.icon) return entity.icon;
if (domain === "weather") return icons.weather[entity?.state] || icons.weather.partlycloudy;
if (domain === "sun") return icons.sun[entity?.state] || icons.sun.below_horizon;
Binary file not shown.
+7
View File
@@ -220,6 +220,10 @@ FIXED = (
('dots-horizontal', 'F01D8'),
# Swing row on the Guition climate card, Home Assistant's icon for swing modes (firmware 0.2.40+).
('arrow-oscillating', 'F1C91'),
# The settings page on the screen itself (firmware 0.2.44+): its menu and the restart row.
('monitor', 'F0379'),
('information-outline', 'F02FD'),
('restart', 'F0709'),
)
# Pickable icons by name, and every glyph the firmware fonts contain.
@@ -230,6 +234,8 @@ GLYPHS = {**{name: codepoint for name, (codepoint, _) in ICONS.items()}, **dict(
DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot-vacuum', 'fan': 'fan',
'cover': 'window-shutter', 'scene': 'sofa', 'script': 'sofa', 'sensor': 'gauge', 'binary_sensor': 'gauge',
'timer': 'timer-outline', 'person': 'account', 'screen': 'clock-outline'}
# The cards the screen brings itself: one icon per entity, not per domain.
BUILTIN_TILES = {'screen.clock': 'clock-outline', 'screen.settings': 'cog'}
FALLBACK = 'power'
CONTROL_GLYPHS = ('play', 'pause', 'stop', 'skip-next', 'skip-previous', 'volume-high', 'volume-off', 'arrow-up', 'arrow-down',
'arrow-expand-horizontal', 'arrow-collapse-horizontal', 'home-map-marker', 'plus', 'minus', 'chevron-left',
@@ -250,6 +256,7 @@ def editor():
return {'groups': [{'label': group, 'icons': [{'name': name, 'cp': codepoint, 'label': label} for name, codepoint, label in icons]}
for group, icons in GROUPS],
'defaults': {domain: GLYPHS[name] for domain, name in DEFAULTS.items()}, 'fallback': GLYPHS[FALLBACK],
'builtin': {entity: GLYPHS[name] for entity, name in BUILTIN_TILES.items()},
'weather': {state: GLYPHS[name] for state, name in WEATHER.items()},
'sun': {'above_horizon': GLYPHS['weather-sunset-down'], 'below_horizon': GLYPHS['weather-sunset-up']},
# Glyphs of the direct controls on wide cards, so the mockup previews them.