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
+1 -1
View File
@@ -72,7 +72,7 @@ class SkillText(unittest.TestCase):
def test_every_yaml_example_parses(self):
import yaml
blocks = re.findall(r'```yaml\n(.*?)```', claude_skill.text(), re.S)
self.assertEqual(len(blocks), 7)
self.assertEqual(len(blocks), 8)
parsed = [yaml.safe_load(block) for block in blocks]
# Tiles first (0.2.51): putting one on a screen, and ordering a page.
self.assertEqual(parsed[0]['actions'][0]['event'], 'esp_screens_add_tile')
+1 -1
View File
@@ -233,7 +233,7 @@ class ParityTests(unittest.TestCase):
self.assertIn('set_hidden(p,!(awake() && now.is_valid()));', second_hand)
self.assertIn('part_line(w,18,w.points+28,2,w.hand_width)', second_hand)
tick = TILES.split('inline void tick() {', 1)[1]
self.assertIn('t.builtin() && t.display=="analog")second_hand(w,now);', tick)
self.assertIn('t.is_clock() && t.display=="analog")second_hand(w,now);', tick)
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
if HAS_AIOHTTP:
+3 -3
View File
@@ -8,7 +8,7 @@ import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
from core import discover, installation_yaml, packets, state_message, validate_layout, validate_settings
from core import SETTINGS_BESIDE_BLOCK, discover, installation_yaml, packets, state_message, validate_layout, validate_settings
class ProtocolTests(unittest.TestCase):
def test_unicode_chunks_and_limits(self):
@@ -125,11 +125,11 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(ValueError): fresh.save('text.screen',{**saved,'settings':{'brightness':0}})
self.assertEqual(path.read_bytes(),before)
await fresh.sync_one('text.screen',saved)
self.assertEqual(fresh.ha.messages[0][1]['settings'],{k:v for k,v in settings.items() if k not in ('swipe_pages','rotation')})
self.assertEqual(fresh.ha.messages[0][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK})
await fresh.sync_one('text.screen',saved)
self.assertEqual(len(fresh.ha.messages),2)
await fresh.sync_one('text.screen',saved,True)
self.assertEqual(fresh.ha.messages[2][1]['settings'],{k:v for k,v in settings.items() if k not in ('swipe_pages','rotation')})
self.assertEqual(fresh.ha.messages[2][1]['settings'],{k:v for k,v in settings.items() if k not in SETTINGS_BESIDE_BLOCK})
async def test_reorder_aborts_old_batch(self):
with tempfile.TemporaryDirectory() as temp:
+112
View File
@@ -0,0 +1,112 @@
"""The settings page on the screen itself (app 0.2.52, firmware 0.2.44).
Two halves have to agree without ever seeing each other: the row table in settings_screen.h writes keys
that only mean something if ESP Screens knows them, and the eleven-key `settings` block older firmware
insists on may never grow. Both are checked here, against the real files.
"""
import importlib.util
import json
import re
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
import test_portal
from core import (BUILTIN, SETTING_RULES, SETTINGS_BESIDE_BLOCK, entity_id, min_firmware, validate_layout,
validate_settings)
SCREEN = (ROOT / 'components/smart_display/settings_screen.h').read_text()
RUNTIME = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
# The keys the firmware's own block still carries; everything newer travels as its own key.
FROZEN = {'standby_enabled', 'standby_seconds', 'brightness', 'standby_brightness', 'night_enabled',
'night_start', 'night_end', 'night_brightness', 'show_clock', 'clock_24h', 'home_on_standby'}
assert FROZEN == set(SETTING_RULES) - set(SETTINGS_BESIDE_BLOCK)
class SettingsPage(unittest.TestCase):
def test_every_key_the_screen_reports_is_a_setting_the_app_knows(self):
keys = set(re.findall(r'changed\("(\w+)"', SCREEN))
self.assertTrue(keys, 'no rows found in settings_screen.h')
self.assertLessEqual(keys, set(SETTING_RULES), 'the screen writes a key ESP Screens would drop')
def test_the_frozen_settings_block_keeps_exactly_eleven_keys(self):
# Firmware before 0.2.44 refuses a `settings` object of any other size; that is why the newer
# settings travel as their own keys.
self.assertEqual(re.search(r'if \(obj\.size\(\) != (\d+)\) return false;', RUNTIME).group(1), '11')
self.assertEqual(FROZEN, set(SETTING_RULES) & FROZEN)
def test_new_settings_default_and_validate(self):
clean = validate_settings({})
self.assertEqual(clean['auto_home'], True)
self.assertEqual(clean['auto_home_seconds'], 120)
self.assertEqual(validate_settings({'auto_home_seconds': 30})['auto_home_seconds'], 30)
for bad in ({'auto_home_seconds': 29}, {'auto_home_seconds': 3601}, {'auto_home': 1},
{'auto_home_seconds': True}):
with self.assertRaises(ValueError):
validate_settings(bad)
def test_the_settings_tile_is_a_builtin_that_needs_new_firmware(self):
self.assertIn('screen.settings', BUILTIN)
self.assertTrue(entity_id('screen.settings'))
self.assertEqual(min_firmware({'tiles': [{'entity': 'screen.settings'}]}), (0, 2, 44))
self.assertLess(min_firmware({'tiles': [{'entity': 'screen.clock'}]}), (0, 2, 44))
layout = validate_layout({'title': 'Home', 'tiles': [{'entity': 'screen.settings', 'name': 'Settings'}]})
self.assertEqual(layout['tiles'][0]['entity'], 'screen.settings')
@unittest.skipUnless(importlib.util.find_spec('aiohttp'), 'Run using .venv-portal/bin/python for server tests')
class LayoutMessage(unittest.IsolatedAsyncioTestCase):
async def test_the_new_keys_ride_beside_the_frozen_block(self):
with tempfile.TemporaryDirectory() as temp:
manager = test_portal.ManagerTests().setup_manager(Path(temp) / 'screens.json')
layout = {'title': 'Home', 'tiles': [{'entity': 'light.a'}],
'settings': validate_settings({'auto_home': False, 'auto_home_seconds': 600})}
manager.save('text.screen', layout)
message = manager.layout_message('text.screen', manager.layouts['text.screen'], {})
self.assertEqual(set(message['settings']), FROZEN, 'older firmware refuses any other size')
self.assertEqual(message['auto_home'], False)
self.assertEqual(message['auto_home_seconds'], 600)
# A screen that never stored settings still gets the defaults, not a missing key.
plain = manager.layout_message('text.screen', {**manager.layouts['text.screen'],
'settings': validate_settings({})}, {})
self.assertEqual((plain['auto_home'], plain['auto_home_seconds']), (True, 120))
self.assertLess(len(json.dumps(message)), 4096, 'the screen refuses a message over 4 KB')
class Firmware(unittest.TestCase):
"""The board profiles have to carry the page, its way in, and the timer that closes it."""
def setUp(self):
self.profiles = {name: (ROOT / name).read_text()
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml')}
def test_both_boards_wire_the_page_and_its_gesture(self):
for name, text in self.profiles.items():
self.assertIn('settings_screen::attach_hold', text, name)
self.assertIn('settings_screen::report', text, name)
self.assertIn('settings_screen::may_open', text, name)
self.assertIn('- action: open_settings', text, name)
self.assertIn('- id: go_home', text, name)
self.assertIn('runtime_tiles::auto_home', text, name)
self.assertIn('SCREEN_FIRMWARE_VERSION: "0.2.44"', text, name)
def test_the_hold_strip_covers_the_top_bar_and_no_more(self):
for name, text in self.profiles.items():
values = dict(re.findall(r'^ (\w+): "([^"]*)"', text, re.M))
width, scroll = int(values['DISPLAY_W']), int(values['SCROLL_Y'])
x = int(values['SETTINGS_HOLD_X'].replace('${EDGE_SWIPE_BAND_PX}', values.get('EDGE_SWIPE_BAND_PX', '0')))
hold = values['SETTINGS_HOLD_W'].replace('${DISPLAY_W}', values['DISPLAY_W'])
self.assertLessEqual(x + int(hold), width, name)
self.assertGreater(scroll, 20, name)
if 'EDGE_SWIPE_BAND_PX' in values:
# The page swipe owns both edge bands; the hold may not start there.
band = int(values['EDGE_SWIPE_BAND_PX'])
self.assertGreaterEqual(x, band, name)
self.assertLessEqual(x + int(hold), width - band, name)
if __name__ == '__main__':
unittest.main()
+125
View File
@@ -0,0 +1,125 @@
#define SETTINGS_SCREEN_TEST
#include "../components/smart_display/settings_screen.h"
#include <cassert>
#include <string>
using namespace settings_screen;
// The row of a page by its label, so a test says what it means.
static const Row &row_named(const Page &page, const std::string &label) {
for (uint8_t i = 0; i < page.count; ++i)
if (label == page.rows[i].label) return page.rows[i];
assert(false);
return page.rows[0];
}
int main() {
// ---- the duration ladder: seconds low down, quarters of an hour up top, and symmetric ----
assert(ladder_step(60) == 30 && ladder_step(600) == 60 && ladder_step(1800) == 300);
assert(ladder_step(3600) == 900 && ladder_step(36000) == 1800);
Row timeout = duration("After", nullptr, nullptr, 30, 3600);
assert(stepped(timeout, 120, 1) == 150 && stepped(timeout, 120, -1) == 90);
assert(stepped(timeout, 300, 1) == 360 && stepped(timeout, 360, -1) == 300);
assert(stepped(timeout, 300, -1) == 270 && stepped(timeout, 270, 1) == 300);
assert(stepped(timeout, 30, -1) == 30 && stepped(timeout, 3600, 1) == 3600);
assert(at_end(timeout, 30, -1) && at_end(timeout, 3600, 1) && !at_end(timeout, 120, -1));
// ---- a number stops at its ends, a moment walks around midnight ----
Row percent = number("Brightness", nullptr, nullptr, 5, 100, 5, "%");
assert(stepped(percent, 100, 1) == 100 && stepped(percent, 5, -1) == 5 && stepped(percent, 95, 1) == 100);
Row start = moment("Starts", nullptr, nullptr);
assert(stepped(start, 1320, 1) == 1335 && stepped(start, 1425, 1) == 0 && stepped(start, 0, -1) == 1425);
assert(!at_end(start, 0, -1));
// Holding the key walks whole hours, and first lands on the hour it is in.
assert(stepped(start, 1320, 1, true) == 1380 && stepped(start, 1320, -1, true) == 1260);
assert(stepped(start, 1327, 1, true) == 1380 && stepped(start, 1327, -1, true) == 1320);
assert(stepped(start, 1380, 1, true) == 1440 % 1440); // 23:00 + 1 h wraps to 00:00
assert(stepped(start, 0, -1, true) == 1380); // 00:00 - 1 h is 23:00
// ---- texts ----
assert(duration_text(45) == "45 sec" && duration_text(60) == "1 min" && duration_text(120) == "2 min");
assert(duration_text(3600) == "1 h" && duration_text(5400) == "1 h 30" && duration_text(86400) == "24 h");
assert(moment_text(1320, true) == "22:00" && moment_text(0, true) == "00:00");
assert(moment_text(1320, false) == "10:00 PM" && moment_text(0, false) == "12:00 AM");
assert(moment_text(750, false) == "12:30 PM" && moment_text(60, false) == "1:00 AM");
// ---- rows read and write the real settings ----
screen_settings::current = screen_settings::Settings{};
const Page &light = pages[1];
const Row &brightness = row_named(light, "Brightness");
assert(value_text(brightness) == "100%");
brightness.write(40);
assert(screen_settings::current.brightness == 40 && value_text(brightness) == "40%");
// Lowering the normal brightness pulls the two dim levels down with it, exactly like the add-on.
screen_settings::current.standby_brightness = 60;
brightness.write(30);
assert(screen_settings::current.standby_brightness == 30);
const Row &standby = row_named(light, "Standby brightness");
standby.write(90);
assert(screen_settings::current.standby_brightness == 30); // never above the normal brightness
assert(screen_settings::current.valid());
const Page &screen = pages[3];
const Row &clock = row_named(screen, "Clock");
assert(clock.option_count == 2 && value_text(clock) == "24 hour");
clock.write(0);
assert(screen_settings::current.clock_24h == 0 && value_text(clock) == "12 hour");
const Row &home = row_named(screen, "Back to page 1");
home.write(0);
assert(auto_home == 0 && value_text(home) == "Off");
const Row &rotate = row_named(screen, "Rotation");
rotation_supported = false;
assert(!visible_row(rotate)); // a board that cannot turn never shows the row
rotation_supported = true;
assert(visible_row(rotate));
rotate.write(2);
assert(rotation == 180 && value_text(rotate) == "180°");
rotate.write(3);
assert(rotation == 270);
rotate.write(0);
assert(rotation == 0);
// ---- every page hangs together: menu rows open a real page, controls can be worked ----
assert(PAGE_COUNT == 5);
for (uint8_t p = 0; p < PAGE_COUNT; ++p) {
const Page &page = pages[p];
assert(page.title && page.count && page.count <= 8);
for (uint8_t i = 0; i < page.count; ++i) {
const Row &row = page.rows[i];
assert(row.label && *row.label);
if (row.kind == Kind::page) assert(row.opens > 0 && row.opens < PAGE_COUNT && *row.icon);
if (row.kind == Kind::toggle || row.kind == Kind::choice || row.kind == Kind::number ||
row.kind == Kind::duration || row.kind == Kind::moment)
assert(row.read && row.write);
if (row.kind == Kind::number) assert(row.step > 0 && row.high > row.low);
if (row.kind == Kind::duration) assert(row.high > row.low);
if (row.kind == Kind::choice) assert(row.options && row.option_count);
if (row.kind == Kind::info) assert(row.text);
if (row.kind == Kind::action) assert(row.run && *row.icon);
}
}
// Every page is reachable from the menu.
bool reached[PAGE_COUNT] = {true};
for (uint8_t i = 0; i < pages[0].count; ++i) reached[pages[0].rows[i].opens] = true;
for (bool page : reached) assert(page);
// ---- how many rows fit, and when the pager appears ----
bool paged = false;
assert(fitting_rows(180, 34, 4, 26, 4, paged) == 4 && !paged); // CYD: four rows, no pager
assert(fitting_rows(180, 34, 4, 26, 6, paged) == 4 && paged); // six rows: 4 + 2
assert(fitting_rows(372, 52, 8, 40, 6, paged) == 6 && !paged); // Guition: the whole group at once
assert(fitting_rows(372, 52, 8, 40, 9, paged) == 5 && paged);
assert(fitting_rows(40, 34, 4, 26, 5, paged) >= 1); // never zero rows
// ---- a change is stored, applied and reported once ----
static int stored = 0, applied = 0;
static std::string last_key;
static int32_t last_value = 0;
store = [] { ++stored; };
apply = [] { ++applied; };
report = [](const char *key, int32_t value) { last_key = key; last_value = value; };
row_named(light, "Auto standby").write(0);
assert(stored == 1 && applied == 1 && last_key == "standby_enabled" && last_value == 0);
row_named(screen, "After").write(600);
assert(stored == 2 && last_key == "auto_home_seconds" && last_value == 600 && auto_home_seconds == 600);
}
+9 -1
View File
@@ -48,7 +48,15 @@ class IconSetTests(unittest.TestCase):
for domains, code in re.findall(r'if \((d == "\w+"(?: \|\| d == "\w+")*)\) return "\\U000(F[0-9A-F]{4})";', body):
for domain in re.findall(r'"(\w+)"', domains):
native[domain] = code
self.assertEqual(native, {domain: tile_icons.GLYPHS[name] for domain, name in tile_icons.DEFAULTS.items()})
# The built-in cards pick per entity, not per domain: a clock or the settings page.
settings, clock = re.search(
r'if \(d == "screen"\) return tile\.is_settings\(\) \? "\\U000(F[0-9A-F]{4})" : "\\U000(F[0-9A-F]{4})";', body).groups()
builtin = {entity: tile_icons.GLYPHS[name] for entity, name in tile_icons.BUILTIN_TILES.items()}
self.assertEqual({'screen.settings': settings, 'screen.clock': clock}, builtin)
self.assertEqual(tile_icons.editor()['builtin'], builtin)
self.assertEqual(tile_icons.GLYPHS[tile_icons.DEFAULTS['screen']], clock)
expected = {domain: tile_icons.GLYPHS[name] for domain, name in tile_icons.DEFAULTS.items() if domain != 'screen'}
self.assertEqual(native, expected)
self.assertIn(f'return "\\U000{tile_icons.GLYPHS[tile_icons.FALLBACK]}";', body)
weather = HEADER.split('inline const char *weather_icon(const std::string &condition) {', 1)[1].split('\n}', 1)[0]
for state, name in tile_icons.WEATHER.items():