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
+7 -1
View File
@@ -17,7 +17,7 @@ lights, the heating or the vacuum.
**What.** Firmware for two affordable panels, the 2.8-inch CYD and the 4-inch Guition, with up to
48 tiles over eight pages: lights, climate, blinds and curtains, the vacuum, media, the weather,
history graphs, clocks and timers. A tile can take the whole page, one big switch you push without
history graphs, clocks and timers, and on a Guition your cameras. A tile can take the whole page, one big switch you push without
looking, and a tile can go to another page. An automation can put an alert on every screen when someone rings
the bell, and a Guition shows who is there with the doorbell camera's picture. A tap can run any
action Home Assistant has for a tile, and Dark mode suits a screen beside the bed.
@@ -62,6 +62,12 @@ also be changed on the screen or by an automation, and your own YAML for one scr
<img src="docs/images/guition-dark-media.png" width="32%" alt="The media card in dark mode: the same cover and keys on a black page">
</p>
<p align="center"><sub>Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. A lamp with modes (a WLED) gets an effects page with a drum picker, named and filled from what Home Assistant lists for it. The media card shows what plays with its album cover, fetched by ESP Screens like a camera picture (<a href="docs/CAMERA.md">how</a>). Rendered from the firmware's own LVGL code with a demo home.</sub></p>
<p align="center">
<img src="docs/images/guition-camera-tiles.png" width="32%" alt="Cameras as tiles on the Guition: the front door camera, the garden camera live, the doorbell's last ring, next to the door, the porch light and the garage door">
<img src="docs/images/guition-camera.png" width="32%" alt="A camera tile tapped: the front door camera full screen, with the round back key and the camera's name at the top">
<img src="docs/images/guition-alert-camera.png" width="32%" alt="The same camera in an alert: its picture across the top of the card, with the card's rounded corners">
</p>
<p align="center"><sub>Cameras are tiles too, not only part of an alert: any camera or snapshot in Home Assistant (a doorbell's last ring, for example) goes on a Guition like any other tile. Tap it for the picture full screen, refreshed every four seconds; an alert can carry the same picture. The CYD has no memory for pictures (<a href="docs/CAMERA.md">how it works</a>).</sub></p>
<p align="center">
<img src="docs/images/cyd-home.png" width="32%" alt="CYD 2.8-inch screen: the weather forecast, a kitchen timer, the coffee machine, a lamp and power usage as a large value">
<img src="docs/images/cyd-page-2.png" width="32%" alt="Second CYD page: Sonos volume, presence, a scene and an energy graph">
+9
View File
@@ -117,6 +117,11 @@ on the screen itself, and how updates work.
off shows a power key. A Guition fetches the cover through ESP Screens, like a camera picture
([docs/CAMERA.md](docs/CAMERA.md)); the CYD shows the player's icon in its place. A media tile of
size **Full page** is the same card on the page, with the keys and the volume working on the tile.
- **Cameras** on a Guition (app 0.2.66 / firmware 0.2.57): a `camera.*` entity, or an `image.*` one such
as a doorbell's last ring, is a tile like any other. A tap opens the picture full screen, refreshed every
four seconds, with the round back key; standby and **Back to page 1** close it. The same camera can bring its
picture to an alert ([With a camera picture](#with-a-camera-picture)). The CYD has no memory for pictures and
the editor doesn't offer it camera tiles. How the picture travels: [docs/CAMERA.md](docs/CAMERA.md).
- **History card** for sensors, numbers, binary sensors, people, and switches, the way Home
Assistant shows history: a line with an axis in round steps and clock times for numbers,
with the highest and lowest moment, and a timeline with the time in each state for on/off,
@@ -297,6 +302,10 @@ actions:
camera: camera.front_door
```
<p align="center">
<img src="docs/images/guition-camera-tiles.png" width="41%" alt="Cameras as tiles on the Guition: the front door camera, the garden camera live, the doorbell's last ring, next to the door, the porch light and the garage door">
</p>
A camera or image entity also works as a **tile** on a Guition: a tap opens it full screen, refreshed
every four seconds. How the image travels (port 8098 of the app, no token on the screen) is in
[docs/CAMERA.md](docs/CAMERA.md).
+98 -19
View File
@@ -2875,6 +2875,26 @@ inline void control_event(lv_event_t *e) {
auto a=tile_controls::press_key(t,command,w.key_args[n]);
if(a.valid())action(a.service,t.entity,a.key,a.value);
}
// The one spinner of the firmware: a busy card, the starting screen and a camera that loads (firmware 0.2.73+). The
// ring in the spinner paint, the arc in Home Assistant's blue. Null where the board builds no spinner.
inline lv_obj_t *spinner_create(lv_obj_t *parent, int size, int arc) {
#if LV_USE_SPINNER
auto *spinner = lv_spinner_create(parent);
lv_spinner_set_anim_params(spinner, 900, 200);
lv_obj_set_size(spinner, size, size);
lv_obj_remove_flag(spinner, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_arc_width(spinner, arc, LV_PART_MAIN);
lv_obj_set_style_arc_width(spinner, arc, LV_PART_INDICATOR);
lv_obj_add_style(spinner, theme::style(theme::Paint::spinner), LV_PART_MAIN);
lv_obj_set_style_arc_color(spinner, lv_color_hex(theme::ha::RAIN), LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(spinner, LV_OPA_TRANSP, LV_PART_KNOB);
lv_obj_set_style_pad_all(spinner, 0, LV_PART_KNOB);
return spinner;
#else
(void) parent; (void) size; (void) arc;
return nullptr;
#endif
}
// A busy card is covered by a translucent white sheet with a small spinner until
// Home Assistant confirms; the sheet also swallows taps meanwhile.
inline void set_busy(Widgets &w,bool busy,bool large){
@@ -2883,13 +2903,8 @@ inline void set_busy(Widgets &w,bool busy,bool large){
w.busy=lv_obj_create(w.tile);lv_obj_remove_style_all(w.busy);lv_obj_remove_flag(w.busy,LV_OBJ_FLAG_SCROLLABLE);lv_obj_add_flag(w.busy,LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_style(w.busy,theme::style(theme::Paint::veil),0);lv_obj_set_style_bg_opa(w.busy,LV_OPA_60,0);
lv_obj_set_style_radius(w.busy,lv_obj_get_style_radius(w.tile,LV_PART_MAIN),0);
#if LV_USE_SPINNER
w.spinner=lv_spinner_create(w.busy);lv_spinner_set_anim_params(w.spinner,900,200);
int size=large?30:20;lv_obj_set_size(w.spinner,size,size);lv_obj_center(w.spinner);lv_obj_remove_flag(w.spinner,LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_arc_width(w.spinner,large?4:3,LV_PART_MAIN);lv_obj_set_style_arc_width(w.spinner,large?4:3,LV_PART_INDICATOR);
lv_obj_add_style(w.spinner,theme::style(theme::Paint::spinner),LV_PART_MAIN);lv_obj_set_style_arc_color(w.spinner,lv_color_hex(theme::ha::RAIN),LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(w.spinner,LV_OPA_TRANSP,LV_PART_KNOB);lv_obj_set_style_pad_all(w.spinner,0,LV_PART_KNOB);
#endif
w.spinner=spinner_create(w.busy,large?30:20,large?4:3);
if(w.spinner)lv_obj_center(w.spinner);
}
// Cover the whole card, padding included, at the width the card asks for: a slot that just turned
// wide is still single width in LVGL's own coordinates. Unchanged sizes cost LVGL nothing.
@@ -3297,11 +3312,45 @@ inline lv_timer_t *fill_timer=nullptr;
inline bool fill_refreshed=false;
inline uint32_t fill_step_ms=0;
inline bool fill_cards(size_t cards);
// The starting screen (firmware 0.2.73+): what the screen waits for in the middle of the page with a spinner under it,
// until the first layout arrives. The first render() makes it and the first layout deletes it, spinner and all.
inline lv_obj_t *boot_panel = nullptr, *boot_text = nullptr, *boot_spinner = nullptr;
inline void boot_status(lv_obj_t *page, const char *text) {
const int width = lv_display_get_horizontal_resolution(lv_obj_get_display(page));
const bool large = width >= 480;
const int ring = large ? 48 : 32, gap = large ? 24 : 16, text_width = width - 2 * lv_obj_get_style_x(room_label, LV_PART_MAIN);
const lv_font_t *font = watch_font ? watch_font : lv_obj_get_style_text_font(room_label, LV_PART_MAIN);
if (!boot_panel) {
boot_panel = lv_obj_create(page);
lv_obj_remove_style_all(boot_panel);
lv_obj_remove_flag(boot_panel, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(boot_panel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(boot_panel, lv_pct(100), lv_pct(100));
// The name's place in the drawing order: under the tiles, the cards and an alert.
lv_obj_move_to_index(boot_panel, lv_obj_get_index(room_label));
boot_text = lv_label_create(boot_panel);
lv_obj_add_style(boot_text, theme::style(theme::Paint::ink), 0);
lv_obj_set_style_text_font(boot_text, font, 0);
lv_obj_set_style_text_align(boot_text, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(boot_text, LV_LABEL_LONG_WRAP);
lv_obj_set_width(boot_text, text_width);
boot_spinner = spinner_create(boot_panel, ring, large ? 5 : 4);
}
if (strcmp(lv_label_get_text(boot_text), text) == 0) return;
lv_label_set_text(boot_text, text);
// The text and the spinner as one block in the middle of the page.
lv_point_t size;
lv_text_get_size(&size, text, font, 0, 0, text_width, LV_TEXT_FLAG_NONE);
lv_obj_align(boot_text, LV_ALIGN_CENTER, 0, -(ring + gap) / 2);
if (boot_spinner) lv_obj_align(boot_spinner, LV_ALIGN_CENTER, 0, (size.y + gap) / 2);
}
// Before the first layout the screen is starting: HA connects, then ESP Screens sends the tiles.
inline void render(lv_obj_t *room) {
if (!enabled) return;
room_label=room; swipe_profile::Lap lap;
label(room, !model.configured ? (!ha_connected() ? "Connecting to Home Assistant..." : "Waiting for ESP Screens...") : !model.ready() ? "Loading tiles..." : !ha_connected() ? "HA not connected" : !feed_alive() ? "ESP Screens not active" : model.title);
if (!model.configured) boot_status(lv_obj_get_parent(room), !ha_connected() ? "Connecting to Home Assistant" : "Waiting for ESP Screens");
else if (boot_panel) { lv_obj_delete(boot_panel); boot_panel = boot_text = boot_spinner = nullptr; }
label(room, !model.configured ? "" : !model.ready() ? "Loading tiles..." : !ha_connected() ? "HA not connected" : !feed_alive() ? "ESP Screens not active" : model.title);
render_header();
lap(swipe_profile::HEADER);
bool all=dirty_all || (!dirty_tiles && !dirty_header);
@@ -3927,6 +3976,8 @@ inline camera_view::Feed camera;
// show the page below at once.
inline bool camera_release_due = false;
inline lv_obj_t *camera_root = nullptr, *camera_picture = nullptr, *camera_note = nullptr, *camera_back = nullptr, *camera_title = nullptr;
// Turns in the middle until the first image is there (firmware 0.2.73+); a note (no image) takes its place.
inline lv_obj_t *camera_spinner = nullptr;
// The alert's image: the board's frame at the bottom left of the card, the camera the app announced for the next alert
// and the one the card on screen shows.
inline lv_obj_t *alert_frame = nullptr, *alert_picture = nullptr, *alert_frame_icon = nullptr;
@@ -4061,6 +4112,7 @@ inline void camera_request(const std::string &entity, int size, uint32_t backgro
inline void camera_note_text(const char *text) {
if (!camera_note) return;
if (camera_spinner) { lv_obj_delete(camera_spinner); camera_spinner = nullptr; }
lv_label_set_text(camera_note, text);
if (text[0]) lv_obj_remove_flag(camera_note, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(camera_note, LV_OBJ_FLAG_HIDDEN);
@@ -4074,7 +4126,7 @@ inline void camera_release() {
inline void camera_close() {
if (!camera_root) return;
lv_obj_delete(camera_root);
camera_root = camera_picture = camera_note = camera_back = camera_title = nullptr;
camera_root = camera_picture = camera_note = camera_back = camera_title = camera_spinner = nullptr;
camera_release_due = true;
ESP_LOGI("camera", "closed %s", camera.entity.c_str());
camera = camera_view::Feed{};
@@ -4102,7 +4154,13 @@ inline void camera_open(const std::string &entity, const std::string &name) {
if (detail_font) lv_obj_set_style_text_font(camera_note, detail_font, 0);
lv_obj_set_style_text_color(camera_note, theme::color(theme::CAMERA_NOTE), 0);
lv_obj_center(camera_note);
camera_note_text("Loading image");
camera_note_text("");
// The starting screen's spinner, its ring dark on the black page in both looks.
camera_spinner = spinner_create(camera_root, large ? 48 : 32, large ? 5 : 4);
if (camera_spinner) {
lv_obj_set_style_arc_color(camera_spinner, theme::color(theme::CAMERA_TRACK), LV_PART_MAIN);
lv_obj_center(camera_spinner);
}
// The same top bar as a tile's card: a round back arrow at the left, the name centred.
const int bar = large ? 60 : 40, bar_x = large ? 16 : 10, bar_y = large ? 16 : 8;
camera_back = lv_obj_create(camera_root);
@@ -4133,6 +4191,20 @@ inline void camera_open(const std::string &entity, const std::string &name) {
lv_obj_set_size(camera_title, width - 2 * (bar_x + bar + 8), title_font ? lv_font_get_line_height(title_font) : 20);
lv_label_set_text(camera_title, name.c_str());
ESP_LOGI("camera", "open %s", entity.c_str());
// Asked for now rather than on the next tick (firmware 0.2.73+): the answer is most of the wait.
const uint32_t now = esphome::millis();
if (awake() && fresh() && camera.should_ask(now)) {
camera.ask(now);
camera_request(entity);
}
}
// The next image, never under a finger: a load that starts now would hold up the tap on its way.
inline void camera_load(uint32_t now) {
auto *input = lv_indev_get_next(nullptr);
if (input && lv_indev_get_state(input) == LV_INDEV_STATE_PRESSED) return;
camera.start(now);
camera_full.load(camera.url);
}
// The board's interval (250 ms): ask for a link, or load the image again when it is time. Loading happens here, in
@@ -4156,11 +4228,7 @@ inline void camera_tick() {
camera.ask(now);
camera_request(camera.entity);
} else if (camera.should_load(now)) {
// Not under a finger: a load that starts now would hold up the tap that is on its way.
auto *input = lv_indev_get_next(nullptr);
if (input && lv_indev_get_state(input) == LV_INDEV_STATE_PRESSED) return;
camera.start(now);
camera_full.load(camera.url);
camera_load(now);
}
}
@@ -4202,7 +4270,13 @@ inline void camera_answer(const std::string &view, const std::string &entity, co
if (view == "full") {
if (!camera_root || camera.entity != entity) return;
camera.link(url);
if (url.empty() && !camera.shown) camera_note_text("No image from this camera");
if (url.empty()) {
if (!camera.shown) camera_note_text("No image from this camera");
return;
}
// Loaded now rather than on the next tick (firmware 0.2.73+), as an alert's image is.
const uint32_t now = esphome::millis();
if (awake() && camera.should_load(now)) camera_load(now);
return;
}
if (view == "cover") { // the media card's album cover (firmware 0.2.64+)
@@ -4233,12 +4307,16 @@ inline void camera_answer(const std::string &view, const std::string &entity, co
}
// LVGL's image widget is only built for a board whose profile draws images (the Guition's hidden seed); the CYD's has none.
inline void camera_show(lv_obj_t *parent, lv_obj_t *&picture, lv_image_dsc_t *source, bool fresh_pixels) {
// `radius` rounds the picture's corners (the alert's, firmware 0.2.73+): LVGL 9.5's software renderer clips an image
// to its own radius row by row with a one-row mask (radius_only in lv_draw_sw_img.c), without a layer; clip_corner on
// the frame would draw the frame into a layer of its size instead.
inline void camera_show(lv_obj_t *parent, lv_obj_t *&picture, lv_image_dsc_t *source, bool fresh_pixels, int32_t radius = 0) {
#if LV_USE_IMAGE
if (!source || !source->data) return;
if (!picture) {
picture = lv_image_create(parent);
lv_obj_remove_flag(picture, LV_OBJ_FLAG_CLICKABLE);
if (radius > 0) lv_obj_set_style_radius(picture, radius, LV_PART_MAIN);
lv_image_set_src(picture, source);
lv_obj_center(picture);
return;
@@ -4249,7 +4327,7 @@ inline void camera_show(lv_obj_t *parent, lv_obj_t *&picture, lv_image_dsc_t *so
lv_image_set_src(picture, source);
lv_obj_invalidate(picture);
#else
(void) parent; (void) picture; (void) source; (void) fresh_pixels;
(void) parent; (void) picture; (void) source; (void) fresh_pixels; (void) radius;
#endif
}
@@ -4258,7 +4336,8 @@ inline void camera_loaded(bool thumb, bool cached) {
if (thumb) {
alert_thumb_loading = false;
if (alert_camera.empty() || !alert_frame) return;
camera_show(alert_frame, alert_picture, camera_thumb.source(), !cached);
// The picture takes the frame's radius, the alert card's own (the board profile sets it on the frame).
camera_show(alert_frame, alert_picture, camera_thumb.source(), !cached, lv_obj_get_style_radius(alert_frame, LV_PART_MAIN));
if (alert_picture && alert_frame_icon) lv_obj_add_flag(alert_frame_icon, LV_OBJ_FLAG_HIDDEN);
ESP_LOGI("camera", "alert picture shown");
return;
+3 -1
View File
@@ -76,7 +76,8 @@ enum Role : uint8_t {
// ---- a camera image: dark in both looks, like a photo viewer (firmware 0.2.57+)
CAMERA_PAGE, // behind the image full screen and in the alert's image frame
CAMERA_INK, // the camera's name over the image
CAMERA_NOTE, // "Loading image", "No image from this camera"
CAMERA_NOTE, // "No image from this camera"
CAMERA_TRACK, // the ring of the spinner while the first image loads (firmware 0.2.73+)
// ---- the CYD's touch calibration: dark in both looks, a yellow crosshair
CALIBRATION_PAGE,
CALIBRATION_INK,
@@ -144,6 +145,7 @@ inline constexpr Pair ROLES[ROLE_COUNT] = {
/* CAMERA_PAGE */ {0x000000, 0x000000},
/* CAMERA_INK */ {0xF2F2F2, 0xDADADA},
/* CAMERA_NOTE */ {0x9E9E9E, 0x8A8A8A},
/* CAMERA_TRACK */ {0x393D42, 0x393D42},
/* CALIBRATION_PAGE */ {0x101820, 0x101820},
/* CALIBRATION_INK */ {0xFFFFFF, 0xFFFFFF},
/* CALIBRATION_MARK */ {0xFFD34D, 0xFFD34D},
+17 -7
View File
@@ -3,11 +3,13 @@
App 0.2.66 with firmware 0.2.57 shows camera images on a Guition 4848S040:
- **A camera tile.** Add a `camera.*` or `image.*` entity as a tile. A tap opens the image full
screen, with the round back key at the top left like every card. The image is refreshed every
four seconds while it is open. It is not video: ESPHome has no video decoder.
screen, with the round back key at the top left like every card, and a spinner until the first
image is there (firmware 0.2.73). The image is refreshed every four seconds while it is open. It
is not video: ESPHome has no video decoder.
- **An alert with a picture.** Add `camera: camera.front_door` to the `esp_screens_show_alert`
event. The card shows the picture of that moment across its top (it stays that picture); a tap
on it opens the camera full screen over the alert, and Back returns to the alert.
event. The card shows the picture of that moment across its top (it stays that picture), with
the card's round corners (firmware 0.2.73); a tap on it opens the camera full screen over the
alert, and Back returns to the alert.
The CYD has no memory for images (a 320×180 image needs 115 KB in one piece, the CYD's largest
free block is about 45 KB). It shows the alert without the picture, and the editor doesn't offer
@@ -70,9 +72,10 @@ screen's made the picture change after 1.5 s one time and 6 s the next.) A slow
images older, never the screen slower, and nothing queues up. Nobody loading means nothing fetched.
A link that nobody loads for two minutes stops working; an alert's picture stays for half an hour.
**Why BMP.** ESPHome decodes a BMP piece by piece while it downloads (4 KB per round of its main
loop). A JPEG of the full screen took 0.6 s in one piece on the Guition, during which the screen
missed taps. A full-screen BMP is about 390 KB; on the LAN that takes one to three seconds.
**Why BMP.** ESPHome decodes a BMP piece by piece while it downloads (16 KB per round of its main
loop since firmware 0.2.73, 4 KB before). A JPEG of the full screen took 0.6 s in one piece on the
Guition, during which the screen missed taps. A full-screen BMP is about 390 KB; on the bench
Guition it comes in about 1.8 s (2.8 s with 4 KB).
## Network
@@ -91,6 +94,13 @@ missed taps. A full-screen BMP is about 390 KB; on the LAN that takes one to thr
- The internal heap stays level while a camera refreshes: 77.2 KB free after 91 images in six minutes,
and PSRAM unchanged.
- Standby, **Back to page 1** and a new layout close the camera; nothing loads in standby.
- The first image (firmware 0.2.73, measured 2026-09-19 with an EZVIZ camera): the screen asks when
the camera opens and loads the link as soon as it comes. About 2 s when the app still has the
camera's last snapshot (it keeps one for 30 s after the last load), 4.5 s when it must ask Home
Assistant first, of which 2.4 s is the camera's own snapshot. Firmware 0.2.72 took 3.4 s and
5.8 s. Details in docs/TEST_RESULTS_0287.md.
- `power_save_mode: none` under `wifi:` (the YAML ESP Screens writes has it since app 0.2.24) keeps
the screen from waiting on its Wi-Fi now and then while an image comes in.
## For developers
+26
View File
@@ -219,6 +219,32 @@ icons sit off-center in the browser).
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys.
### Compatibility 0.2.87 / firmware 0.2.73
Firmware only, plus README pictures and docs/CAMERA.md. No protocol, storage, preference or key change: an older app
drives this firmware as before, and the camera messages are the same; only when the screen sends and loads changed.
- Starting screen: while `!model.configured`, `render()` calls `boot_status()`, which keeps a text in `watch_font`
(paint `ink`) and a spinner as one block in the middle of the page, at the room label's index in the drawing order;
the room label is empty meanwhile. The first layout deletes the block (`boot_panel`), spinner and all.
`ROOM_NAME` defaults to "" (was "Choose tiles in HA").
- `spinner_create(parent, size, arc)` makes every spinner: the busy sheet (30/20 px, unchanged), the starting screen
and the camera (48/32 px).
- Camera full screen: `camera_open()` asks at once (awake and fresh); `camera_answer()` loads a new link at once
through `camera_load()`, which like `camera_tick()` never starts under a finger. `camera_tick()` still does the
4 s refresh and the retries. `camera_spinner` turns until the first picture or a note; its ring is the new theme
role `CAMERA_TRACK` (0x393D42 in both looks). "Loading image" is gone.
- Guition `online_image` `buffer_size` 16384 (was 4096) for `camera_image` and `alert_image`: the full picture in
about 1.8 s instead of 2.8 s on the bench; without Wi-Fi power save no loop over 50 ms. 32 KB held a loop 0.1 s.
- Alert picture: `alert_image_frame` has the alert card's radius (18) and `camera_show()` gives the alert's picture the
frame's radius. LVGL 9.5 clips an image to its own radius row by row (`radius_only` in `lv_draw_sw_img.c`) without a
layer, so `clip_corner` stays out (`tests/test_layer_free.py`). About 5 % more render time for the picture on the
host, only when it is drawn.
- Tests: `test_camera` (the first picture waits for no tick), `test_header_bar` (the starting screen;
`render_header()` anywhere in `render()`), `test_theme` (the spinner paint in `spinner_create()`).
Details: docs/TEST_RESULTS_0287.md.
### Compatibility 0.2.85 / firmware 0.2.71
Tile colours as Home Assistant draws them. No protocol, storage or editor change: the state messages are the same as
+172
View File
@@ -0,0 +1,172 @@
# Test results app 0.2.87 / firmware 0.2.73 (2026-09-19)
After the README picture refresh Max asked for four things:
- cameras in the README as tiles of their own ("zodat mensen snappen dat camera niet alleen tijdens alert is");
- the alert card's rounded corners on its camera picture, if that stays light;
- the spinner instead of "Loading image" on a camera that opens, and a look at why its first picture takes so long;
- a nicer starting screen: the text in the middle, a spinner under it.
Performance comes first for him, and the add-on often runs on a Raspberry Pi 4 class machine (his Home Assistant
Yellow is one), so work moved to the add-on is not free either. Everything below stays on ESPHome's and LVGL's own
means: `online_image`'s `buffer_size`, LVGL's spinner and image radius, no ESP-IDF or lwIP settings.
## Round corners on the alert's picture
### Three ways to round the picture
- **`clip_corner` on the frame.** LVGL 9.5 then draws the frame and its picture into a layer of the frame's size to
clip them (392 × 220 ARGB8888, about 345 KB, on every redraw). The project keeps layers out:
`tests/test_layer_free.py` refuses `clip_corner` (see the CYD memory research of 0.2.50).
- **Corners baked in by the add-on**, as for the media covers. The screen would copy a square picture as it does now,
but the add-on would have to know the exact colour behind each corner: the alert's swatch in the light or the dark
look, which only `theme.h` on the screen knows, and it would have to make the picture again (Pillow, on a Pi) when
the look changes while the alert is up. A wrong guess shows as coloured corners.
- **LVGL's own image radius (chosen).** `lv_image` passes its `radius` style to the draw as `clip_radius`, and the
software renderer's `radius_only()` (`lv_draw_sw_img.c`) blends the picture row by row with a mask of one row
(392 bytes). Rows between the corners come back from the mask as fully covered and are copied as before; only the
rows of the corners are blended through the mask. No layer and no allocation beyond that row.
`LV_DRAW_SW_COMPLEX` is 1 in ESPHome's `lv_conf.h`, so this path is compiled in; rounded rectangles already use it.
### What changed
- `packages/boards/guition-4848s040.yaml`: `alert_image_frame` has radius 18, the radius of the alert card
(`packages/core.yaml`), instead of 0, so the frame behind a picture that doesn't fill it is rounded too.
- `components/smart_display/runtime_tiles.h`: `camera_show(..., radius)` gives the picture that radius when it
creates it; `camera_loaded()` passes the frame's radius for the alert's picture. The camera full screen and the
media cover pass none and stay square.
- README.md: a camera row under "On the screen" (`guition-camera-tiles.png` new: a page with a camera, a live camera
and a doorbell's last ring; the camera full screen; the alert with its round picture). README_EXTENDED.md: a
Cameras bullet under "What you can configure" and the tiles picture beside "With a camera picture".
### Measured (host build on the Mac, `.esphome/readme-render/readme-0919/time_radius.py`)
The render time of the picture's frame after invalidating it (LVGL's render events minus the flushes to the SDL
window, which are the same for both), 40 redraws per run, four runs alternating between the two radii:
| Radius | Fastest | Median of the runs' medians |
|---|---|---|
| 0 (square, as before) | 1269 µs | 1340 µs |
| 18 (the card's) | 1352 µs | 1413 µs |
About 5 % more, and only when the picture is drawn: when the alert appears, when the camera full screen closes over
it, when something next to it is redrawn. A picture that stays up costs nothing afterwards. These are host numbers;
the Guition is slower in absolute terms, and per row the extra work stays one 392-byte memset and one blend call.
## The starting screen
Until the first layout the top bar read "Connecting to Home Assistant..." and then "Waiting for ESP Screens..." where
the screen's name goes (0.2.43). Now `render()` calls `boot_status()`, which puts the text in the middle of the page in
the name's font (`watch_font`, 27 px Guition, 18 px CYD) with a spinner under it (48/32 px, 24/16 px apart), the two
as one block in the middle; the name stays empty. The first layout deletes the block, spinner and all, so nothing
turns behind the tiles and no animation runs for the rest of the day. The block sits in the name's place in the
drawing order, under the tiles, the cards and an alert (an alert can come before the tiles). It is drawn in the
shared paints (`ink`, `spinner`), so Dark mode colours it like the rest. `ROOM_NAME`, the name in the profile until the
first `render()`, is empty instead of "Choose tiles in HA", which showed for a moment at every start. The text has no
"..." any more; the spinner says it is busy.
The three spinners of the firmware (a busy card, the starting screen, a camera that loads) come from one
`spinner_create()` in `runtime_tiles.h`; the busy card's looks the same as before.
## A camera that opens
### The spinner
The camera full screen shows the spinner in the middle until the first picture is there, instead of "Loading image".
Its ring uses the new theme role `CAMERA_TRACK` (0x393D42, the dark look's spinner ring), since the camera page is
black in both looks. A note ("No image from this camera") takes the spinner's place; the first picture ends both.
### Where the wait went
A camera opened full screen on the bench Guition (fw 0.2.72, 4 KB chunks), timed from the screen's own log
(`.esphome/camera-speed/timeline.py`), with Max's EZVIZ camera `camera.max`:
1. 0.2-0.35 s until the screen asked: `camera_tick()`, every 250 ms, sent the request, not `camera_open()`.
2. 2.6 s until the link came: Home Assistant's snapshot of the EZVIZ takes 2.4 s (the camera itself; measured the
same in 0.2.66); the add-on's encoding is small next to it (a 1080p JPEG to the 480 × 270 BMP takes 10 ms on the
Mac, several times that on a Pi 4 class machine).
3. Up to 0.25 s until the load started: the next tick again.
4. 2.8 s download: 388,854 bytes at 136 KB/s.
Step 4 is ESPHome's `online_image`: every round of the main loop it reads one `buffer_size` (then 4 KB) from the
connection and decodes it, and `esp_http_client_read` waits until that chunk is complete. 4 KB a round came to about
136 KB/s. A bigger chunk a round is faster (the table below), up to what one TCP connection with lwIP's 5,760-byte
window gets over this Wi-Fi, roughly 300 KB/s. The same 2.7-3.3 s was measured in 0.2.66 from the Mac and from the
Yellow: the limit was on the screen's side, not the sender's.
### What changed
- `camera_open()` asks right away; `camera_answer()` starts the load as soon as the link comes, as an alert's
picture already did. Both through `camera_load()`, which, like the tick, never starts under a finger.
`camera_tick()` keeps doing the refresh every 4 s and the retries.
- `online_image` `buffer_size` 16384 instead of 4096, for both images.
### Measured on the bench Guition
`.esphome/camera-speed/speed.py`: per open, from the `preview_camera` call (the same path as a tap on the tile) until
`online_image` reports the picture complete. Cold: 35 s after the last close, so the add-on has forgotten the snapshot
and asks Home Assistant for a new one. Warm: 5 s after the last close, so the add-on answers with the snapshot it has
and fetches the next one meanwhile. Three opens each (two cold for 32 KB); medians. The 0.2.73 builds went over the
air from `.esphome/camera-speed/guition-wallbox*.yaml` (the Device Builder's API, OTA and Wi-Fi settings, the packages
of this branch). One "cold" open of the power-save-off build found the snapshot still there and is left out.
| Build | Wi-Fi power save | Cold open | Warm open | Download | Longest loop wait during the opens |
|---|---|---|---|---|---|
| fw 0.2.72, 4 KB, tick | light (ESPHome's default) | 5.80 s | 3.40 s | 2.86 s (136 KB/s) | 131 ms (lvgl), 65 ms |
| 0.2.73, 16 KB | light | 4.36 s | 2.09 s | 1.74-1.91 s (216-223 KB/s) | 58, 61, 77 ms |
| 0.2.73, 16 KB | none | 4.24 s | 1.99 s | 1.73-1.80 s (223 KB/s) | none over 50 ms |
| 0.2.73, 32 KB | none | 3.94 s | 1.62 s | 1.42-1.45 s (271 KB/s) | 101 ms |
| 0.2.73, 16 KB (this release) | light | 4.55 s | 2.06 s | 1.75-1.90 s (208-218 KB/s) | 93, 107 ms |
The screen now asks 20-150 ms after the call (the API call and the log line's way back included) instead of
210-345 ms, and loads about 150 ms after asking when the add-on has the snapshot. "Longest loop wait" is ESPHome's own
"took a long time" warning (over 50 ms). With Wi-Fi power save, `online_image` now and then held a loop up to 107 ms
(65 ms with 4 KB, next to a 131 ms LVGL frame in the same opens); without power save nothing went over 50 ms.
- **16 KB, not 32 KB.** 32 KB saves 0.3 s more, but a read of 32 KB waits for several TCP windows (lwIP's is 5,760
bytes in ESPHome's build) and held a loop 101 ms even without power save: a tap in that moment waits as long.
- **Wi-Fi power save.** The bench Guition's Device Builder YAML dates from before 0.2.24 and has no
`power_save_mode: none`, so it runs ESPHome's default `light`: pings 103-191 ms (117 ms average) against 5-94 ms
(21 ms) without it. With 16 KB chunks it made no difference to the download, but without it no loop waited over
50 ms. The YAML the app writes has had `power_save_mode: none` since 0.2.24 (EASY_SETUP.md says why); the package
doesn't set it, since it is the owner's Wi-Fi block.
- **What remains** of a cold open is the camera: 2.4 s of the 4.5 s is Home Assistant's snapshot of the EZVIZ. Only
fetching before the tap would hide it (when a camera's page shows, or when the screen wakes). That polls cameras
nobody looks at, and a battery camera wakes for it; it is left for Max to decide.
- Not changed: lwIP's TCP window and other ESP-IDF settings (`sdkconfig_options`). They would speed up the reads
further but are memory and platform tuning outside ESPHome's defaults.
## Automated
- `tools/check.sh` (worktree, after the rebase on 0.2.86): Python 473 tests OK (two new: `test_camera` "the first
picture waits for no tick", `test_header_bar` "a starting screen says what it waits for in the middle"; the
position check of `render_header()` now looks in `render()` as a whole, and the spinner paint check in
`spinner_create()`), C++ 19/19, packages, icons, editor tests, types, build and bundle PASS.
- `tools/check.sh --firmware --baseline 1677504`: CYD 1,678,816 B of 1,835,008 B = 91.5 % (156,192 B free),
+1,312 B against 0.2.86 (WARN: the 90-93 % band, under the 8 KB that needs Max's OK); Guition 2,055,280 B = 25.3 %.
## Renders
- Host builds from this branch (`.esphome/readme-render/readme-0919/boot_camera_host.py`, both boards, light and
dark): the starting screen with both texts, page 1 after the layout without it, and on the Guition the camera page
with the spinner, with the picture and with the note.
- `guition-alert-camera.png` from the new firmware on the host: only the four corners of the picture changed
(1,400 pixels of the 960 × 960 README image).
- `guition-camera-tiles.png`: the camera tiles page of the README demo home (Home Assistant's icons and words), the
front door camera idle and grey, the garden camera live and amber, since 0.2.85's colours.
## Hardware
- Bench Guition Wallbox (192.168.146.136): four builds of this branch over the air for the table above (its first
row is the toggle-key session's 0.2.72 bench build that was on it); it runs this release's firmware (16 KB, its
own `light` Wi-Fi). An LVGL snapshot during an open shows the spinner on the black camera page. After a
restart the add-on's layout was there before my own API connection (6.8 s), and the snapshot showed "Loading tiles..."
with the tiles in place: the starting screen had gone as it should.
- The CYD: compiled only.
## Not tested
- The round alert picture on the panel: the add-on sends an alert picture only with the `esp_screens_show_alert`
event, which goes to every screen, Studio 1 included. It shows with the next doorbell or camera alert.
- The starting screen on the panels themselves: it is up for the few seconds between the start and ESP Screens'
layout. Max sees it at the next update or restart.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+9 -6
View File
@@ -371,9 +371,11 @@ api:
# Camera images (firmware 0.2.57+): ESP Screen Manager serves each image already sized for this screen, as an uncompressed
# BMP on its own port on the LAN (screen_manager/app/camera_feed.py). ESPHome decodes a BMP piece by piece as it arrives,
# 4 KB per loop, where a JPEG of the full screen held the loop for 0.6 s; touch and drawing keep their turn. One image full
# screen and one in an alert's frame; runtime_tiles.h decides when they load and frees them when they close. Plain HTTP
# on the LAN, no certificates.
# one `buffer_size` per loop, where a JPEG of the full screen held the loop for 0.6 s; touch and drawing keep their turn.
# 16 KB a loop (firmware 0.2.73+): the full picture came in 1.8 s instead of 2.8 s with 4 KB. Without Wi-Fi power save no
# loop waited over 50 ms for it (with power save now and then 0.1 s, against 65 ms with 4 KB); 32 KB saved 0.3 s more but
# held a loop 0.1 s even without power save (docs/TEST_RESULTS_0287.md). One image full screen and one in an alert's
# frame; runtime_tiles.h decides when they load and frees them when they close. Plain HTTP on the LAN, no certificates.
http_request:
timeout: 4s
verify_ssl: false
@@ -385,7 +387,7 @@ online_image:
url: "http://127.0.0.1/camera.bmp" # set when a camera opens
format: BMP
type: RGB565
buffer_size: 4096
buffer_size: 16384
on_download_finished:
- lambda: 'runtime_tiles::camera_loaded(false, cached);'
on_error:
@@ -394,7 +396,7 @@ online_image:
url: "http://127.0.0.1/camera.bmp" # set when an alert brings an image
format: BMP
type: RGB565
buffer_size: 4096
buffer_size: 16384
on_download_finished:
- lambda: 'runtime_tiles::camera_loaded(true, cached);'
on_error:
@@ -574,6 +576,7 @@ lvgl:
widgets:
# The alert's camera image (firmware 0.2.57+) across the top of the card, shown when the app announced
# one; a tap opens the camera full screen over the alert. runtime_tiles.h puts the image in it.
# Rounded like the alert card itself (firmware 0.2.73+); the image takes this radius (camera_loaded).
- obj:
id: alert_image_frame
x: ${ALERT_IMAGE_INSET}
@@ -583,7 +586,7 @@ lvgl:
hidden: true
styles: paint_camera
bg_opa: COVER
radius: 0
radius: 18
border_width: 0
shadow_width: 0
pad_all: 0
+2 -2
View File
@@ -14,10 +14,10 @@
# fonts/materialdesignicons-webfont.ttf (Apache 2.0, github.com/Templarian/MaterialDesign-Webfont).
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.72"
SCREEN_FIRMWARE_VERSION: "0.2.73"
DASHBOARD_INBOX_INTERNAL: "false"
CALIBRATION_ON_BOOT: "false" # a bench build: start on the touch test page
ROOM_NAME: "Choose tiles in HA" # shown top left until ESP Screens sends the screen name
ROOM_NAME: "" # top left until ESP Screens sends the screen name
## DIM settings
AUTO_DIM: "true" # true = enable auto dimming
+10
View File
@@ -1,3 +1,13 @@
## 0.2.87 (firmware 0.2.73)
A starting screen with a spinner, a camera that opens sooner with a spinner, and round corners on an alert's camera picture.
- **A starting screen with a spinner.** Until the tiles arrive, a screen says in the middle what it waits for, *Connecting to Home Assistant* and then *Waiting for ESP Screens*, with a turning spinner under it. Before, that was a line of text in the top bar. The text and the spinner go as soon as the tiles come, so nothing keeps turning behind them.
- **A camera opens sooner, with a spinner.** The camera full screen shows the spinner until the first picture is there, instead of the words *Loading image*; a camera without a picture still says so. The first picture also comes sooner on a Guition. The screen asks for it the moment the camera opens and starts loading it the moment ESP Screens answers, where it used to wait for its next quarter-second tick each time. It also loads 16 KB of the picture at a time instead of 4 KB. On the bench Guition a camera the add-on had just shown opened in 2.1 s instead of 3.4 s, and a camera it had to ask Home Assistant for first in 4.6 s instead of 5.8 s. The rest of that wait is the camera itself: the EZVIZ answers in 2.4 s.
- **The picture in an alert has round corners**, the same as the alert card around it, on a Guition. LVGL rounds the picture itself while it draws it, row by row, without an extra buffer. Drawing the picture takes about 5 % longer than a square one, and only when it is drawn (the alert appearing, or coming back after the camera full screen); a picture that stays up costs nothing more. The camera full screen keeps its square picture from edge to edge.
- **Cameras are in the README**, as tiles of their own and not only in an alert: a page with a camera, a live camera and a doorbell's last ring, the picture full screen, and the same camera in an alert.
- Needs firmware 0.2.73: press **Update** on the screen. The starting screen is on both boards; the camera changes are the Guition's. Includes everything from 0.2.86. CYD firmware: 1,678,816 bytes, 91.5 % of the update slot (1,312 bytes more than 0.2.86).
## 0.2.86 (firmware 0.2.72)
The on/off switch on a wide tile switches again.
+1 -1
View File
@@ -37,7 +37,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.72'
FIRMWARE_VERSION = '0.2.73'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.86"
version: "0.2.87"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+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)