diff --git a/README.md b/README.md
index f6a28ce..9855d12 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,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
twenty tiles over four pages: lights, climate, blinds and curtains, the vacuum, media, the weather,
history graphs, clocks and timers. An automation can put an alert on every screen when someone rings
-the bell.
+the bell, and a Guition shows who is there with the doorbell camera's picture.
**How.** ESP Screen Manager is an app inside Home Assistant. It flashes a new screen over USB, updates
it over Wi-Fi and sends it your tiles. Changing a screen is pick, drag and **Save & send to screen**:
@@ -77,11 +77,16 @@ no reflash, no YAML to write, no blueprint, MQTT or token.
## Alerts
+
+
+
+
+Someone at the door? One event in an automation wakes every screen and shows it. Add the doorbell camera and a Guition shows who is there; tap the picture, or a camera tile, for the camera full screen, refreshed every few seconds. Camera images.
-Someone at the door? One action in an automation wakes the screen and shows it, on one screen or all of them. How alerts work.
+Any alert, on one screen or all of them, in a pastel color of your choice. How alerts work.
## Installing from Home Assistant
diff --git a/README_EXTENDED.md b/README_EXTENDED.md
index 8c42d82..445253c 100644
--- a/README_EXTENDED.md
+++ b/README_EXTENDED.md
@@ -209,6 +209,35 @@ actions:
**`esp_screens_dismiss_alert`** clears the alert on every screen. The app has to be running
for these two events; the per-screen actions work without it.
+### With a camera picture
+
+
+
+
+
+
+From app 0.2.66, the event takes one more field: **`camera`**, a `camera.*` or `image.*` entity.
+A Guition with firmware 0.2.57+ shows that camera's picture of the moment across the top of the
+card; a tap on it opens the camera full screen over the alert, and Back returns to the alert.
+Other screens show the same alert without the picture. The per-screen actions have no `camera`
+field.
+
+```yaml
+actions:
+ - event: esp_screens_show_alert
+ event_data:
+ title: "Someone is at the door"
+ subtitle: "Front door"
+ icon: doorbell
+ color: orange
+ button_text: "Coming"
+ camera: camera.front_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).
+
### Ask Claude
Use Claude Code in Home Assistant? **Settings → Claude → Install for Claude Code** writes an
diff --git a/components/smart_display/backlight_fade.h b/components/smart_display/backlight_fade.h
deleted file mode 100644
index a5e404f..0000000
--- a/components/smart_display/backlight_fade.h
+++ /dev/null
@@ -1,60 +0,0 @@
-#pragma once
-#include
-#include
-#include
-#include "esphome/components/ledc/ledc_output.h"
-#include "esphome/components/light/light_state.h"
-
-// Backlight fades through the LEDC hardware fader. ESPHome's software
-// transition steps the duty from the main loop, and the full LVGL redraws that
-// standby triggers on the Guition's RGB panel stall that loop long enough to
-// make dimming visibly jerky. The peripheral steps on its own clock instead.
-namespace backlight_fade {
-template struct Peek : T {
- static uint8_t channel(T &o) { return o.*(&Peek::channel_); }
- static uint8_t bits(T &o) { return o.*(&Peek::bit_depth_); }
-};
-inline bool installed = false, hardware_owns = false;
-inline ledc_mode_t mode(uint8_t channel) {
-#ifdef SOC_LEDC_SUPPORT_HS_MODE
- return channel < 8 ? LEDC_HIGH_SPEED_MODE : LEDC_LOW_SPEED_MODE;
-#else
- return LEDC_LOW_SPEED_MODE;
-#endif
-}
-// Returns false when the fader is unavailable; the caller keeps ESPHome's transition.
-inline bool start(esphome::ledc::LEDCOutput &output, esphome::light::LightState &light, float level, uint32_t ms) {
-#ifndef SOC_LEDC_SUPPORT_FADE_STOP
- return false; // Classic ESP32 cannot interrupt a running fade for a touch wake.
-#else
- using P = Peek;
- const auto channel = static_cast(P::channel(output));
- const uint8_t bits = P::bits(output);
- if (!bits) return false;
- if (!installed) installed = ledc_fade_func_install(0) == ESP_OK;
- if (!installed) return false;
- const auto speed = mode(channel);
- const uint32_t max_duty = (1u << bits) - 1;
- ledc_fade_stop(speed, channel);
- // ESPHome parks 0 % and 100 % with ledc_stop and leaves a stale duty register.
- // Restart the channel at the level it believes, unless a fade already owns it.
- float current = 0;
- light.current_values_as_brightness(¤t);
- const uint32_t from = hardware_owns ? ledc_get_duty(speed, channel) : static_cast(std::lround(current * max_duty));
- ledc_set_duty(speed, channel, from);
- ledc_update_duty(speed, channel);
- hardware_owns = true;
- const auto target = static_cast(std::lround(light.gamma_correct_lut(std::clamp(level, 0.0f, 1.0f)) * max_duty));
- return ledc_set_fade_with_time(speed, channel, target, ms) == ESP_OK &&
- ledc_fade_start(speed, channel, LEDC_FADE_NO_WAIT) == ESP_OK;
-#endif
-}
-// Hand the finished level back to ESPHome so HA and later transitions start from the truth.
-inline void sync(esphome::ledc::LEDCOutput &output, esphome::light::LightState &light, float level) {
-#ifdef SOC_LEDC_SUPPORT_FADE_STOP
- if (hardware_owns) ledc_fade_stop(mode(Peek::channel(output)), static_cast(Peek::channel(output)));
-#endif
- light.turn_on().set_transition_length(0).set_brightness(std::clamp(level, 0.0f, 1.0f)).perform();
- hardware_owns = false;
-}
-} // namespace backlight_fade
diff --git a/components/smart_display/camera_view.h b/components/smart_display/camera_view.h
new file mode 100644
index 0000000..0f76390
--- /dev/null
+++ b/components/smart_display/camera_view.h
@@ -0,0 +1,50 @@
+#pragma once
+// A camera image full screen (firmware 0.2.57+, Guition): when to ask ESP Screen Manager for a link and when to load it
+// again. Pure bookkeeping, free of LVGL and ESPHome, so tests/test_camera_view.cpp can check it on a PC; runtime_tiles.h
+// draws the view and the board's online_image loads the link.
+//
+// The app answers `esphome.screen_camera` with a link to a BMP it keeps fresh (screen_manager/app/camera_feed.py).
+// Loading shares the main loop with touch and drawing, so the view loads one image at a time and leaves time free after
+// each. The rhythm is fixed rather than as fast as the Wi-Fi allows: a picture every four seconds, every time, reads as
+// smoother than one after three seconds and the next after five. Never a queue: a slow load makes the next one later.
+#include
+#include
+
+namespace camera_view {
+constexpr uint32_t REFRESH_MS = 4000; // from the start of one load to the start of the next: a steady rhythm
+constexpr uint32_t GAP_MS = 800; // at least this long between the end of one load and the next
+constexpr uint32_t ASK_AGAIN_MS = 10000; // a link that does not come (or an app without an image) is asked for again
+constexpr uint8_t MAX_FAILURES = 3; // a link that fails this often in a row is old: ask for a new one
+constexpr uint32_t PENDING_MS = 20000; // an alert's camera announced this long before the alert still belongs to it
+
+struct Feed {
+ std::string entity, url;
+ uint32_t asked_at = 0, started_at = 0, finished_at = 0;
+ bool asked = false, loading = false, shown = false, empty = false; // empty: the app said it has no image
+ uint8_t failures = 0;
+
+ void open(const std::string &camera) { *this = Feed{}; entity = camera; }
+ bool open() const { return !entity.empty(); }
+ bool should_ask(uint32_t now) const { return open() && !loading && url.empty() && (!asked || now - asked_at >= ASK_AGAIN_MS); }
+ void ask(uint32_t now) { asked = true; asked_at = now; }
+ // The app's answer: a link, or none (no image from Home Assistant, or a camera this screen may not show).
+ void link(const std::string &address) {
+ url = address;
+ empty = address.empty();
+ failures = 0;
+ started_at = finished_at = 0;
+ }
+ bool should_load(uint32_t now) const {
+ if (!open() || loading || url.empty()) return false;
+ if (!started_at) return true;
+ return now - started_at >= REFRESH_MS && now - finished_at >= GAP_MS;
+ }
+ void start(uint32_t now) { loading = true; started_at = now ? now : 1; }
+ void finish(uint32_t now, bool ok) {
+ loading = false;
+ finished_at = now;
+ if (ok) { shown = true; failures = 0; return; }
+ if (++failures >= MAX_FAILURES) { url.clear(); asked = false; failures = 0; }
+ }
+};
+} // namespace camera_view
diff --git a/components/smart_display/guition_diagnostics.h b/components/smart_display/guition_diagnostics.h
index 13b94f1..9d4b273 100644
--- a/components/smart_display/guition_diagnostics.h
+++ b/components/smart_display/guition_diagnostics.h
@@ -23,9 +23,13 @@ inline bool check_geometry(lv_obj_t *obj) {
}
#if LV_USE_SNAPSHOT
inline lv_draw_buf_t *snapshot = nullptr;
+// LVGL's top layer (an alert, a camera full screen) when something is shown on it, with its transparency.
+inline lv_draw_buf_t *overlay = nullptr;
inline int snapshot_row = 0;
inline void snapshot_begin() {
if (snapshot) lv_draw_buf_destroy(snapshot);
+ if (overlay) lv_draw_buf_destroy(overlay);
+ overlay = nullptr;
snapshot = lv_snapshot_take(lv_screen_active(), LV_COLOR_FORMAT_RGB565);
snapshot_row = 0;
if (!snapshot || snapshot->header.w != 480 || snapshot->header.h != 480) {
@@ -34,7 +38,24 @@ inline void snapshot_begin() {
ESP_LOGE("ui_image", "IMAGE FAIL allocation/dimensions");
return;
}
- ESP_LOGI("ui_image", "IMAGE BEGIN 240 240");
+ for (uint32_t i = 0; i < lv_obj_get_child_count(lv_layer_top()); ++i)
+ if (!lv_obj_has_flag(lv_obj_get_child(lv_layer_top(), i), LV_OBJ_FLAG_HIDDEN)) {
+ overlay = lv_snapshot_take(lv_layer_top(), LV_COLOR_FORMAT_ARGB8888);
+ if (overlay && (overlay->header.w != 480 || overlay->header.h != 480)) { lv_draw_buf_destroy(overlay); overlay = nullptr; }
+ break;
+ }
+ ESP_LOGI("ui_image", "IMAGE BEGIN 240 240%s", overlay ? " with top layer" : "");
+}
+// The top layer's pixel over the page's, as the panel shows them.
+inline uint16_t layered(uint16_t pixel, int x, int y) {
+ if (!overlay) return pixel;
+ const uint8_t *p = overlay->data + y * overlay->header.stride + x * 4; // B, G, R, A
+ const unsigned alpha = p[3];
+ if (!alpha) return pixel;
+ const unsigned r = ((pixel >> 11) & 31) * 255 / 31, g = ((pixel >> 5) & 63) * 255 / 63, b = (pixel & 31) * 255 / 31;
+ const unsigned mr = (p[2] * alpha + r * (255 - alpha)) / 255, mg = (p[1] * alpha + g * (255 - alpha)) / 255,
+ mb = (p[0] * alpha + b * (255 - alpha)) / 255;
+ return static_cast((mr * 31 / 255) << 11 | (mg * 63 / 255) << 5 | (mb * 31 / 255));
}
inline void snapshot_next_row() {
if (!snapshot) return;
@@ -43,7 +64,7 @@ inline void snapshot_next_row() {
static constexpr char hex[] = "0123456789abcdef";
char line[961];
for (int x = 0; x < 240; ++x) {
- uint16_t pixel = row[x * 2];
+ uint16_t pixel = layered(row[x * 2], x * 2, snapshot_row * 2);
for (int digit = 0; digit < 4; ++digit)
line[x * 4 + digit] = hex[(pixel >> (12 - digit * 4)) & 15];
}
@@ -52,6 +73,8 @@ inline void snapshot_next_row() {
if (++snapshot_row == 240) {
lv_draw_buf_destroy(snapshot);
snapshot = nullptr;
+ if (overlay) lv_draw_buf_destroy(overlay);
+ overlay = nullptr;
ESP_LOGI("ui_image", "IMAGE COMPLETE");
}
}
diff --git a/components/smart_display/runtime_model.h b/components/smart_display/runtime_model.h
index c51da7d..1e37a0c 100644
--- a/components/smart_display/runtime_model.h
+++ b/components/smart_display/runtime_model.h
@@ -23,7 +23,7 @@ inline bool valid_entity(const std::string &entity) {
std::string domain = entity.substr(0, dot);
// screen.* are built-in cards without a Home Assistant entity behind them.
if (domain == "screen") return entity == "screen.clock" || entity == "screen.settings";
- for (const auto *allowed : {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor", "binary_sensor", "input_select", "select", "number", "input_number", "weather", "media_player", "button", "input_button", "sun", "timer", "person"})
+ for (const auto *allowed : {"light", "switch", "input_boolean", "scene", "script", "climate", "vacuum", "fan", "cover", "sensor", "binary_sensor", "input_select", "select", "number", "input_number", "weather", "media_player", "button", "input_button", "sun", "timer", "person", "camera", "image"})
if (domain == allowed) return true;
return false;
}
diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h
index c14658f..22b7a30 100644
--- a/components/smart_display/runtime_tiles.h
+++ b/components/smart_display/runtime_tiles.h
@@ -11,6 +11,7 @@
#include "light_controls.h"
#include "tile_controls.h"
#include "history_view.h"
+#include "camera_view.h"
#include "swipe_profile.h"
#include "esphome/components/json/json_util.h"
#include "esphome/components/api/api_server.h"
@@ -71,6 +72,10 @@ inline const char *weather_text(const std::string &condition);
inline std::string timer_text(const Tile &t);
inline std::string last_run_text(uint32_t epoch);
inline void history_received();
+// Camera images full screen and on an alert (firmware 0.2.57+, the Guition binds them; see the end of this file).
+inline void camera_open(const std::string &entity, const std::string &name);
+inline void camera_answer(const std::string &view, const std::string &entity, const std::string &url);
+inline bool camera_supported();
inline uint32_t domain_accent(const Tile &t);
inline const char *icon_for(const Tile &tile);
inline void label(lv_obj_t *obj, const std::string &text);
@@ -340,6 +345,16 @@ inline std::string receive(const std::string &payload) {
result = model.ready() ? "Synced" : "Loading tiles";
return true;
}
+ if (op == "camera") {
+ // A link to a camera's image (app 0.2.66+): the answer to camera_request ("full"), or an alert's image ("alert",
+ // announced with an empty link before show_alert and sent again with the link). Only ESP Screens' own port.
+ const std::string view = string(root["t"], 8), entity = string(root["e"], 120), url = string(root["u"], 240);
+ if (!valid_entity(entity) || (view != "full" && view != "alert")) return false;
+ if (!url.empty() && url.rfind("http://", 0) != 0) return false;
+ camera_answer(view, entity, url);
+ result = model.ready() ? "Synced" : "Loading tiles";
+ return true;
+ }
if (op == "history") {
// A detail card's history (app 0.2.59+), the answer to history_request. Checked whole before it replaces
// the one the screen holds.
@@ -1684,6 +1699,7 @@ inline const char *icon_for(const Tile &tile) {
if (d == "timer") return "\U000F051B";
if (d == "person") return "\U000F0004";
if (d == "screen") return tile.is_settings() ? "\U000F0493" : "\U000F0150";
+ if (d == "camera" || d == "image") return "\U000F07AE";
return "\U000F0425";
}
// HA duration strings ("0:05:00") to seconds; 0 when unusable.
@@ -1749,6 +1765,8 @@ inline void event(lv_event_t *event) {
auto d = tile.domain();
// Scenes/scripts often have timestamps or 'off'; unavailable devices never act.
if (!tile.available() || tile.loading(esphome::millis()) || tile.tap=="none") return;
+ // A camera or an image entity opens full screen on a board that draws images (firmware 0.2.57+).
+ if(d=="camera" || d=="image"){if(camera_supported())camera_open(tile.entity,tile.name);return;}
bool open = code == LV_EVENT_LONG_PRESSED || d == "climate" || d == "vacuum" || d == "cover";
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="detail")open=true;
if(code==LV_EVENT_SHORT_CLICKED && tile.tap=="toggle")open=false;
@@ -2395,6 +2413,8 @@ inline void render_slot(size_t slot) {
else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? t.extra().sunrise+" - "+t.extra().sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon";
else if (d == "timer") value = timer_text(t);
else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run);
+ else if (d == "camera") value = t.state == "streaming" ? "Live" : t.state == "recording" ? "Recording" : "Tap to view";
+ else if (d == "image") value = t.last_run ? "Tap to view" : "No image yet";
else if (d == "binary_sensor" && (value == "on" || value == "off")) value = tile_controls::binary_state_text(t.device_class, value == "on");
else if (value == "on") value = "On";
else if (value == "off") value = "Off";
@@ -3084,3 +3104,261 @@ inline std::string vacuum_option(unsigned index) {
return index < speeds.size() ? speeds[index] : "";
}
}
+
+// ---- Camera images (firmware 0.2.57+) ----
+// A camera or image tile, and an alert's image, open the camera full screen: the image as large as fits, the round back
+// key at the top left like on every card, the name beside it. ESP Screen Manager fetches the snapshot, sizes it for this
+// screen and serves it on its own port; camera_view::Feed decides when to ask for a link and when to load it again. The
+// board binds the two online_images (the full view and the alert's frame) through ImageHooks; a board without them (the
+// CYD) never opens the view. Nothing here exists while no camera is open, apart from the alert's small frame.
+namespace runtime_tiles {
+struct ImageHooks {
+ std::function load; // set the online_image's URL and download it
+ std::function release; // free the decoded image
+ std::function source; // the decoded image for LVGL
+};
+inline ImageHooks camera_full, camera_thumb;
+inline camera_view::Feed camera;
+// Freeing the image waits for the next tick: ending a download under way can take a few hundred ms, and Back should
+// 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;
+// 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;
+inline std::function alert_room; // the board makes the card taller for the frame, or back
+inline std::string alert_announced, alert_camera, alert_url;
+inline uint32_t alert_announced_at = 0, alert_retry_at = 0;
+inline uint8_t alert_retries = 0;
+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
+
+inline bool camera_supported() { return static_cast(camera_full.load); }
+inline bool camera_visible() { return camera_root != nullptr; }
+
+// Asks ESP Screen Manager for a link (app 0.2.66+ answers with op "camera"). An event, like history_request.
+inline void camera_request(const std::string &entity) {
+ if (inbox.empty()) return;
+ esphome::api::HomeassistantActionRequest request;
+ request.service = esphome::StringRef("esphome.screen_camera");
+ request.is_event = true;
+ const std::string keys[] = {"inbox", "entity"}, values[] = {inbox, entity};
+ request.data.init(2);
+ for (int i = 0; i < 2; ++i) {
+ esphome::api::HomeassistantServiceMap entry;
+ entry.key = esphome::StringRef(keys[i]);
+ entry.value = esphome::StringRef(values[i]);
+ request.data.push_back(entry);
+ }
+ esphome::api::global_api_server->send_homeassistant_action(request);
+ ESP_LOGI("camera", "asked for %s", entity.c_str());
+}
+
+inline void camera_note_text(const char *text) {
+ if (!camera_note) return;
+ 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);
+}
+
+inline void camera_release() {
+ camera_release_due = false;
+ if (camera_full.release) camera_full.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_release_due = true;
+ ESP_LOGI("camera", "closed %s", camera.entity.c_str());
+ camera = camera_view::Feed{};
+}
+
+inline void camera_open(const std::string &entity, const std::string &name) {
+ if (!camera_supported() || !valid_entity(entity)) return;
+ camera_close();
+ // The last camera's image goes before this one loads into the same online_image.
+ if (camera_release_due) camera_release();
+ camera.open(entity);
+ const int width = lv_display_get_horizontal_resolution(lv_display_get_default());
+ const bool large = width >= 480;
+ // On the top layer: above the tiles, every card and an alert, which is there again after Back.
+ camera_root = lv_obj_create(lv_layer_top());
+ lv_obj_remove_style_all(camera_root);
+ lv_obj_set_size(camera_root, lv_pct(100), lv_pct(100));
+ lv_obj_remove_flag(camera_root, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_add_flag(camera_root, LV_OBJ_FLAG_CLICKABLE); // nothing reaches the tiles below
+ lv_obj_set_style_bg_color(camera_root, theme::color(theme::CAMERA_PAGE), 0);
+ lv_obj_set_style_bg_opa(camera_root, LV_OPA_COVER, 0);
+ camera_note = lv_label_create(camera_root);
+ 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");
+ // 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);
+ lv_obj_remove_style_all(camera_back);
+ lv_obj_set_pos(camera_back, bar_x, bar_y);
+ lv_obj_set_size(camera_back, bar, bar);
+ lv_obj_add_flag(camera_back, LV_OBJ_FLAG_CLICKABLE);
+ lv_obj_set_style_radius(camera_back, LV_RADIUS_CIRCLE, 0);
+ lv_obj_set_style_bg_opa(camera_back, LV_OPA_COVER, 0);
+ lv_obj_set_style_bg_color(camera_back, theme::color(theme::KEY), 0);
+ lv_obj_set_style_bg_color(camera_back, theme::color(theme::KEY_PRESSED), LV_STATE_PRESSED);
+ auto *arrow = lv_label_create(camera_back);
+ if (mini_icon_font) lv_obj_set_style_text_font(arrow, mini_icon_font, 0);
+ lv_obj_set_style_text_color(arrow, theme::color(theme::INK), 0);
+ lv_label_set_text(arrow, "\U000F004D");
+ lv_obj_center(arrow);
+ lv_obj_add_event_cb(camera_back, [](lv_event_t *) {
+ // Closed after this event: the key that sends it goes with the view.
+ lv_async_call([](void *) { camera_close(); }, nullptr);
+ }, LV_EVENT_SHORT_CLICKED, nullptr);
+ const lv_font_t *title_font = watch_font ? watch_font : detail_font;
+ camera_title = lv_label_create(camera_root);
+ if (title_font) lv_obj_set_style_text_font(camera_title, title_font, 0);
+ lv_obj_set_style_text_color(camera_title, theme::color(theme::CAMERA_INK), 0);
+ lv_obj_set_style_text_align(camera_title, LV_TEXT_ALIGN_CENTER, 0);
+ lv_label_set_long_mode(camera_title, LV_LABEL_LONG_DOT);
+ lv_obj_set_pos(camera_title, bar_x + bar + 8, bar_y + (bar - (title_font ? lv_font_get_line_height(title_font) : 20)) / 2);
+ 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());
+}
+
+// The board's interval (250 ms): ask for a link, or load the image again when it is time. Loading happens here, in
+// ESPHome's loop, and never while the screen is in standby.
+inline void camera_tick() {
+ const uint32_t now = esphome::millis();
+ if (camera_release_due && !camera_root) camera_release();
+ if (alert_retry_at && now >= alert_retry_at) {
+ alert_retry_at = 0;
+ if (!alert_camera.empty() && !alert_url.empty() && camera_thumb.load) {
+ alert_thumb_loading = true;
+ camera_thumb.load(alert_url);
+ }
+ }
+ if (!camera_root || !awake()) return;
+ if (camera.should_ask(now)) {
+ if (!fresh()) return;
+ 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);
+ }
+}
+
+inline void alert_picture_clear() {
+ alert_retry_at = 0;
+ if (alert_picture) { lv_obj_delete(alert_picture); alert_picture = nullptr; }
+ if (alert_frame_icon) lv_obj_remove_flag(alert_frame_icon, LV_OBJ_FLAG_HIDDEN);
+ if (alert_thumb_loading || alert_camera.size()) { if (camera_thumb.release) camera_thumb.release(); }
+ alert_thumb_loading = false;
+}
+
+// alert_show: the card gets its frame when the app announced a camera for it just before. A new alert closes the camera.
+inline void alert_prepare() {
+ camera_close();
+ alert_picture_clear();
+ const bool with_image = camera_supported() && alert_frame && !alert_announced.empty() &&
+ esphome::millis() - alert_announced_at < camera_view::PENDING_MS;
+ alert_camera = with_image ? alert_announced : std::string();
+ alert_url.clear();
+ alert_announced.clear();
+ if (alert_frame) {
+ if (with_image) lv_obj_remove_flag(alert_frame, LV_OBJ_FLAG_HIDDEN);
+ else lv_obj_add_flag(alert_frame, LV_OBJ_FLAG_HIDDEN);
+ }
+ if (alert_room) alert_room(with_image);
+}
+
+// alert_dismiss: the frame goes, and so does its image.
+inline void alert_clear() {
+ alert_picture_clear();
+ alert_camera.clear();
+ alert_url.clear();
+ if (alert_frame) lv_obj_add_flag(alert_frame, LV_OBJ_FLAG_HIDDEN);
+}
+
+inline void camera_answer(const std::string &view, const std::string &entity, const std::string &url) {
+ if (!camera_supported()) return;
+ 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");
+ return;
+ }
+ if (url.empty()) { // announced before its alert
+ alert_announced = entity;
+ alert_announced_at = esphome::millis();
+ return;
+ }
+ if (entity != alert_camera || !alert_frame || lv_obj_has_flag(alert_frame, LV_OBJ_FLAG_HIDDEN)) return;
+ alert_picture_clear();
+ alert_url = url;
+ alert_retries = 0;
+ alert_thumb_loading = true;
+ camera_thumb.load(url);
+}
+
+// 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) {
+#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);
+ lv_image_set_src(picture, source);
+ lv_obj_center(picture);
+ return;
+ }
+ if (!fresh_pixels) return;
+ // The same buffer with new pixels. LVGL keeps no decoded copy of an RGB565 image (its image cache is off in
+ // ESPHome's build), so drawing the area again shows them.
+ lv_image_set_src(picture, source);
+ lv_obj_invalidate(picture);
+#else
+ (void) parent; (void) picture; (void) source; (void) fresh_pixels;
+#endif
+}
+
+// The board's online_image triggers. `thumb`: the alert's frame; `cached`: the app answered 304, the image is unchanged.
+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);
+ if (alert_picture && alert_frame_icon) lv_obj_add_flag(alert_frame_icon, LV_OBJ_FLAG_HIDDEN);
+ return;
+ }
+ if (!camera_root) return;
+ camera.finish(esphome::millis(), true);
+ const bool first = camera_picture == nullptr;
+ camera_show(camera_root, camera_picture, camera_full.source(), !cached);
+ if (first && camera_picture) {
+ camera_note_text("");
+ lv_obj_move_foreground(camera_back);
+ lv_obj_move_foreground(camera_title);
+ }
+}
+
+inline void camera_failed(bool thumb) {
+ if (thumb) {
+ alert_thumb_loading = false;
+ if (!alert_camera.empty() && !alert_picture && alert_retries < ALERT_IMAGE_RETRIES) {
+ ++alert_retries;
+ alert_retry_at = esphome::millis() + 1500;
+ }
+ return;
+ }
+ if (!camera_root) return;
+ camera.finish(esphome::millis(), false);
+ if (!camera.shown) camera_note_text("No image from this camera");
+}
+} // namespace runtime_tiles
diff --git a/components/smart_display/theme.h b/components/smart_display/theme.h
index ff49afe..35b7639 100644
--- a/components/smart_display/theme.h
+++ b/components/smart_display/theme.h
@@ -73,6 +73,10 @@ enum Role : uint8_t {
AMBER_TRACK, // the track of a light's brightness slider
HISTORY_OFF, // an off or away stretch on a timeline (the add-on's CFCFCF)
HISTORY_EMPTY, // a stretch without data on a timeline (the add-on's E6E6E6)
+ // ---- 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"
// ---- the CYD's touch calibration: dark in both looks, a yellow crosshair
CALIBRATION_PAGE,
CALIBRATION_INK,
@@ -137,6 +141,9 @@ inline constexpr Pair ROLES[ROLE_COUNT] = {
/* AMBER_TRACK */ {0xFDEFC3, 0x483B16},
/* HISTORY_OFF */ {0xCFCFCF, 0x4A4A4A},
/* HISTORY_EMPTY */ {0xE6E6E6, 0x2A2A2A},
+ /* CAMERA_PAGE */ {0x000000, 0x000000},
+ /* CAMERA_INK */ {0xF2F2F2, 0xDADADA},
+ /* CAMERA_NOTE */ {0x9E9E9E, 0x8A8A8A},
/* CALIBRATION_PAGE */ {0x101820, 0x101820},
/* CALIBRATION_INK */ {0xFFFFFF, 0xFFFFFF},
/* CALIBRATION_MARK */ {0xFFD34D, 0xFFD34D},
@@ -281,7 +288,7 @@ inline lv_color_t rgb(uint32_t value) { return lv_color_hex(value); }
constexpr uint8_t NONE = 0xFF;
enum class Paint : uint8_t {
page, page_soft, page_pressed, card, raised, key, key_pressed, track, ink, ink_soft, muted, subtle, slate,
- scrim, veil, accent_tint, accent_bright, button_dark, button_dark_pressed, knob, spinner, calibration, COUNT
+ scrim, veil, accent_tint, accent_bright, button_dark, button_dark_pressed, knob, spinner, calibration, camera, COUNT
};
struct PaintRoles { uint8_t bg, border, text, line, arc; };
inline constexpr PaintRoles PAINTS[static_cast(Paint::COUNT)] = {
@@ -307,6 +314,7 @@ inline constexpr PaintRoles PAINTS[static_cast(Paint::COUNT)] = {
/* knob */ {KNOB, NONE, NONE, NONE, NONE},
/* spinner */ {NONE, NONE, NONE, NONE, SPINNER_TRACK},
/* calibration */ {CALIBRATION_PAGE, NONE, CALIBRATION_INK, NONE, NONE},
+ /* camera */ {CAMERA_PAGE, NONE, CAMERA_NOTE, NONE, NONE},
};
// Fill a style with a paint in the current look. Setting a property the style already has allocates nothing.
inline void fill(lv_style_t *style, Paint paint) {
diff --git a/docs/CAMERA.md b/docs/CAMERA.md
new file mode 100644
index 0000000..0131dba
--- /dev/null
+++ b/docs/CAMERA.md
@@ -0,0 +1,84 @@
+# Camera images on a Guition
+
+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.
+- **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.
+
+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
+camera tiles for it.
+
+## A doorbell
+
+```yaml
+triggers:
+ - trigger: state
+ entity_id: event.front_door_ding
+actions:
+ - event: esp_screens_show_alert
+ event_data:
+ title: Someone is at the door
+ icon: doorbell
+ camera: camera.front_door
+ timeout: 60
+```
+
+Any `camera.*` entity works, and so does an `image.*` entity, such as the snapshot a doorbell or
+motion integration keeps of its last event.
+
+## How the image gets to the screen
+
+The screen never talks to Home Assistant about images, and it never holds a Home Assistant token.
+
+1. The screen asks ESP Screen Manager for a camera (the event `esphome.screen_camera`), or the app
+ sends the picture of an alert by itself.
+2. The app fetches the snapshot from Home Assistant with its own access (the same pictures the
+ Home Assistant frontend shows), and makes it exactly as large as the screen draws it: at most
+ 480×480 full screen, 392×220 on an alert card, proportions kept.
+3. It serves the result as an uncompressed 24-bit BMP on **port 8098** under a random link, and
+ sends the link to the screen. ESPHome's `online_image` loads it.
+
+While a camera is open, the screen loads its link every four seconds, one image at a time. The app
+serves the last snapshot at once and starts fetching the next one, so each load gets a picture one
+load younger: the picture changes at the screen's steady pace. (A fetch on its own clock next to the
+screen's made the picture change after 1.5 s one time and 6 s the next.) A slow camera makes the
+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.
+
+## Network
+
+- **Home Assistant OS:** the app publishes port 8098 on the Home Assistant host. Keep it at 8098 in
+ the app's network settings; the screens need to reach Home Assistant's address on that port.
+- **Docker** (docs/DOCKER.md): the container uses the host network, so 8098 is open as is. When
+ the screens reach the host under another address, set `SCREEN_CAMERA_URL`, for example
+ `http://192.168.1.20:8098`.
+- The images travel unencrypted over the LAN, like the screens' other HTTP traffic. Links are
+ random, short-lived, and only issued for a camera on that screen's tiles or in a recent alert.
+
+## Memory and speed (Guition, measured 2026-09-17)
+
+- Full screen: 480×270 RGB565 is 259 KB of PSRAM; the alert picture 172 KB. Both are freed when
+ the camera or the alert closes. A new alert closes an open camera first.
+- 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.
+
+## For developers
+
+- `screen_manager/app/camera_feed.py`: fetching, sizing, links and the port.
+- `components/smart_display/camera_view.h`: when to ask for a link and when to load again
+ (`tests/test_camera_view.cpp`).
+- `components/smart_display/runtime_tiles.h`: the full-screen view, the alert picture and the
+ `camera` message.
+- `guition-4848s040.yaml`: the two `online_image` components, the alert frame, and the diagnostic
+ action `preview_camera` (an entity opens it, an empty entity closes it).
+- `tests/test_camera.py`: the app side and the words both sides share.
diff --git a/docs/DOCKER.md b/docs/DOCKER.md
index c9772da..3b1becf 100644
--- a/docs/DOCKER.md
+++ b/docs/DOCKER.md
@@ -80,6 +80,10 @@ under **Settings → Devices & services** in Home Assistant, and then choose the
ESPHome folder. `data/build` and `data/platformio` are caches.
- **Claude:** **Install for Claude Code** is meant for the Claude Code app on Home Assistant
OS. Use **Download for claude.ai** instead.
+- **Camera images (Guition, app 0.2.66+):** the screens load camera pictures from port **8098**
+ of this host, on all its addresses, so keep that port open to the screens. The app uses Home
+ Assistant's own LAN address; when the screens reach this host under another one, set
+ `SCREEN_CAMERA_URL` (see below). The links are random and short-lived. [docs/CAMERA.md](CAMERA.md)
## Updating
@@ -96,8 +100,10 @@ After an update, ESP Screens shows per screen whether newer firmware is availabl
| Setting | What it does |
| --- | --- |
-| `SCREEN_DEV: "1"` | Runs the app outside the Supervisor: it reads the token from `HA_TOKEN_FILE` and only listens on 127.0.0.1 |
+| `SCREEN_DEV: "1"` | Runs the app outside the Supervisor: it reads the token from `HA_TOKEN_FILE` and serves its page on 127.0.0.1 only (camera images still on port 8098 for the screens) |
| `HA_API` | Home Assistant's address, ending in `/api` |
| `HA_TOKEN_FILE` | The token file inside the container (the `ha_token` secret) |
| `ESPHOME_CONFIG` | The ESPHome folder inside the container |
| `SCREEN_DATA` | Layouts, update settings, and build caches |
+| `SCREEN_CAMERA_URL` | Optional: where screens load camera images, such as `http://192.168.1.20:8098`; by default Home Assistant's own LAN address |
+| `SCREEN_CAMERA_PORT` | Optional: the camera image port instead of 8098 |
diff --git a/docs/GUITION.md b/docs/GUITION.md
index 4165b9f..70cee11 100644
--- a/docs/GUITION.md
+++ b/docs/GUITION.md
@@ -44,10 +44,10 @@ four pages; navigation disappears at six or fewer. The current interface has a
light gray background, white cards, and colored domain icons. From 0.2.10, you
can choose a pastel background with dark text per tile. Standby starts
after ten minutes without touch by default and is adjustable in the management page.
-From 0.2.13, the backlight dims via the LEDC hardware fader (`backlight_fade.h`,
-1.5 s to standby, 80 ms to wake): the full LVGL redraw on standby no longer
-interrupts the transition. ESPHome's light state is synced after the fade,
-so the entity in HA and later transitions stay correct.
+The backlight dims through ESPHome's own light transition (1.5 s to standby, 80 ms to
+wake). Firmware 0.2.13-0.2.56 used the LEDC hardware fader (`backlight_fade.h`) instead;
+0.2.57 went back to ESPHome's transition, because that fader reached into ESPHome's LEDC
+output, which ESPHome 2026.8 no longer allows.
The existing tile actions, climate control, and vacuum card are preserved.
Every card type works in all twenty positions.
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index 1a1a636..af0d348 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -176,6 +176,51 @@ icons sit off-center in the browser); run `generate_packages.py` afterward.
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys.
+### Compatibility 0.2.66 / firmware 0.2.57
+
+Camera images on the Guition (docs/CAMERA.md). Storage, keys, preferences and the settings block are unchanged; the
+layout message is unchanged apart from two tile domains, and the `show_alert` action keeps its seven fields.
+
+- New tile domains `camera` and `image` in `core.DOMAINS` and the firmware's `valid_entity()`. `min_firmware()` asks
+ 0.2.57 for them, and `Manager.save` refuses them for a screen whose board is not in `camera_feed.BOXES` (the CYD)
+ before it asks for firmware, so a CYD never hears "update first". The editor hides them for other boards; the top
+ bar refuses them (`header_entity`). An `image` tile carries `x.last` like a scene. Older firmware never gets these
+ tiles because of `min_firmware`.
+- New protocol, all optional: the screen fires `esphome.screen_camera` {inbox, entity} when a camera opens; the app
+ answers through `screen_message` with op `camera` {t: `full`|`alert`, e, u}. An empty `u` means no image. For an
+ alert the app sends op `camera` with `t: alert` and an empty `u` to screens that can show it (`camera_feed.can_show`:
+ a Guition on 0.2.57+) BEFORE `show_alert`, so the card opens with room, and the link after it. `show_alert` has no
+ camera field on purpose: Home Assistant requires every field of an ESPHome action, so a new field would break every
+ automation calling `esphome._show_alert` directly. The camera is only a field of the
+ `esp_screens_show_alert` event (`core.alert_camera`, `ALERT_CAMERA_FIELD`).
+- The app publishes port 8098 (`config.yaml` `ports`) and serves `GET /camera/.bmp` without the ingress guard.
+ Tokens are 24 random URL-safe characters, per camera and size; live links expire 120 s after their last use, an
+ alert's still after 30 min; at most 64 links. Only a camera on the screen's layout or in an alert of the last
+ 30 min gets one. The address is Home Assistant's default adapter from the `network` websocket command, else the
+ host of `network/url`; `SCREEN_CAMERA_URL` / `SCREEN_CAMERA_PORT` override it (Docker). A port that cannot be
+ bound logs an error and leaves the rest of the app running.
+- Images are 24-bit BMP, not JPEG: ESPHome 2026.6.2 decodes a JPEG in one loop call (623 ms for 480x270 on the
+ Guition) and a BMP per downloaded chunk (`buffer_size: 4096`). Boxes: full 480x480, alert 392x220, proportions kept.
+- Fetching follows the screen: serving a live link starts the next snapshot fetch (`CameraFeed.frame(fresh=True)`),
+ one at a time per camera, so the picture changes at the screen's pace. The screen loads every 4 s start to start with
+ at least 800 ms free after a load (`camera_view.h`), never under a finger and never in standby. An alert fetches its
+ own snapshot (`now=True`), never one kept from an earlier view. A failing camera is asked again after 5-30 s.
+- Firmware (Guition profile only): `http_request` (4 s timeout, no TLS), two top-level `online_image`s
+ (`camera_image`, `alert_image`, the form 2026.6.2 knows; 2026.7+ reads it with a deprecation note), a hidden LVGL
+ `image` seed so `LV_USE_IMAGE` is compiled, the `alert_image_frame` in the alert card, `preview_camera` for
+ diagnostics and renders, theme roles `CAMERA_PAGE/INK/NOTE` and paint `camera`. The load hooks only call `set_url()`
+ for a new link, since it forgets the ETag. `runtime_tiles.h` builds the full view on `lv_layer_top()`; closing
+ frees the image on the next tick. `LV_USE_IMAGE` guards the drawing, so the CYD (no image widget) still compiles;
+ the CYD profile binds no hooks and never opens a camera.
+- Swipes, the settings hold and Back to page 1 treat an open camera like an open card; `close_cards` (standby,
+ Back to page 1, a new layout) closes it, and a new alert closes it first.
+- `guition_diagnostics.h` snapshots composite the top layer, so `capture_ui.py` shows alerts and the camera.
+- `backlight_fade.h` is gone. It derived from `esphome::ledc::LEDCOutput` to read its channel and bit depth, and
+ ESPHome 2026.8 declared that class `final`, so the Guition package no longer compiled in ESPHome Device Builder
+ 2026.8+ (the app's own ESPHome 2026.6.2 still built it). `set_backlight` now only calls ESPHome's light transition
+ (1.5 s to standby, 80 ms to wake); a new call interrupts a running transition as ESPHome does. Both profiles and the
+ camera code were compiled with ESPHome 2026.6.2 and 2026.8.1 for this release.
+
### Compatibility 0.2.65 / firmware 0.2.56
Both board profiles and the editor page; the app raises `FIRMWARE_VERSION`. Storage, tile protocol, preferences, keys
diff --git a/docs/TEST_RESULTS_0266.md b/docs/TEST_RESULTS_0266.md
new file mode 100644
index 0000000..c2b721a
--- /dev/null
+++ b/docs/TEST_RESULTS_0266.md
@@ -0,0 +1,95 @@
+# Test results app 0.2.66 / firmware 0.2.57 (2026-09-17)
+
+Camera images on the Guition, asked for by Max: an alert that shows who is at the door, and a camera tile that opens a
+refreshed image full screen. Plus the Guition building again with ESPHome 2026.8+. See CHANGELOG 0.2.66, docs/CAMERA.md
+and the compatibility note in docs/RELEASING.md. Built on 0.2.65 (`9567ab5`).
+
+## Design decisions, with the measurement behind each
+
+- **The add-on serves the image, the screen holds no token.** Home Assistant's camera access token changes every five
+ minutes and a long-lived token on every screen opens all of Home Assistant. The app fetches with its own access and
+ serves a random, short-lived link on port 8098 (Max chose a LAN port over a file in `/config/www`).
+- **BMP, not JPEG.** On the bench Guition (Wi-Fi -70 dBm) ESPHome 2026.6.2 decoded a 480x270 JPEG in one loop call:
+ `online_image took a long time for an operation (623 ms)`, long enough to lose a tap. A 24-bit BMP is decoded per
+ 4 KB as it downloads; the longest single loop operation over a six-minute soak was 353 ms, typically under 170 ms.
+ The full-screen BMP is 388,854 bytes and downloads in 2.7-3.3 s over that Wi-Fi (the same from the Mac and from the
+ Home Assistant Yellow, so the screen's Wi-Fi is the limit).
+- **A steady rhythm.** Max saw the picture change after 3, 5, 7 s. The EZVIZ camera answered every snapshot in a
+ steady 2.4 s (10 fetches: 2.36-2.56 s), so the cause was the app fetching on its own 2.6 s clock next to the screen's
+ 3.75 s. Now each load starts the next fetch and the screen loads every 4 s start to start: pulling every 4 s against
+ the real camera gave camera timestamps 5, 4, 4, 4, 4, 3, 4, 5, 3, 5 s apart (the camera stamps whole seconds).
+- **The alert keeps the picture of its moment** (Max's choice); a tap opens the camera now. An alert fetches its own
+ snapshot, never one kept from a view in the last 30 s (found in review).
+- **Alert design** (Max): the picture across the top of the card, 392x220, title and subtitle under it.
+- **CYD: no images.** 320x180 RGB565 needs 115 KB in one piece; the CYD's largest free block is 41-53 KB.
+
+## Automated
+
+- Python (`.venv-portal`, with Pillow 12.2.0 as in the ESPHome image): 337 tests OK (334 before the review fixes).
+ New `tests/test_camera.py` (23): the rules (domains, firmware, board, header, alert field), BMP encoding of JPEG, PNG
+ with alpha, GIF and portrait snapshots (24 bits, no compression, exact size), links (304, expiry, bounds, stills),
+ fetching per load, a failing camera's pause, an alert's own snapshot, the HTTP port, the broadcast order (announce,
+ alert, link) with and without an image, requests a screen may and may not make, camera tiles only on a Guition, the
+ words firmware and app share, and the profile's `set_url` guard. `test_wake_sleep.py` counts `preview_camera` among
+ the writers of `last_use_ms`; `tile_icons` knows the cctv default.
+- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I.`): 17/17 PASS, new `tests/test_camera_view.cpp` (asking, loading
+ rhythm, the gap after a slow load, failures, an empty link).
+- `generate_packages.py --check` green. No token or Home Assistant URL in tracked files.
+- Code review (high) found three issues, all fixed with tests: the alert picture could be a snapshot kept from a view
+ up to 30 s earlier; a busy camera port stopped the whole app; `set_url()` on every load threw away the ETag, so an
+ unchanged picture was downloaded again instead of answering 304.
+
+## Builds
+
+Easy profiles with the generated packages and local components (ESPHome 2026.6.2, the app's own):
+
+| Profile | RAM | Flash | vs 0.2.65 |
+|---|---|---|---|
+| CYD | 23.8% (77,940 B) | 77.6% (1,423,815 B) | +368 B RAM, +2.6 KB flash |
+| Guition | 25.5% (83,516 B) | 20.5% (1,664,915 B) | +2.6 KB RAM, +140 KB flash (http_request, online_image, BMP decoder) |
+
+(0.2.65's numbers came from check profiles with other device names, a few bytes apart.)
+
+ESPHome 2026.8.1 (ESPHome Device Builder on Max's Home Assistant), separate build folders: both packages compile.
+Before this release the Guition package failed there: `backlight_fade.h` derived from `esphome::ledc::LEDCOutput`,
+which ESPHome 2026.8 made `final`. With Max's agreement the hardware fader is gone and the backlight uses ESPHome's own
+transition. Warnings left for later: top-level `online_image:` is removed in ESPHome 2027.1.0 (use
+`image: - platform: online_image` once min_version is 2026.7+), and `platformio_options: build_flags` in 2026.12.0.
+
+## Hardware (bench, Max's home Home Assistant, HA 2026.9.2 on a Home Assistant Yellow)
+
+The app ran as a local add-on built from this tree on the Yellow (store add-on stopped meanwhile); both screens OTA.
+
+| Check | Result |
+|---|---|
+| Guition boots 0.2.57 (compiled 15:22:34), inbox Synced, `sensor.guition_wallbox_screen_firmware` 0.2.57 | yes |
+| `diagnostics/run_ui_test.py` on the Guition | PASS: 10 page checks and 50 overlay render cycles |
+| Sleep and Wake from Home Assistant twice, ESPHome transition | no long operations logged; Max watched: "dimmen gaat mooi" |
+| Alert with `camera: camera.max` through the real event | picture after 1.8-1.9 s (258,774 B, 391x220) |
+| An `image.*` entity as the alert camera | loads (392x98: that entity's own 400x100 picture) |
+| Alert without camera, Guition and CYD | the old card, unchanged |
+| Tap on the alert picture (Max) | camera full screen; Back returns to the alert |
+| Camera tile tapped (Max), refresh | seven images, 2.73-2.79 s downloads each, Back closes it |
+| Camera open, then an alert with a camera | camera closes (PSRAM back to 6.09 MB), alert picture loads |
+| Camera opened over an alert | both images held: PSRAM 5.66 MB free; close keeps the alert |
+| Six-minute soak, 91 images | internal heap level at 77.2 KB free, PSRAM unchanged, no errors |
+| Five real alerts in a row (Max testing) | all shown, pictures 0.9-1.9 s, no failed download |
+| First image after the add-on started | one `ESP_ERR_HTTP_CONNECT`; the alert picture now retries three times |
+| Home Assistant Yellow while a camera refreshes | add-on CPU 0-1.5 %, 51 MB |
+| CYD boots 0.2.57 (compiled 15:23:49), inbox Synced | yes |
+| Alert with a camera on the CYD | shown without the picture, OK closes it |
+| Camera tile saved for the CYD | refused: "Camera images need a Guition screen." |
+
+Internal heap from the screens' own sensors, five minutes after the release build booted (the Guition's window held the
+UI self test, Sleep/Wake twice, a camera view and an alert with a picture; the CYD's a sync and an alert):
+
+| Screen | Free | Largest block | Lowest since boot | Before (earlier today) |
+|---|---|---|---|---|
+| Guition 0.2.57 | 80,108 B | 34,816 B | 56,548 B | 0.2.55 after hours of use: 83,172 / 40,960 / 72,472 B |
+| CYD 0.2.57 | 114,024 B | 57,344 B | 104,884 B | 0.2.54 after hours of use: 104,424 / 49,152 / 85,600 B |
+
+PSRAM on the Guition stays near 6.1 MB free with nothing open, 5.9 MB with a camera and 5.7 MB with a camera over an
+alert. The Guition's lowest point is lower than this morning's after a UI self test plus camera and alert in the same
+five minutes; the soak kept the heap level, so it is headroom used at once, not a leak.
+
+Not tested: the Docker route (no Linux Docker host here); a doorbell integration (Max has none, `camera.max` stood in).
diff --git a/docs/images/guition-alert-camera.png b/docs/images/guition-alert-camera.png
new file mode 100644
index 0000000..0bc75ba
Binary files /dev/null and b/docs/images/guition-alert-camera.png differ
diff --git a/docs/images/guition-camera.png b/docs/images/guition-camera.png
new file mode 100644
index 0000000..3f6f001
Binary files /dev/null and b/docs/images/guition-camera.png differ
diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml
index 1ab12f6..8fbd577 100644
--- a/guition-4848s040.yaml
+++ b/guition-4848s040.yaml
@@ -2,7 +2,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
- SCREEN_FIRMWARE_VERSION: "0.2.56"
+ SCREEN_FIRMWARE_VERSION: "0.2.57"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
@@ -69,6 +69,16 @@ substitutions:
ALERT_TITLE_MAX: "64" # bytes; the label ends one line with an ellipsis
ALERT_BUTTON_MAX: "16" # bytes on the OK button
ALERT_SUBTITLE_MAX: "240" # bytes; wraps over the rows the card has
+ # An alert with a camera image (firmware 0.2.57+): the image across the top of a taller card, the words under it.
+ ALERT_CARD_H_IMAGE: "452"
+ ALERT_IMAGE_INSET: "14"
+ ALERT_IMAGE_SHIFT: "236" # the icon, title and subtitle move down by the image and a gap
+ ALERT_SUBTITLE_H_IMAGE: "62" # two lines between the title and the button
+ # Camera images (firmware 0.2.57+): the boxes ESP Screen Manager sizes a snapshot for (camera_feed.BOXES).
+ CAMERA_FULL_W: "480"
+ CAMERA_FULL_H: "480"
+ CAMERA_THUMB_W: "392"
+ CAMERA_THUMB_H: "220"
OVERLAY_SLIDER_W: "144"
OVERLAY_SLIDER_H: "300"
OVERLAY_ICON_Y: "148"
@@ -105,7 +115,6 @@ esphome:
-
-
-
- -
-
-
name: ${DEVICE_NAME}
@@ -197,12 +206,14 @@ esphome:
theme::fill(id(paint_accent_bright), Paint::accent_bright);
theme::fill(id(paint_button_dark), Paint::button_dark);
theme::fill(id(paint_button_dark_pressed), Paint::button_dark_pressed);
+ theme::fill(id(paint_camera), Paint::camera);
};
theme::paints();
theme::set_dark(settings_screen::dark_mode != 0);
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
runtime_tiles::hide_detail();
+ runtime_tiles::camera_close();
id(active_entity).clear();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -210,6 +221,32 @@ esphome:
lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN);
};
runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); };
+ // Camera images (firmware 0.2.57+): the two online_images and the alert's frame.
+ // set_url() forgets the ETag, so it is only called for a new link: an unchanged picture then answers 304.
+ runtime_tiles::camera_full.load = [](const std::string &url) {
+ static std::string current;
+ if (url != current) { id(camera_image).set_url(url); current = url; }
+ id(camera_image).update();
+ };
+ runtime_tiles::camera_full.release = []() { id(camera_image).release(); };
+ runtime_tiles::camera_full.source = []() { return id(camera_image).get_lv_image_dsc(); };
+ runtime_tiles::camera_thumb.load = [](const std::string &url) {
+ static std::string current;
+ if (url != current) { id(alert_image).set_url(url); current = url; }
+ id(alert_image).update();
+ };
+ runtime_tiles::camera_thumb.release = []() { id(alert_image).release(); };
+ runtime_tiles::camera_thumb.source = []() { return id(alert_image).get_lv_image_dsc(); };
+ runtime_tiles::alert_frame = id(alert_image_frame);
+ runtime_tiles::alert_frame_icon = id(alert_image_icon);
+ runtime_tiles::alert_room = [](bool image) {
+ const int shift = image ? ${ALERT_IMAGE_SHIFT} : 0;
+ lv_obj_set_height(id(alert_card), image ? ${ALERT_CARD_H_IMAGE} : ${ALERT_CARD_H});
+ lv_obj_set_y(id(alert_icon), ${ALERT_ICON_Y} + shift);
+ lv_obj_set_y(id(alert_title), ${ALERT_TITLE_Y} + shift);
+ lv_obj_set_y(id(alert_subtitle), ${ALERT_SUBTITLE_Y} + shift);
+ lv_obj_set_height(id(alert_subtitle), image ? ${ALERT_SUBTITLE_H_IMAGE} : ${ALERT_SUBTITLE_H});
+ };
runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); };
runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl));
runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl));
@@ -285,7 +322,7 @@ esphome:
settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); };
// A dimmed screen, a calibration or an alert owns the glass; the page waits.
settings_screen::may_open = []() {
- return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active);
+ return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active) && !runtime_tiles::camera_visible();
};
settings_screen::drift = []() { return cyd::touch_guard.distance(); };
settings_screen::before_open = []() { id(close_cards).execute(); };
@@ -407,6 +444,20 @@ api:
}
runtime_tiles::show_detail(index);
}
+ # A camera full screen from Home Assistant (firmware 0.2.57+), as a tap on its tile opens it; an empty entity closes
+ # it. Only a camera on the screen's tiles or in a recent alert gets an image from ESP Screens.
+ - action: preview_camera
+ variables:
+ entity: string
+ then:
+ - lambda: |-
+ if (entity.empty()) { runtime_tiles::camera_close(); return; }
+ id(last_use_ms) = millis();
+ id(wake_display).execute();
+ std::string name = entity;
+ for (size_t i = 0; i < runtime_tiles::model.count; ++i)
+ if (runtime_tiles::model.tiles[i].entity == entity) { name = runtime_tiles::model.tiles[i].name; break; }
+ runtime_tiles::camera_open(entity, name);
# The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away
# (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1.
# Handy for support ("look at Screen -> Rotation") and for the render tests.
@@ -504,10 +555,43 @@ wifi:
captive_portal:
+# 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.
+http_request:
+ timeout: 4s
+ verify_ssl: false
+
+# The top-level online_image form that ESPHome 2026.6.2 (min_version) knows; 2026.7+ still reads it and suggests
+# `image: - platform: online_image`, which this profile can use once min_version reaches 2026.7.
+online_image:
+ - id: camera_image
+ url: "http://127.0.0.1/camera.bmp" # set when a camera opens
+ format: BMP
+ type: RGB565
+ buffer_size: 4096
+ on_download_finished:
+ - lambda: 'runtime_tiles::camera_loaded(false, cached);'
+ on_error:
+ - lambda: 'runtime_tiles::camera_failed(false);'
+ - id: alert_image
+ url: "http://127.0.0.1/camera.bmp" # set when an alert brings an image
+ format: BMP
+ type: RGB565
+ buffer_size: 4096
+ on_download_finished:
+ - lambda: 'runtime_tiles::camera_loaded(true, cached);'
+ on_error:
+ - lambda: 'runtime_tiles::camera_failed(true);'
+
interval:
- interval: 250ms
then:
- - lambda: runtime_tiles::tick();
+ - lambda: |-
+ runtime_tiles::tick();
+ runtime_tiles::camera_tick();
- interval: 1s
then:
- if:
@@ -1306,7 +1390,8 @@ touchscreen:
if (!step) continue;
const char *blocked = !runtime_tiles::enabled ? "no runtime tiles" : !runtime_tiles::swipe_pages ? "setting off"
: id(display_dimmed) ? "screen dimmed" : id(calibration_active) ? "calibration" : !id(active_entity).empty() ? "card open"
- : (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)) ? "detail card open" : nullptr;
+ : (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)) ? "detail card open"
+ : runtime_tiles::camera_visible() ? "camera open" : nullptr;
if (blocked) { ESP_LOGI("touch", "edge swipe ignored: %s", blocked); continue; }
cyd::touch_guard.consume();
for (auto *indev = lv_indev_get_next(nullptr); indev; indev = lv_indev_get_next(indev)) lv_indev_wait_release(indev);
@@ -1473,6 +1558,7 @@ lvgl:
- id: paint_accent_bright
- id: paint_button_dark
- id: paint_button_dark_pressed
+ - id: paint_camera
# ---------- ALERT OVERLAY (HA action show_alert) ----------
# LVGL's top layer sits above every page, card and the dim overlay; the card
@@ -1542,6 +1628,39 @@ lvgl:
text_font: sublabel_big
styles: paint_ink_soft
clickable: false
+ # 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.
+ - obj:
+ id: alert_image_frame
+ x: ${ALERT_IMAGE_INSET}
+ y: ${ALERT_IMAGE_INSET}
+ width: ${CAMERA_THUMB_W}
+ height: ${CAMERA_THUMB_H}
+ hidden: true
+ styles: paint_camera
+ bg_opa: COVER
+ radius: 0
+ border_width: 0
+ shadow_width: 0
+ pad_all: 0
+ clickable: true
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pressed:
+ bg_opa: COVER
+ on_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 14) && !runtime_tiles::alert_camera.empty();'
+ then:
+ - lambda: 'runtime_tiles::camera_open(runtime_tiles::alert_camera, id(alert_title_text));'
+ widgets:
+ - label:
+ id: alert_image_icon
+ align: CENTER
+ text: "\U000F07AE"
+ text_font: materialdesign_icons
+ clickable: false
- obj:
id: alert_ok
align: BOTTOM_RIGHT
@@ -1591,6 +1710,11 @@ lvgl:
height: 2
spin_time: 1s
arc_length: 60deg
+ # Never shown either: it makes ESPHome compile LVGL's image widget, which a camera image is drawn with.
+ - image:
+ id: camera_image_seed
+ hidden: true
+ src: alert_image
- label:
id: lbl_room
x: 16
@@ -3619,7 +3743,7 @@ script:
auto now = id(homeassistant_time).now();
const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1;
const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness;
- // Standby dims slowly; the tile redraws below no longer interrupt the hardware fade.
+ // Standby dims slowly (ESPHome's transition); waking is quick.
id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300);
if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -3669,7 +3793,7 @@ script:
lambda: |-
if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false;
if (id(display_dimmed)) return false;
- bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 ||
+ bool away = settings_screen::visible() || runtime_tiles::camera_visible() || !id(active_entity).empty() || id(tile_page) != 0 ||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN));
return away && (millis() - id(last_use_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL;
then:
@@ -3694,6 +3818,8 @@ script:
if (id(alert_active)) id(alert_report).execute("replaced");
// An alert is the one thing that outranks the settings page, so that closes first.
settings_screen::close();
+ // An open camera closes; the card makes room for an image the app announced for this alert.
+ runtime_tiles::alert_prepare();
const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX});
id(alert_active) = true;
id(alert_title_text) = alert.title;
@@ -3733,6 +3859,7 @@ script:
- lambda: |-
id(alert_active) = false;
id(last_touch_ms) = millis();
+ runtime_tiles::alert_clear();
ESP_LOGI("alert", "dismissed: %s", reason.c_str());
- lvgl.widget.hide: alert_overlay
- script.stop: alert_timeout
@@ -3789,20 +3916,16 @@ script:
- lvgl.widget.hide: dim_wake_overlay
- script.execute: close_cards
- # One owner for the backlight level: the LEDC fader steps the duty itself, so
- # LVGL redraws cannot make the transition stutter. A new call interrupts the
- # running fade from its current duty; afterwards ESPHome's light state is synced.
+ # One owner for the backlight level, through ESPHome's own light transition (firmware 0.2.57+). Firmware 0.2.13-0.2.56
+ # stepped the duty with the LEDC hardware fader by reaching into ESPHome's LEDC output, which ESPHome 2026.8 made final;
+ # a new call interrupts the running transition from where it is, as ESPHome does for any light.
- id: set_backlight
mode: restart
parameters:
level: int
ms: int
then:
- - lambda: |-
- if (!backlight_fade::start(*id(gpio_backlight_pwm), *id(back_light), level / 100.0f, ms))
- id(back_light).turn_on().set_transition_length(ms).set_brightness(level / 100.0f).perform();
- - delay: !lambda "return ms + 40;"
- - lambda: backlight_fade::sync(*id(gpio_backlight_pwm), *id(back_light), level / 100.0f);
+ - lambda: id(back_light).turn_on().set_transition_length(ms).set_brightness(level / 100.0f).perform();
# Standby: the standby time ran out (the 1 s interval checks Auto standby) or Sleep was pressed.
# The settings page and every card close; with "also on standby" the screen goes back to page 1 too.
diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml
index 79d8c47..d205429 100644
--- a/home-like-2432s028.yaml
+++ b/home-like-2432s028.yaml
@@ -17,7 +17,7 @@
############################################################
substitutions:
- SCREEN_FIRMWARE_VERSION: "0.2.56"
+ SCREEN_FIRMWARE_VERSION: "0.2.57"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
diff --git a/packages/cyd.yaml b/packages/cyd.yaml
index 6ec0a60..be9c458 100644
--- a/packages/cyd.yaml
+++ b/packages/cyd.yaml
@@ -18,7 +18,7 @@
############################################################
substitutions:
- SCREEN_FIRMWARE_VERSION: "0.2.56"
+ SCREEN_FIRMWARE_VERSION: "0.2.57"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
diff --git a/packages/guition.yaml b/packages/guition.yaml
index b4ce68c..f1b8fe4 100644
--- a/packages/guition.yaml
+++ b/packages/guition.yaml
@@ -3,7 +3,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
- SCREEN_FIRMWARE_VERSION: "0.2.56"
+ SCREEN_FIRMWARE_VERSION: "0.2.57"
# 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not
# (like iOS). The CYD keeps a limit because its resistive panel jumps.
TOUCH_MOVE_LIMIT_PX: "0"
@@ -70,6 +70,16 @@ substitutions:
ALERT_TITLE_MAX: "64" # bytes; the label ends one line with an ellipsis
ALERT_BUTTON_MAX: "16" # bytes on the OK button
ALERT_SUBTITLE_MAX: "240" # bytes; wraps over the rows the card has
+ # An alert with a camera image (firmware 0.2.57+): the image across the top of a taller card, the words under it.
+ ALERT_CARD_H_IMAGE: "452"
+ ALERT_IMAGE_INSET: "14"
+ ALERT_IMAGE_SHIFT: "236" # the icon, title and subtitle move down by the image and a gap
+ ALERT_SUBTITLE_H_IMAGE: "62" # two lines between the title and the button
+ # Camera images (firmware 0.2.57+): the boxes ESP Screen Manager sizes a snapshot for (camera_feed.BOXES).
+ CAMERA_FULL_W: "480"
+ CAMERA_FULL_H: "480"
+ CAMERA_THUMB_W: "392"
+ CAMERA_THUMB_H: "220"
OVERLAY_SLIDER_W: "144"
OVERLAY_SLIDER_H: "300"
OVERLAY_ICON_Y: "148"
@@ -109,7 +119,6 @@ esphome:
-
-
-
- -
-
-
name: ${DEVICE_NAME}
@@ -201,12 +210,14 @@ esphome:
theme::fill(id(paint_accent_bright), Paint::accent_bright);
theme::fill(id(paint_button_dark), Paint::button_dark);
theme::fill(id(paint_button_dark_pressed), Paint::button_dark_pressed);
+ theme::fill(id(paint_camera), Paint::camera);
};
theme::paints();
theme::set_dark(settings_screen::dark_mode != 0);
runtime_tiles::settings_changed = []() { id(apply_screen_settings).execute(); };
runtime_tiles::dismiss = []() {
runtime_tiles::hide_detail();
+ runtime_tiles::camera_close();
id(active_entity).clear();
lv_obj_add_flag(id(brightness_overlay), LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(id(color_detail_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -214,6 +225,32 @@ esphome:
lv_obj_add_flag(id(climate_mode_overlay), LV_OBJ_FLAG_HIDDEN);
};
runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); };
+ // Camera images (firmware 0.2.57+): the two online_images and the alert's frame.
+ // set_url() forgets the ETag, so it is only called for a new link: an unchanged picture then answers 304.
+ runtime_tiles::camera_full.load = [](const std::string &url) {
+ static std::string current;
+ if (url != current) { id(camera_image).set_url(url); current = url; }
+ id(camera_image).update();
+ };
+ runtime_tiles::camera_full.release = []() { id(camera_image).release(); };
+ runtime_tiles::camera_full.source = []() { return id(camera_image).get_lv_image_dsc(); };
+ runtime_tiles::camera_thumb.load = [](const std::string &url) {
+ static std::string current;
+ if (url != current) { id(alert_image).set_url(url); current = url; }
+ id(alert_image).update();
+ };
+ runtime_tiles::camera_thumb.release = []() { id(alert_image).release(); };
+ runtime_tiles::camera_thumb.source = []() { return id(alert_image).get_lv_image_dsc(); };
+ runtime_tiles::alert_frame = id(alert_image_frame);
+ runtime_tiles::alert_frame_icon = id(alert_image_icon);
+ runtime_tiles::alert_room = [](bool image) {
+ const int shift = image ? ${ALERT_IMAGE_SHIFT} : 0;
+ lv_obj_set_height(id(alert_card), image ? ${ALERT_CARD_H_IMAGE} : ${ALERT_CARD_H});
+ lv_obj_set_y(id(alert_icon), ${ALERT_ICON_Y} + shift);
+ lv_obj_set_y(id(alert_title), ${ALERT_TITLE_Y} + shift);
+ lv_obj_set_y(id(alert_subtitle), ${ALERT_SUBTITLE_Y} + shift);
+ lv_obj_set_height(id(alert_subtitle), image ? ${ALERT_SUBTITLE_H_IMAGE} : ${ALERT_SUBTITLE_H});
+ };
runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); };
runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl));
runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl));
@@ -289,7 +326,7 @@ esphome:
settings_screen::restart_device = []() { ESP_LOGI("settings", "restart from the screen"); App.safe_reboot(); };
// A dimmed screen, a calibration or an alert owns the glass; the page waits.
settings_screen::may_open = []() {
- return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active);
+ return runtime_tiles::enabled && !id(display_dimmed) && !id(calibration_active) && !id(alert_active) && !runtime_tiles::camera_visible();
};
settings_screen::drift = []() { return cyd::touch_guard.distance(); };
settings_screen::before_open = []() { id(close_cards).execute(); };
@@ -411,6 +448,20 @@ api:
}
runtime_tiles::show_detail(index);
}
+ # A camera full screen from Home Assistant (firmware 0.2.57+), as a tap on its tile opens it; an empty entity closes
+ # it. Only a camera on the screen's tiles or in a recent alert gets an image from ESP Screens.
+ - action: preview_camera
+ variables:
+ entity: string
+ then:
+ - lambda: |-
+ if (entity.empty()) { runtime_tiles::camera_close(); return; }
+ id(last_use_ms) = millis();
+ id(wake_display).execute();
+ std::string name = entity;
+ for (size_t i = 0; i < runtime_tiles::model.count; ++i)
+ if (runtime_tiles::model.tiles[i].entity == entity) { name = runtime_tiles::model.tiles[i].name; break; }
+ runtime_tiles::camera_open(entity, name);
# The settings page from Home Assistant: `page` 0 is its menu, 1-4 open a group straight away
# (Brightness, Night, Screen, This screen) and a negative page closes it and goes back to page 1.
# Handy for support ("look at Screen -> Rotation") and for the render tests.
@@ -493,10 +544,43 @@ api:
captive_portal:
+# 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.
+http_request:
+ timeout: 4s
+ verify_ssl: false
+
+# The top-level online_image form that ESPHome 2026.6.2 (min_version) knows; 2026.7+ still reads it and suggests
+# `image: - platform: online_image`, which this profile can use once min_version reaches 2026.7.
+online_image:
+ - id: camera_image
+ url: "http://127.0.0.1/camera.bmp" # set when a camera opens
+ format: BMP
+ type: RGB565
+ buffer_size: 4096
+ on_download_finished:
+ - lambda: 'runtime_tiles::camera_loaded(false, cached);'
+ on_error:
+ - lambda: 'runtime_tiles::camera_failed(false);'
+ - id: alert_image
+ url: "http://127.0.0.1/camera.bmp" # set when an alert brings an image
+ format: BMP
+ type: RGB565
+ buffer_size: 4096
+ on_download_finished:
+ - lambda: 'runtime_tiles::camera_loaded(true, cached);'
+ on_error:
+ - lambda: 'runtime_tiles::camera_failed(true);'
+
interval:
- interval: 250ms
then:
- - lambda: runtime_tiles::tick();
+ - lambda: |-
+ runtime_tiles::tick();
+ runtime_tiles::camera_tick();
- interval: 1s
then:
- if:
@@ -1295,7 +1379,8 @@ touchscreen:
if (!step) continue;
const char *blocked = !runtime_tiles::enabled ? "no runtime tiles" : !runtime_tiles::swipe_pages ? "setting off"
: id(display_dimmed) ? "screen dimmed" : id(calibration_active) ? "calibration" : !id(active_entity).empty() ? "card open"
- : (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)) ? "detail card open" : nullptr;
+ : (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN)) ? "detail card open"
+ : runtime_tiles::camera_visible() ? "camera open" : nullptr;
if (blocked) { ESP_LOGI("touch", "edge swipe ignored: %s", blocked); continue; }
cyd::touch_guard.consume();
for (auto *indev = lv_indev_get_next(nullptr); indev; indev = lv_indev_get_next(indev)) lv_indev_wait_release(indev);
@@ -1462,6 +1547,7 @@ lvgl:
- id: paint_accent_bright
- id: paint_button_dark
- id: paint_button_dark_pressed
+ - id: paint_camera
# ---------- ALERT OVERLAY (HA action show_alert) ----------
# LVGL's top layer sits above every page, card and the dim overlay; the card
@@ -1531,6 +1617,39 @@ lvgl:
text_font: sublabel_big
styles: paint_ink_soft
clickable: false
+ # 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.
+ - obj:
+ id: alert_image_frame
+ x: ${ALERT_IMAGE_INSET}
+ y: ${ALERT_IMAGE_INSET}
+ width: ${CAMERA_THUMB_W}
+ height: ${CAMERA_THUMB_H}
+ hidden: true
+ styles: paint_camera
+ bg_opa: COVER
+ radius: 0
+ border_width: 0
+ shadow_width: 0
+ pad_all: 0
+ clickable: true
+ scrollable: false
+ scrollbar_mode: 'OFF'
+ pressed:
+ bg_opa: COVER
+ on_click:
+ - if:
+ condition:
+ lambda: 'return cyd::touch_guard.accept(millis(), 14) && !runtime_tiles::alert_camera.empty();'
+ then:
+ - lambda: 'runtime_tiles::camera_open(runtime_tiles::alert_camera, id(alert_title_text));'
+ widgets:
+ - label:
+ id: alert_image_icon
+ align: CENTER
+ text: "\U000F07AE"
+ text_font: materialdesign_icons
+ clickable: false
- obj:
id: alert_ok
align: BOTTOM_RIGHT
@@ -1580,6 +1699,11 @@ lvgl:
height: 2
spin_time: 1s
arc_length: 60deg
+ # Never shown either: it makes ESPHome compile LVGL's image widget, which a camera image is drawn with.
+ - image:
+ id: camera_image_seed
+ hidden: true
+ src: alert_image
- label:
id: lbl_room
x: 16
@@ -3608,7 +3732,7 @@ script:
auto now = id(homeassistant_time).now();
const int minute = now.is_valid() ? now.hour * 60 + now.minute : -1;
const int level = id(display_dimmed) ? settings.dim_level(minute) : settings.brightness;
- // Standby dims slowly; the tile redraws below no longer interrupt the hardware fade.
+ // Standby dims slowly (ESPHome's transition); waking is quick.
id(set_backlight).execute(level, id(display_dimmed) ? 1500 : 300);
if (id(display_dimmed)) { lv_obj_clear_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(id(dim_wake_overlay)); }
else lv_obj_add_flag(id(dim_wake_overlay), LV_OBJ_FLAG_HIDDEN);
@@ -3658,7 +3782,7 @@ script:
lambda: |-
if (!runtime_tiles::auto_home || id(touch_down) || id(calibration_active) || id(alert_active)) return false;
if (id(display_dimmed)) return false;
- bool away = settings_screen::visible() || !id(active_entity).empty() || id(tile_page) != 0 ||
+ bool away = settings_screen::visible() || runtime_tiles::camera_visible() || !id(active_entity).empty() || id(tile_page) != 0 ||
(runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root, LV_OBJ_FLAG_HIDDEN));
return away && (millis() - id(last_use_ms)) > (uint32_t) runtime_tiles::auto_home_seconds * 1000UL;
then:
@@ -3683,6 +3807,8 @@ script:
if (id(alert_active)) id(alert_report).execute("replaced");
// An alert is the one thing that outranks the settings page, so that closes first.
settings_screen::close();
+ // An open camera closes; the card makes room for an image the app announced for this alert.
+ runtime_tiles::alert_prepare();
const auto alert = screen_alert::make(title, subtitle, icon, color, button_text, timeout, flash, ${ALERT_TITLE_MAX}, ${ALERT_SUBTITLE_MAX}, ${ALERT_BUTTON_MAX});
id(alert_active) = true;
id(alert_title_text) = alert.title;
@@ -3722,6 +3848,7 @@ script:
- lambda: |-
id(alert_active) = false;
id(last_touch_ms) = millis();
+ runtime_tiles::alert_clear();
ESP_LOGI("alert", "dismissed: %s", reason.c_str());
- lvgl.widget.hide: alert_overlay
- script.stop: alert_timeout
@@ -3778,20 +3905,16 @@ script:
- lvgl.widget.hide: dim_wake_overlay
- script.execute: close_cards
- # One owner for the backlight level: the LEDC fader steps the duty itself, so
- # LVGL redraws cannot make the transition stutter. A new call interrupts the
- # running fade from its current duty; afterwards ESPHome's light state is synced.
+ # One owner for the backlight level, through ESPHome's own light transition (firmware 0.2.57+). Firmware 0.2.13-0.2.56
+ # stepped the duty with the LEDC hardware fader by reaching into ESPHome's LEDC output, which ESPHome 2026.8 made final;
+ # a new call interrupts the running transition from where it is, as ESPHome does for any light.
- id: set_backlight
mode: restart
parameters:
level: int
ms: int
then:
- - lambda: |-
- if (!backlight_fade::start(*id(gpio_backlight_pwm), *id(back_light), level / 100.0f, ms))
- id(back_light).turn_on().set_transition_length(ms).set_brightness(level / 100.0f).perform();
- - delay: !lambda "return ms + 40;"
- - lambda: backlight_fade::sync(*id(gpio_backlight_pwm), *id(back_light), level / 100.0f);
+ - lambda: id(back_light).turn_on().set_transition_length(ms).set_brightness(level / 100.0f).perform();
# Standby: the standby time ran out (the 1 s interval checks Auto standby) or Sleep was pressed.
# The settings page and every card close; with "also on standby" the screen goes back to page 1 too.
diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md
index 5cff1d1..9c8ee4a 100644
--- a/screen_manager/CHANGELOG.md
+++ b/screen_manager/CHANGELOG.md
@@ -1,3 +1,16 @@
+## 0.2.66 (firmware 0.2.57)
+
+Camera images on the Guition: see who is at the door.
+
+- **An alert with a picture.** Add `camera: camera.front_door` to the `esp_screens_show_alert` event and a Guition shows the camera's picture of that moment across the top of the alert card, with the title and subtitle under it. A tap on the picture opens the camera full screen over the alert; Back returns to the alert. Any `camera.*` or `image.*` entity works. Other screens show the same alert without the picture.
+- **A camera tile.** Add a `camera.*` or `image.*` entity as a tile on a Guition. A tap opens the image full screen, with the round back key at the top left like on every card, and the image refreshes every four seconds, at a steady pace, while it is open. It is not video: ESPHome has no video decoder. Standby and **Back to page 1** close it, and a new alert closes it before it opens.
+- **How it works.** The screen never gets a Home Assistant token. ESP Screen Manager fetches the snapshot, makes it exactly as large as the screen draws it and serves it on a new port, **8098**, under a random link that stops working when nobody uses it. On Home Assistant OS the app opens that port itself; keep it at 8098. docs/CAMERA.md has the details, Docker included.
+- **Smooth while it loads.** The image arrives as an uncompressed BMP, which the Guition decodes piece by piece while it downloads; a JPEG held the screen for more than half a second per image, long enough to miss a tap. The app fetches the camera's next snapshot each time the screen loads one, so a new picture follows every four seconds instead of after one and a half seconds one time and six the next. A slow camera makes the picture older, never the screen slower.
+- **The alert's picture is the moment it rang.** It stays as it was when the alert came in; tap it for the camera now.
+- **The Guition builds with ESPHome 2026.8 and newer.** Its firmware no longer uses a backlight fader of its own, which reached into ESPHome's LEDC output; ESPHome 2026.8 closed that off, so **Install** in ESPHome Device Builder failed for a Guition. Standby now dims through ESPHome's own light transition, like any light. Updating from ESP Screens was not affected.
+- **The Claude skill and the Alerts cheatsheet** describe the `camera` field. Install the skill again from Settings → Claude to get it.
+- Needs firmware 0.2.57 on the Guition: press **Update** on the screen. The CYD gets 0.2.57 too, without camera images: it has no memory for them.
+
## 0.2.65 (firmware 0.2.56)
Wake lights the screen, and ESP Screens lets you pick the screen.
diff --git a/screen_manager/app/camera_feed.py b/screen_manager/app/camera_feed.py
new file mode 100644
index 0000000..93ec071
--- /dev/null
+++ b/screen_manager/app/camera_feed.py
@@ -0,0 +1,283 @@
+"""Camera images on a Guition screen (app 0.2.66, firmware 0.2.57).
+
+A screen never asks Home Assistant for a camera itself. This app fetches the snapshot with its own token (a
+camera's access token changes every five minutes), makes it exactly as large as the screen shows it and serves
+it as an uncompressed 24-bit BMP on its own port on the LAN, where the screen's ESPHome `online_image` loads it.
+ESPHome decodes a BMP piece by piece while it downloads; a JPEG of the full screen held the Guition's main loop for
+0.6 s (measured 2026-09-17), long enough to lose a tap. On the LAN the larger file costs nothing that matters.
+
+Links are random and short-lived, and only a camera on the screen's layout or in a recent alert gets one. A screen
+that loads a link gets the last snapshot at once, however long the camera takes to answer (ESPHome's HTTP client waits
+in the screen's main loop, where touch and drawing wait with it), and the load starts fetching the next snapshot.
+"""
+import asyncio
+import hashlib
+import io
+import logging
+import os
+import re
+import secrets
+import time
+
+LOG = logging.getLogger(__name__)
+
+DOMAINS = ('camera', 'image')
+MIN_FIRMWARE = (0, 2, 57)
+PORT = 8098
+# A camera nobody loaded a picture of for this long is forgotten, its last snapshot with it.
+WATCH_SECONDS = 30
+# A link to a live camera nobody used for this long is forgotten; the screen asks for a new one.
+LINK_SECONDS = 120
+# An alert's still stays loadable this long, and its camera may be opened full screen that long.
+STILL_SECONDS = 1800
+FIRST_FRAME_SECONDS = 8
+FETCH_SECONDS = 15
+MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024
+MAX_LINKS = 64
+# What the screens load (online_image `format: BMP`).
+CONTENT_TYPE = 'image/bmp'
+# The pixel box per board and view; the image keeps its proportions inside it. Equal to the CAMERA_*
+# substitutions of packages/guition.yaml (tests/test_camera.py).
+BOXES = {'guition': {'full': (480, 480), 'thumb': (392, 220)}}
+
+
+def supported(entity):
+ """True for a camera or image entity id."""
+ return (isinstance(entity, str) and len(entity) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', entity) is not None
+ and entity.split('.')[0] in DOMAINS)
+
+
+def version(text):
+ try:
+ parts = tuple(int(part) for part in str(text or '').split('.'))
+ except ValueError:
+ return ()
+ return parts if len(parts) == 3 else ()
+
+
+def can_show(screen):
+ """A paired Guition with firmware that draws camera images."""
+ return bool(screen) and screen.get('board') in BOXES and version(screen.get('firmware')) >= MIN_FIRMWARE
+
+
+def fit(size, box):
+ """The largest size with the proportions of `size` that fits `box`, at least one pixel each way."""
+ width, height = size
+ scale = min(box[0] / width, box[1] / height)
+ return max(1, min(box[0], round(width * scale))), max(1, min(box[1], round(height * scale)))
+
+
+def encode(raw, box):
+ """Any snapshot Home Assistant hands out (JPEG, PNG, GIF, WebP) as a 24-bit BMP that fits `box`, the size of the
+ screen's buffer."""
+ from PIL import Image, ImageOps
+ with Image.open(io.BytesIO(raw)) as source:
+ # A JPEG decodes at a half, quarter or eighth of its size while that is still larger than the box.
+ source.draft('RGB', box)
+ image = ImageOps.exif_transpose(source)
+ if image.mode in ('RGBA', 'LA', 'P', 'PA'):
+ image = image.convert('RGBA')
+ ground = Image.new('RGB', image.size)
+ ground.paste(image, mask=image.getchannel('A'))
+ image = ground
+ elif image.mode != 'RGB':
+ image = image.convert('RGB')
+ size = fit(image.size, box)
+ if image.size != size:
+ image = image.resize(size, Image.Resampling.LANCZOS)
+ out = io.BytesIO()
+ image.save(out, 'BMP')
+ return out.getvalue()
+
+
+class Watch:
+ """One camera: its last snapshot, the sizes made of it and the fetch on its way."""
+
+ def __init__(self, now):
+ self.used = now
+ self.raw, self.digest = None, ''
+ self.frames = {}
+ self.failures = 0
+ self.retry_at = 0.0
+ self.task = None
+
+
+class Link:
+ __slots__ = ('entity', 'box', 'still', 'etag', 'used', 'lifetime')
+
+ def __init__(self, entity, box, now, still=None):
+ self.entity, self.box, self.used = entity, box, now
+ self.still = still
+ self.etag = f'"{hashlib.sha1(still).hexdigest()[:16]}"' if still else ''
+ self.lifetime = STILL_SECONDS if still else LINK_SECONDS
+
+
+class CameraFeed:
+ def __init__(self, fetch, clock=time.monotonic):
+ self.fetch, self.clock = fetch, clock
+ self.watches, self.links = {}, {}
+
+ # ----- fetching -----
+ # A camera is fetched when a screen loads its picture: serving one starts fetching the next, so each load gets a
+ # picture exactly one load younger and the picture changes in the screen's own steady rhythm. A fetch that ran on
+ # its own clock next to the screen's made the picture change after 1.5 s one time and after 6 s the next (measured
+ # 2026-09-17 with an EZVIZ camera that takes a steady 2.4 s per snapshot). Nobody loading means nothing fetched.
+ def watch(self, entity):
+ now = self.clock()
+ for old in [e for e, w in self.watches.items() if now - w.used > WATCH_SECONDS and (w.task is None or w.task.done())]:
+ del self.watches[old]
+ watch = self.watches.get(entity)
+ if watch is None:
+ watch = self.watches[entity] = Watch(now)
+ watch.used = now
+ return watch
+
+ def refresh(self, entity, watch):
+ """Start fetching the next snapshot, unless one is on its way or the camera failed a moment ago."""
+ if (watch.task is None or watch.task.done()) and self.clock() >= watch.retry_at:
+ watch.task = asyncio.ensure_future(self.fetch_one(entity, watch))
+
+ async def fetch_one(self, entity, watch):
+ try:
+ raw = await asyncio.wait_for(self.fetch(entity), FETCH_SECONDS)
+ if not raw:
+ raise ValueError('empty image')
+ digest = hashlib.sha1(raw).hexdigest()
+ if digest != watch.digest:
+ watch.raw, watch.digest, watch.frames = raw, digest, {}
+ if watch.failures:
+ LOG.info('Camera %s answers again', entity)
+ watch.failures = 0
+ except asyncio.CancelledError:
+ raise
+ except Exception as error:
+ watch.failures += 1
+ # A camera that fails is asked again after a pause, up to half a minute.
+ watch.retry_at = self.clock() + min(30, 5 * watch.failures)
+ if watch.failures in (1, 30):
+ LOG.info('No image from %s (%s)', entity, type(error).__name__)
+
+ async def frame(self, entity, box, wait=FIRST_FRAME_SECONDS, fresh=True, now=False):
+ """(etag, BMP) of the camera's last snapshot at `box`, or None when it has none (yet). The first snapshot is
+ waited for; `fresh` starts fetching the next one for the next load. `now` (an alert) waits for a snapshot whose
+ fetch starts now or is already on its way, never one kept from an earlier load."""
+ watch = self.watch(entity)
+ if now:
+ if watch.task is None or watch.task.done():
+ watch.retry_at = 0.0
+ self.refresh(entity, watch)
+ try:
+ await asyncio.wait_for(asyncio.shield(watch.task), wait)
+ except asyncio.TimeoutError:
+ return None
+ if watch.failures:
+ return None
+ elif watch.raw is None:
+ self.refresh(entity, watch)
+ if watch.task is not None and not watch.task.done():
+ try:
+ await asyncio.wait_for(asyncio.shield(watch.task), wait)
+ except asyncio.TimeoutError:
+ return None
+ raw, digest = watch.raw, watch.digest
+ if raw is None:
+ return None
+ if fresh:
+ self.refresh(entity, watch)
+ cached = watch.frames.get(box)
+ if cached is None or cached[0] != digest:
+ try:
+ image = await asyncio.get_running_loop().run_in_executor(None, encode, raw, box)
+ except Exception as error:
+ LOG.info('The image of %s cannot be read (%s)', entity, type(error).__name__)
+ return None
+ cached = watch.frames[box] = (digest, image)
+ return f'"{digest[:16]}-{box[0]}x{box[1]}"', cached[1]
+
+ # ----- links -----
+ def prune(self):
+ now = self.clock()
+ for token in [token for token, link in self.links.items() if now - link.used > link.lifetime]:
+ del self.links[token]
+ while len(self.links) >= MAX_LINKS:
+ del self.links[min(self.links, key=lambda token: self.links[token].used)]
+
+ def link(self, entity, box, still=None):
+ """A new random token for one camera at one size; `still` makes it one fixed image (an alert's)."""
+ self.prune()
+ token = secrets.token_urlsafe(18)
+ self.links[token] = Link(entity, box, self.clock(), still)
+ return token
+
+ async def serve(self, token, etag=None):
+ """(HTTP status, BMP or None, etag) for one request of a screen."""
+ link = self.links.get(token)
+ now = self.clock()
+ if link is None or now - link.used > link.lifetime:
+ self.links.pop(token, None)
+ return 404, None, ''
+ link.used = now
+ if link.still:
+ return (304, None, link.etag) if etag == link.etag else (200, link.still, link.etag)
+ found = await self.frame(link.entity, link.box)
+ if found is None:
+ return 503, None, ''
+ tag, image = found
+ return (304, None, tag) if etag == tag else (200, image, tag)
+
+
+def web_app(feed):
+ """The camera port: GET /camera/.bmp and nothing else, open to the LAN like the screens are."""
+ from aiohttp import web
+
+ async def image(request):
+ status, body, etag = await feed.serve(request.match_info['token'], request.headers.get('If-None-Match'))
+ headers = {'Cache-Control': 'no-cache', **({'ETag': etag} if etag else {})}
+ if status == 200:
+ return web.Response(body=body, content_type=CONTENT_TYPE, headers=headers)
+ if status == 304:
+ return web.Response(status=304, headers=headers)
+ return web.Response(status=status, text='No image' if status == 503 else 'Unknown link', headers=headers)
+
+ app = web.Application(client_max_size=1024)
+ app.router.add_get(r'/camera/{token:[A-Za-z0-9_-]{16,64}}.bmp', image)
+ return app
+
+
+def port():
+ try:
+ value = int(os.environ.get('SCREEN_CAMERA_PORT', PORT))
+ except ValueError:
+ return PORT
+ return value if 0 < value < 65536 else PORT
+
+
+async def base_url(request, cache={}):
+ """http://: where screens reach this app: SCREEN_CAMERA_URL when set, else Home Assistant's
+ own LAN address (the add-on's port is published on the host). `request` is HomeAssistant.request."""
+ override = os.environ.get('SCREEN_CAMERA_URL', '').rstrip('/')
+ if override:
+ return override
+ now = time.monotonic()
+ if cache.get('url') and now - cache.get('at', 0) < 600:
+ return cache['url']
+ host = None
+ try:
+ network = await request('network')
+ for adapter in (network or {}).get('adapters', []):
+ addresses = [item.get('address') for item in adapter.get('ipv4') or [] if item.get('address')]
+ if adapter.get('default') and addresses:
+ host = addresses[0]
+ break
+ except Exception as error:
+ LOG.info('Reading the network adapters failed (%s)', type(error).__name__)
+ if host is None:
+ try:
+ from urllib.parse import urlparse
+ host = urlparse((await request('network/url') or {}).get('internal') or '').hostname
+ except Exception as error:
+ LOG.info('Reading the internal URL failed (%s)', type(error).__name__)
+ if not host:
+ return None
+ cache.update(url=f'http://{host}:{port()}', at=now)
+ return cache['url']
diff --git a/screen_manager/app/claude_skill.py b/screen_manager/app/claude_skill.py
index ef88c67..e675771 100644
--- a/screen_manager/app/claude_skill.py
+++ b/screen_manager/app/claude_skill.py
@@ -12,7 +12,7 @@ from pathlib import Path
import zipfile
import tile_icons
-from core import (ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
+from core import (ALERT_CAMERA_FIELD, ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
ALERT_MIN_FIRMWARE, ALERT_SUGGESTED_ICONS, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW,
CONTROLS, DISPLAYS, MAX_PAGES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS,
TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE, SETTING_ENTITIES_MIN_FIRMWARE,
@@ -181,6 +181,8 @@ actions:
|---|---|---|---|
{fields}
+The event for every screen takes one more field, `{ALERT_CAMERA_FIELD[0]}`: {ALERT_CAMERA_FIELD[2]} Example: `{ALERT_CAMERA_FIELD[0]}: {ALERT_CAMERA_FIELD[3]}`. Use a real `camera.*` or `image.*` entity from this Home Assistant (a doorbell integration usually has one); the per-screen actions have no such field.
+
Text limits are in bytes; an accented letter takes two. Keep the title short: a screen shows about twenty characters of it on one line and ends a longer title with an ellipsis. Put details in the subtitle.
## Colors
diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py
index edc633c..9ae1d72 100644
--- a/screen_manager/app/core.py
+++ b/screen_manager/app/core.py
@@ -9,15 +9,18 @@ import secrets
import tile_icons
-DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen'.split())
+DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen camera image'.split())
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings'}
NEW_DOMAINS = frozenset('sun timer person screen'.split())
+# A camera or an image entity opens full screen on a Guition with firmware 0.2.57+ (camera_feed.py).
+CAMERA_DOMAINS = frozenset(('camera', 'image'))
+CAMERA_MIN_FIRMWARE = (0, 2, 57)
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.56'
+FIRMWARE_VERSION = '0.2.57'
# 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.
@@ -308,7 +311,7 @@ HEADER_SHOWS = ('always', 'active')
def header_entity(value):
return (isinstance(value, str) and len(value) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value) is not None
- and value.split('.')[0] in (DOMAINS - {'screen'}) | HEADER_ONLY_DOMAINS)
+ and value.split('.')[0] in (DOMAINS - {'screen'} - CAMERA_DOMAINS) | HEADER_ONLY_DOMAINS)
def header_items(layout):
"""Items the top bar shows: the stored ones, else what firmware before the top bar drew (the clock)."""
@@ -350,6 +353,8 @@ def validate_header(data):
def min_firmware(layout):
"""Oldest firmware that still accepts this layout; None when any version works."""
+ if any(t['entity'].split('.')[0] in CAMERA_DOMAINS for t in layout['tiles']):
+ return CAMERA_MIN_FIRMWARE
if any(t['entity'] == 'screen.settings' for t in layout['tiles']):
return (0, 2, 44)
if any(t.get('options', {}).get('background') == 'none' for t in layout['tiles']):
@@ -865,8 +870,9 @@ def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=N
if hours:
result['hours'] = hours
return result or None
- if domain in ('scene', 'script', 'button', 'input_button'):
- # When it last ran: scripts report last_triggered; scenes and buttons carry the time as their state.
+ if domain in ('scene', 'script', 'button', 'input_button', 'image'):
+ # When it last ran: scripts report last_triggered; scenes and buttons carry the time as their state, and so does
+ # an image entity (when its picture last changed).
last = epoch(attrs.get('last_triggered') if domain == 'script' else states.get(tile['entity'], {}).get('state'))
return {'last': last} if last else None
if domain == 'sun':
@@ -981,6 +987,9 @@ ALERT_LIMITS = {'cyd': {'title': 48, 'subtitle': 160, 'button_text': 12}, 'guiti
ALERT_SUGGESTED_ICONS = ('doorbell', 'bell', 'bell-ring', 'alert-outline', 'alarm-light', 'lock', 'lock-open-variant', 'door-open',
'window-closed-variant', 'motion-sensor', 'cctv', 'smoke-detector', 'water-alert', 'fire', 'mailbox', 'car',
'account', 'account-group', 'washing-machine', 'robot-vacuum', 'timer-outline', 'check')
+# Not an argument of show_alert: the app sends the image itself to screens that can draw it (app 0.2.66, a Guition with
+# firmware 0.2.57+) and leaves it out for the others. (name, label, explanation, example) like ALERT_FIELDS.
+ALERT_CAMERA_FIELD = ('camera', 'Camera', 'A camera or image entity. A Guition with firmware 0.2.57+ shows its picture of that moment across the top of the card; a tap on it opens the camera full screen. Other screens show the alert without it. Only through the esp_screens_show_alert event.', 'camera.front_door')
# The firmware's MAX_TIMEOUT_SECONDS.
ALERT_MAX_TIMEOUT = 86400
# One alert for every screen (app 0.2.45): an automation fires one of these Home Assistant events and the
@@ -999,6 +1008,7 @@ def alert_reference():
'endings': [{'action': action, 'label': label} for action, label in ALERT_ENDINGS],
'fallback_icon': ALERT_FALLBACK_ICON, 'fallback_cp': tile_icons.GLYPHS[ALERT_FALLBACK_ICON],
'fields': [{'name': name, 'type': kind, 'label': label, 'help': help_, 'example': example} for name, kind, label, help_, example in ALERT_FIELDS],
+ 'camera': dict(zip(('name', 'label', 'help', 'example'), ALERT_CAMERA_FIELD)),
'limits': ALERT_LIMITS,
'colors': [{'name': name, 'label': item['label'], 'color': item['color']} for name, item in TILE_BACKGROUNDS.items() if item['color']],
'suggested_icons': [{'name': name, 'cp': tile_icons.GLYPHS[name]} for name in ALERT_SUGGESTED_ICONS],
@@ -1050,6 +1060,14 @@ def alert_data(data):
unusable.append(name)
return service, unusable
+def alert_camera(data):
+ """(entity, usable): the alert's `camera` field when it names a camera or image entity; ('', True) without one."""
+ value = data.get(ALERT_CAMERA_FIELD[0]) if isinstance(data, dict) else None
+ if value is None or value == '':
+ return '', True
+ usable = isinstance(value, str) and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value.strip()) is not None and value.strip().split('.')[0] in CAMERA_DOMAINS
+ return (value.strip(), True) if usable else ('', False)
+
def alert_targets(screens):
"""(ready, skipped): the paired screens that can show an alert now, and the others with the reason.
diff --git a/screen_manager/app/server.py b/screen_manager/app/server.py
index 509a567..5b4ee63 100644
--- a/screen_manager/app/server.py
+++ b/screen_manager/app/server.py
@@ -10,13 +10,14 @@ import secrets
import time
from datetime import datetime, timedelta, timezone
import math
+import camera_feed
import claude_skill
from firmware import Firmware
import tile_icons
from updates import Updater
from aiohttp import ClientError, ClientSession, ClientTimeout, WSMsgType, web
-from core import BROADCAST_EVENTS, BUILTIN, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
+from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state
import header_bar
import history_card
@@ -107,6 +108,8 @@ class HomeAssistant:
self.services_changed = asyncio.Event()
# History a detail card asks for when it opens (firmware 0.2.51+): {inbox, entity, hours}, for Manager.card_history_loop.
self.history_requests = asyncio.Queue()
+ # A camera a screen opens full screen (firmware 0.2.57+): {inbox, entity}, for Manager.camera_loop.
+ self.camera_requests = asyncio.Queue()
async def request(self, kind, **data):
if self.ws is None or self.ws.closed:
@@ -142,6 +145,8 @@ class HomeAssistant:
self.changed.set()
elif event.get('event_type') == 'esphome.screen_history':
self.history_requests.put_nowait(body)
+ elif event.get('event_type') == 'esphome.screen_camera':
+ self.camera_requests.put_nowait(body)
elif event.get('event_type') == 'state_changed':
eid = body.get('entity_id')
if body.get('new_state'):
@@ -205,6 +210,7 @@ class HomeAssistant:
await self.request('subscribe_events', event_type='state_changed')
await self.request('subscribe_events', event_type='esphome.screen_setting')
await self.request('subscribe_events', event_type='esphome.screen_history')
+ await self.request('subscribe_events', event_type='esphome.screen_camera')
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS, *SERVICE_EVENTS):
await self.request('subscribe_events', event_type=event_type)
self.states = {s['entity_id']: s for s in await self.request('get_states')}
@@ -287,6 +293,21 @@ class HomeAssistant:
domain, service = action.split('.', 1)
await self.request('call_service', domain=domain, service=service, service_data=data)
+ async def camera_image(self, entity):
+ """The picture of a camera or image entity as Home Assistant hands it to its own frontend."""
+ path = 'camera_proxy' if entity.startswith('camera.') else 'image_proxy'
+ async with self.session.get(f'{self.base}/{path}/{entity}', headers={'Authorization': 'Bearer ' + self.token},
+ timeout=ClientTimeout(total=camera_feed.FETCH_SECONDS)) as response:
+ response.raise_for_status()
+ if (response.content_length or 0) > camera_feed.MAX_SNAPSHOT_BYTES:
+ raise ValueError('image too large')
+ raw = bytearray()
+ async for chunk in response.content.iter_chunked(65536):
+ raw += chunk
+ if len(raw) > camera_feed.MAX_SNAPSHOT_BYTES:
+ raise ValueError('image too large')
+ return bytes(raw)
+
async def fire(self, event_type, data):
"""One Home Assistant event of our own, such as the answer to a tile event."""
await self.request('fire_event', event_type=event_type, event_data=data)
@@ -395,6 +416,10 @@ class Manager:
self.retry_at, self.retries, self.no_answers = {}, {}, set()
# History for detail cards (firmware 0.2.51+): (entity, hours) -> (monotonic, message), and the fetches under way.
self.card_histories, self.card_history_fetches = {}, {}
+ # Camera images (firmware 0.2.57+): the feed behind the camera port, and the cameras of recent alerts
+ # (entity -> monotonic time) that a screen may open full screen without a tile.
+ self.camera = camera_feed.CameraFeed(lambda entity: self.ha.camera_image(entity))
+ self.alert_cameras = {}
if self.path.exists():
raw = json.loads(self.path.read_text())
# Versioned persistent data. Never silently overwrite an unknown schema.
@@ -794,6 +819,9 @@ class Manager:
if screen is None:
raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
layout = validate_layout(data)
+ # A CYD has no memory for camera images, whatever its firmware; say so before asking for an update.
+ if any(t['entity'].split('.')[0] in CAMERA_DOMAINS for t in layout['tiles']) and screen.get('board') not in camera_feed.BOXES:
+ raise ValueError('Camera images need a Guition screen.')
needed = self.needs_firmware(inbox, layout, screen)
if needed:
raise ValueError(f"Install screen firmware {needed} or newer first for these tiles.")
@@ -1160,19 +1188,101 @@ class Manager:
except Exception as error:
LOG.warning('Refreshing history failed (%s)', type(error).__name__)
+ # ----- Camera images (firmware 0.2.57+) -----
+ async def camera_loop(self):
+ """Answer the cameras screens open full screen, a few at a time."""
+ limit = asyncio.Semaphore(4)
+
+ async def answer(request):
+ async with limit:
+ try:
+ await self.answer_camera(request)
+ except Exception as error:
+ LOG.warning('Camera for %s was not sent (%s)', request.get('entity') if isinstance(request, dict) else '?', type(error).__name__)
+ queue = getattr(self.ha, 'camera_requests', None)
+ while queue is not None:
+ request = await queue.get()
+ asyncio.ensure_future(answer(request))
+
+ def camera_allowed(self, inbox, entity):
+ """A camera on the screen's own layout, or one a recent alert showed."""
+ seen = self.alert_cameras.get(entity)
+ if seen is not None and time.monotonic() - seen < camera_feed.STILL_SECONDS:
+ return True
+ return entity in {tile['entity'] for tile in self.layouts.get(inbox, {}).get('tiles', [])}
+
+ async def camera_message(self, entity, view, board, still=None):
+ """The screen message for one camera view: a link to its image, or an empty link when there is none."""
+ url = ''
+ base = await camera_feed.base_url(self.ha.request)
+ if base:
+ box = camera_feed.BOXES[board][view]
+ if view == 'thumb':
+ token = self.camera.link(entity, box, still) if still else None
+ else:
+ token = self.camera.link(entity, box) if await self.camera.frame(entity, box) else None
+ url = f'{base}/camera/{token}.bmp' if token else ''
+ else:
+ LOG.warning('Camera images: no address for this app on the LAN; set SCREEN_CAMERA_URL')
+ return {'v': 1, 'op': 'camera', 't': 'alert' if view == 'thumb' else 'full', 'e': entity, 'u': url}
+
+ async def answer_camera(self, request):
+ """One screen's request: a camera it may show, on a screen that draws camera images."""
+ if not isinstance(request, dict):
+ return
+ inbox = self.aliases.get(request.get('inbox'), request.get('inbox'))
+ entity = request.get('entity')
+ screen = self.screen(inbox) if isinstance(inbox, str) else None
+ if not camera_feed.supported(entity) or not camera_feed.can_show(screen) or not screen.get('online'):
+ return
+ action = self.transport(inbox, screen)
+ if not action or not self.camera_allowed(inbox, entity):
+ return
+ message = await self.camera_message(entity, 'full', screen['board'])
+ await self.ha.send(inbox, message, action)
+ LOG.info('Camera %s on %s%s', entity, screen['name'], '' if message['u'] else ': no image')
+
+ async def alert_images(self, camera, screens):
+ """The picture of an alert's camera at that moment, on every screen that draws it."""
+ self.alert_cameras[camera] = time.monotonic()
+ for old in [entity for entity, moment in self.alert_cameras.items() if time.monotonic() - moment > camera_feed.STILL_SECONDS]:
+ del self.alert_cameras[old]
+ boards = {}
+ for screen in screens:
+ boards.setdefault(screen['board'], []).append(screen)
+ for board, group in boards.items():
+ found = await self.camera.frame(camera, camera_feed.BOXES[board]['thumb'], fresh=False, now=True)
+ message = await self.camera_message(camera, 'thumb', board, found[1] if found else None)
+ results = await asyncio.gather(*(self.ha.send(screen['id'], message, self.transport(screen['id'], screen)) for screen in group),
+ return_exceptions=True)
+ failed = sum(isinstance(result, BaseException) for result in results)
+ LOG.info('Alert image of %s: %d of %d screens%s', camera, len(group) - failed, len(group), '' if message['u'] else ' (no image)')
+
async def broadcast(self, event_type, data):
"""An alert event for every screen: the matching action on each screen that can show it, all at once."""
action = BROADCAST_EVENTS[event_type]
service_data, unusable = alert_data(data) if action == 'show_alert' else ({}, [])
+ camera, usable = alert_camera(data) if event_type == BROADCAST_SHOW else ('', True)
+ if not usable:
+ unusable.append('camera')
if unusable:
LOG.warning('%s: unusable %s left empty', event_type, ', '.join(unusable))
ready, skipped = alert_targets(self.screens())
+ # A screen that draws the image hears about it before the alert, so the card opens with room for it.
+ viewers = [screen for screen in ready if camera and camera_feed.can_show(screen) and self.transport(screen['id'], screen)]
+ if viewers:
+ pending = {'v': 1, 'op': 'camera', 't': 'alert', 'e': camera, 'u': ''}
+ await asyncio.gather(*(self.ha.send(screen['id'], pending, self.transport(screen['id'], screen)) for screen in viewers),
+ return_exceptions=True)
results = await asyncio.gather(*(self.ha.call(alert_service(screen['node'], action), service_data) for screen in ready),
return_exceptions=True)
failed = [(screen, type(result).__name__) for screen, result in zip(ready, results) if isinstance(result, BaseException)]
notes = [f"{screen['name']} {reason}" for screen, reason in skipped + failed]
LOG.info('%s: %d of %d screens%s', event_type, len(ready) - len(failed), len(ready) + len(skipped),
f" (not: {'; '.join(notes)})" if notes else '')
+ shown = [screen for screen, result in zip(ready, results) if not isinstance(result, BaseException)]
+ if viewers and any(screen in shown for screen in viewers):
+ await self.alert_images(camera, [screen for screen in viewers if screen in shown])
return {'sent': len(ready) - len(failed), 'skipped': len(skipped), 'failed': len(failed)}
async def tile_event(self, event_type, data):
@@ -1416,10 +1526,19 @@ async def main():
runner = web.AppRunner(create_app(manager, development), access_log=None)
await runner.setup()
await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start()
+ # Camera images for the screens: their own port on the LAN, not the ingress page (docs/CAMERA.md).
+ cameras = web.AppRunner(camera_feed.web_app(manager.camera), access_log=None)
+ await cameras.setup()
+ try:
+ await web.TCPSite(cameras, '0.0.0.0', camera_feed.port()).start()
+ except OSError as error:
+ # Everything else still works; only camera images stay away.
+ LOG.error('Camera images are off: port %d is not available (%s)', camera_feed.port(), error)
try:
await asyncio.gather(ha.run(), manager.run(), manager.history_loop(), manager.updates.run(),
- manager.alert_loop(), manager.tile_loop(), manager.card_history_loop())
+ manager.alert_loop(), manager.tile_loop(), manager.card_history_loop(), manager.camera_loop())
finally:
+ await cameras.cleanup()
await runner.cleanup()
if __name__ == '__main__':
diff --git a/screen_manager/app/static/app.js b/screen_manager/app/static/app.js
index 122ffa3..3873c11 100644
--- a/screen_manager/app/static/app.js
+++ b/screen_manager/app/static/app.js
@@ -806,6 +806,8 @@ const domains = {
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
timer: ["Timer", "⏱", "#008577", "#def3ed"],
person: ["Person", "☺", "#2f7d32", "#e1f2e2"],
+ camera: ["Camera", "◧", "#3d4a57", "#e6ebf0"],
+ image: ["Image", "◧", "#3d4a57", "#e6ebf0"],
};
const displayNames = { standard: "standard", watch: "large value", forecast: "weather forecast", graph: "graph", digital: "digital clock", analog: "analog clock", sunpath: "sun path" };
// Same rule as the add-on: only a wide card in the standard layout shows direct controls;
@@ -2009,8 +2011,11 @@ function renderResults() {
if (!layout) return;
const query = $("#search").value.toLocaleLowerCase();
const chosen = new Set(layout.tiles.map((t) => t.entity));
+ // Camera images need a Guition (app 0.2.66); the add-on refuses them for other screens too.
+ const guition = inventory.screens.find((s) => s.id === selected)?.board === "guition";
const matches = [...(inventory.builtin || []), ...inventory.entities].filter(
(e) =>
+ (guition || !["camera", "image"].includes(e.id.split(".")[0])) &&
(!filter || e.id.startsWith(filter + ".") ||
({switch:"input_boolean", number:"input_number", select:"input_select", weather:"sun"}[filter] === e.id.split(".")[0])) &&
`${e.name} ${e.id} ${e.device} ${e.area}`
@@ -2612,6 +2617,8 @@ function alertWaitYaml(action) {
function alertAllYaml() {
const fields = inventory.alerts?.fields || [];
const lines = fields.map((f) => ` ${f.name}: ${f.type === "string" ? (/^[a-z][a-z0-9-]*$/.test(f.example) ? f.example : yamlString(f.example)) : String(f.example)}`);
+ const camera = inventory.alerts?.camera;
+ if (camera) lines.push(` # ${camera.name}: ${camera.example} # a Guition shows its picture on the card`);
return `event: ${inventory.alerts?.broadcast?.show || "esp_screens_show_alert"}\nevent_data:\n${lines.join("\n")}`;
}
function renderAlertBroadcast() {
@@ -2751,6 +2758,16 @@ function renderAlertTables() {
row.append(...cells);
table.append(row);
}
+ // The camera image (app 0.2.66): a field of the event for every screen, not of a screen's own action.
+ if (alerts.camera) {
+ const row = node("tr"), name = node("td"), example = node("td");
+ name.append(node("code", alerts.camera.name), node("small", alerts.camera.label));
+ example.append(node("code", alerts.camera.example));
+ const cells = [name, node("td", "entity"), node("td", alerts.camera.help), example, node("td", "Guition, firmware 0.2.57+")];
+ cells.forEach((cell, index) => cell.dataset.label = ["Field", "Type", "What it does", "Example", "Limit"][index]);
+ row.append(...cells);
+ table.append(row);
+ }
const endings = $("#alerts-ending-table");
endings.replaceChildren();
const endHead = node("tr");
diff --git a/screen_manager/app/tile_icons.py b/screen_manager/app/tile_icons.py
index f20f260..9298c48 100644
--- a/screen_manager/app/tile_icons.py
+++ b/screen_manager/app/tile_icons.py
@@ -235,7 +235,7 @@ GLYPHS = {**{name: codepoint for name, (codepoint, _) in ICONS.items()}, **dict(
# Mirrors runtime_tiles::icon_for() so the editor mockup shows what the screen draws.
DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot-vacuum', 'fan': 'fan',
'cover': 'window-shutter', 'scene': 'sofa', 'script': 'sofa', 'sensor': 'gauge', 'binary_sensor': 'gauge',
- 'timer': 'timer-outline', 'person': 'account', 'screen': 'clock-outline'}
+ 'timer': 'timer-outline', 'person': 'account', 'camera': 'cctv', 'image': 'cctv', 'screen': 'clock-outline'}
# The cards the screen brings itself: one icon per entity, not per domain.
BUILTIN_TILES = {'screen.clock': 'clock-outline', 'screen.settings': 'cog'}
FALLBACK = 'power'
diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml
index 21be98b..d387a12 100644
--- a/screen_manager/config.yaml
+++ b/screen_manager/config.yaml
@@ -1,5 +1,5 @@
name: ESP Screen Manager
-version: "0.2.65"
+version: "0.2.66"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
@@ -10,6 +10,11 @@ init: false
homeassistant_api: true
ingress: true
ingress_port: 8099
+# Camera images for Guition screens (docs/CAMERA.md): the screens load them from this port on the LAN.
+ports:
+ 8098/tcp: 8098
+ports_description:
+ 8098/tcp: Camera images for the screens (keep 8098)
panel_icon: mdi:monitor-dashboard
panel_title: ESP Screens
panel_admin: true
diff --git a/tests/test_camera.py b/tests/test_camera.py
new file mode 100644
index 0000000..760ee1b
--- /dev/null
+++ b/tests/test_camera.py
@@ -0,0 +1,399 @@
+"""Camera images on a Guition (app 0.2.66, firmware 0.2.57): the app fetches, sizes and serves the image on its own
+port; the screen asks with esphome.screen_camera and loads the link with ESPHome's online_image."""
+import asyncio
+import importlib.util
+import io
+from pathlib import Path
+import re
+import sys
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / 'screen_manager/app'))
+import camera_feed # noqa: E402
+from core import (ALERT_FIELDS, BROADCAST_SHOW, CAMERA_MIN_FIRMWARE, alert_camera, alert_reference, entity_id, # noqa: E402
+ header_entity, min_firmware, validate_layout, extras)
+
+HAS_PIL = importlib.util.find_spec('PIL') is not None
+HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
+if HAS_AIOHTTP:
+ from server import HomeAssistant, Manager
+
+PROFILE = (ROOT / 'guition-4848s040.yaml').read_text()
+TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
+
+
+def picture(fmt, size, mode='RGB'):
+ from PIL import Image
+ out = io.BytesIO()
+ Image.new(mode, size, (200, 30, 90) if mode == 'RGB' else None).save(out, fmt)
+ return out.getvalue()
+
+
+class Rules(unittest.TestCase):
+ def test_camera_and_image_tiles_need_the_camera_firmware(self):
+ self.assertTrue(entity_id('camera.front_door') and entity_id('image.doorbell'))
+ self.assertEqual(camera_feed.MIN_FIRMWARE, CAMERA_MIN_FIRMWARE)
+ layout = validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.front_door', 'name': ''}]})
+ self.assertEqual(min_firmware(layout), (0, 2, 57))
+ # Automatic and "open control" both open the image full screen; view only keeps the tile still.
+ validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.front_door', 'name': '', 'options': {'tap': 'detail'}}]})
+ validate_layout({'title': 'Hall', 'tiles': [{'entity': 'image.doorbell', 'name': '', 'options': {'tap': 'none'}}]})
+ # A top bar item shows a state; a camera has nothing worth reading there.
+ self.assertFalse(header_entity('camera.front_door'))
+
+ def test_an_image_tile_knows_when_its_picture_changed(self):
+ states = {'image.doorbell': {'state': '2026-09-17T12:00:00+00:00', 'attributes': {}}}
+ self.assertEqual(extras({'entity': 'image.doorbell'}, states), {'last': 1789646400})
+
+ def test_which_screens_draw_images(self):
+ self.assertTrue(camera_feed.can_show({'board': 'guition', 'firmware': '0.2.57'}))
+ self.assertTrue(camera_feed.can_show({'board': 'guition', 'firmware': '0.3.0'}))
+ for screen in ({'board': 'guition', 'firmware': '0.2.56'}, {'board': 'unknown', 'firmware': '0.2.57'},
+ {'board': 'guition', 'firmware': 'unknown'}, None, {}):
+ self.assertFalse(camera_feed.can_show(screen), screen)
+ self.assertTrue(camera_feed.supported('camera.max') and camera_feed.supported('image.max_motion'))
+ for value in ('light.kitchen', 'camera', 'Camera.Max', None, 42, 'camera.' + 'x' * 120):
+ self.assertFalse(camera_feed.supported(value), value)
+
+ def test_the_alert_camera_field(self):
+ self.assertEqual(alert_camera({'camera': 'camera.front_door'}), ('camera.front_door', True))
+ self.assertEqual(alert_camera({'camera': ' image.doorbell '}), ('image.doorbell', True))
+ self.assertEqual(alert_camera({}), ('', True))
+ self.assertEqual(alert_camera({'camera': ''}), ('', True))
+ for value in ('light.hall', True, ['camera.max'], 'camera'):
+ self.assertEqual(alert_camera({'camera': value}), ('', False), value)
+ # Not an argument of the show_alert action: older firmware would refuse the whole call.
+ self.assertNotIn('camera', [name for name, *_ in ALERT_FIELDS])
+ self.assertEqual(alert_reference()['camera']['name'], 'camera')
+
+ def test_the_boxes_are_the_profile_sizes(self):
+ subs = dict(re.findall(r'^ (CAMERA_\w+): "(\d+)"', PROFILE, re.M))
+ self.assertEqual(camera_feed.BOXES['guition'], {'full': (int(subs['CAMERA_FULL_W']), int(subs['CAMERA_FULL_H'])),
+ 'thumb': (int(subs['CAMERA_THUMB_W']), int(subs['CAMERA_THUMB_H']))})
+
+ def test_the_firmware_and_the_app_speak_the_same_words(self):
+ self.assertIn('request.service = esphome::StringRef("esphome.screen_camera");', TILES)
+ self.assertIn("event_type='esphome.screen_camera'", (ROOT / 'screen_manager/app/server.py').read_text())
+ self.assertIn('if (op == "camera") {', TILES)
+ self.assertIn('"camera", "image"})', (ROOT / 'components/smart_display/runtime_model.h').read_text())
+ # The profile loads both images and binds them; the CYD has none, so it never opens a camera.
+ for needle in ('online_image:\n - id: camera_image', ' - id: alert_image', 'runtime_tiles::camera_loaded(false, cached);',
+ 'runtime_tiles::camera_loaded(true, cached);', 'runtime_tiles::camera_tick();', 'runtime_tiles::alert_prepare();',
+ 'runtime_tiles::alert_clear();', 'runtime_tiles::camera_close();', 'id: alert_image_frame'):
+ self.assertIn(needle, PROFILE, needle)
+ # A link only replaces the URL when it is new: set_url() forgets the ETag that makes an unchanged picture a 304.
+ self.assertEqual(PROFILE.count('if (url != current) {'), 2)
+ # A busy camera port leaves the rest of the app running.
+ self.assertIn("except OSError as error:\n # Everything else still works; only camera images stay away.", (ROOT / 'screen_manager/app/server.py').read_text())
+ cyd = (ROOT / 'home-like-2432s028.yaml').read_text()
+ self.assertNotIn('online_image', cyd)
+ self.assertNotIn('camera_full.load', cyd)
+ # The add-on's port is published, and the Docker route passes it on with the host network.
+ config = (ROOT / 'screen_manager/config.yaml').read_text()
+ self.assertIn(f'{camera_feed.PORT}/tcp: {camera_feed.PORT}', config)
+
+
+@unittest.skipUnless(HAS_PIL, 'Pillow comes with ESPHome in the add-on image')
+class Encoding(unittest.TestCase):
+ def test_every_snapshot_becomes_a_bmp_that_fits(self):
+ from PIL import Image
+ cases = [(picture('JPEG', (1920, 1080)), (480, 480), (480, 270)),
+ (picture('JPEG', (1920, 1080)), (392, 220), (391, 220)),
+ (picture('PNG', (400, 100), 'RGBA'), (480, 480), (480, 120)),
+ (picture('GIF', (550, 512), 'P'), (392, 220), (236, 220)),
+ (picture('JPEG', (1080, 1440)), (480, 480), (360, 480)),
+ (picture('PNG', (100, 50)), (480, 480), (480, 240))]
+ for raw, box, size in cases:
+ bmp = camera_feed.encode(raw, box)
+ with Image.open(io.BytesIO(bmp)) as image:
+ self.assertEqual((image.format, image.size, image.mode), ('BMP', size, 'RGB'))
+ # What ESPHome's BMP decoder reads: 24 bits per pixel, no compression, rows padded to four bytes.
+ self.assertEqual((bmp[:2], int.from_bytes(bmp[28:30], 'little'), int.from_bytes(bmp[30:34], 'little')), (b'BM', 24, 0))
+ self.assertEqual(len(bmp), int.from_bytes(bmp[10:14], 'little') + size[1] * ((size[0] * 3 + 3) // 4 * 4))
+ with self.assertRaises(Exception):
+ camera_feed.encode(b'not an image', (480, 480))
+
+ def test_fit_keeps_proportions(self):
+ self.assertEqual(camera_feed.fit((1920, 1080), (480, 480)), (480, 270))
+ self.assertEqual(camera_feed.fit((1, 5000), (224, 126)), (1, 126))
+ self.assertEqual(camera_feed.fit((5000, 1), (224, 126)), (224, 1))
+
+
+class Clock:
+ def __init__(self): self.now = 1000.0
+ def __call__(self): return self.now
+
+
+@unittest.skipUnless(HAS_PIL, 'Pillow comes with ESPHome in the add-on image')
+class Feed(unittest.IsolatedAsyncioTestCase):
+ def feed(self, answers):
+ self.fetched = []
+ clock = Clock()
+
+ async def fetch(entity):
+ self.fetched.append(entity)
+ answer = answers[min(len(self.fetched), len(answers)) - 1]
+ if isinstance(answer, Exception):
+ raise answer
+ return answer
+ return camera_feed.CameraFeed(fetch, clock=clock), clock
+
+ async def settle(self):
+ for _ in range(5):
+ await asyncio.sleep(0)
+
+ async def test_links_serve_the_last_frame_and_answer_unchanged_images_with_304(self):
+ feed, clock = self.feed([picture('JPEG', (1920, 1080))])
+ token = feed.link('camera.max', (480, 480))
+ status, bmp, etag = await feed.serve(token)
+ self.assertEqual((status, bmp[:2]), (200, b'BM'))
+ self.assertRegex(etag, r'^"[0-9a-f]{16}-480x480"$')
+ await self.settle()
+ # The camera answered the same picture again: the screen keeps what it has.
+ self.assertEqual(await feed.serve(token, etag), (304, None, etag))
+ self.assertEqual((await feed.serve('unknown'))[0], 404)
+ # A link nobody used for LINK_SECONDS is gone; the screen asks for a new one.
+ clock.now += camera_feed.LINK_SECONDS + 1
+ self.assertEqual((await feed.serve(token))[0], 404)
+
+ async def test_each_load_fetches_the_next_picture_and_nothing_is_fetched_between_loads(self):
+ first, second, third = picture('JPEG', (640, 360)), picture('PNG', (640, 360)), picture('BMP', (640, 360))
+ feed, clock = self.feed([first, second, third])
+ token = feed.link('camera.max', (480, 480))
+ _, one, tag_one = await feed.serve(token)
+ self.assertEqual(self.fetched, ['camera.max'] * 2, 'the first picture, and the next one started at once')
+ await self.settle()
+ await asyncio.sleep(0.05)
+ self.assertEqual(len(self.fetched), 2, 'nobody loads, nothing is fetched')
+ clock.now += 4
+ _, two, tag_two = await feed.serve(token, tag_one)
+ self.assertNotEqual(tag_two, tag_one, 'the picture fetched after the last load')
+ self.assertEqual(len(self.fetched), 3)
+ # Two screens loading at once share one fetch.
+ await feed.serve(token, tag_two)
+ await feed.serve(token, tag_two)
+ self.assertLessEqual(len(self.fetched), 4)
+
+ async def test_a_camera_that_fails_is_asked_again_after_a_pause(self):
+ feed, clock = self.feed([ConnectionError('500'), ConnectionError('500'), picture('JPEG', (640, 360))])
+ token = feed.link('camera.woonkamer_live', (480, 480))
+ with self.assertLogs(camera_feed.LOG, 'INFO'):
+ self.assertEqual((await feed.serve(token))[0], 503)
+ self.assertEqual((await feed.serve(token))[0], 503)
+ self.assertEqual(len(self.fetched), 1, 'no second try within the pause')
+ clock.now += 5
+ self.assertEqual((await feed.serve(token))[0], 503)
+ self.assertEqual(len(self.fetched), 2)
+ clock.now += 10
+ self.assertEqual((await feed.serve(token))[0], 200)
+ self.assertEqual(len(self.fetched), 4, 'the good picture, then the next one')
+
+ async def test_an_alert_gets_a_snapshot_of_its_own_moment(self):
+ first, second = picture('JPEG', (640, 360)), picture('PNG', (640, 360))
+ feed, clock = self.feed([first, second])
+ tag_first, _ = await feed.frame('camera.front_door', (392, 220), fresh=False)
+ self.assertEqual(len(self.fetched), 1)
+ clock.now += 20
+ # The bell rings 20 s later: the kept snapshot is not the moment, a new fetch is.
+ tag_alert, _ = await feed.frame('camera.front_door', (392, 220), fresh=False, now=True)
+ self.assertEqual(len(self.fetched), 2)
+ self.assertNotEqual(tag_alert, tag_first)
+
+ async def test_an_alert_whose_camera_fails_gets_no_old_picture(self):
+ feed, clock = self.feed([picture('JPEG', (640, 360)), ConnectionError('offline')])
+ await feed.frame('camera.front_door', (392, 220), fresh=False)
+ clock.now += 20
+ with self.assertLogs(camera_feed.LOG, 'INFO'):
+ self.assertIsNone(await feed.frame('camera.front_door', (392, 220), fresh=False, now=True))
+
+ async def test_a_camera_nobody_loads_is_forgotten(self):
+ feed, clock = self.feed([picture('JPEG', (64, 36))])
+ await feed.frame('camera.max', (480, 480), fresh=False)
+ self.assertIn('camera.max', feed.watches)
+ clock.now += camera_feed.WATCH_SECONDS + 1
+ feed.watch('camera.garden')
+ self.assertNotIn('camera.max', feed.watches)
+
+ async def test_a_still_is_one_fixed_image(self):
+ feed, _ = self.feed([RuntimeError('never asked')])
+ bmp = camera_feed.encode(picture('JPEG', (640, 360)), (392, 220))
+ token = feed.link('camera.max', (392, 220), bmp)
+ status, body, etag = await feed.serve(token)
+ self.assertEqual((status, body), (200, bmp))
+ self.assertEqual((await feed.serve(token, etag))[0], 304)
+ self.assertEqual(self.fetched, [])
+
+ async def test_the_number_of_links_is_bounded(self):
+ feed, clock = self.feed([picture('JPEG', (64, 36))])
+ tokens = []
+ for _ in range(camera_feed.MAX_LINKS + 10):
+ clock.now += 0.01
+ tokens.append(feed.link('camera.max', (480, 480)))
+ self.assertLessEqual(len(feed.links), camera_feed.MAX_LINKS)
+ self.assertNotIn(tokens[0], feed.links)
+ self.assertIn(tokens[-1], feed.links)
+ self.assertTrue(all(re.fullmatch(r'[A-Za-z0-9_-]{24}', token) for token in tokens))
+
+ async def test_the_http_port(self):
+ from aiohttp.test_utils import TestClient, TestServer
+ feed, _ = self.feed([picture('JPEG', (1920, 1080))])
+ token = feed.link('camera.max', (480, 480))
+ async with TestClient(TestServer(camera_feed.web_app(feed))) as client:
+ response = await client.get(f'/camera/{token}.bmp')
+ body = await response.read()
+ self.assertEqual((response.status, response.content_type, int(response.headers['Content-Length'])), (200, 'image/bmp', len(body)))
+ etag = response.headers['ETag']
+ self.assertEqual((await client.get(f'/camera/{token}.bmp', headers={'If-None-Match': etag})).status, 304)
+ self.assertEqual((await client.get('/camera/abcdefghijklmnopqrstuvwx.bmp')).status, 404)
+ self.assertEqual((await client.get('/api/inventory')).status, 404)
+ self.assertEqual((await client.post(f'/camera/{token}.bmp')).status, 405)
+
+
+def fake_ha(image=None):
+ """Home Assistant with two current screens (a Guition and a CYD) and an old Guition."""
+ class HA:
+ online = True
+
+ def __init__(self):
+ self.registry, self.devices, self.states = [], [], {}
+ for device, node, firmware, guition in (('d1', 'hall', '0.2.57', True), ('d2', 'desk', '0.2.57', False),
+ ('d3', 'attic', '0.2.56', True)):
+ self.registry += [{'entity_id': f'text.{device}_tiles', 'platform': 'esphome', 'original_name': 'Tile settings', 'device_id': device},
+ {'entity_id': f'sensor.{device}_node', 'platform': 'esphome', 'original_name': 'Device name', 'device_id': device},
+ {'entity_id': f'sensor.{device}_fw', 'platform': 'esphome', 'original_name': 'Screen firmware', 'device_id': device}]
+ if guition:
+ self.registry.append({'entity_id': f'select.{device}_type', 'platform': 'esphome', 'original_name': 'Guition screen type', 'device_id': device})
+ self.devices.append({'id': device, 'name': node.title()})
+ self.states.update({f'text.{device}_tiles': {'state': 'Synced'}, f'sensor.{device}_node': {'state': node},
+ f'sensor.{device}_fw': {'state': firmware}})
+ self.areas = []
+ self.changed = asyncio.Event()
+ self.log = []
+ self.responses = set()
+
+ async def call(self, action, data):
+ self.log.append(('call', action, data))
+
+ async def send(self, inbox, message, action=None, respond=False):
+ self.log.append(('send', inbox, dict(message)))
+
+ async def request(self, kind, **data):
+ if kind == 'network':
+ return {'adapters': [{'name': 'end0', 'default': True, 'ipv4': [{'address': '192.168.1.57'}]}]}
+ raise ConnectionError(kind)
+
+ async def camera_image(self, entity):
+ self.log.append(('fetch', entity))
+ if image is None:
+ raise ConnectionError('500')
+ return image
+ return HA()
+
+
+@unittest.skipUnless(HAS_AIOHTTP and HAS_PIL, 'Run using .venv-portal/bin/python for server tests')
+class App(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ camera_feed.base_url.__defaults__[0].clear()
+
+ async def test_an_alert_with_a_camera(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ ha = fake_ha(picture('JPEG', (1920, 1080)))
+ m = Manager(ha, Path(tmp) / 'screens.json')
+ await m.broadcast(BROADCAST_SHOW, {'title': 'Someone is at the door', 'camera': 'camera.front_door'})
+ sends = [entry for entry in ha.log if entry[0] == 'send']
+ calls = [entry for entry in ha.log if entry[0] == 'call']
+ # Only the current Guition hears of the image: first announced, then the alert, then the link.
+ self.assertEqual([target for _, target, _ in sends], ['text.d1_tiles', 'text.d1_tiles'])
+ self.assertEqual(sends[0][2], {'v': 1, 'op': 'camera', 't': 'alert', 'e': 'camera.front_door', 'u': ''})
+ self.assertLess(ha.log.index(sends[0]), ha.log.index(calls[0]))
+ self.assertEqual(len(calls), 3, 'every current screen gets the alert itself')
+ self.assertNotIn('camera', calls[0][2])
+ url = sends[1][2]['u']
+ self.assertRegex(url, r'^http://192\.168\.1\.57:8098/camera/[A-Za-z0-9_-]{24}\.bmp$')
+ token = url.rsplit('/', 1)[1][:-4]
+ self.assertEqual(m.camera.links[token].box, (392, 220))
+ self.assertEqual((await m.camera.serve(token))[0], 200)
+ # The alert's camera may be opened full screen without a tile.
+ self.assertTrue(m.camera_allowed('text.d1_tiles', 'camera.front_door'))
+
+ async def test_an_alert_whose_camera_has_no_image_still_goes_out(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ ha = fake_ha(None)
+ m = Manager(ha, Path(tmp) / 'screens.json')
+ with self.assertLogs('screen_manager', 'INFO') as logs:
+ await m.broadcast(BROADCAST_SHOW, {'title': 'Door', 'camera': 'camera.front_door'})
+ sends = [entry for entry in ha.log if entry[0] == 'send']
+ self.assertEqual([message['u'] for _, _, message in sends], ['', ''])
+ self.assertEqual(len([entry for entry in ha.log if entry[0] == 'call']), 3)
+ self.assertTrue(any('(no image)' in line for line in logs.output), logs.output)
+
+ async def test_an_unusable_camera_is_named_and_left_out(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ ha = fake_ha(picture('JPEG', (64, 36)))
+ m = Manager(ha, Path(tmp) / 'screens.json')
+ with self.assertLogs('screen_manager', 'INFO') as logs:
+ await m.broadcast(BROADCAST_SHOW, {'title': 'Door', 'camera': 'light.hall'})
+ self.assertEqual([entry[0] for entry in ha.log], ['call'] * 3)
+ self.assertTrue(any('unusable camera left empty' in line for line in logs.output), logs.output)
+
+ async def test_a_screen_asks_for_a_camera_it_may_show(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ ha = fake_ha(picture('JPEG', (1920, 1080)))
+ m = Manager(ha, Path(tmp) / 'screens.json')
+ m.layouts['text.d1_tiles'] = validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.max', 'name': ''}]})
+ with self.assertLogs('screen_manager', 'INFO'):
+ await m.answer_camera({'inbox': 'text.d1_tiles', 'entity': 'camera.max'})
+ (_, inbox, message), = [entry for entry in ha.log if entry[0] == 'send']
+ self.assertEqual((inbox, message['op'], message['t'], message['e']), ('text.d1_tiles', 'camera', 'full', 'camera.max'))
+ token = message['u'].rsplit('/', 1)[1][:-4]
+ self.assertEqual(m.camera.links[token].box, (480, 480))
+ ha.log.clear()
+ # Not on its layout, a CYD, an old Guition, a light: no link at all.
+ for request in ({'inbox': 'text.d1_tiles', 'entity': 'camera.garden'}, {'inbox': 'text.d2_tiles', 'entity': 'camera.max'},
+ {'inbox': 'text.d3_tiles', 'entity': 'camera.max'}, {'inbox': 'text.d1_tiles', 'entity': 'light.hall'},
+ {'inbox': 'text.unknown', 'entity': 'camera.max'}, 'nonsense'):
+ await m.answer_camera(request)
+ self.assertEqual([entry for entry in ha.log if entry[0] == 'send'], [])
+
+ async def test_camera_tiles_only_on_a_guition(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ ha = fake_ha()
+ m = Manager(ha, Path(tmp) / 'screens.json')
+ m.inventory = lambda: (m.screens(), [{'id': 'camera.max'}])
+ m.write_layouts = lambda layouts: None
+ m.notify = lambda: None
+ with self.assertRaisesRegex(ValueError, 'Guition'):
+ m.save('text.d2_tiles', {'title': 'Desk', 'tiles': [{'entity': 'camera.max', 'name': ''}]})
+ # A CYD on old firmware hears the same, not "update first": an update doesn't make room for images.
+ ha.states['sensor.d2_fw']['state'] = '0.2.54'
+ m._screens_key = None
+ with self.assertRaisesRegex(ValueError, 'Guition'):
+ m.save('text.d2_tiles', {'title': 'Desk', 'tiles': [{'entity': 'camera.max', 'name': ''}]})
+ with self.assertRaisesRegex(ValueError, '0.2.57'):
+ m.save('text.d3_tiles', {'title': 'Attic', 'tiles': [{'entity': 'camera.max', 'name': ''}]})
+ m.save('text.d1_tiles', {'title': 'Hall', 'tiles': [{'entity': 'camera.max', 'name': ''}]})
+ self.assertEqual(m.layouts['text.d1_tiles']['tiles'][0]['entity'], 'camera.max')
+
+ async def test_home_assistant_queues_camera_requests(self):
+ from aiohttp import WSMsgType
+
+ class Message:
+ type = WSMsgType.TEXT
+ def __init__(self, data): self.data = data
+ def json(self): return self.data
+
+ class Socket:
+ def __aiter__(self): return self._iterate()
+ async def _iterate(self):
+ yield Message({'type': 'event', 'event': {'event_type': 'esphome.screen_camera', 'data': {'inbox': 'text.d1_tiles', 'entity': 'camera.max'}}})
+ ha = HomeAssistant(None, 'http://ha/api', 'token')
+ ha.ws = Socket()
+ with self.assertRaises(ConnectionError):
+ await ha.read()
+ self.assertEqual(ha.camera_requests.get_nowait(), {'inbox': 'text.d1_tiles', 'entity': 'camera.max'})
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_camera_view.cpp b/tests/test_camera_view.cpp
new file mode 100644
index 0000000..2dbf12f
--- /dev/null
+++ b/tests/test_camera_view.cpp
@@ -0,0 +1,65 @@
+// clang++ -std=c++17 -Wall -Wextra -Werror -I. tests/test_camera_view.cpp -o /tmp/test_camera_view && /tmp/test_camera_view
+#include "../components/smart_display/camera_view.h"
+#include
+
+using namespace camera_view;
+
+int main() {
+ Feed feed;
+ assert(!feed.open() && !feed.should_ask(0) && !feed.should_load(0));
+
+ // Opening asks at once; a link that does not come is asked for again after ASK_AGAIN_MS.
+ feed.open("camera.front_door");
+ assert(feed.should_ask(1000));
+ feed.ask(1000);
+ assert(!feed.should_ask(1000 + ASK_AGAIN_MS - 1));
+ assert(feed.should_ask(1000 + ASK_AGAIN_MS));
+
+ // A link loads at once, then every REFRESH_MS from the start of the last load.
+ feed.link("http://192.168.1.2:8098/camera/abcdefghijklmnopqrstuvwx.jpg");
+ assert(!feed.should_ask(2000) && feed.should_load(2000));
+ feed.start(2000);
+ assert(!feed.should_load(2100) && !feed.should_ask(2100));
+ feed.finish(2300, true);
+ assert(feed.shown && !feed.should_load(2000 + REFRESH_MS - 1) && feed.should_load(2000 + REFRESH_MS));
+
+ // A load that ends near the next start still leaves GAP_MS for touch and drawing before the next one.
+ const uint32_t slow_end = 5000 + REFRESH_MS - 100;
+ feed.start(5000);
+ feed.finish(slow_end, true);
+ assert(!feed.should_load(5000 + REFRESH_MS) && !feed.should_load(slow_end + GAP_MS - 1) && feed.should_load(slow_end + GAP_MS));
+ // An ordinary load keeps the rhythm: the next start is REFRESH_MS after this one, however quickly it ended.
+ feed.start(20000);
+ feed.finish(20000 + REFRESH_MS / 2, true);
+ assert(!feed.should_load(20000 + REFRESH_MS - 1) && feed.should_load(20000 + REFRESH_MS));
+
+ // Failures keep the link until MAX_FAILURES in a row; then a new link is asked for, the image stays shown.
+ uint32_t now = 10000;
+ for (uint8_t i = 0; i < MAX_FAILURES; ++i) {
+ assert(!feed.url.empty());
+ feed.start(now);
+ feed.finish(now + 100, false);
+ now += REFRESH_MS;
+ }
+ assert(feed.url.empty() && feed.shown && feed.should_ask(now));
+
+ // An app without an image answers an empty link: nothing loads, and the view asks again later.
+ feed.ask(now);
+ feed.link("");
+ assert(feed.empty && !feed.should_load(now) && !feed.should_ask(now + 1) && feed.should_ask(now + ASK_AGAIN_MS));
+
+ // A success after a failure starts the count again.
+ feed.link("http://192.168.1.2:8098/camera/abcdefghijklmnopqrstuvwx.jpg");
+ feed.start(now); feed.finish(now + 10, false);
+ feed.start(now + REFRESH_MS); feed.finish(now + REFRESH_MS + 10, true);
+ assert(feed.failures == 0 && !feed.url.empty());
+
+ // Opening another camera forgets everything of the last one; millis() 0 still counts as a start.
+ feed.open("image.doorbell_snapshot");
+ assert(feed.url.empty() && !feed.shown && feed.should_ask(0));
+ feed.link("http://h/camera/x.jpg");
+ feed.start(0);
+ feed.finish(10, true);
+ assert(!feed.should_load(REFRESH_MS - 1) && feed.should_load(REFRESH_MS + 1));
+ return 0;
+}
diff --git a/tests/test_wake_sleep.py b/tests/test_wake_sleep.py
index d474a8f..b8ae188 100644
--- a/tests/test_wake_sleep.py
+++ b/tests/test_wake_sleep.py
@@ -84,9 +84,9 @@ class Profiles(unittest.TestCase):
self.assertIn('- script.execute: back_to_page_1_when_due', interval, name)
self.assertNotIn('auto_home', interval, f'{name}: one copy of the rule')
self.assertEqual(len(re.findall(r'script\.execute: back_to_page_1_when_due', text)), 2, name)
- # Written by the boot, the three touchscreen triggers, Home Assistant opening the settings page and the
- # UI self test only; Wake, an alert ending and Auto standby restart the standby time alone.
- self.assertEqual(len(re.findall(r'id\(last_use_ms\) = millis\(\);', text)), 6, name)
+ # Written by the boot, the three touchscreen triggers, Home Assistant opening the settings page (and, on the
+ # Guition, a camera) and the UI self test only; Wake, an alert ending and Auto standby restart the standby time alone.
+ self.assertEqual(len(re.findall(r'id\(last_use_ms\) = millis\(\);', text)), 7 if 'preview_camera' in text else 6, name)
touch = section(text, 'touchscreen')
for trigger in ('on_touch', 'on_update', 'on_release'):
body = re.search(rf'^ {trigger}:\n(.*?)(?=^ [a-z_]+:|\Z)', touch, re.M | re.S)[1]
diff --git a/tools/render_topbar.py b/tools/render_topbar.py
index 3c0f1e3..fea0a52 100644
--- a/tools/render_topbar.py
+++ b/tools/render_topbar.py
@@ -23,7 +23,7 @@ WORK = ROOT / '.esphome' / 'render-topbar'
PROFILES = {'guition': 'guition-4848s040.yaml', 'cyd': 'home-like-2432s028.yaml'}
FONTS = ('headline', 'time_label', 'sublabel_big', 'label', 'materialdesign_icons', 'materialdesign_icons_mini')
# Hardware headers the host cannot compile; the top bar needs none of them.
-SKIP = {'backlight_fade.h', 'guition_diagnostics.h', '__pycache__'}
+SKIP = {'guition_diagnostics.h', '__pycache__'}
# Header band per board: page width and height above the tiles.
BANDS = {'guition': (480, 72), 'cyd': (320, 40)}