Show a spinner while a screen starts and while a camera loads, bring a camera's first picture sooner, and round the alert's camera picture (0.2.87)

Starting screen: until the first layout, render() keeps the status text
("Connecting to Home Assistant", "Waiting for ESP Screens") and a spinner
as one block in the middle of the page; the first layout deletes both.
The room label stays empty meanwhile, and ROOM_NAME defaults to "".

Camera: the full screen shows a spinner instead of "Loading image". The
screen asks when the camera opens and loads a link as soon as it comes
(camera_load, never under a finger) instead of waiting for the 250 ms
tick twice, and online_image reads 16 KB per loop instead of 4 KB. On the
bench Guition with the EZVIZ camera: cold 5.8 -> 4.55 s, warm 3.4 -> 2.06 s.

Alert picture: the image takes the frame's radius (18, the alert card's);
LVGL's software renderer clips it row by row, without a layer.

One spinner_create() makes the busy sheet's, the starting screen's and the
camera's spinner; theme role CAMERA_TRACK colours the camera's ring. The
README gets a row of camera pictures; docs/CAMERA.md, RELEASING.md and
TEST_RESULTS_0287.md describe the measurements.

Checked with tools/check.sh (473 Python tests, 19/19 C++, editor) and
--firmware: CYD 1,678,816 B (91.5 %, +1,312 B), Guition 2,055,280 B
(25.3 %). Host renders of both boards in both looks; four OTA builds on
the bench Guition Wallbox, which runs this firmware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-19 08:16:41 +02:00
parent 2d343a450f
commit ff9d3af9f8
17 changed files with 387 additions and 40 deletions
+15
View File
@@ -96,6 +96,21 @@ class Rules(unittest.TestCase):
config = (ROOT / 'screen_manager/config.yaml').read_text()
self.assertIn(f'{camera_feed.PORT}/tcp: {camera_feed.PORT}', config)
def test_the_first_picture_waits_for_no_tick(self):
# Firmware 0.2.73+: the camera asks when it opens and loads the link when it comes, not on the next 250 ms tick;
# the spinner turns until the first picture or a note is there.
opened = TILES.split('inline void camera_open(const std::string &entity, const std::string &name) {', 1)[1].split('\n}\n', 1)[0]
self.assertIn('camera_spinner = spinner_create(camera_root,', opened)
self.assertIn('if (awake() && fresh() && camera.should_ask(now)) {', opened)
answer = TILES.split('inline void camera_answer(const std::string &view, const std::string &entity, const std::string &url) {', 1)[1].split('\n}\n', 1)[0]
self.assertIn('if (awake() && camera.should_load(now)) camera_load(now);', answer)
# Never under a finger, from the answer or from the tick.
load = TILES.split('inline void camera_load(uint32_t now) {', 1)[1].split('\n}\n', 1)[0]
self.assertIn('lv_indev_get_state(input) == LV_INDEV_STATE_PRESSED) return;', load)
note = TILES.split('inline void camera_note_text(const char *text) {', 1)[1].split('\n}\n', 1)[0]
self.assertIn('lv_obj_delete(camera_spinner);', note)
self.assertNotIn('Loading image', TILES)
@unittest.skipUnless(HAS_PIL, 'Pillow comes with ESPHome in the add-on image')
class Encoding(unittest.TestCase):
+16 -1
View File
@@ -221,9 +221,24 @@ class ParityTests(unittest.TestCase):
self.assertIn('runtime_tiles::time_label = id(lbl_time);', text, name)
refresh = text.split(' - id: ui_refresh', 1)[1].split('return;', 1)[0]
self.assertNotIn('lbl_time', refresh, name)
self.assertIn('render_header();', TILES.split('inline void render(lv_obj_t *room) {', 1)[1].split('\n', 5)[4])
self.assertIn('render_header();', TILES.split('inline void render(lv_obj_t *room) {', 1)[1].split('\n}', 1)[0])
self.assertLessEqual(HEADER_MIN_FIRMWARE, tuple(int(p) for p in FIRMWARE_VERSION.split('.')))
def test_a_starting_screen_says_what_it_waits_for_in_the_middle(self):
# Firmware 0.2.73+: until the first layout the text and a spinner stand in the middle and the name stays empty;
# the first layout deletes them, so no spinner turns behind the tiles.
render = TILES.split('inline void render(lv_obj_t *room) {', 1)[1].split('\n}', 1)[0]
self.assertIn('if (!model.configured) boot_status(lv_obj_get_parent(room), !ha_connected() ? '
'"Connecting to Home Assistant" : "Waiting for ESP Screens");', render)
self.assertIn('else if (boot_panel) { lv_obj_delete(boot_panel);', render)
self.assertIn('label(room, !model.configured ? "" :', render)
boot = TILES.split('inline void boot_status(', 1)[1].split('\n}', 1)[0]
self.assertIn('boot_spinner = spinner_create(boot_panel,', boot)
# Under the tiles, the cards and an alert, in the name's place.
self.assertIn('lv_obj_move_to_index(boot_panel, lv_obj_get_index(room_label));', boot)
for name in PROFILES:
self.assertNotIn('Choose tiles in HA', profiles.text(name), name)
def test_second_hand_runs_only_while_the_screen_is_awake(self):
for name in PROFILES:
text = profiles.text(name)
+1 -1
View File
@@ -111,7 +111,7 @@ class Paints(unittest.TestCase):
tiles = (COMPONENT / 'runtime_tiles.h').read_text()
for needle in ('lv_obj_add_style(w.slider,theme::style(theme::Paint::knob),LV_PART_KNOB);',
'lv_obj_add_style(w.busy,theme::style(theme::Paint::veil),0);',
'lv_obj_add_style(w.spinner,theme::style(theme::Paint::spinner),LV_PART_MAIN);',
'lv_obj_add_style(spinner, theme::style(theme::Paint::spinner), LV_PART_MAIN);',
'lv_obj_add_style(part, theme::style(theme::Paint::slate), 0);',
'inline void restyle() {'):
self.assertIn(needle, tiles)