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
+76 -1
View File
@@ -18,7 +18,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.43"
SCREEN_FIRMWARE_VERSION: "0.2.44"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -98,6 +98,11 @@ substitutions:
TILE_W: "147"
TILE_H: "52"
HEADER_Y: "14"
# Holding the top bar opens the settings page (0.2.44+). The whole strip counts on this board;
# a finger that travels further than the drift limit of a tap is swiping, not holding.
SETTINGS_HOLD_X: "0"
SETTINGS_HOLD_W: "${DISPLAY_W}"
SETTINGS_HOLD_DRIFT_PX: "24"
OVERLAY_SLIDER_W: "96"
OVERLAY_SLIDER_H: "160"
OVERLAY_ICON_Y: "74"
@@ -263,6 +268,7 @@ esphome:
- <esphome/components/smart_display/cyd_ui.h>
- <esphome/components/smart_display/light_controls.h>
- <esphome/components/smart_display/alert_overlay.h>
- <esphome/components/smart_display/settings_screen.h>
name: ${DEVICE_NAME}
friendly_name: ${DEVICE_FRIENDLY_NAME}
on_boot:
@@ -378,6 +384,31 @@ esphome:
screen_calibration::isolation = [](bool active) { id(calibration_active) = active; };
screen_calibration::setup(id(home_page)->obj, id(sublabel)->get_lv_font(), id(headline)->get_lv_font(),
${TOUCH_CAL_X_MIN}, ${TOUCH_CAL_X_MAX}, ${TOUCH_CAL_Y_MIN}, ${TOUCH_CAL_Y_MAX});
# The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager
# writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built
# when it opens and thrown away when it closes, so it costs nothing while it is closed.
- lambda: |-
if (!runtime_tiles::enabled) return;
settings_screen::title_font = id(headline)->get_lv_font();
settings_screen::row_font = id(sublabel_big)->get_lv_font();
settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font();
settings_screen::store = []() { runtime_tiles::persist_settings(); };
settings_screen::apply = []() { id(apply_screen_settings).execute(); };
settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); };
settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); };
settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; };
settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); };
settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); };
settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); };
// A dimmed screen, a calibration or an alert owns the glass; the page waits.
settings_screen::may_open = []() {
return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active);
};
settings_screen::drift = []() { return cyd::touch_guard.distance(); };
settings_screen::before_open = []() { id(close_cards).execute(); };
settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX};
settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y});
- light.turn_on:
id: back_light
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
@@ -474,6 +505,19 @@ api:
}
runtime_tiles::show_detail(index);
}
# The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away
# (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1.
# Handy for support ("look at Screen -> Rotation") and for the render tests.
- action: open_settings
variables:
page: int
then:
- lambda: |-
if (page < 0) { id(go_home).execute(); return; }
id(wake_display).execute();
settings_screen::open((uint8_t) page);
ESP_LOGI("settings", "page %d open from Home Assistant", page);
# Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA.
- action: preview_climate_picker
then:
@@ -557,6 +601,19 @@ interval:
return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL);
then:
- script.execute: dim_display
# Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps
# showing what someone looked at minutes ago; after this long the screen is a screen again.
# Independent of standby, which may be off or much later.
- if:
condition:
lambda: |-
if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false;
if (id(display_dimmed)) return false;
bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 ||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN));
return away && (millis() - id(last_touch_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL;
then:
- script.execute: go_home
time:
- platform: homeassistant
@@ -1008,6 +1065,9 @@ font:
- "\U000F059B" # weather-sunset-down
- "\U000F01D8" # dots-horizontal
- "\U000F1C91" # arrow-oscillating
- "\U000F0379" # monitor
- "\U000F02FD" # information-outline
- "\U000F0709" # restart
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
id: materialdesign_icons_mini
@@ -1088,6 +1148,8 @@ text_sensor:
update_interval: 60s
- platform: wifi_info
ip_address:
# `id` so the settings page on the screen can show the same address (0.2.44+).
id: wifi_ip
name: "IP address"
entity_category: diagnostic
@@ -3547,6 +3609,15 @@ script:
- lvgl.widget.hide: climate_detail_overlay
- lvgl.widget.hide: climate_mode_overlay
# Everything back to the overview: the settings page, every card and the first page of tiles.
# Used by standby, by the "Back to page 1" timeout and by the Home Assistant action.
- id: go_home
then:
- lambda: 'settings_screen::close();'
- script.execute: close_cards
- lambda: 'id(tile_page) = 0;'
- script.execute: show_tile_page
# ---------- ALERT (HA action show_alert) ----------
# The screen wakes, keeps its normal brightness and shows the card until OK or the
# timeout (0: until OK); the standby interval waits on alert_active. A new alert
@@ -3564,6 +3635,8 @@ script:
then:
- lambda: |-
if (id(alert_active)) id(alert_report).execute("replaced");
// An alert is the one thing that outranks the settings page, so that closes first.
settings_screen::close();
const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX});
id(alert_active) = true;
id(alert_title_text) = alert.title;
@@ -3678,6 +3751,8 @@ script:
- lambda: |-
id(display_dimmed) = true;
id(apply_screen_settings).execute();
// A settings page behind a dark screen helps nobody; it always closes.
settings_screen::close();
if (screen_settings::current.home_on_standby) {
// Runtime detail cards live above the tile grid, so paging alone cannot close them.
runtime_tiles::hide_detail();
+76 -1
View File
@@ -3,7 +3,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.43"
SCREEN_FIRMWARE_VERSION: "0.2.44"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
@@ -67,6 +67,11 @@ substitutions:
TILE_W: "218"
TILE_H: "108"
HEADER_Y: "22"
# Holding the top bar opens the settings page (0.2.44+). The two edge bands stay clear: a swipe
# that starts up there still changes the page.
SETTINGS_HOLD_X: "${EDGE_SWIPE_BAND_PX}"
SETTINGS_HOLD_W: "416"
SETTINGS_HOLD_DRIFT_PX: "32"
# Alert overlay (HA action show_alert), 480x480: one card centred on LVGL's top layer.
ALERT_CARD_W: "420"
@@ -127,6 +132,7 @@ esphome:
- <esphome/components/smart_display/guition_diagnostics.h>
- <esphome/components/smart_display/backlight_fade.h>
- <esphome/components/smart_display/alert_overlay.h>
- <esphome/components/smart_display/settings_screen.h>
name: ${DEVICE_NAME}
friendly_name: ${DEVICE_FRIENDLY_NAME}
on_boot:
@@ -235,6 +241,31 @@ esphome:
if (kind == 'f') id(set_climate_fan_mode).execute(index);
else id(set_climate_swing_mode).execute(index);
};
# The settings page the screen opens on its own (0.2.44+): the same values ESP Screen Manager
# writes, reachable by holding the top bar. Fonts and hooks only; the page itself is built
# when it opens and thrown away when it closes, so it costs nothing while it is closed.
- lambda: |-
if (!runtime_tiles::enabled) return;
settings_screen::title_font = id(headline)->get_lv_font();
settings_screen::row_font = id(sublabel_big)->get_lv_font();
settings_screen::icon_font = id(materialdesign_icons_mini)->get_lv_font();
settings_screen::store = []() { runtime_tiles::persist_settings(); };
settings_screen::apply = []() { id(apply_screen_settings).execute(); };
settings_screen::report = [](const char *key, int32_t value) { runtime_tiles::setting_event(key, value); };
settings_screen::name_text = []() { return std::string("${DEVICE_FRIENDLY_NAME}"); };
settings_screen::address_text = []() { return id(wifi_ip).state.empty() ? std::string("No network") : id(wifi_ip).state; };
settings_screen::firmware_text = []() { return std::string("${SCREEN_FIRMWARE_VERSION}"); };
settings_screen::link_text = []() { return std::string(esphome::api_is_connected() ? "Connected" : "Not connected"); };
settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); };
// A dimmed screen, a calibration or an alert owns the glass; the page waits.
settings_screen::may_open = []() {
return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active);
};
settings_screen::drift = []() { return cyd::touch_guard.distance(); };
settings_screen::before_open = []() { id(close_cards).execute(); };
settings_screen::hold_limit = ${SETTINGS_HOLD_DRIFT_PX};
settings_screen::tap_limit = ${TOUCH_MOVE_LIMIT_PX};
settings_screen::attach_hold(id(home_page)->obj, ${SETTINGS_HOLD_X}, 0, ${SETTINGS_HOLD_W}, ${SCROLL_Y});
- light.turn_on:
id: back_light
brightness: !lambda "return screen_settings::current.brightness / 100.0f;"
@@ -337,6 +368,19 @@ api:
}
runtime_tiles::show_detail(index);
}
# The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away
# (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1.
# Handy for support ("look at Screen -> Rotation") and for the render tests.
- action: open_settings
variables:
page: int
then:
- lambda: |-
if (page < 0) { id(go_home).execute(); return; }
id(wake_display).execute();
settings_screen::open((uint8_t) page);
ESP_LOGI("settings", "page %d open from Home Assistant", page);
# Diagnostics: the climate mode picker and the busy sheet of a tile, without touching the glass or HA.
- action: preview_climate_picker
then:
@@ -420,6 +464,19 @@ interval:
return (millis() - id(last_touch_ms)) > (screen_settings::current.standby_seconds * 1000UL);
then:
- script.execute: dim_display
# Back to page 1 by itself (0.2.44+). A card, a settings page or a second page left open keeps
# showing what someone looked at minutes ago; after this long the screen is a screen again.
# Independent of standby, which may be off or much later.
- if:
condition:
lambda: |-
if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false;
if (id(display_dimmed)) return false;
bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 ||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN));
return away && (millis() - id(last_touch_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL;
then:
- script.execute: go_home
time:
- platform: homeassistant
@@ -870,6 +927,9 @@ font:
- "\U000F059B" # weather-sunset-down
- "\U000F01D8" # dots-horizontal
- "\U000F1C91" # arrow-oscillating
- "\U000F0379" # monitor
- "\U000F02FD" # information-outline
- "\U000F0709" # restart
- file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf"
id: materialdesign_icons_mini
@@ -955,6 +1015,8 @@ text_sensor:
update_interval: 60s
- platform: wifi_info
ip_address:
# `id` so the settings page on the screen can show the same address (0.2.44+).
id: wifi_ip
name: "IP address"
entity_category: diagnostic
@@ -3502,6 +3564,15 @@ script:
- lvgl.widget.hide: climate_detail_overlay
- lvgl.widget.hide: climate_mode_overlay
# Everything back to the overview: the settings page, every card and the first page of tiles.
# Used by standby, by the "Back to page 1" timeout and by the Home Assistant action.
- id: go_home
then:
- lambda: 'settings_screen::close();'
- script.execute: close_cards
- lambda: 'id(tile_page) = 0;'
- script.execute: show_tile_page
# ---------- ALERT (HA action show_alert) ----------
# The screen wakes, keeps its normal brightness and shows the card until OK or the
# timeout (0: until OK); the standby interval waits on alert_active. A new alert
@@ -3519,6 +3590,8 @@ script:
then:
- lambda: |-
if (id(alert_active)) id(alert_report).execute("replaced");
// An alert is the one thing that outranks the settings page, so that closes first.
settings_screen::close();
const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX});
id(alert_active) = true;
id(alert_title_text) = alert.title;
@@ -3637,6 +3710,8 @@ script:
- lambda: |-
id(display_dimmed) = true;
id(apply_screen_settings).execute();
// A settings page behind a dark screen helps nobody; it always closes.
settings_screen::close();
if (screen_settings::current.home_on_standby) {
// Runtime detail cards live above the tile grid, so paging alone cannot close them.
runtime_tiles::hide_detail();