Show who is at the door: camera images on the Guition (0.2.66)

An alert from esp_screens_show_alert takes a `camera` (a camera.* or image.* entity): a Guition with firmware
0.2.57 shows that moment's picture across the top of the card, and a tap opens the camera full screen. A camera
or image tile opens the same full-screen view, refreshed every four seconds at a steady pace.

ESP Screen Manager fetches the snapshot with its own access, sizes it for the screen and serves a 24-bit BMP
under a random, short-lived link on port 8098; the screen loads it with ESPHome's online_image and holds no
Home Assistant token. BMP because ESPHome decodes it per chunk, where a full-screen JPEG held the loop 0.6 s.
The CYD keeps its alerts without the picture and gets no camera tiles.

The Guition also builds with ESPHome 2026.8+ again: backlight_fade.h derived from ESPHome's LEDC output, which
2026.8 made final, so the backlight now dims through ESPHome's own light transition.

Tested on the bench boards and a local add-on on the Home Assistant Yellow; docs/TEST_RESULTS_0266.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-17 15:31:11 +02:00
parent 9567ab591a
commit d60a0f122b
31 changed files with 1847 additions and 117 deletions
+7 -2
View File
@@ -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
<p align="center">
<img src="docs/images/guition-alert-camera.png" width="41%" alt="An alert on the Guition with the front door camera's picture across the top: someone is at the door, with a Coming button">
<img src="docs/images/guition-camera.png" width="41%" alt="The front door camera full screen on the Guition, with the round back key and the camera's name at the top">
</p>
<p align="center"><sub>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. <a href="docs/CAMERA.md">Camera images</a>.</sub></p>
<p align="center">
<img src="docs/images/guition-alert.png" width="41%" alt="An alert on the Guition: someone is at the door, with a Coming button">
<img src="docs/images/editor-alerts.png" width="53%" alt="The Alerts cheatsheet in ESP Screens: the action name of every screen, ready to copy">
</p>
<p align="center"><sub>Someone at the door? One action in an automation wakes the screen and shows it, on one screen or all of them. <a href="README_EXTENDED.md#alert-from-an-automation">How alerts work</a>.</sub></p>
<p align="center"><sub>Any alert, on one screen or all of them, in a pastel color of your choice. <a href="README_EXTENDED.md#alert-from-an-automation">How alerts work</a>.</sub></p>
## Installing from Home Assistant
+29
View File
@@ -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
<p align="center">
<img src="docs/images/guition-alert-camera.png" width="41%" alt="An alert on the Guition with the front door camera's picture across the top: someone is at the door, with a Coming button">
<img src="docs/images/guition-camera.png" width="41%" alt="The front door camera full screen on the Guition, with the round back key and the camera's name at the top">
</p>
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
-60
View File
@@ -1,60 +0,0 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <driver/ledc.h>
#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<typename T> 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<esphome::ledc::LEDCOutput>;
const auto channel = static_cast<ledc_channel_t>(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(&current);
const uint32_t from = hardware_owns ? ledc_get_duty(speed, channel) : static_cast<uint32_t>(std::lround(current * max_duty));
ledc_set_duty(speed, channel, from);
ledc_update_duty(speed, channel);
hardware_owns = true;
const auto target = static_cast<uint32_t>(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<esphome::ledc::LEDCOutput>::channel(output)), static_cast<ledc_channel_t>(Peek<esphome::ledc::LEDCOutput>::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
+50
View File
@@ -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 <cstdint>
#include <string>
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
+25 -2
View File
@@ -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<uint16_t>((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");
}
}
+1 -1
View File
@@ -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;
}
+278
View File
@@ -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<void(const std::string &)> load; // set the online_image's URL and download it
std::function<void()> release; // free the decoded image
std::function<lv_image_dsc_t *()> 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<void(bool)> alert_room; // the board makes the card taller for the frame, or back
inline std::string alert_announced, alert_camera, alert_url;
inline 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<bool>(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
+9 -1
View File
@@ -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<size_t>(Paint::COUNT)] = {
@@ -307,6 +314,7 @@ inline constexpr PaintRoles PAINTS[static_cast<size_t>(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) {
+84
View File
@@ -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.
+7 -1
View File
@@ -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 |
+4 -4
View File
@@ -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.
+45
View File
@@ -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.<screen>_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/<token>.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
+95
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

+138 -15
View File
@@ -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:
- <esphome/components/smart_display/cyd_ui.h>
- <esphome/components/smart_display/light_controls.h>
- <esphome/components/smart_display/guition_diagnostics.h>
- <esphome/components/smart_display/backlight_fade.h>
- <esphome/components/smart_display/alert_overlay.h>
- <esphome/components/smart_display/settings_screen.h>
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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+138 -15
View File
@@ -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:
- <esphome/components/smart_display/cyd_ui.h>
- <esphome/components/smart_display/light_controls.h>
- <esphome/components/smart_display/guition_diagnostics.h>
- <esphome/components/smart_display/backlight_fade.h>
- <esphome/components/smart_display/alert_overlay.h>
- <esphome/components/smart_display/settings_screen.h>
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.
+13
View File
@@ -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.
+283
View File
@@ -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/<token>.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://<address>:<port> 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']
+3 -1
View File
@@ -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
+23 -5
View File
@@ -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.
+121 -2
View File
@@ -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__':
+17
View File
@@ -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");
+1 -1
View File
@@ -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'
+6 -1
View File
@@ -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
+399
View File
@@ -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()
+65
View File
@@ -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 <cassert>
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;
}
+3 -3
View File
@@ -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]
+1 -1
View File
@@ -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)}