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