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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¤t);
|
||||
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
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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']
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user