diff --git a/README.md b/README.md index d4c8ac8..dd540e1 100644 --- a/README.md +++ b/README.md @@ -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 The media card in dark mode: the same cover and keys on a black page

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 (how). Rendered from the firmware's own LVGL code with a demo home.

+

+ 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 + A camera tile tapped: the front door camera full screen, with the round back key and the camera's name at the top + The same camera in an alert: its picture across the top of the card, with the card's rounded corners +

+

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 (how it works).

CYD 2.8-inch screen: the weather forecast, a kitchen timer, the coffee machine, a lamp and power usage as a large value Second CYD page: Sonos volume, presence, a scene and an energy graph diff --git a/README_EXTENDED.md b/README_EXTENDED.md index 9652c03..e6d7be9 100644 --- a/README_EXTENDED.md +++ b/README_EXTENDED.md @@ -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 ``` +

+ 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 +

+ 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). diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index 92384c5..da5bb54 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -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; diff --git a/components/smart_display/theme.h b/components/smart_display/theme.h index 6ba482a..eff1577 100644 --- a/components/smart_display/theme.h +++ b/components/smart_display/theme.h @@ -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}, diff --git a/docs/CAMERA.md b/docs/CAMERA.md index 07c0b01..f3c471f 100644 --- a/docs/CAMERA.md +++ b/docs/CAMERA.md @@ -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 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c7b299f..da2b8b2 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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 diff --git a/docs/TEST_RESULTS_0287.md b/docs/TEST_RESULTS_0287.md new file mode 100644 index 0000000..82411d7 --- /dev/null +++ b/docs/TEST_RESULTS_0287.md @@ -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. diff --git a/docs/images/guition-alert-camera.png b/docs/images/guition-alert-camera.png index ad8f9df..cac706a 100644 Binary files a/docs/images/guition-alert-camera.png and b/docs/images/guition-alert-camera.png differ diff --git a/docs/images/guition-camera-tiles.png b/docs/images/guition-camera-tiles.png new file mode 100644 index 0000000..deda85d Binary files /dev/null and b/docs/images/guition-camera-tiles.png differ diff --git a/packages/boards/guition-4848s040.yaml b/packages/boards/guition-4848s040.yaml index 2f29d72..c651cae 100644 --- a/packages/boards/guition-4848s040.yaml +++ b/packages/boards/guition-4848s040.yaml @@ -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 diff --git a/packages/core.yaml b/packages/core.yaml index 88d531d..58f51c8 100644 --- a/packages/core.yaml +++ b/packages/core.yaml @@ -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 diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index bd03635..5b72b86 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -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. diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index f073dea..21da973 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -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. diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index 3b201b5..537c5b6 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -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 diff --git a/tests/test_camera.py b/tests/test_camera.py index eb2d432..6760add 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -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): diff --git a/tests/test_header_bar.py b/tests/test_header_bar.py index 3a2aa5b..4e70080 100644 --- a/tests/test_header_bar.py +++ b/tests/test_header_bar.py @@ -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) diff --git a/tests/test_theme.py b/tests/test_theme.py index 5d83a6a..ecd64c8 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -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)