Free memory on the CYD and drop the old manual profile (0.2.50)

The CYD restarted under load: a slider fill rounded less than its track made
LVGL draw the fill into a buffer of its own size on every redraw (15-20 KB), and
when that allocation failed the refresh retried until the task watchdog fired.
Track and fill now share one radius, and tests/test_layer_free.py keeps every
layer-making style out of the profiles, the packages and the firmware headers.

A tile keeps only the fields every card uses; climate modes, select options,
weather, sun and timer times, the media title and the vacuum rows moved to an
Extra block that exists only while a state needs one, and the state copy became
a fingerprint. runtime_tiles::model went from 24,296 to 8,216 bytes.

The manual profile that bound tiles to fixed entities in the YAML is gone:
331 TILE* substitutions, DYNAMIC_TILES/TILE_COUNT/DIRECT_ACTIONS, the 80 Home
Assistant subscriptions, its tap handlers and scripts, its own vacuum card, its
guides and helper scripts, and the five stale header copies in the repository
root that two C++ tests were compiling instead of the components. esphome config
of both Easy profiles is unchanged by that cleanup.

tile_scroll and the wake overlay no longer light up on a tap, and the debug
sensor min_free reports the lowest free heap since boot. On the bench CYD static
RAM went from 32.0% to 22.8% and free heap from 64-77 KB to about 101 KB, with
78 KB as the lowest point over thirteen hours without a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-16 08:06:41 +02:00
parent 79a84a4c77
commit 08197c4ae1
45 changed files with 854 additions and 12896 deletions
+16 -5
View File
@@ -8,13 +8,15 @@ int main() {
assert(!guard.accept(100,1)); assert(!guard.accept_slider(100,1));
guard.begin(200); assert(guard.accept(300,1));
// A tile keeps the fingerprint of its last state message, not the message itself.
auto rev=[](const char *state){ return runtime_tiles::state_revision(state,""); };
runtime_tiles::Tile t;
t.revision="off"; t.begin(100);
t.revision=rev("off"); t.begin(100);
assert(t.loading(200)); assert(t.awaiting_action(200));
t.observe("off"); assert(!t.confirmed);
t.observe("on"); assert(t.confirmed);
t.observe(rev("off")); assert(!t.confirmed);
t.observe(rev("on")); assert(t.confirmed);
assert(t.loading(999)); assert(!t.loading(1100));
t.begin(2000); t.observe("on");
t.begin(2000); t.observe(rev("on"));
assert(t.loading(7999)); assert(!t.loading(8000)); assert(!t.awaiting_action(8000));
t.begin(0xFFFFFFF0); assert(t.loading(20));
t.begin(100,true);
@@ -31,6 +33,15 @@ int main() {
sw.begin(300); sw.observe(runtime_tiles::state_revision("on","{}"));
assert(sw.loading(1000)); // Repeated identical data is not confirmation.
assert(!sw.loading(6300));
sw.entity="input_boolean.test"; sw.begin(0xFFFFFFF0); sw.observe("off");
sw.entity="input_boolean.test"; sw.begin(0xFFFFFFF0); sw.observe(rev("off"));
assert(sw.loading(20)); assert(!sw.loading(200));
// The fingerprint follows state and attributes, and the streaming writer (what receive() hands to
// ArduinoJson) gives the same value as hashing the joined text.
assert(runtime_tiles::state_revision("on","{\"brightness\":255}")!=runtime_tiles::state_revision("on","{\"brightness\":254}"));
assert(runtime_tiles::state_revision("on","{}")!=runtime_tiles::state_revision("off","{}"));
assert(runtime_tiles::state_revision("o","n{}")!=runtime_tiles::state_revision("on","{}"));
runtime_tiles::Fingerprint f; f.add("on"); f.write('\n');
const char json[]="{\"a\":1}"; f.write(reinterpret_cast<const uint8_t*>(json),sizeof(json)-1);
assert(f.value==runtime_tiles::state_revision("on","{\"a\":1}"));
}
+3 -3
View File
@@ -101,10 +101,10 @@ class AlertTests(unittest.TestCase):
self.assertGreater(v('ALERT_SUBTITLE_MAX'), v('ALERT_TITLE_MAX'), name)
def test_helper_header_is_included_locally_and_in_the_remote_package(self):
header = ' - <esphome/components/smart_display/alert_overlay.h>\n'
for board, name in PROFILES.items():
self.assertIn(' - alert_overlay.h\n', (ROOT / name).read_text(), name)
self.assertIn(' - <esphome/components/smart_display/alert_overlay.h>\n', (ROOT / PACKAGES[board]).read_text(), board)
self.assertIn('components/smart_display/alert_overlay.h', (ROOT / 'alert_overlay.h').read_text())
self.assertIn(header, (ROOT / name).read_text(), name)
self.assertIn(header, (ROOT / PACKAGES[board]).read_text(), board)
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -1,4 +1,4 @@
#include "../cyd_ui.h"
#include "../components/smart_display/cyd_ui.h"
#include <cassert>
#include <limits>
int main() {
+63
View File
@@ -0,0 +1,63 @@
"""The packages a screen builds from: generated from the board profiles, runtime tiles only."""
import importlib.util
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BOARDS = {'cyd': 'home-like-2432s028.yaml', 'guition': 'guition-4848s040.yaml'}
# The old manual profile bound tiles to fixed entities in the YAML. ESP Screens sends the tiles now, so
# none of this may come back: the fixed subscriptions, its tap handlers and scripts, its own vacuum card.
GONE = ['ha_state_tile1', 'tile1_brightness', 'tile6_climate_humidity', 'tile6_cover_state', 'smartdisplay_action',
'publish_action', 'do_tile_action', 'open_vacuum_overlay', 'vacuum_action', 'vacuum_fan_speed', 'vacuum_overlay',
'vacspd_quiet', 'vacuum_actions', 'DYNAMIC_TILES', 'TILE_COUNT', 'DIRECT_ACTIONS', 'TILE1_ENTITY',
'light_controls::subscribe']
def load_generator():
spec = importlib.util.spec_from_file_location('generate_packages', ROOT / 'tools/generate_packages.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class PackageTests(unittest.TestCase):
def test_packages_are_current(self):
generator = load_generator()
for board in BOARDS:
self.assertEqual((ROOT / 'packages' / f'{board}.yaml').read_text(), generator.generate(board), board)
def test_no_secrets_and_no_local_paths(self):
for board in BOARDS:
package = (ROOT / 'packages' / f'{board}.yaml').read_text()
self.assertNotIn('!secret', package)
self.assertNotIn('type: local', package)
self.assertIn('url: https://github.com/MaxGramser/homeassistant_espscreen.git', package)
def test_the_manual_profile_is_gone_from_profiles_and_packages(self):
for board, name in BOARDS.items():
for path in (ROOT / name, ROOT / 'packages' / f'{board}.yaml'):
text = '\n'.join(line for line in path.read_text().split('\n') if not line.lstrip().startswith('#'))
for key in GONE:
self.assertNotIn(key, text, f'{path.name} still carries {key}')
def test_runtime_tiles_keep_what_they_bind_and_open(self):
for board in BOARDS:
package = (ROOT / 'packages' / f'{board}.yaml').read_text()
for key in ['runtime_tiles::bind(9, id(tile10)', 'runtime_tiles::enabled = true;', 'id: open_value_overlay',
'id: ui_refresh', 'runtime_tiles::render(id(lbl_room));', 'id: climate_detail_overlay',
'id: color_detail_overlay']:
self.assertIn(key, package, board)
# The CYD keeps its resistive calibration on the screen itself; the Guition's GT911 needs none.
self.assertIn('screen_calibration::setup(', (ROOT / 'packages/cyd.yaml').read_text())
def test_generator_refuses_a_fixed_home_assistant_subscription(self):
generator = load_generator()
text = (ROOT / BOARDS['cyd']).read_text()
stray = ' - platform: homeassistant\n id: stray\n entity_id: sensor.stray\n\nbinary_sensor:\n'
with self.assertRaises(ValueError):
generator.generate('cyd', text.replace('binary_sensor:\n', stray, 1))
if __name__ == '__main__':
unittest.main()
+1 -13
View File
@@ -2,12 +2,10 @@
from pathlib import Path
import re
import sys
import tempfile
import unittest
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'tools'))
import verify_gt911
import new_device
SOURCE=(ROOT/'guition-4848s040.yaml').read_text()
VALUES=dict(re.findall(r'^ (\w+): "([^"]*)"',SOURCE,re.M))
@@ -49,9 +47,8 @@ class GuitionTests(unittest.TestCase):
self.assertEqual(len(rectangles),6)
self.assertIn('height: 44',SOURCE.split('id: page_next',1)[1][:200])
def test_all_tiles_guard_events_and_clip_long_titles(self):
def test_all_tiles_clip_long_titles(self):
for i in range(1,11):
self.assertEqual(SOURCE.count(f'cyd::touch_guard.accept(millis(), {i})'),2)
self.assertRegex(SOURCE,rf'id: t{i}_title\n\s+height: 24\n\s+width: 130\n\s+long_mode: DOT')
self.assertEqual(VALUES['AUTO_DIM_TIMEOUT'],'600')
@@ -65,13 +62,4 @@ class GuitionTests(unittest.TestCase):
self.assertEqual(verify_gt911.screen_point(20,30,90),(30,459))
self.assertEqual(verify_gt911.screen_point(20,30,270),(449,20))
def test_initializer_does_not_copy_resistive_calibration(self):
with tempfile.TemporaryDirectory() as tmp:
new_device.create(tmp,'wallbox-test','Wallbox Test','guition')
profile=(Path(tmp)/'guition-device.yaml').read_text()
self.assertIn('!include guition-4848s040.yaml',profile)
self.assertIn('DEVICE_NAME: "wallbox-test"',profile)
self.assertFalse((Path(tmp)/'calibration.yaml').exists())
self.assertTrue((Path(tmp)/'secrets.yaml').exists())
if __name__=='__main__':unittest.main()
+63
View File
@@ -0,0 +1,63 @@
"""Drawing that needs no buffers of its own: styles that make LVGL 9.5 render a widget into a separate layer.
Such a layer is a full-size buffer (width x height x 4 bytes) allocated on every redraw. The CYD has no PSRAM:
a slider fill rounded less than its track cost 15-20 KB per frame there, and when LVGL cannot get that memory
it retries inside the frame until the task watchdog resets the board.
"""
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PROFILES = ['home-like-2432s028.yaml', 'guition-4848s040.yaml', 'packages/cyd.yaml', 'packages/guition.yaml']
HEADERS = sorted((ROOT / 'components/smart_display').glob('*.h'))
# Style properties that give a widget a layer (lv_obj_style.c calculate_layer_type, lv_refr.c clip corners).
YAML_LAYER_STYLES = re.compile(r'^\s*(opa_layered|transform_rotation|transform_scale(?:_x|_y)?|transform_zoom|transform_skew(?:_x|_y)?'
r'|blend_mode|bitmap_mask_src|drop_shadow_\w+|blur_\w+):', re.M)
C_LAYER_STYLES = re.compile(r'lv_obj_set_style_(opa_layered|transform_rotation|transform_scale(?:_x|_y)?|transform_skew(?:_x|_y)?'
r'|blend_mode|bitmap_mask_src|drop_shadow_\w+|blur_\w+)\(|LV_STYLE_(OPA_LAYERED|TRANSFORM_ROTATION'
r'|TRANSFORM_SCALE\w*|TRANSFORM_SKEW\w*|BLEND_MODE|BITMAP_MASK_SRC|CLIP_CORNER)\b')
class LayerFreeTests(unittest.TestCase):
def test_board_profiles_use_no_layer_styles(self):
for name in PROFILES:
text = (ROOT / name).read_text()
self.assertEqual(YAML_LAYER_STYLES.findall(text), [], name)
self.assertEqual(re.findall(r'^\s*clip_corner: true', text, re.M), [], name)
def test_firmware_sets_no_layer_styles(self):
for path in HEADERS:
text = path.read_text()
self.assertEqual(C_LAYER_STYLES.findall(text), [], path.name)
self.assertNotRegex(text, r'lv_obj_set_style_clip_corner\([^;]*\btrue\b', path.name)
def test_slider_fill_keeps_the_track_radius_in_code(self):
"""Every fill radius in the firmware is the same expression as a track radius set beside it."""
statement = re.compile(r'(?:lv_obj_set_style_radius\((\w+(?:\.\w+)?),\s*|set_number\((\w+),\s*LV_STYLE_RADIUS,\s*)'
r'([^;]*?),\s*(LV_PART_MAIN|LV_PART_INDICATOR)\s*\)')
checked = 0
for path in HEADERS:
text = path.read_text()
found = [(m.group(1) or m.group(2), m.group(3).strip(), m.group(4)) for m in statement.finditer(text)]
for widget, value, part in found:
if part != 'LV_PART_INDICATOR':
continue
checked += 1
self.assertIn((widget, value, 'LV_PART_MAIN'), found, f'{path.name}: fill radius {value} of {widget} differs from its track')
self.assertGreaterEqual(checked, 2)
def test_yaml_slider_fill_keeps_the_track_radius(self):
for name in PROFILES:
text = (ROOT / name).read_text()
for match in re.finditer(r'^(\s+)- (?:slider|bar):\n((?:\1 .*\n|\s*\n)+)', text, re.M):
block, indent = match.group(2), match.group(1) + ' '
track = re.search(r'^' + indent + r'radius: (\S+)', block, re.M)
fill = re.search(r'^' + indent + r'indicator:\n(?:' + indent + r' .*\n)*?' + indent + r' radius: (\S+)', block, re.M)
if fill:
self.assertTrue(track and track.group(1) == fill.group(1), f'{name}: slider fill radius {fill.group(1)} differs from its track')
if __name__ == '__main__':
unittest.main()
+5 -9
View File
@@ -23,16 +23,12 @@ class LayoutTests(unittest.TestCase):
self.assertFalse(x < xx + ww and x + w > xx and y < yy + hh and y + h > yy)
rectangles.append((x, y, w, h))
def test_each_tile_filters_both_events_before_side_effects(self):
def test_runtime_binds_every_tile_and_guards_a_tap(self):
"""The tiles are bound by the runtime; it filters a tap before anything happens."""
for n in range(1, 11):
start = SOURCE.index(f' id: tile{n}\n')
next_obj = re.search(r'^ - obj:', SOURCE[start:], re.M)
end = start + next_obj.start() if next_obj else len(SOURCE)
block = SOURCE[start:end]
for event in ('short_click', 'long_press'):
match = re.search(r'on_' + event + r':\s+- if:\s+condition:\s+lambda: (.*?)\n', block)
self.assertIsNotNone(match, (n, event))
self.assertIn(f'cyd::touch_guard.accept(millis(), {n})', match[1])
self.assertIn(f'runtime_tiles::bind({n - 1}, id(tile{n})', SOURCE)
runtime = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
self.assertIn('if (!allowed(esphome::millis(), 100 + w.index, model.tiles[w.index].entity)) return;', runtime)
def test_navigation_is_above_grid_but_below_modal_overlays(self):
grid = SOURCE.index(' id: tile_scroll')
+1 -1
View File
@@ -1,5 +1,5 @@
#define LIGHT_CONTROLS_TEST
#include "../light_controls.h"
#include "../components/smart_display/light_controls.h"
#include <cassert>
int main() {
using namespace light_controls;
+1 -41
View File
@@ -1,17 +1,14 @@
"""Offline regression tests for installation, calibration and safe export."""
"""Offline regression tests for the guided USB touch calibration."""
import copy
from pathlib import Path
import re
import sys
import tempfile
import unittest
import zipfile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import calibrate
import new_device
import export_bundle
def measurements():
@@ -86,41 +83,4 @@ class CalibrationTests(unittest.TestCase):
self.assertEqual(next(Path(tmp).glob('*.bak-*')).read_text(), 'old')
class InstallationTests(unittest.TestCase):
def test_unique_secrets_and_no_overwrite(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
new_device.create(a, 'display-a', 'Display A')
new_device.create(b, 'display-b', 'Display B')
original = (Path(a)/'secrets.yaml').read_text()
self.assertNotEqual(original, (Path(b)/'secrets.yaml').read_text())
self.assertNotIn('WILL_BE_UNIQUELY_GENERATED', original)
with self.assertRaises(ValueError): new_device.create(a, 'new', 'New')
self.assertEqual(original, (Path(a)/'secrets.yaml').read_text())
def test_invalid_device_name(self):
with tempfile.TemporaryDirectory() as tmp:
for name in ('../oops', 'UPPER', 'has space', 'trailing-'):
with self.assertRaises(ValueError): new_device.create(tmp, name, 'Test')
self.assertEqual(list(Path(tmp).iterdir()), [])
def test_export_is_neutral_and_has_required_sources(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp)/'starter.zip'
export_bundle.export(ROOT, output)
with zipfile.ZipFile(output) as archive:
names = archive.namelist()
for forbidden in ('secrets.yaml', 'device.yaml', 'calibration.yaml', '.git/', '.esphome/', '.log', '.bin', '.json'):
self.assertFalse(any(('/'+forbidden in n if forbidden.endswith('/') else n.endswith(forbidden) if forbidden.startswith('.') else n.endswith('/'+forbidden)) for n in names), forbidden)
self.assertIn('cyd-display/.gitignore', names)
base = archive.read('cyd-display/home-like-2432s028.yaml').decode()
values = dict(re.findall(r'^ (\w+): "([^"]*)"', base, re.M))
self.assertEqual(values['TOUCH_AFFINE_XX'], '1.000000000')
self.assertEqual(values['DIRECT_ACTIONS'], 'false')
for i in range(1,11):
self.assertRegex(values[f'TILE{i}_ENTITY'], r'\.(example_|unused_)')
archive.extractall(tmp)
new_device.create(Path(tmp)/'cyd-display', 'fresh-board', 'Fresh board')
with self.assertRaises(FileExistsError): export_bundle.export(ROOT, output)
if __name__ == '__main__': unittest.main()
+31
View File
@@ -101,3 +101,34 @@ static void test_explicit_slots() {
assert(changed && !moved && !m.ready() && place(m, p) == 2 && p[0].slot == 1 && p[1].page == 1);
}
struct RunSlots { RunSlots() { test_explicit_slots(); } } run_slots;
// What only some tiles carry lives in an Extra that exists only while a state needs it.
static void test_extra() {
using namespace runtime_tiles;
Tile lamp;
assert(!lamp.extra_ptr() && lamp.extra().options.empty() && !lamp.choice('m'));
lamp.set_extra(Extra{});
assert(!lamp.extra_ptr()); // an empty block is never allocated
Extra climate; climate.hvac_modes = "[\"off\",\"heat\"]"; climate.hvac_action = "heating";
lamp.set_extra(std::move(climate));
Extra *kept = lamp.extra_ptr();
assert(kept && lamp.extra().hvac_action == "heating");
Extra again; again.hvac_action = "idle";
lamp.set_extra(std::move(again));
assert(lamp.extra_ptr() == kept && lamp.extra().hvac_action == "idle" && lamp.extra().hvac_modes.empty());
Tile copy = lamp; // a copy owns its own block
copy.edit_extra().hvac_action = "cooling";
assert(lamp.extra().hvac_action == "idle" && copy.extra().hvac_action == "cooling");
lamp.set_extra(Extra{});
assert(!lamp.extra_ptr() && lamp.extra().hvac_action.empty());
Extra robot; Choice mode; mode.kind = 'm'; mode.values = {"vacuum", "mop"}; robot.choices.push_back(mode); robot.room = "Kitchen";
Tile vacuum; vacuum.set_extra(std::move(robot));
assert(vacuum.choice('m') && vacuum.choice('m')->values.size() == 2 && !vacuum.choice('w') && vacuum.extra().room == "Kitchen");
vacuum.choice('m')->sent = "mop";
assert(static_cast<const Tile &>(vacuum).choice('m')->sent == "mop");
// A new layout resets the slots, blocks included.
Model m; bool changed = false;
assert(m.set_layout({"vacuum.robot"}, "Home", changed));
m.tiles[0] = vacuum;
assert(m.set_layout({"light.a"}, "Home", changed) && changed && !m.tiles[0].extra_ptr());
}
struct RunTileExtra { RunTileExtra() { test_extra(); } } run_tile_extra;
+11 -11
View File
@@ -49,7 +49,7 @@ int main() {
assert(keys_for(old_robot, keys) == 2 && key_action(old_robot, VACUUM_START).service == "vacuum.turn_on");
// Media: playback keys follow supported_features; mute flips is_volume_muted.
Tile sonos = make("media_player.sonos", "playing", 8321599); sonos.volume = 0.17f; sonos.media_title = "TV"; sonos.controls = "playback";
Tile sonos = make("media_player.sonos", "playing", 8321599); sonos.volume = 0.17f; sonos.edit_extra().media_title = "TV"; sonos.controls = "playback";
assert(keys_for(sonos, keys) == 3 && !strcmp(keys[1].icon, glyph::PAUSE) && keys[1].command == MEDIA_PLAY_PAUSE);
assert(status_text(sonos) == "TV · 17%");
Action mute = key_action(sonos, MEDIA_MUTE);
@@ -60,13 +60,13 @@ int main() {
assert(status_text(radio) == "Idle");
// Climate: mode keys in priority order, at most three, the active one checked.
Tile ac = make("climate.ac", "cool"); ac.hvac_modes = "[\"off\",\"heat_cool\",\"cool\",\"heat\",\"fan_only\",\"dry\"]"; ac.controls = "mode"; ac.current = 21.5f; ac.target = 20; ac.step = 1;
Tile ac = make("climate.ac", "cool"); ac.edit_extra().hvac_modes = "[\"off\",\"heat_cool\",\"cool\",\"heat\",\"fan_only\",\"dry\"]"; ac.controls = "mode"; ac.current = 21.5f; ac.target = 20; ac.step = 1;
assert(keys_for(ac, keys) == 3);
assert(keys[0].arg == "off" && keys[1].arg == "heat" && keys[2].arg == "cool" && keys[2].checked && !keys[1].checked);
Action mode = key_action(ac, HVAC_MODE, keys[1].arg);
assert(mode.service == "climate.set_hvac_mode" && mode.key == "hvac_mode" && mode.value == "heat");
assert(status_text(ac) == "Cool · 21.5°");
ac.hvac_action = "cooling"; assert(status_text(ac) == "Cooling · 21.5°");
ac.edit_extra().hvac_action = "cooling"; assert(status_text(ac) == "Cooling · 21.5°");
assert(edit_target(ac) == 20 && edit_step(ac) == 1);
Action set = edit_action(ac, step_value(edit_target(ac), edit_step(ac), ac.minimum, ac.maximum, 1));
assert(set.service == "climate.set_temperature" && set.key == "temperature" && set.value == "21");
@@ -76,12 +76,12 @@ int main() {
Tile number = make("number.target", "55"); number.minimum = 0; number.maximum = 100; number.step = 5;
assert(edit_target(number) == 55 && edit_action(number, step_value(55, 5, 0, 100, -1)).value == "50");
assert(edit_action(number, 50).service == "number.set_value");
Tile select = make("select.stand", "Comfort"); select.options = {"Eco", "Comfort", "Boost"}; select.option_count = 3; select.controls = "stepper";
Tile select = make("select.stand", "Comfort"); select.edit_extra().options = {"Eco", "Comfort", "Boost"}; select.controls = "stepper";
assert(panel_kind(select) == "chevrons");
assert(keys_for(select, keys) == 2 && !keys[0].disabled);
assert(key_action(select, SELECT_NEXT).value == "Boost" && key_action(select, SELECT_PREVIOUS).value == "Eco");
select.state = "Boost"; assert(key_action(select, SELECT_NEXT).value == "Eco");
select.option_count = 1; assert(keys_for(select, keys) == 2 && keys[0].disabled);
select.edit_extra().options.resize(1); assert(keys_for(select, keys) == 2 && keys[0].disabled);
// Timer, run buttons and toggles.
Tile timer = make("timer.eggs", "active"); timer.controls = "buttons";
@@ -109,12 +109,12 @@ int main() {
auto row = [](char kind, const char *entity, const char *current, std::vector<std::string> values, const char *roles = "") {
runtime_tiles::Choice c; c.kind = kind; c.entity = entity; c.current = current; c.values = values; c.labels = values; c.roles = roles; return c;
};
pippa.choices = {row('m', "select.woonkamer_s8_schoonmaakmodus", "vac_and_mop", {"vacuum", "vac_and_mop", "mop", "custom"}, "vbma"),
pippa.edit_extra().choices = {row('m', "select.woonkamer_s8_schoonmaakmodus", "vac_and_mop", {"vacuum", "vac_and_mop", "mop", "custom"}, "vbma"),
row('w', "select.s8_intensiteit_van_dweilen", "intense", {"mild", "standard", "intense"}),
row('s', "", "", {"quiet", "balanced", "turbo", "max", "max_plus"})};
pippa.fan_speed = "max";
settle_suction(pippa);
assert(pippa.choice('s')->current == "max" && pippa.choices.size() == 3);
pippa.edit_extra().fan_speed = "max";
settle_suction(pippa.edit_extra());
assert(pippa.choice('s')->current == "max" && pippa.extra().choices.size() == 3);
auto rows = vacuum_rows(pippa, 0);
assert(rows.suction && rows.water && vacuum_role(pippa, 0) == 'b');
pippa.choice('m')->current = "mop"; rows = vacuum_rows(pippa, 0);
@@ -133,8 +133,8 @@ int main() {
assert(choice_action(pippa, 's', "turbo").service == "vacuum.set_fan_speed" && choice_action(pippa, 's', "turbo").value == "turbo");
// An older manager sends no suction row: the vacuum's own four speeds, with the card's names.
Tile old_robot2 = make("vacuum.old", "docked", 30524);
old_robot2.fan_speed_count = 2; old_robot2.fan_speeds[0] = "quiet"; old_robot2.fan_speeds[1] = "balanced"; old_robot2.fan_speed = "balanced";
settle_suction(old_robot2);
old_robot2.edit_extra().fan_speeds = {"quiet", "balanced"}; old_robot2.edit_extra().fan_speed = "balanced";
settle_suction(old_robot2.edit_extra());
assert(old_robot2.choice('s') && old_robot2.choice('s')->labels[1] == "Normal" && old_robot2.choice('s')->current == "balanced");
rows = vacuum_rows(old_robot2, 0);
assert(rows.suction && !rows.water && !choice_action(old_robot2, 'm', "mop").valid());