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 +

+ An alert on the Guition with the front door camera's picture across the top: someone is at the door, with a Coming button + The front door camera full screen on the Guition, with the round back key and the camera's name at the top +

+

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.

An alert on the Guition: someone is at the door, with a Coming button The Alerts cheatsheet in ESP Screens: the action name of every screen, ready to copy

-

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

+ An alert on the Guition with the front door camera's picture across the top: someone is at the door, with a Coming button + The front door camera full screen on the Guition, with the round back key and the camera's name at the top +

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