Load one picture at a time on a Guition: an alert's picture before a media tile's cover (0.2.77)

A download shares ESPHome's loop with touch and drawing. An alert already
closes every card, and the card's cover with it; a media tile over the whole
page stays under the alert. Its cover now waits while the alert's picture is
announced, loading or due for a retry, and an alert picture that arrives while
a cover is on its way (or a dropped cover's download is still being closed)
starts right after it. The host harness checks all three cases on the Guition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 17:15:54 +02:00
parent f13d8ad0ed
commit 079ffcf059
4 changed files with 58 additions and 14 deletions
+31 -4
View File
@@ -3756,10 +3756,22 @@ inline lv_obj_t *camera_root = nullptr, *camera_picture = nullptr, *camera_note
inline lv_obj_t *alert_frame = nullptr, *alert_picture = nullptr, *alert_frame_icon = nullptr; inline lv_obj_t *alert_frame = nullptr, *alert_picture = nullptr, *alert_frame_icon = nullptr;
inline std::function<void(bool)> alert_room; // the board makes the card taller for the frame, or back inline std::function<void(bool)> alert_room; // the board makes the card taller for the frame, or back
inline std::string alert_announced, alert_camera, alert_url; inline std::string alert_announced, alert_camera, alert_url;
inline uint32_t alert_announced_at = 0, alert_retry_at = 0; inline uint32_t alert_announced_at = 0, alert_retry_at = 0, alert_shown_at = 0;
inline uint8_t alert_retries = 0; inline uint8_t alert_retries = 0;
inline bool alert_thumb_loading = false; inline bool alert_thumb_loading = false;
constexpr uint8_t ALERT_IMAGE_RETRIES = 3; // an alert's picture is worth another try after a failed connection constexpr uint8_t ALERT_IMAGE_RETRIES = 3; // an alert's picture is worth another try after a failed connection
// One picture loads at a time (firmware 0.2.64+): a download shares ESPHome's loop with touch and drawing, and two at
// once (a cover and an alert's picture) would double the time a tap can wait. An alert closes every card (wake_display
// runs close_cards), so the card's cover goes with it; a media tile over the whole page stays under the alert. Its
// cover waits while the alert's picture is announced, on its way or about to be tried again; a cover already on its
// way finishes and the alert's picture starts right after it. A full camera and the cover share one image, so they
// never load together. Memory is not the limit here: the pictures live in PSRAM (a cover ~70 KB, the alert's 172 KB).
inline bool alert_image_due() {
if (alert_camera.empty() || alert_picture) return false;
if (alert_thumb_loading || alert_retry_at) return true;
// The link has not come yet: wait for it as long as an announced camera still belongs to its alert.
return alert_url.empty() && esphome::millis() - alert_shown_at < camera_view::PENDING_MS;
}
inline bool camera_supported() { return static_cast<bool>(camera_full.load); } inline bool camera_supported() { return static_cast<bool>(camera_full.load); }
inline bool camera_visible() { return camera_root != nullptr; } inline bool camera_visible() { return camera_root != nullptr; }
@@ -3835,6 +3847,7 @@ inline void cover_tick(uint32_t now) {
if (!cover_visible()) { cover_drop(); return; } if (!cover_visible()) { cover_drop(); return; }
if (!awake()) return; if (!awake()) return;
if (!cover.open()) cover.open(cover_wish.entity, true); if (!cover.open()) cover.open(cover_wish.entity, true);
if (alert_image_due()) return; // the alert's picture first
if (cover.should_ask(now)) { if (cover.should_ask(now)) {
if (!fresh()) return; if (!fresh()) return;
cover.ask(now); cover.ask(now);
@@ -3843,6 +3856,7 @@ inline void cover_tick(uint32_t now) {
auto *input = lv_indev_get_next(nullptr); auto *input = lv_indev_get_next(nullptr);
if (input && lv_indev_get_state(input) == LV_INDEV_STATE_PRESSED) return; if (input && lv_indev_get_state(input) == LV_INDEV_STATE_PRESSED) return;
cover.start(now); cover.start(now);
ESP_LOGI("camera", "cover load %s", cover.entity.c_str());
camera_full.load(cover.url); camera_full.load(cover.url);
} }
} }
@@ -3950,10 +3964,12 @@ inline void camera_open(const std::string &entity, const std::string &name) {
inline void camera_tick() { inline void camera_tick() {
const uint32_t now = esphome::millis(); const uint32_t now = esphome::millis();
if (camera_release_due && !camera_root) camera_release(); if (camera_release_due && !camera_root) camera_release();
if (alert_retry_at && now >= alert_retry_at) { // A retry, or an alert's picture that waited for a cover on its way (one picture at a time).
if (alert_retry_at && now >= alert_retry_at && !cover.loading) {
alert_retry_at = 0; alert_retry_at = 0;
if (!alert_camera.empty() && !alert_url.empty() && camera_thumb.load) { if (!alert_camera.empty() && !alert_url.empty() && camera_thumb.load) {
alert_thumb_loading = true; alert_thumb_loading = true;
ESP_LOGI("camera", "alert picture load");
camera_thumb.load(alert_url); camera_thumb.load(alert_url);
} }
} }
@@ -3987,6 +4003,7 @@ inline void alert_prepare() {
const bool with_image = camera_supported() && alert_frame && !alert_announced.empty() && const bool with_image = camera_supported() && alert_frame && !alert_announced.empty() &&
esphome::millis() - alert_announced_at < camera_view::PENDING_MS; esphome::millis() - alert_announced_at < camera_view::PENDING_MS;
alert_camera = with_image ? alert_announced : std::string(); alert_camera = with_image ? alert_announced : std::string();
alert_shown_at = esphome::millis();
alert_url.clear(); alert_url.clear();
alert_announced.clear(); alert_announced.clear();
if (alert_frame) { if (alert_frame) {
@@ -4027,7 +4044,15 @@ inline void camera_answer(const std::string &view, const std::string &entity, co
alert_picture_clear(); alert_picture_clear();
alert_url = url; alert_url = url;
alert_retries = 0; alert_retries = 0;
// A cover on its way finishes first, and a dropped one is closed first (on the next tick); camera_tick starts this
// one right after.
if (cover.loading || camera_release_due) {
alert_retry_at = esphome::millis() | 1;
ESP_LOGI("camera", "alert picture waits for the cover");
return;
}
alert_thumb_loading = true; alert_thumb_loading = true;
ESP_LOGI("camera", "alert picture load");
camera_thumb.load(url); camera_thumb.load(url);
} }
@@ -4059,11 +4084,12 @@ inline void camera_loaded(bool thumb, bool cached) {
if (alert_camera.empty() || !alert_frame) return; if (alert_camera.empty() || !alert_frame) return;
camera_show(alert_frame, alert_picture, camera_thumb.source(), !cached); camera_show(alert_frame, alert_picture, camera_thumb.source(), !cached);
if (alert_picture && alert_frame_icon) lv_obj_add_flag(alert_frame_icon, LV_OBJ_FLAG_HIDDEN); if (alert_picture && alert_frame_icon) lv_obj_add_flag(alert_frame_icon, LV_OBJ_FLAG_HIDDEN);
ESP_LOGI("camera", "alert picture shown");
return; return;
} }
if (!camera_root) { if (!camera_root) {
// The media card's cover (firmware 0.2.64+): the same online_image, loaded once. // The media card's cover (firmware 0.2.64+): the same online_image, loaded once.
if (cover.loading) { cover.finish(esphome::millis(), true); cover_arrived(); } if (cover.loading) { cover.finish(esphome::millis(), true); ESP_LOGI("camera", "cover loaded"); cover_arrived(); }
return; return;
} }
if (!camera.loading) return; // a cover's download that ended after the camera opened: not this camera's picture if (!camera.loading) return; // a cover's download that ended after the camera opened: not this camera's picture
@@ -4080,6 +4106,7 @@ inline void camera_loaded(bool thumb, bool cached) {
inline void camera_failed(bool thumb) { inline void camera_failed(bool thumb) {
if (thumb) { if (thumb) {
alert_thumb_loading = false; alert_thumb_loading = false;
ESP_LOGI("camera", "alert picture failed");
if (!alert_camera.empty() && !alert_picture && alert_retries < ALERT_IMAGE_RETRIES) { if (!alert_camera.empty() && !alert_picture && alert_retries < ALERT_IMAGE_RETRIES) {
++alert_retries; ++alert_retries;
alert_retry_at = esphome::millis() + 1500; alert_retry_at = esphome::millis() + 1500;
@@ -4087,7 +4114,7 @@ inline void camera_failed(bool thumb) {
return; return;
} }
if (!camera_root) { if (!camera_root) {
if (cover.loading) cover.finish(esphome::millis(), false); // tried again after the gap, three times at most if (cover.loading) { cover.finish(esphome::millis(), false); ESP_LOGI("camera", "cover failed"); } // tried again after the gap, three times at most
return; return;
} }
if (!camera.loading) return; if (!camera.loading) return;
+3
View File
@@ -29,6 +29,9 @@ what plays, with the title, the artist and the album, a progress bar and the key
- A player without a picture (a radio station, a player that is off) keeps the player's icon in - A player without a picture (a radio station, a player that is off) keeps the player's icon in
the cover's place; the app answers with an empty link and the screen stops asking. the cover's place; the app answers with an empty link and the screen stops asking.
- The CYD shows the same card without the picture: its icon stands in for the cover. - The CYD shows the same card without the picture: its icon stands in for the cover.
- One picture loads at a time. An alert closes an open card and its cover. Under a media tile of
size *Full page* the alert's picture goes first: the tile's cover waits until the alert's picture
is there, and a cover already on its way finishes before the alert's picture starts.
## A doorbell ## A doorbell
+23 -10
View File
@@ -21,6 +21,13 @@ See CHANGELOG 0.2.77. Built on 0.2.76 (`bdb1f12`).
screen takes the shared image buffer and the cover comes back after. The camera answer parser accepts `t: cover`. screen takes the shared image buffer and the cover comes back after. The camera answer parser accepts `t: cover`.
The palette pass leaves the media tile's parts alone (it painted them the card's grey). A player's state falls The palette pass leaves the media tile's parts alone (it painted them the card's grey). A player's state falls
back to the screen's own word ("Playing") when Home Assistant sent none. back to the screen's own word ("Playing") when Home Assistant sent none.
- One picture at a time (asked by Max after approving the renders: "two images at once, is that too much?"). An
alert already closes every card (`wake_display` runs `close_cards`), so the card's cover is dropped with it. A
media tile over the whole page stays under the alert: `alert_image_due()` holds its cover while the alert's
picture is announced, loading or due for a retry, and an alert picture that arrives while a cover is on its way
(or while a dropped cover's download is still being closed) waits for it (`alert_retry_at`, started by
`camera_tick`). Memory was never the limit: the pictures live in PSRAM (cover ~70 KB, alert 172 KB); the rule
keeps the main loop, where touch waits, to one download.
- `runtime_model.h`: `Extra` carries artist, album, picture mark, duration, position and its moment. - `runtime_model.h`: `Extra` carries artist, album, picture mark, duration, position and its moment.
- `camera_view.h`: `open(entity, once)`, `loaded`; an empty answer to a one-time feed stops the asking. - `camera_view.h`: `open(entity, once)`, `loaded`; an empty answer to a one-time feed stops the asking.
- `theme.h`: `theme::of(lv_color_t)` (the 24-bit value of a drawn colour, for the cover's corner colour). - `theme.h`: `theme::of(lv_color_t)` (the 24-bit value of a drawn colour, for the cover's corner colour).
@@ -39,7 +46,7 @@ See CHANGELOG 0.2.77. Built on 0.2.76 (`bdb1f12`).
fetch-on-change and links, the app's answer through `Manager.answer_camera` (Guition 0.2.64 gets a link, a stream fetch-on-change and links, the app's answer through `Manager.answer_camera` (Guition 0.2.64 gets a link, a stream
an empty one, a stranger nothing, older firmware and a CYD nothing). Whole Python suite: 409 + 14, pass. an empty one, a stranger nothing, older firmware and a CYD nothing). Whole Python suite: 409 + 14, pass.
- Host harness (`.esphome/readme-render/media/media_host.py`, real ESPHome API, virtual finger, the app's own - Host harness (`.esphome/readme-render/media/media_host.py`, real ESPHome API, virtual finger, the app's own
`answer_camera` behind `/dev/cover`): **Guition 57/57, CYD 42/42.** Covered: the card of a playing player (title, `answer_camera` behind `/dev/cover`): **Guition 64/64, CYD 42/42.** Covered: the card of a playing player (title,
artist · album, elapsed from the reported position and running on, keys enabled, slider at 32 %, bar track and artist · album, elapsed from the reported position and running on, keys enabled, slider at 32 %, bar track and
fill), the cover asked for with size and page colour and loaded once over the placeholder, pause → `media_play_pause` fill), the cover asked for with size and page colour and loaded once over the placeholder, pause → `media_play_pause`
with the keys faded during the wait and back after, next, mute (`is_volume_muted: true`), the slider drag → with the keys faded during the wait and back after, next, mute (`is_volume_muted: true`), the slider drag →
@@ -52,6 +59,10 @@ See CHANGELOG 0.2.77. Built on 0.2.76 (`bdb1f12`).
card which takes the cover at its own size, closing gives it back to the tile); a stream over the page (no times, card which takes the cover at its own size, closing gives it back to the tile); a stream over the page (no times,
no cover, the study cover dropped); an idle player ("Not playing"); a wide media tile with a volume control keeps no cover, the study cover dropped); an idle player ("Not playing"); a wide media tile with a volume control keeps
its panel; no errors in the firmware log. The CYD asks for no cover anywhere. its panel; no errors in the firmware log. The CYD asks for no cover anywhere.
Alerts with a camera (Guition): an alert closes the open media card and drops its cover, and its picture loads
alone; under a full-page media tile whose cover is not loaded yet, the cover waits until the alert's picture is
shown and loads after it; a cover on its way (a link that answers after 1.5 s) finishes first and the alert's
picture ("waits for the cover") follows. The firmware log shows the downloads one after the other in all three.
- Renders (`render_media.py`, both boards, light and dark): card of a playing player, a stream, a paused TV, an off - Renders (`render_media.py`, both boards, light and dark): card of a playing player, a stream, a paused TV, an off
player, tiles over the page (study, radio, idle). Sent to Max for approval. player, tiles over the page (study, radio, idle). Sent to Max for approval.
- ESP32 builds of both Easy Setup profiles: see the sizes below. - ESP32 builds of both Easy Setup profiles: see the sizes below.
@@ -59,12 +70,12 @@ See CHANGELOG 0.2.77. Built on 0.2.76 (`bdb1f12`).
## Sizes ## Sizes
Both Easy Setup profiles compile with ESPHome 2026.6.2 (on Python 3.11, see the pitfalls), no warnings. Both Easy Setup profiles compile with ESPHome 2026.6.2, no warnings (see the pitfalls for this Mac's Python).
| Build | Flash | RAM | | Build | Flash | RAM |
|---|---|---| |---|---|---|
| Guition (easy-guition-device) | 1,971,583 B, 24.3 % of 8 MB | 76,844 B, 23.5 % | | Guition (easy-guition-device) | 1,971,975 B, 24.3 % of 8 MB | 76,852 B, 23.5 % |
| CYD (easy-cyd-device) | 1,582,063 B, 86.2 % of the 1.835 MB update slot | 71,276 B, 21.8 % | | CYD (easy-cyd-device) | 1,582,183 B, 86.2 % of the 1.835 MB update slot | 71,276 B, 21.8 % |
## Hardware ## Hardware
@@ -77,9 +88,11 @@ Pending at the time of writing.
- LVGL's `LV_LABEL_LONG_DOT` writes the dots into the label's text: a probe sees "Miles Davis · Kin...". - LVGL's `LV_LABEL_LONG_DOT` writes the dots into the label's text: a probe sees "Miles Davis · Kin...".
- `tick()` re-enables every object in `detail_actions` after a wait: a key the player lacks must stay out of it. - `tick()` re-enables every object in `detail_actions` after a wait: a key the player lacks must stay out of it.
- The tile palette pass paints every custom part; a mode that paints its own parts must be skipped there. - The tile palette pass paints every custom part; a mode that paints its own parts must be skipped there.
- Homebrew's python@3.14.7 on this Mac has a broken `pyexpat` (it wants a libexpat symbol macOS 26.2 lacks), so - Homebrew's python@3.14.7 on this Mac has a broken `pyexpat` (it loads macOS's `/usr/lib/libexpat.1.dylib`, which
`platform.mac_ver()` returns '' and both pip's truststore and `uv` refuse the interpreter. The pioarduino platform lacks `_XML_SetAllocTrackerActivationThreshold`), so `platform.mac_ver()` returns '' and pip's truststore and `uv`
recreates `~/.platformio/penv` from the running Python and then cannot install into it. The ESP32 builds of this refuse the interpreter. The pioarduino platform then cannot rebuild `~/.platformio/penv` ("Failed to install Python
release ran from an ESPHome 2026.6.2 environment on PlatformIO's own bundled Python 3.11 (`.esphome/venv311`, dependencies into penv"). Setting `DYLD_LIBRARY_PATH=/opt/homebrew/opt/expat/lib` is not enough, because
local); `brew reinstall python@3.14` (or expat) is the real fix. Also: never run two ESPHome builds at once, the pioarduino starts `uv` without it. What works: ESPHome 2026.6.2 in a venv on PlatformIO's own bundled Python 3.11
second one clears the penv the first one uses. (`~/.platformio/python3`), where `mac_ver()` is fine. Every session must build with that same Python: pioarduino
recreates the penv whenever the Python version changes, so a build on another Python breaks the next one elsewhere.
`brew reinstall python@3.14` (or expat) is the real fix. Never run two ESPHome builds at once.
+1
View File
@@ -5,6 +5,7 @@ A media card like a phone's "now playing", with the album cover on a Guition.
- **The media card is new.** Tap a media player's tile and the card shows the album cover, the title, the artist and the album, a progress bar that runs while the track plays with the elapsed and total time at its ends, three round keys (previous, play or pause on the blue key, next) and a volume row: a mute key, a wide slider with a round knob, and the percentage. The keys the player does not offer are faded, the mute key and the slider follow what Home Assistant reports (a muted player shows a grey fill and *Muted*), and a player that is off shows one power key. A stream without a length shows no bar and no times. It works in the light and the dark look. - **The media card is new.** Tap a media player's tile and the card shows the album cover, the title, the artist and the album, a progress bar that runs while the track plays with the elapsed and total time at its ends, three round keys (previous, play or pause on the blue key, next) and a volume row: a mute key, a wide slider with a round knob, and the percentage. The keys the player does not offer are faded, the mute key and the slider follow what Home Assistant reports (a muted player shows a grey fill and *Muted*), and a player that is off shows one power key. A stream without a length shows no bar and no times. It works in the light and the dark look.
- **The album cover** comes the way camera images do (app 0.2.66): the screen asks ESP Screen Manager, the app fetches the picture from Home Assistant with its own access, makes it exactly the size the card asks for with the corners already rounded over the card's colour, and serves it on port 8098. A Guition loads it once and keeps it until the picture changes, the card closes or the page turns. The CYD has no memory for pictures and shows the player's icon in the cover's place, as it does when a player has no picture (a radio station, a player that is off). - **The album cover** comes the way camera images do (app 0.2.66): the screen asks ESP Screen Manager, the app fetches the picture from Home Assistant with its own access, makes it exactly the size the card asks for with the corners already rounded over the card's colour, and serves it on port 8098. A Guition loads it once and keeps it until the picture changes, the card closes or the page turns. The CYD has no memory for pictures and shows the player's icon in the cover's place, as it does when a player has no picture (a radio station, a player that is off).
- **A media tile over the whole page** (size *Full page*) is the same card under the tile's head: the cover at the left, the track, the bar and the keys beside it, the volume row along the bottom. The keys and the slider work on the tile; holding the tile still opens the card. - **A media tile over the whole page** (size *Full page*) is the same card under the tile's head: the cover at the left, the track, the bar and the keys beside it, the volume row along the bottom. The keys and the slider work on the tile; holding the tile still opens the card.
- **One picture at a time.** A download shares the screen's loop with touch and drawing, so a Guition never loads two pictures at once. An alert closes an open card, and the card's cover goes with it. A media tile over the whole page stays under the alert, and there the alert's picture goes first: a cover that has not started waits until the alert's picture is there, and a cover already on its way finishes before the alert's picture starts. A camera full screen and the cover share one picture, so they never load together. Memory is not the limit: the pictures live in PSRAM (a cover about 70 KB, the alert's 172 KB).
- **The card's clock runs on the screen.** Home Assistant reports where a track was and when; the bar and the elapsed time run on from there every second without any traffic, and stand still while the track is paused. - **The card's clock runs on the screen.** Home Assistant reports where a track was and when; the bar and the elapsed time run on from there every second without any traffic, and stand still while the track is paused.
- Claude in Home Assistant and the editor need nothing new: a `media_player` tile is the same tile, with the new card behind it. - Claude in Home Assistant and the editor need nothing new: a `media_player` tile is the same tile, with the new card behind it.
- Needs firmware 0.2.64 for the new card; the app serves covers only to a Guition on that firmware. Older firmware keeps the old card. Includes everything from 0.2.76. - Needs firmware 0.2.64 for the new card; the app serves covers only to a Guition on that firmware. Older firmware keeps the old card. Includes everything from 0.2.76.