Show a live picture on a camera tile, run an action from the alert button, roll long media titles, and keep full-page tiles in their own colour (0.2.91)
Live picture (issue #4): a camera or image tile on a Guition takes Display -> Live picture with a pace of 15 or 30 s. The camera tiles of a page share one download: the screen asks ESP Screens for them together (esphome.screen_camera with tiles, size and bg) and the app serves one BMP, a strip of squares with the corners rounded over each tile's own colour; every tile draws its square out of it with LVGL's image offset (LV_IMAGE_ALIGN_TOP_LEFT: the default centres a taller source). A third online_image on the Guition holds the strip; it loads after the alert's picture, a cover and the camera full screen, never under a card, in standby or under a finger. The strip is served whole every time: ESPHome's http_request logs a 304 as a failed request. Firmware 0.2.77. Alert button: esp_screens_show_alert takes action and data; the app performs the action once when the button's report (esphome.screen_alert, ok) comes from any screen. show_alert keeps its seven fields, so every firmware from 0.2.31 has it. Media card and full-page media tile: a title or artist line that is too wide rolls by (LV_LABEL_LONG_SCROLL_CIRCULAR) instead of ending in dots. Full-page tiles are white or their own pastel like every other tile; the tint in the state colour of firmware 0.2.62 to 0.2.76 is gone. Host harness (27 checks) and bench Guition + local add-on on the Yellow: docs/TEST_RESULTS_0291.md. CYD firmware 1,633,728 B, 89.0 %. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -81,7 +81,7 @@ https://github.com/user-attachments/assets/f9f6a933-d388-4c8b-9c9a-011dfa37617b
|
||||
<img src="docs/images/guition-camera.png" width="32%" alt="A camera tile tapped: the front door camera full screen, with the round back key and the camera's name at the top">
|
||||
<img src="docs/images/guition-alert-camera.png" width="32%" alt="The same camera in an alert: its picture across the top of the card, with the card's rounded corners">
|
||||
</p>
|
||||
<p align="center"><sub>Cameras are tiles too, not only part of an alert: any camera or snapshot in Home Assistant (a doorbell's last ring, for example) goes on a Guition like any other tile. Tap it for the picture full screen, refreshed every four seconds; an alert can carry the same picture. The CYD has no memory for pictures (<a href="docs/CAMERA.md">how it works</a>).</sub></p>
|
||||
<p align="center"><sub>Cameras are tiles too, not only part of an alert: any camera or snapshot in Home Assistant (a doorbell's last ring, for example) goes on a Guition like any other tile. Tap it for the picture full screen, refreshed every four seconds, or let the tile itself show a small live picture in the icon's place (Display → Live picture, every 15 or 30 s); an alert can carry the same picture. The CYD has no memory for pictures (<a href="docs/CAMERA.md">how it works</a>).</sub></p>
|
||||
<p align="center">
|
||||
<img src="docs/images/cyd-home.png" width="32%" alt="CYD 2.8-inch screen: the weather forecast, a kitchen timer, the coffee machine, a lamp and power usage as a large value">
|
||||
<img src="docs/images/cyd-page-2.png" width="32%" alt="Second CYD page: Sonos volume, presence, a scene and an energy graph">
|
||||
|
||||
+40
-7
@@ -53,11 +53,9 @@ on the screen itself, and how updates work.
|
||||
**presence** (`person.*`). Pick them in the library like any other tile, or
|
||||
drag them straight into the screen mockup; **Double-width** is an option for every tile, and so is
|
||||
**Full page** (firmware 0.2.62+; not for a Go to page tile): the tile takes the whole page and is
|
||||
one big button that lights up in its state colour while on, so a screen by the door switches the
|
||||
light when you push anywhere on it.
|
||||
Only something that can be off lights up: a light, switch, airco, fan, blind, speaker, robot, script,
|
||||
timer, camera, person or binary sensor. Sensors, numbers, selects, scenes, the weather and the sun stay plain
|
||||
(firmware 0.2.71+).
|
||||
one big button, so a screen by the door switches the light when you push anywhere on it. It is white
|
||||
or its own pastel like every other tile, and its icon shows the state (firmware 0.2.77+; before that
|
||||
the whole tile took its state colour while on).
|
||||
Its small slider, direct controls or graph sit at the bottom of the page. A **Go to page** tile
|
||||
(`screen.page_1` to `screen.page_8`) opens another page: a full-page light switch on page 1, a menu on page 2.
|
||||
Under **Goes to page** the editor offers the pages the screen has and the next, empty one, where a new sub-page
|
||||
@@ -254,7 +252,8 @@ Home Assistant asks for all seven fields; leave a field empty (`""`, `0`, `false
|
||||
don't use it. A new alert replaces the current one. Every end is reported as the event
|
||||
**`esphome.screen_alert`** with `action` (`ok`, `timeout`, `replaced`, or `remote`), `title`,
|
||||
`screen`, and the `device_id` that Home Assistant adds, so an automation can wait for OK.
|
||||
**`esphome.<screen>_dismiss_alert`** clears the card remotely.
|
||||
**`esphome.<screen>_dismiss_alert`** clears the card remotely. With the event for every screen, the
|
||||
button can also perform a Home Assistant action of your choice (below).
|
||||
|
||||
### All screens at once
|
||||
|
||||
@@ -307,9 +306,43 @@ actions:
|
||||
</p>
|
||||
|
||||
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
|
||||
every four seconds. From app 0.2.91 with firmware 0.2.77 a camera tile can show a **live picture** in
|
||||
the icon's place: in the tile's settings choose **Display → Live picture** and a pace, every 15 or
|
||||
30 seconds. The picture is a small square with the tile's rounded corners, the middle of the camera's
|
||||
view, and it refreshes while that page is on the screen; a tap still opens the camera full screen. The
|
||||
camera tiles of one page share one download, so six live tiles cost the screen no more than one.
|
||||
How the image travels (port 8098 of the app, no token on the screen) is in
|
||||
[docs/CAMERA.md](docs/CAMERA.md).
|
||||
|
||||
### With a button that does something
|
||||
|
||||
From app 0.2.91 the event takes an **`action`**: a Home Assistant action the app performs when the
|
||||
button is pressed, on whichever screen, once per alert. **`data`** gives the action's fields. The
|
||||
screen itself only reports the press (the `esphome.screen_alert` event it always sent), so this works
|
||||
with every screen from firmware 0.2.31 and needs no update. A timeout, a new alert over it or
|
||||
`esp_screens_dismiss_alert` leaves the action unperformed. The per-screen actions have no `action`
|
||||
field.
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- event: esp_screens_show_alert
|
||||
event_data:
|
||||
title: "Someone is at the gate"
|
||||
subtitle: "Open it?"
|
||||
icon: doorbell
|
||||
button_text: "Open"
|
||||
timeout: 120
|
||||
action: script.open_gate
|
||||
- event: esp_screens_show_alert
|
||||
event_data:
|
||||
title: "Lights are still on downstairs"
|
||||
button_text: "Turn off"
|
||||
action: light.turn_off
|
||||
data:
|
||||
entity_id: light.downstairs
|
||||
transition: 3
|
||||
```
|
||||
|
||||
### Ask Claude
|
||||
|
||||
Use Claude Code in Home Assistant? **Settings → Claude → Install for Claude Code** writes an
|
||||
|
||||
@@ -24,14 +24,19 @@ struct Feed {
|
||||
// once (firmware 0.2.64+): an album cover on the media card. It loads one time per link and stays; a new cover comes
|
||||
// with a new link (the card asks again when Home Assistant's picture changes), never on a clock.
|
||||
bool once = false, loaded = false; // loaded: this link's image is on screen
|
||||
// every (firmware 0.2.77+): the live pictures on a page's camera tiles load at their own pace, 15 or 30 s, where the
|
||||
// camera full screen keeps REFRESH_MS.
|
||||
uint32_t every = REFRESH_MS;
|
||||
uint8_t failures = 0;
|
||||
|
||||
void open(const std::string &camera, bool one_load = false) { *this = Feed{}; entity = camera; once = one_load; }
|
||||
void open(const std::string &camera, bool one_load = false, uint32_t every_ms = REFRESH_MS) { *this = Feed{}; entity = camera; once = one_load; every = every_ms; }
|
||||
bool open() const { return !entity.empty(); }
|
||||
// A cover the app has none of (or an app that knows no covers) is asked for once: the card keeps its placeholder.
|
||||
// A feed whose app has no picture asks again at its own pace, never sooner than ASK_AGAIN_MS.
|
||||
bool should_ask(uint32_t now) const {
|
||||
if (once && empty) return false;
|
||||
return open() && !loading && url.empty() && (!asked || now - asked_at >= ASK_AGAIN_MS);
|
||||
const uint32_t again = empty && every > ASK_AGAIN_MS ? every : ASK_AGAIN_MS;
|
||||
return open() && !loading && url.empty() && (!asked || now - asked_at >= again);
|
||||
}
|
||||
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).
|
||||
@@ -46,7 +51,7 @@ struct Feed {
|
||||
if (!open() || loading || url.empty()) return false;
|
||||
if (!started_at) return true;
|
||||
if (once) return !loaded && now - finished_at >= GAP_MS; // a failed load is tried again, a loaded one stays
|
||||
return now - started_at >= REFRESH_MS && now - finished_at >= GAP_MS;
|
||||
return now - started_at >= every && now - finished_at >= GAP_MS;
|
||||
}
|
||||
void start(uint32_t now) { loading = true; started_at = now ? now : 1; }
|
||||
void finish(uint32_t now, bool ok) {
|
||||
|
||||
@@ -54,6 +54,18 @@ inline bool valid_entity(const std::string &entity) {
|
||||
if (domain == allowed) return true;
|
||||
return false;
|
||||
}
|
||||
// A comma-separated list of entities (a page's live camera tiles, firmware 0.2.77+); an empty item stands for a tile
|
||||
// without a picture.
|
||||
inline bool valid_entity_list(const std::string &list) {
|
||||
if (list.empty() || list.size() > 400) return false;
|
||||
for (size_t start = 0;; ) {
|
||||
const size_t comma = list.find(',', start);
|
||||
const std::string item = list.substr(start, comma == std::string::npos ? std::string::npos : comma - start);
|
||||
if (!item.empty() && !valid_entity(item)) return false;
|
||||
if (comma == std::string::npos) return true;
|
||||
start = comma + 1;
|
||||
}
|
||||
}
|
||||
// An action as Home Assistant names it (domain.action: lowercase letters, digits, underscores), of any integration.
|
||||
inline bool valid_action(const std::string &action) {
|
||||
if (action.size() > 64) return false;
|
||||
@@ -201,6 +213,10 @@ struct Tile {
|
||||
bool has_hs_color = false;
|
||||
int saturation = 0;
|
||||
std::string tap = "auto", display = "standard", inline_control = "none";
|
||||
// A camera tile's live picture (firmware 0.2.77+): "display": "live" puts a small picture of the camera in the
|
||||
// icon's place, loaded again every `refresh` seconds.
|
||||
uint16_t refresh = 15;
|
||||
bool live() const { const auto d = domain(); return display == "live" && (d == "camera" || d == "image"); }
|
||||
// Double width takes a row; full (firmware 0.2.62+) takes the whole page, all six slots, and is also wide.
|
||||
bool wide = false, full = false;
|
||||
// Direct control set on a wide card (firmware 0.2.19+); empty keeps the plain card.
|
||||
@@ -348,16 +364,6 @@ struct Tile {
|
||||
if (d == "camera") return state == "streaming" || state == "recording";
|
||||
return true;
|
||||
}
|
||||
// A card over the whole page lights up in its state colour while it is on (firmware 0.2.62+): only a tile that can
|
||||
// be off, closed, docked or away, and only while active() calls it active (firmware 0.2.71+). Sensors, numbers,
|
||||
// selects, scenes, buttons, images, the weather and the sun are always active in Home Assistant and stay plain.
|
||||
bool lights_up() const {
|
||||
const auto d = domain();
|
||||
for (const char *on_off : {"light", "switch", "input_boolean", "climate", "fan", "cover", "media_player", "vacuum",
|
||||
"script", "timer", "camera", "person", "binary_sensor"})
|
||||
if (d == on_off) return active();
|
||||
return false;
|
||||
}
|
||||
// A slider shows the card's colour like Home Assistant's tile sliders: grey only while the entity is inactive
|
||||
// (active() above), such as an off light or fan and a media player that is off or in standby. A number with a
|
||||
// value is active there, and a closed cover's position slider keeps the cover's colour so it never looks
|
||||
|
||||
@@ -108,6 +108,7 @@ inline std::string hhmm(const esphome::ESPTime &time) {
|
||||
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 live_tick(uint32_t now);
|
||||
inline void camera_answer(const std::string &view, const std::string &entity, const std::string &url);
|
||||
inline bool camera_supported();
|
||||
// The media card's album cover (firmware 0.2.64+): the card or a tile over the whole page says which cover it shows,
|
||||
@@ -246,9 +247,12 @@ struct Widgets {
|
||||
lv_obj_t *busy{}, *spinner{}; bool busy_drawn=false;
|
||||
// Page fill skeleton: a sheet in the card's colour over its contents until the card is drawn.
|
||||
lv_obj_t *veil{};
|
||||
// A camera tile's live picture in the icon's place (firmware 0.2.77+).
|
||||
lv_obj_t *picture{};
|
||||
};
|
||||
constexpr unsigned POINT_BUFFER = 128;
|
||||
inline std::array<Widgets, 10> widgets;
|
||||
inline void live_place(Widgets &w, const Tile &t, int size, int x, int y);
|
||||
// All icon fonts carry the same generated glyph set, so the first bound one answers for all.
|
||||
inline bool has_icon_glyph(uint32_t codepoint) {
|
||||
for (auto &w : widgets) if (w.icon_font) { lv_font_glyph_dsc_t dsc; return lv_font_get_glyph_dsc(w.icon_font, &dsc, codepoint, 0); }
|
||||
@@ -434,8 +438,9 @@ inline std::string receive(const std::string &payload) {
|
||||
// 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), or the media card's cover
|
||||
// ("cover", app 0.2.77+). 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" && view != "cover")) return false;
|
||||
const std::string view = string(root["t"], 8), entity = string(root["e"], view == "live" ? 400 : 120), url = string(root["u"], 240);
|
||||
// "live" (app 0.2.91+): the page's camera tiles as one strip; `e` lists them, "" for one without a picture.
|
||||
if (view == "live" ? !valid_entity_list(entity) : !valid_entity(entity) || (view != "full" && view != "alert" && view != "cover")) return false;
|
||||
if (!url.empty() && url.rfind("http://", 0) != 0) return false;
|
||||
camera_answer(view, entity, url);
|
||||
result = model.ready() ? "Synced" : "Loading tiles";
|
||||
@@ -574,6 +579,9 @@ inline std::string receive(const std::string &payload) {
|
||||
tile.icon = icon && has_icon_glyph(icon) ? tile_icon::utf8(icon) : "";
|
||||
tile.tap = string(options["tap"]); if (tile.tap.empty()) tile.tap="auto";
|
||||
tile.display = string(options["display"]); if (tile.display.empty()) tile.display="standard";
|
||||
// A live picture's pace (0.2.91+): 15 or 30 s; a missing or odd value keeps the default.
|
||||
const int refresh = options["refresh"].is<int>() ? options["refresh"].as<int>() : 0;
|
||||
tile.refresh = refresh >= 5 && refresh <= 3600 ? refresh : 15;
|
||||
tile.inline_control = string(options["inline"]); if (tile.inline_control.empty()) tile.inline_control="none";
|
||||
// Direct controls (0.2.19+): the manager sends only the set a wide card really shows.
|
||||
tile.controls = string(options["controls"], 16);
|
||||
@@ -2018,6 +2026,9 @@ inline lv_obj_t *media_picture_show(lv_obj_t *parent,lv_obj_t *existing,const me
|
||||
(void)parent;(void)r;(void)src;return existing;
|
||||
#endif
|
||||
}
|
||||
// A media text that is wider than its line rolls by, round and round, and stands still when it fits (firmware 0.2.77+):
|
||||
// LVGL's own circular scroll, at its own pace, where the dots of LV_LABEL_LONG_DOT cut a long title short.
|
||||
inline void marquee(lv_obj_t *label){if(label)lv_label_set_long_mode(label,LV_LABEL_LONG_SCROLL_CIRCULAR);}
|
||||
// The bar's fill and the elapsed time follow the track: once a second from tick(), without a redraw.
|
||||
inline void media_progress(const Tile &t,lv_obj_t *fill,lv_obj_t *elapsed,int bar_w){
|
||||
const auto &x=t.extra();
|
||||
@@ -2054,8 +2065,9 @@ inline void render_media_detail(Tile &t,unsigned index,bool large,int width,int
|
||||
// Title, artist · album.
|
||||
const lv_font_t *title_font=watch_font?watch_font:detail_font,*artist_font=control_font?control_font:detail_font,*small=small_font?small_font:detail_font;
|
||||
const lv_text_align_t align=l.wide?LV_TEXT_ALIGN_LEFT:LV_TEXT_ALIGN_CENTER;
|
||||
detail_text(detail_root,track&&!x.media_title.empty()?x.media_title:std::string(idle_text(usable?t.state:"unavailable")),l.title.x,l.title.y+top,l.title.w,title_font,align,theme::INK);
|
||||
if(l.artist)detail_text(detail_root,track?subtitle(x.media_artist,x.media_album):std::string(),l.artist_line.x,l.artist_line.y+top,l.artist_line.w,artist_font,align,theme::MUTED);
|
||||
// A title or artist line wider than the card rolls by, round and round (firmware 0.2.77+); a shorter one stands still.
|
||||
marquee(detail_text(detail_root,track&&!x.media_title.empty()?x.media_title:std::string(idle_text(usable?t.state:"unavailable")),l.title.x,l.title.y+top,l.title.w,title_font,align,theme::INK));
|
||||
if(l.artist)marquee(detail_text(detail_root,track?subtitle(x.media_artist,x.media_album):std::string(),l.artist_line.x,l.artist_line.y+top,l.artist_line.w,artist_font,align,theme::MUTED));
|
||||
// The progress bar: the fill runs while the track plays; a stream without a length has no bar to show.
|
||||
media_progress_fill=nullptr;media_elapsed_label=nullptr;media_bar_width=l.bar.w;
|
||||
if(track && x.media_duration){
|
||||
@@ -2977,8 +2989,8 @@ inline void set_busy(Widgets &w,bool busy,bool large){
|
||||
if(lv_obj_get_index(w.busy)!=(int32_t)lv_obj_get_child_count(w.tile)-1)lv_obj_move_foreground(w.busy);
|
||||
}
|
||||
// The card that takes a whole page (firmware 0.2.62+). Without a control it is one big button: the icon in a
|
||||
// large circle with the name and the state under it, and the whole card lights up in the state colour while
|
||||
// on (see the palette below), so a wall switch reads from across the room and a push anywhere works. With a
|
||||
// large circle with the name and the state under it (the circle in the state colour, the card white or its own
|
||||
// pastel like every other card), so a wall switch reads from across the room and a push anywhere works. With a
|
||||
// small slider, direct controls or a graph the double-width card's head stays on top and the control takes
|
||||
// a strip at the bottom, so a tap anywhere else still does what a tap on the tile does. The built-in cards
|
||||
// (clock, forecast, sun path) simply get the whole page.
|
||||
@@ -3029,8 +3041,9 @@ inline void render_media_full(Widgets &w,const Tile &t,bool big,int content_w,in
|
||||
auto text=[&](unsigned i,const lv_font_t *font,const Rect &r,lv_text_align_t align,const std::string &value,theme::Role role){
|
||||
auto *p=part_label(w,i,font,r.x,r.y+top,r.w,align,value);lv_label_set_long_mode(p,LV_LABEL_LONG_DOT);set_color(p,LV_STYLE_TEXT_COLOR,theme::color(role));lv_obj_remove_flag(p,LV_OBJ_FLAG_HIDDEN);return p;
|
||||
};
|
||||
text(2,title_font,l.title,LV_TEXT_ALIGN_LEFT,track&&!x.media_title.empty()?x.media_title:std::string(idle_text(usable?t.state:"unavailable")),theme::INK);
|
||||
if(l.artist)text(3,artist_font,l.artist_line,LV_TEXT_ALIGN_LEFT,track?subtitle(x.media_artist,x.media_album):std::string(),theme::MUTED);
|
||||
// The title and the artist line roll by when they are too long (firmware 0.2.77+), as on the card.
|
||||
marquee(text(2,title_font,l.title,LV_TEXT_ALIGN_LEFT,track&&!x.media_title.empty()?x.media_title:std::string(idle_text(usable?t.state:"unavailable")),theme::INK));
|
||||
if(l.artist)marquee(text(3,artist_font,l.artist_line,LV_TEXT_ALIGN_LEFT,track?subtitle(x.media_artist,x.media_album):std::string(),theme::MUTED));
|
||||
else if(w.parts[3])lv_obj_add_flag(w.parts[3],LV_OBJ_FLAG_HIDDEN);
|
||||
// The progress bar and its times; a stream without a length has none.
|
||||
w.media_bar_w=l.bar.w;
|
||||
@@ -3089,6 +3102,7 @@ inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sun
|
||||
int name_h=lv_font_get_line_height(name_font),circle=big?128:64;
|
||||
int block=circle+gap+name_h+2+value_h,top=std::max(0,(content_h-block)/2);
|
||||
lv_obj_set_size(w.circle,circle,circle);lv_obj_set_pos(w.circle,std::max(0,(content_w-circle)/2),top);
|
||||
live_place(w,t,circle,std::max(0,(content_w-circle)/2),top);
|
||||
// The big icon font carries the domain icons; another chosen icon keeps its usual size in the big circle.
|
||||
const lv_font_t *icon_font=big_icon_font && font_has(big_icon_font,icon_for(t))?big_icon_font:w.icon_font;
|
||||
if(lv_obj_get_style_text_font(w.icon,LV_PART_MAIN)!=icon_font){set_font(w.icon,icon_font);lv_obj_center(w.icon);}
|
||||
@@ -3108,6 +3122,7 @@ inline void render_full(Widgets &w,const Tile &t,bool custom,bool clock,bool sun
|
||||
lv_obj_set_size(w.circle,circle,circle);
|
||||
if(lv_obj_get_style_text_font(w.icon,LV_PART_MAIN)!=w.icon_font){set_font(w.icon,w.icon_font);lv_obj_center(w.icon);}
|
||||
lv_obj_set_pos(w.circle,0,big?12:std::max(0,(head_h-circle)/2));
|
||||
live_place(w,t,circle,0,big?12:std::max(0,(head_h-circle)/2));
|
||||
lv_obj_set_pos(w.title,w.title_x,big?w.title_y:text_y);
|
||||
lv_obj_set_pos(w.value,w.value_x,big?w.value_y:text_y+title_h+line_gap);
|
||||
lv_obj_set_width(w.title,std::max(1,content_w-w.title_x));lv_obj_set_width(w.value,std::max(1,content_w-w.value_x));
|
||||
@@ -3224,6 +3239,7 @@ inline void render_slot(size_t slot) {
|
||||
set_busy(w,w.busy_drawn,large_tile);
|
||||
lap(swipe_profile::BUSY);
|
||||
for(auto *o:{w.title,w.value,w.circle,w.unit})set_hidden(o,custom);
|
||||
if(custom && w.picture)lv_obj_add_flag(w.picture,LV_OBJ_FLAG_HIDDEN);
|
||||
if(w.full){
|
||||
lap(swipe_profile::GEOMETRY);
|
||||
render_full(w,t,custom,clock,sunpath,graph,mini,with_panel,large_tile,value,unit,content_w,content_h);
|
||||
@@ -3261,7 +3277,9 @@ inline void render_slot(size_t slot) {
|
||||
lv_obj_set_pos(w.title,text_x,watch?0:(mini||graph_strip||!large_tile)?text_y:w.title_y+lift);
|
||||
lv_obj_set_pos(w.value,watch?0:(mini||graph_strip)?text_x:w.value_x,
|
||||
watch?(large_tile?42:19):(mini||graph_strip||!large_tile)?text_y+title_height+line_gap:w.value_y+lift);
|
||||
lv_obj_set_pos(w.circle,0,(mini||graph_strip||!large_tile)?std::max(0,(header_height-circle_size)/2):12+lift);
|
||||
const int circle_y=(mini||graph_strip||!large_tile)?std::max(0,(header_height-circle_size)/2):12+lift;
|
||||
lv_obj_set_pos(w.circle,0,circle_y);
|
||||
live_place(w,t,circle_size,0,circle_y);
|
||||
// Use the requested coordinates: LVGL getters still return the previous
|
||||
// layout until its next pass when a slot changes from watch/slider to normal.
|
||||
int text_room=content_w-chart_w-(graph_side?(large_tile?10:6):0)-panel_w;
|
||||
@@ -3328,16 +3346,16 @@ inline void render_slot(size_t slot) {
|
||||
set_color(w.slider,LV_STYLE_BG_COLOR,fill_color,LV_PART_INDICATOR);
|
||||
set_color(w.slider,LV_STYLE_BG_COLOR,lv_color_hex(theme::tint(fill,51)),LV_PART_MAIN);
|
||||
slider_bar(w.slider,slider_bar_shown(t,slider_on));
|
||||
// A full-page card lights up in its state colour while it is on (firmware 0.2.62+): an amber lamp, an open blind, a
|
||||
// playing speaker, never a sensor or the weather (Tile::lights_up, firmware 0.2.71+).
|
||||
bool lit=w.full && on && t.lights_up() && available && !t.transparent;
|
||||
set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(lit?theme::tint(state_color,51):theme::surface(t.background)));
|
||||
// Every card, the one over the whole page too, is white or its own pastel (firmware 0.2.77+): the state shows in the
|
||||
// icon and the controls, as on the other sizes. Firmware 0.2.62 to 0.2.76 tinted a full-page card in its state colour
|
||||
// while it was on.
|
||||
set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(theme::surface(t.background)));
|
||||
set_number(w.tile,LV_STYLE_BORDER_WIDTH,1);
|
||||
// "Background: none" hides only the card; geometry and padding stay identical,
|
||||
// and the pressed flash still shows because it lives on the PRESSED state.
|
||||
set_number(w.tile,LV_STYLE_BG_OPA,t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER);
|
||||
set_number(w.tile,LV_STYLE_BORDER_OPA,t.transparent ? LV_OPA_TRANSP : LV_OPA_COVER);
|
||||
set_color(w.tile,LV_STYLE_BORDER_COLOR,lv_color_hex(lit?theme::tint(state_color,110):theme::outline(t.background)));
|
||||
set_color(w.tile,LV_STYLE_BORDER_COLOR,lv_color_hex(theme::outline(t.background)));
|
||||
set_color(w.circle,LV_STYLE_BG_COLOR,circle_color);
|
||||
set_color(w.icon,LV_STYLE_TEXT_COLOR,icon_color);
|
||||
auto title_color=theme::color(theme::INK);
|
||||
@@ -3812,7 +3830,7 @@ inline lv_color_t page_color(const Widgets &w) {
|
||||
return theme::color(theme::PAGE);
|
||||
}
|
||||
inline void skeleton(Widgets &w) {
|
||||
for(auto *o:{w.title,w.value,w.circle,w.unit,w.slider,w.extra,w.panel,w.busy})if(o)lv_obj_add_flag(o,LV_OBJ_FLAG_HIDDEN);
|
||||
for(auto *o:{w.title,w.value,w.circle,w.unit,w.slider,w.extra,w.panel,w.busy,w.picture})if(o)lv_obj_add_flag(o,LV_OBJ_FLAG_HIDDEN);
|
||||
if(!w.veil){
|
||||
w.veil=lv_obj_create(w.tile);lv_obj_remove_style_all(w.veil);
|
||||
lv_obj_remove_flag(w.veil,LV_OBJ_FLAG_CLICKABLE);lv_obj_remove_flag(w.veil,LV_OBJ_FLAG_SCROLLABLE);
|
||||
@@ -4159,6 +4177,162 @@ inline void cover_tick(uint32_t now) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Live pictures on camera tiles (firmware 0.2.77+) ----
|
||||
// A camera tile with "display": "live" shows a small picture of its camera in the icon's place. The camera tiles of
|
||||
// the page on screen share one image: the screen asks for them together (the entities in slot order, the size of the
|
||||
// icon's circle and the colour of each tile behind the rounded corners) and the app serves one strip of squares, top
|
||||
// to bottom, that every tile takes its own square out of (LVGL's image offset). One download per page at the pace of
|
||||
// the fastest of those tiles, 15 or 30 s, in the board's third online_image. It waits for the alert's picture, a
|
||||
// cover or the camera full screen: one picture loads at a time. A page turn, a card over the page or another look
|
||||
// changes what is wanted: the strip is dropped and asked for again.
|
||||
struct LiveWish { std::string entities, grounds; int size = 0; uint32_t every = 15000; };
|
||||
inline LiveWish live_wish;
|
||||
inline camera_view::Feed live; // entity: the list asked for
|
||||
inline std::string live_have; // the list the strip on screen holds, "" for a tile without a picture
|
||||
inline ImageHooks camera_live;
|
||||
inline bool live_supported() { return static_cast<bool>(camera_live.load); }
|
||||
inline bool card_open() { return detail_root && !lv_obj_has_flag(detail_root, LV_OBJ_FLAG_HIDDEN); }
|
||||
// The n-th item of a comma list, or -1 when it is not in it.
|
||||
inline int list_index(const std::string &list, const std::string &item) {
|
||||
int n = 0;
|
||||
for (size_t start = 0;; ++n) {
|
||||
const size_t comma = list.find(',', start);
|
||||
if (list.compare(start, comma == std::string::npos ? std::string::npos : comma - start, item) == 0 && (comma == std::string::npos ? list.size() - start : comma - start) == item.size()) return n;
|
||||
if (comma == std::string::npos) return -1;
|
||||
start = comma + 1;
|
||||
}
|
||||
}
|
||||
// The app's answer names the same tiles in the same order, with "" where it has no picture.
|
||||
inline bool same_list(const std::string &asked, const std::string &answered) {
|
||||
size_t a = 0, b = 0;
|
||||
for (;;) {
|
||||
const size_t ca = asked.find(',', a), cb = answered.find(',', b);
|
||||
const std::string x = asked.substr(a, ca == std::string::npos ? std::string::npos : ca - a), y = answered.substr(b, cb == std::string::npos ? std::string::npos : cb - b);
|
||||
if (!y.empty() && x != y) return false;
|
||||
if ((ca == std::string::npos) != (cb == std::string::npos)) return false;
|
||||
if (ca == std::string::npos) return true;
|
||||
a = ca + 1; b = cb + 1;
|
||||
}
|
||||
}
|
||||
// What the page on screen wants: its live camera tiles in slot order, with the colour under each picture's corners.
|
||||
inline LiveWish live_wanted() {
|
||||
LiveWish want;
|
||||
if (!model.ready()) return want;
|
||||
for (auto &w : widgets) {
|
||||
if (!w.tile || lv_obj_has_flag(w.tile, LV_OBJ_FLAG_HIDDEN) || w.index >= model.count) continue;
|
||||
const auto &t = model.tiles[w.index];
|
||||
if (!t.live()) continue;
|
||||
if (!want.entities.empty()) { want.entities += ','; want.grounds += ','; }
|
||||
want.entities += t.entity;
|
||||
char ground[8];
|
||||
snprintf(ground, sizeof(ground), "%06X", (unsigned) (t.transparent ? theme::hex(theme::PAGE) : theme::surface(t.background)));
|
||||
want.grounds += ground;
|
||||
if (!want.size) want.size = lv_obj_get_style_width(w.circle, LV_PART_MAIN);
|
||||
want.every = std::min<uint32_t>(want.every, t.refresh * 1000u);
|
||||
}
|
||||
return want;
|
||||
}
|
||||
// The strip's square for a tile, or nullptr while the strip is not here (or has no picture of this camera).
|
||||
inline lv_image_dsc_t *live_ready(const std::string &entity, int size, int &square) {
|
||||
if (!live.loaded || live_wish.size != size) return nullptr;
|
||||
square = list_index(live_have, entity);
|
||||
if (square < 0) return nullptr;
|
||||
auto *src = camera_live.source();
|
||||
return src && src->data && src->header.h >= (square + 1) * size ? src : nullptr;
|
||||
}
|
||||
// The pictures go before their buffer does (as the cover's do); the tiles draw their circle again.
|
||||
inline void live_release() {
|
||||
const bool had = live.open();
|
||||
live = camera_view::Feed{};
|
||||
live_have.clear();
|
||||
for (auto &w : widgets) {
|
||||
if (!w.picture || lv_obj_has_flag(w.picture, LV_OBJ_FLAG_HIDDEN)) continue;
|
||||
#if LV_USE_IMAGE
|
||||
lv_image_set_src(w.picture, nullptr);
|
||||
#endif
|
||||
lv_obj_add_flag(w.picture, LV_OBJ_FLAG_HIDDEN);
|
||||
refresh_tile(w.index);
|
||||
}
|
||||
if (had && camera_live.release) camera_live.release();
|
||||
}
|
||||
// The tile's square in the icon's place, or the circle again while the strip is not here (render_slot).
|
||||
inline void live_place(Widgets &w, const Tile &t, int size, int x, int y) {
|
||||
#if LV_USE_IMAGE
|
||||
int square = -1;
|
||||
lv_image_dsc_t *src = t.live() ? live_ready(t.entity, size, square) : nullptr;
|
||||
if (src) {
|
||||
if (!w.picture) {
|
||||
w.picture = lv_image_create(w.tile);
|
||||
lv_obj_remove_flag(w.picture, LV_OBJ_FLAG_CLICKABLE);
|
||||
// From the top left, so the offset picks the square: LVGL's default centres a source larger than its object.
|
||||
lv_image_set_inner_align(w.picture, LV_IMAGE_ALIGN_TOP_LEFT);
|
||||
}
|
||||
lv_image_set_src(w.picture, src);
|
||||
lv_image_set_offset_y(w.picture, -square * size);
|
||||
lv_obj_set_pos(w.picture, x, y);
|
||||
lv_obj_set_size(w.picture, size, size);
|
||||
lv_obj_remove_flag(w.picture, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_invalidate(w.picture);
|
||||
} else if (w.picture) lv_obj_add_flag(w.picture, LV_OBJ_FLAG_HIDDEN);
|
||||
set_hidden(w.circle, src != nullptr);
|
||||
#else
|
||||
(void) w; (void) t; (void) size; (void) x; (void) y;
|
||||
#endif
|
||||
}
|
||||
// Asks for the page's strip: `esphome.screen_camera` with the tiles, the size and the grounds (app 0.2.91+).
|
||||
inline void live_request() {
|
||||
if (inbox.empty()) return;
|
||||
esphome::api::HomeassistantActionRequest request;
|
||||
request.service = esphome::StringRef("esphome.screen_camera");
|
||||
request.is_event = true;
|
||||
char size_text[12];
|
||||
snprintf(size_text, sizeof(size_text), "%d", live_wish.size);
|
||||
const std::string keys[] = {"inbox", "tiles", "size", "bg"}, values[] = {inbox, live_wish.entities, size_text, live_wish.grounds};
|
||||
request.data.init(4);
|
||||
for (int i = 0; i < 4; ++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 the live tiles %s", live_wish.entities.c_str());
|
||||
}
|
||||
inline void live_tick(uint32_t now) {
|
||||
if (!live_supported()) return;
|
||||
LiveWish want = live_wanted();
|
||||
if (want.entities != live_wish.entities || want.grounds != live_wish.grounds || want.size != live_wish.size) {
|
||||
live_wish = want;
|
||||
live_release();
|
||||
if (!want.entities.empty()) live.open(want.entities, false, want.every);
|
||||
}
|
||||
if (!live.open() || camera_root || card_open() || !awake()) return;
|
||||
if (alert_image_due() || alert_thumb_loading || cover.loading || camera.loading) return; // one picture at a time
|
||||
if (live.should_ask(now)) {
|
||||
if (!fresh()) return;
|
||||
live.ask(now);
|
||||
live_request();
|
||||
} else if (live.should_load(now)) {
|
||||
auto *input = lv_indev_get_next(nullptr);
|
||||
if (input && lv_indev_get_state(input) == LV_INDEV_STATE_PRESSED) return;
|
||||
live.start(now);
|
||||
camera_live.load(live.url);
|
||||
}
|
||||
}
|
||||
// The board's third online_image: the strip is here (or unchanged, 304), or failed.
|
||||
inline void live_loaded(bool cached) {
|
||||
if (!live.loading) return;
|
||||
live.finish(esphome::millis(), true);
|
||||
ESP_LOGI("camera", "live tiles %s", cached ? "unchanged" : "loaded");
|
||||
for (auto &w : widgets)
|
||||
if (w.tile && !lv_obj_has_flag(w.tile, LV_OBJ_FLAG_HIDDEN) && w.index < model.count && model.tiles[w.index].live()) refresh_tile(w.index);
|
||||
}
|
||||
inline void live_failed() {
|
||||
if (!live.loading) return;
|
||||
live.finish(esphome::millis(), false);
|
||||
ESP_LOGI("camera", "live tiles failed");
|
||||
}
|
||||
|
||||
// Asks ESP Screen Manager for a link (app 0.2.66+ answers with op "camera"). An event, like history_request.
|
||||
// A cover (firmware 0.2.64+) adds the size it wants and the colour behind its rounded corners; the app bakes both in.
|
||||
inline void camera_request(const std::string &entity, int size, uint32_t background) {
|
||||
@@ -4293,6 +4467,7 @@ inline void camera_tick() {
|
||||
}
|
||||
}
|
||||
cover_tick(now);
|
||||
live_tick(now);
|
||||
if (!camera_root || !awake()) return;
|
||||
if (camera.should_ask(now)) {
|
||||
if (!fresh()) return;
|
||||
@@ -4356,6 +4531,13 @@ inline void camera_answer(const std::string &view, const std::string &entity, co
|
||||
if (url.empty()) ESP_LOGI("camera", "no cover for %s", entity.c_str());
|
||||
return;
|
||||
}
|
||||
if (view == "live") { // the page's camera tiles (firmware 0.2.77+): the list as asked, "" where a picture is missing
|
||||
if (!live.open() || !same_list(live.entity, entity)) return;
|
||||
live_have = entity;
|
||||
live.link(url);
|
||||
if (url.empty()) ESP_LOGI("camera", "no live pictures");
|
||||
return;
|
||||
}
|
||||
if (url.empty()) { // announced before its alert
|
||||
alert_announced = entity;
|
||||
alert_announced_at = esphome::millis();
|
||||
|
||||
+35
-5
@@ -35,6 +35,34 @@ what plays, with the title, the artist and the album, a progress bar and the key
|
||||
size *Full page* the alert's picture goes first: the tile's cover waits until the alert's picture
|
||||
is there, and a cover already on its way finishes before the alert's picture starts.
|
||||
|
||||
## A live picture on a camera tile
|
||||
|
||||
App 0.2.91 with firmware 0.2.77 puts the camera on the tile itself: **Display → Live picture** in the
|
||||
tile's settings, with a pace of 15 or 30 seconds (`refresh`). The tile shows a small square of the
|
||||
camera's view in the icon's place, the middle of the snapshot cut square with the tile's rounded
|
||||
corners, and refreshes it while that page is on the screen. A tap still opens the camera full screen.
|
||||
|
||||
- **One download per page.** The screen asks ESP Screen Manager for all the live tiles of the page at
|
||||
once (the event `esphome.screen_camera` with `tiles`, the entities in slot order, `size`, the side of
|
||||
the icon's circle, and `bg`, the colour of each tile behind the corners). The app answers with one
|
||||
BMP: a strip of squares, top to bottom in that order, and every tile draws its own square out of it
|
||||
(LVGL's image offset). Six live tiles cost the screen one download of about 50 KB; a tile over the
|
||||
whole page gets one square of 128 px.
|
||||
- **At the pace of the fastest tile.** The page loads its strip every 15 s when any of its tiles says
|
||||
15 s. The app fetches a camera again only when that camera's own pace has passed, so a 30 s camera
|
||||
on a 15 s page is fetched every other load. The strip comes whole every time, never as a 304:
|
||||
ESPHome's `http_request` logs a 304 as a failed request and raises its error flag, which a page of
|
||||
slow cameras would do every 15 s. Nobody loading means nothing fetched, as with the camera full
|
||||
screen.
|
||||
- **After the other pictures.** The strip waits for the alert's picture, a cover on its way and the
|
||||
camera full screen (one picture loads at a time), and does not load under an open card, in standby
|
||||
or under a finger. A page turn, dark mode (other colours behind the corners) or a changed tile drops
|
||||
the strip and asks for a new one.
|
||||
- **A camera without a picture** keeps its icon: the app names it with an empty entry in its answer
|
||||
and paints a plain square of the tile's colour in the strip.
|
||||
- The strip lives in a third `online_image` of the Guition profile (`tile_image`, PSRAM); the CYD has
|
||||
none and the editor does not offer the live picture there.
|
||||
|
||||
## A doorbell
|
||||
|
||||
```yaml
|
||||
@@ -105,11 +133,13 @@ Guition it comes in about 1.8 s (2.8 s with 4 KB).
|
||||
|
||||
## For developers
|
||||
|
||||
- `screen_manager/app/camera_feed.py`: fetching, sizing, links and the port.
|
||||
- `screen_manager/app/camera_feed.py`: fetching, sizing, links and the port; `encode_live` and `CameraFeed.live`
|
||||
make the strip for a page's live tiles (`Manager.answer_live` in `server.py` checks the tiles against the layout).
|
||||
- `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.
|
||||
- `packages/boards/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).
|
||||
- `components/smart_display/runtime_tiles.h`: the full-screen view, the alert picture, the live tiles
|
||||
(`live_tick`, `live_place`) and the `camera` message.
|
||||
- `packages/boards/guition-4848s040.yaml`: the three `online_image` components (the camera full screen and the
|
||||
cover, the alert's picture, the live tiles' strip), 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.
|
||||
|
||||
@@ -219,6 +219,48 @@ icons sit off-center in the browser).
|
||||
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
|
||||
profiles must come from the set. No changed preferences or keys.
|
||||
|
||||
### Compatibility 0.2.91 / firmware 0.2.77
|
||||
|
||||
Four things: a live picture on a camera tile, an action behind an alert's button, media texts that roll by, and a
|
||||
full-page tile without a colour of its own. Storage version and preferences unchanged; new tile settings and one new
|
||||
camera view.
|
||||
|
||||
- **Live picture** (`display: live` with `refresh: 15|30` on a `camera.*` or `image.*` tile; `DISPLAYS`,
|
||||
`LIVE_REFRESH`, `LIVE_MIN_FIRMWARE` in `core.py`, `validate_layout` drops `refresh` from any other display). The
|
||||
state message's `o` carries both keys verbatim; firmware before 0.2.77 ignores them (`min_firmware` asks for 0.2.77).
|
||||
The screen sends `esphome.screen_camera` with `tiles` (comma-separated entities in slot order), `size` (the icon
|
||||
circle's side, 24-160) and `bg` (one `RRGGBB` per tile); `Manager.answer_live` refuses a tile that is not a live
|
||||
camera tile of that layout and answers op `camera`, `t: "live"`, `e` the same list with `""` where the app has no
|
||||
picture, `u` a link to a BMP of `size` by `size` times the tiles (`camera_feed.encode_live`, `CameraFeed.live`,
|
||||
`Link.live`). The screen validates `e` with `valid_entity_list` (runtime_model.h, 400 bytes) and matches it to what
|
||||
it asked (`same_list`). The strip is always served whole (200, never 304, whatever `If-None-Match` says): ESPHome's
|
||||
`http_request` logs a 304 as a failed request and raises its error flag (seen on the bench Guition with a radar
|
||||
camera), which a page of slow cameras would do every 15 s; the ETag still goes out, and every picture now carries a
|
||||
`Last-Modified`, because `online_image` warns at each load about either header missing. `camera_view::Feed` got
|
||||
`every` (the pace per feed; the full screen keeps `REFRESH_MS`) and
|
||||
asks again at that pace after an empty answer. The Guition profile has a third `online_image`, `tile_image`, bound
|
||||
as `runtime_tiles::camera_live` with `live_loaded(cached)` / `live_failed()`; `live_tick()` runs from
|
||||
`camera_tick()` after `cover_tick()`. Each tile's square is an `lv_image` in the icon circle's place with
|
||||
`LV_IMAGE_ALIGN_TOP_LEFT` and `offset_y = -n * size` (the default inner alignment centres a taller source, which
|
||||
showed the neighbour's square); the circle hides under it (`live_place`, called wherever `render_slot` and
|
||||
`render_full` place the circle). The strip is released (pictures hidden, `src` cleared first) when the page's wish
|
||||
changes: entities, grounds (dark mode) or size. Loads wait for `alert_image_due()`, a cover or camera on its way,
|
||||
the camera full screen, an open card, standby and a finger. `tests/test_camera.py` (LiveTiles, LiveApp),
|
||||
`tests/test_camera_view.cpp`, `web/tests/components.spec.ts`.
|
||||
- **Alert action** (`action` and `data` in `esp_screens_show_alert`; `ALERT_ACTION_FIELD`, `alert_action` in
|
||||
`core.py`). `show_alert` keeps its seven fields: the app remembers `(key, action, data)` per node of every screen
|
||||
that got the alert (`Manager.alert_actions`), subscribes to `esphome.screen_alert` and queues it with the broadcasts
|
||||
(`alert_loop` → `alert_ended`); `action: ok` performs it once through `HomeAssistant.call` and forgets it on every
|
||||
screen of that alert, any other ending forgets it on that screen, a new alert or a dismissal forgets it too. Works
|
||||
with every firmware from 0.2.31. `tests/test_alert_broadcast.py`; the cheatsheet, the Claude skill and
|
||||
README_EXTENDED document the field.
|
||||
- **Full-page tiles are no longer tinted**: `render_slot` paints every card `theme::surface(t.background)` with
|
||||
`theme::outline` (the `lit` tint of 0.2.62-0.2.76 and `Tile::lights_up()` are gone; `tests/test_state_colours.py`
|
||||
guards it). The editor's mockup never painted the tint, so nothing changes there.
|
||||
- **Media texts roll by**: the title and the artist line of the media card and of the full-page media tile are
|
||||
`LV_LABEL_LONG_SCROLL_CIRCULAR` (`marquee()`, LVGL's default 40 px/s and 300 ms wait; a text that fits stands
|
||||
still). The single and wide tiles keep `LV_LABEL_LONG_DOT` on their one status line.
|
||||
|
||||
### Compatibility 0.2.89 / firmware 0.2.75
|
||||
|
||||
ESPHome 2026.9.0 in the add-on (`screen_manager/Dockerfile`, `requirements.txt`); the packages' `min_version` stays
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Test results app 0.2.91 / firmware 0.2.77 (2026-09-20)
|
||||
|
||||
Four asks in one round: a live picture on a camera tile (issue #4, "show a camera preview on the tile as an
|
||||
option"), an action behind the alert's button, media titles that roll by, and full-page tiles without a colour of
|
||||
their own. The live picture is the one with design in it; the rest is small.
|
||||
|
||||
## Live pictures: the design and why
|
||||
|
||||
- **One picture per page, not per tile.** The screen already has two `online_image`s and a rule that one picture
|
||||
loads at a time (a download shares ESPHome's main loop with touch and drawing). Six tiles each loading their own
|
||||
picture would mean six downloads and six buffers. Instead the screen asks for all the live tiles of the page at
|
||||
once and the app serves one BMP: a strip of squares, top to bottom in slot order. Every tile draws its own square
|
||||
out of that one image with LVGL's image offset. A page with six live tiles costs one download of about 50 KB.
|
||||
- **The corners come from the app.** LVGL's image radius (`clip_radius`) rounds the whole source image, not each
|
||||
square of it, and `clip_corner` would draw into a layer (`tests/test_layer_free.py`). So the app bakes the rounded
|
||||
corners into each square over that tile's own colour, as it does for a media cover; the screen sends the colour of
|
||||
every tile with the request (`bg`), so dark mode and pastel tiles come out right.
|
||||
- **A third `online_image`** (`tile_image`) holds the strip: it lives beside a cover or a camera full screen instead
|
||||
of sharing their buffer, and PSRAM has room. Loading still waits its turn: after the alert's picture, a cover or
|
||||
the camera full screen, never under an open card, in standby or under a finger.
|
||||
- **The pace is the tile's.** 15 or 30 s per tile; the page loads at the pace of its fastest tile, and the app fetches
|
||||
a camera again only when that camera's own pace has passed. Nobody loading means nothing fetched.
|
||||
- **The size is the icon's.** The square takes the icon circle's place and size (54 px on a Guition tile, 128 px on a
|
||||
tile over the whole page), so the tile's text does not move.
|
||||
|
||||
## Host harness (Mac, host build of the Guition package, real add-on code)
|
||||
|
||||
`live_host.py` builds the Guition package as a host program with probe actions and drives it over the ESPHome API
|
||||
against a demo home with five cameras (`live_demo_home.py`, the real `Manager.answer_camera` behind `/dev/camera`);
|
||||
git-ignored tooling under `.esphome/readme-render/live/`. 27 checks, all passing:
|
||||
|
||||
- page 1 asks for its three live tiles (two single tiles and a wide one, one of them on a pastel) with size 54 and
|
||||
the three colours; the strip loads and every tile shows its own square (offsets 0, −54, −108), the circle hidden
|
||||
under it, the plain camera tile keeping its icon;
|
||||
- the page loads again after 15 s; the 15 s camera is fetched again while the 30 s camera is not, and after 30 s
|
||||
both are;
|
||||
- the camera full screen pauses the strip (no load for 17 s) and it loads again once closed, the pictures back;
|
||||
- a full-page live tile alone on page 2 asks at 128 px and shows its picture at 128;
|
||||
- a page without live tiles wants nothing and holds no picture; a media tile over the whole page still asks for its
|
||||
cover; a camera without a picture keeps its icon (the app answers with an empty entry and no link);
|
||||
- back on page 1 the strip is asked for again; a card over the page pauses the loads and closing it resumes them;
|
||||
dark mode asks again with the dark grounds (`1A1A1A,4A2422,1A1A1A`) and shows the dark strip.
|
||||
|
||||
Found on the host, fixed before the bench: LVGL's default inner alignment centres a source that is taller than its
|
||||
object, so every tile showed the square of its neighbour (and the corners of the wrong colour); the picture now sets
|
||||
`LV_IMAGE_ALIGN_TOP_LEFT`. The host's `http_request` fails a 304 outright where the ESP32's `online_image` takes it as
|
||||
"unchanged"; the harness counts both as a load.
|
||||
|
||||
The rolling title was checked the same way: two renders of a full-page media tile 1.5 s apart differ only in the rows
|
||||
of the title and the artist line.
|
||||
|
||||
## Bench Guition (192.168.146.136, firmware 0.2.77 from this tree over OTA, local add-on 0.2.91 on the Yellow)
|
||||
|
||||
- `camera.buienradar` live on page 1: the screen asked for the strip at once, the radar showed as a rounded square
|
||||
in the icon's place on the dark tile (`capture_ui.py`), and every 15 s the screen loaded again. The radar changes
|
||||
every five minutes, so the app answered 304 in between, and ESPHome's `http_request` logged every one of those as
|
||||
`HTTP Request failed; Code: 304` and raised its error flag (`online_image` then said "Download skipped"). The app
|
||||
now serves a live strip whole every time, never a 304 (about 9 KB for one tile, 50 KB for six, on the LAN), and
|
||||
without the ETag `online_image` warned "No header with name etag found" and "... last-modified ..." at every load,
|
||||
so every picture now carries both headers. No "took a long time" warning during the loads.
|
||||
- The alert with `action: persistent_notification.create` reached both bench screens; the button's report
|
||||
(`esphome.screen_alert`, `action: ok`, sent the way the screen sends it) made the notification once, and the add-on
|
||||
logged `Alert button on guition-wallbox: persistent_notification.create performed`.
|
||||
|
||||
## Builds
|
||||
|
||||
`tools/check.sh`: Python 516 tests, C++ tests, packages, icons, translations (9 languages, 1431 texts), editor tests
|
||||
(106), types and build. `tools/check.sh --firmware` with ESPHome 2026.9.0: CYD 1,633,728 B, 89.0 % of the update slot
|
||||
(832 B more than 0.2.90); Guition 2,058,368 B.
|
||||
@@ -266,6 +266,14 @@ substitutions:
|
||||
};
|
||||
runtime_tiles::camera_thumb.release = []() { id(alert_image).release(); };
|
||||
runtime_tiles::camera_thumb.source = []() { return id(alert_image).get_lv_image_dsc(); };
|
||||
// The live pictures of a page's camera tiles (firmware 0.2.77+): one strip in the third online_image.
|
||||
runtime_tiles::camera_live.load = [](const std::string &url) {
|
||||
static std::string current;
|
||||
if (url != current) { id(tile_image).set_url(url); current = url; }
|
||||
id(tile_image).update();
|
||||
};
|
||||
runtime_tiles::camera_live.release = []() { id(tile_image).release(); };
|
||||
runtime_tiles::camera_live.source = []() { return id(tile_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) {
|
||||
@@ -380,7 +388,8 @@ api:
|
||||
# 16 KB a loop (firmware 0.2.73+): the full picture came in 1.8 s instead of 2.8 s with 4 KB. A loop waited over 50 ms
|
||||
# for it in one of eleven opens with Wi-Fi awake (0.1 s), in five of twelve with power save; 32 KB saved 0.3 s more but
|
||||
# held a loop 0.1 s more often (docs/TEST_RESULTS_0287.md, 0288). 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.
|
||||
# frame, and one strip with the live pictures of a page's camera tiles (firmware 0.2.77+); 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
|
||||
@@ -406,6 +415,16 @@ online_image:
|
||||
- lambda: 'runtime_tiles::camera_loaded(true, cached);'
|
||||
on_error:
|
||||
- lambda: 'runtime_tiles::camera_failed(true);'
|
||||
# The live pictures of a page's camera tiles (firmware 0.2.77+): one strip of small squares, at most ~70 KB.
|
||||
- id: tile_image
|
||||
url: "http://127.0.0.1/camera.bmp" # set when a page with live camera tiles is on screen
|
||||
format: BMP
|
||||
type: RGB565
|
||||
buffer_size: 16384
|
||||
on_download_finished:
|
||||
- lambda: 'runtime_tiles::live_loaded(cached);'
|
||||
on_error:
|
||||
- lambda: 'runtime_tiles::live_failed();'
|
||||
|
||||
text_sensor:
|
||||
- platform: template
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
# fonts/materialdesignicons-webfont.ttf (Apache 2.0, github.com/Templarian/MaterialDesign-Webfont).
|
||||
############################################################
|
||||
substitutions:
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.76"
|
||||
SCREEN_FIRMWARE_VERSION: "0.2.77"
|
||||
DASHBOARD_INBOX_INTERNAL: "false"
|
||||
CALIBRATION_ON_BOOT: "false" # a bench build: start on the touch test page
|
||||
ROOM_NAME: "" # top left until ESP Screens sends the screen name
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
## 0.2.91 (firmware 0.2.77)
|
||||
|
||||
Your cameras on the tiles themselves, an alert button that does something, and media titles that roll by.
|
||||
|
||||
- **A live picture on a camera tile.** On a Guition, a camera or image tile can show a small picture of its camera in the icon's place: in the tile's settings choose **Display → Live picture** and a pace, every 15 or 30 seconds. The picture is the middle of the camera's view as a rounded square in the tile's own colour, refreshed while that page is on the screen; a tap still opens the camera full screen. The camera tiles of one page share one download (ESP Screens serves them as one strip of squares), so six live tiles cost the screen no more than one, and a 30 s camera on a 15 s page is fetched every other time. The live pictures wait for an alert's picture, an album cover or the camera full screen, and never load under an open card, in standby or under a finger. Asked for in issue #4.
|
||||
- **An action behind the alert button.** The `esp_screens_show_alert` event takes `action` (a Home Assistant action such as `script.open_gate` or `light.turn_off`) and `data` for its fields: ESP Screens performs it once when the button is pressed on any screen. A timeout, a new alert or a dismissal leaves it unperformed. The screens only report the press, as they always did, so this works with every screen from firmware 0.2.31. The Alerts cheatsheet and the Claude skill document the field.
|
||||
- **A full-page tile keeps its own colour.** A tile over the whole page is white, or the pastel you gave it, like every other tile; its icon shows the state. Firmware 0.2.62 to 0.2.76 tinted the whole tile in its state colour while it was on (a playing speaker turned the page blue), which is gone.
|
||||
- **Media titles roll by.** A title or an artist line that is too wide for the media card or the full-page media tile now rolls by, round and round, instead of ending in dots; a text that fits stands still.
|
||||
- Needs firmware 0.2.77 for the live pictures and the rolling titles: press **Update** on the screen. Includes everything from 0.2.90. CYD firmware: 1,633,728 bytes, 89.0 % of the update slot (832 bytes more than 0.2.90).
|
||||
|
||||
## 0.2.90 (firmware 0.2.76)
|
||||
|
||||
ESP Screens speaks your language: the screens, the editor and its messages in nine languages, with the numbers, the clock and Home Assistant's own words as your country writes them.
|
||||
|
||||
@@ -11,6 +11,7 @@ that loads a link gets the last snapshot at once, however long the camera takes
|
||||
in the screen's main loop, where touch and drawing wait with it), and the load starts fetching the next snapshot.
|
||||
"""
|
||||
import asyncio
|
||||
from email.utils import formatdate
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
@@ -33,6 +34,16 @@ COVER_DOMAINS = ('media_player',)
|
||||
COVER_MIN_FIRMWARE = (0, 2, 64)
|
||||
COVER_SIZES = (48, 320) # the smallest and largest cover a screen may ask for, in pixels
|
||||
COVER_RADIUS_SHARE = 12 # the corner is a twelfth of the size, at least 4 px (media_card.h radius_for)
|
||||
# Live pictures on camera tiles (app 0.2.91, firmware 0.2.77): a tile with "display": "live" shows a small square of its
|
||||
# camera in the icon's place. The camera tiles of a page share one image: the screen asks for them together and the
|
||||
# app serves one strip of squares, top to bottom in the screen's order, each cut to the middle of its snapshot with
|
||||
# the corners rounded over that tile's own colour. A camera is fetched again only when its tile's pace (15 or 30 s)
|
||||
# has passed since the last fetch, so a page loading every 15 s leaves a 30 s camera alone in between.
|
||||
LIVE_MIN_FIRMWARE = (0, 2, 77)
|
||||
LIVE_SIZES = (24, 160) # a square's side, in pixels
|
||||
LIVE_MAX_TILES = 6 # one page
|
||||
LIVE_REFRESH = (15, 30) # the paces a tile may choose, in seconds
|
||||
LIVE_RADIUS_SHARE = 6 # a rounder corner than a cover's: the square is small
|
||||
PORT = 8098
|
||||
# A camera nobody loaded a picture of for this long is forgotten, its last snapshot with it.
|
||||
WATCH_SECONDS = 30
|
||||
@@ -73,6 +84,30 @@ def can_show_cover(screen):
|
||||
return bool(screen) and screen.get('board') in BOXES and (screen_firmware(screen) or (0, 0, 0)) >= COVER_MIN_FIRMWARE
|
||||
|
||||
|
||||
def can_show_live(screen):
|
||||
"""A paired Guition with firmware that draws live pictures on camera tiles."""
|
||||
return bool(screen) and screen.get('board') in BOXES and (screen_firmware(screen) or (0, 0, 0)) >= LIVE_MIN_FIRMWARE
|
||||
|
||||
|
||||
def live_request(request):
|
||||
"""(entities, size, grounds) a screen asked live pictures for, or None when it is not a request the app can serve:
|
||||
`tiles` the camera tiles of its page as a comma-separated list, `size` the square's side within LIVE_SIZES and
|
||||
`bg` one colour of six hex digits per tile, the tile's own, behind the corners."""
|
||||
tiles, grounds = request.get('tiles'), request.get('bg')
|
||||
if not isinstance(tiles, str) or not isinstance(grounds, str):
|
||||
return None
|
||||
entities, colours = tiles.split(','), grounds.split(',')
|
||||
try:
|
||||
size = int(request.get('size'))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not 1 <= len(entities) <= LIVE_MAX_TILES or len(colours) != len(entities) or len(set(entities)) != len(entities):
|
||||
return None
|
||||
if not LIVE_SIZES[0] <= size <= LIVE_SIZES[1] or not all(supported(e) for e in entities) or not all(re.fullmatch(r'[0-9A-Fa-f]{6}', c) for c in colours):
|
||||
return None
|
||||
return entities, size, [int(c, 16) for c in colours]
|
||||
|
||||
|
||||
def cover_request(request):
|
||||
"""(size, background) a screen asked for, or None when the request is not a cover request the app can serve:
|
||||
`size` in pixels within COVER_SIZES, `bg` six hex digits of the colour behind the corners."""
|
||||
@@ -148,9 +183,59 @@ def encode_cover(raw, size, background):
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _square(image, size, background, radius_share):
|
||||
"""The middle of `image` as a square of `size` pixels with rounded corners over `background` (0xRRGGBB)."""
|
||||
from PIL import Image, ImageDraw
|
||||
width, height = image.size
|
||||
side = min(width, height)
|
||||
if width != height:
|
||||
left, top = (width - side) // 2, (height - side) // 2
|
||||
image = image.crop((left, top, left + side, top + side))
|
||||
if image.size != (size, size):
|
||||
image = image.resize((size, size), Image.Resampling.LANCZOS)
|
||||
radius = max(4, size // radius_share)
|
||||
mask = Image.new('L', (size, size), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
|
||||
ground = Image.new('RGB', (size, size), tuple((background >> shift) & 0xFF for shift in (16, 8, 0)))
|
||||
ground.paste(image, mask=mask)
|
||||
return ground
|
||||
|
||||
|
||||
def _opaque(source, background):
|
||||
"""An RGB image from any picture Home Assistant hands out, transparency landing on `background`."""
|
||||
from PIL import Image, ImageOps
|
||||
image = ImageOps.exif_transpose(source)
|
||||
if image.mode in ('RGBA', 'LA', 'P', 'PA'):
|
||||
image = image.convert('RGBA')
|
||||
ground = Image.new('RGB', image.size, tuple((background >> shift) & 0xFF for shift in (16, 8, 0)))
|
||||
ground.paste(image, mask=image.getchannel('A'))
|
||||
return ground
|
||||
return image if image.mode == 'RGB' else image.convert('RGB')
|
||||
|
||||
|
||||
def encode_live(raws, size, grounds):
|
||||
"""The strip a page's camera tiles share: one square of `size` pixels per tile, top to bottom, each the middle of
|
||||
its snapshot with rounded corners over that tile's colour (`grounds`, 0xRRGGBB each); a tile without a snapshot
|
||||
(None) gets a plain square of its colour. A 24-bit BMP of `size` by `size` times the number of tiles."""
|
||||
from PIL import Image
|
||||
strip = Image.new('RGB', (size, size * len(raws)))
|
||||
for n, (raw, background) in enumerate(zip(raws, grounds)):
|
||||
if raw is None:
|
||||
square = Image.new('RGB', (size, size), tuple((background >> shift) & 0xFF for shift in (16, 8, 0)))
|
||||
else:
|
||||
with Image.open(io.BytesIO(raw)) as source:
|
||||
source.draft('RGB', (size * 2, size * 2))
|
||||
square = _square(_opaque(source, background), size, background, LIVE_RADIUS_SHARE)
|
||||
strip.paste(square, (0, n * size))
|
||||
out = io.BytesIO()
|
||||
strip.save(out, 'BMP')
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
class Watch:
|
||||
"""One camera or media player: its last picture, the sizes made of it and the fetch on its way. For a media
|
||||
player `picture` is the address the raw picture came from, so a changed picture is fetched again."""
|
||||
player `picture` is the address the raw picture came from, so a changed picture is fetched again. `fetched_at`
|
||||
is when the last fetch started: a live tile fetches again only when its own pace has passed."""
|
||||
|
||||
def __init__(self, now):
|
||||
self.used = now
|
||||
@@ -159,16 +244,18 @@ class Watch:
|
||||
self.frames = {}
|
||||
self.failures = 0
|
||||
self.retry_at = 0.0
|
||||
self.fetched_at = 0.0
|
||||
self.task = None
|
||||
|
||||
|
||||
class Link:
|
||||
__slots__ = ('entity', 'box', 'still', 'etag', 'used', 'lifetime', 'cover')
|
||||
__slots__ = ('entity', 'box', 'still', 'etag', 'used', 'lifetime', 'cover', 'live')
|
||||
|
||||
def __init__(self, entity, box, now, still=None, cover=None):
|
||||
def __init__(self, entity, box, now, still=None, cover=None, live=None):
|
||||
self.entity, self.box, self.used = entity, box, now
|
||||
self.still = still
|
||||
self.cover = cover # (size, background) of a media player's cover, else None
|
||||
self.live = live # (entities, size, grounds, paces) of a page's live camera tiles, else None
|
||||
self.etag = f'"{hashlib.sha1(still).hexdigest()[:16]}"' if still else ''
|
||||
self.lifetime = STILL_SECONDS if still else LINK_SECONDS
|
||||
|
||||
@@ -180,6 +267,7 @@ class CameraFeed:
|
||||
self.fetch, self.clock = fetch, clock
|
||||
self.fetch_cover, self.picture = fetch_cover, picture
|
||||
self.watches, self.links = {}, {}
|
||||
self.strips = {}
|
||||
|
||||
# ----- fetching -----
|
||||
# A camera is fetched when a screen loads its picture: serving one starts fetching the next, so each load gets a
|
||||
@@ -199,6 +287,7 @@ class CameraFeed:
|
||||
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.fetched_at = self.clock()
|
||||
watch.task = asyncio.ensure_future(self.fetch_one(entity, watch))
|
||||
|
||||
async def fetch_one(self, entity, watch):
|
||||
@@ -258,6 +347,44 @@ class CameraFeed:
|
||||
cached = watch.frames[box] = (digest, image)
|
||||
return f'"{digest[:16]}-{box[0]}x{box[1]}"', cached[1]
|
||||
|
||||
# ----- live tiles -----
|
||||
# A page's strip is made when the screen loads its link, out of the last snapshot of every tile's camera, and
|
||||
# serving it starts the next fetch of the cameras whose pace has passed: a 15 s page fetches a 30 s camera every
|
||||
# other load. The first load waits for the cameras that have no snapshot yet, all at once.
|
||||
async def live_one(self, entity, pace, wait):
|
||||
watch = self.watch(entity)
|
||||
if 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
|
||||
elif self.clock() - watch.fetched_at >= pace:
|
||||
self.refresh(entity, watch)
|
||||
return watch.raw
|
||||
|
||||
async def live(self, entities, size, grounds, paces, wait=FIRST_FRAME_SECONDS):
|
||||
"""(etag, BMP, entities with '' where a camera has no snapshot) of the strip for a page's camera tiles at
|
||||
`size` with `grounds` behind the corners and `paces` in seconds per tile, or None when no camera has one."""
|
||||
raws = await asyncio.gather(*(self.live_one(entity, pace, wait) for entity, pace in zip(entities, paces)))
|
||||
if all(raw is None for raw in raws):
|
||||
return None
|
||||
digests = [self.watches[entity].digest if raw is not None else '' for entity, raw in zip(entities, raws)]
|
||||
key = ('live', size, tuple(grounds), tuple(digests))
|
||||
tag = f'"{hashlib.sha1("|".join(digests).encode()).hexdigest()[:16]}-l{size}"' # names the strip; not sent
|
||||
cached = self.strips.get(key)
|
||||
if cached is None:
|
||||
try:
|
||||
image = await asyncio.get_running_loop().run_in_executor(None, encode_live, raws, size, grounds)
|
||||
except Exception as error:
|
||||
LOG.info('The live pictures of %s cannot be read (%s)', ', '.join(entities), type(error).__name__)
|
||||
return None
|
||||
self.strips = {key: image} # the last strip only: the next load makes another anyway
|
||||
else:
|
||||
image = cached
|
||||
return tag, image, [entity if raw is not None else '' for entity, raw in zip(entities, raws)]
|
||||
|
||||
# ----- covers -----
|
||||
# A media player's picture is fetched when a screen loads its cover link and Home Assistant's picture is another
|
||||
# one than the last fetched (or none was fetched yet): a cover never changes on its own between two loads, and the
|
||||
@@ -321,12 +448,12 @@ class CameraFeed:
|
||||
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, cover=None):
|
||||
def link(self, entity, box, still=None, cover=None, live=None):
|
||||
"""A new random token for one camera at one size; `still` makes it one fixed image (an alert's), `cover`
|
||||
(size, background) a media player's cover."""
|
||||
(size, background) a media player's cover, `live` (entities, size, grounds, paces) a page's live tiles."""
|
||||
self.prune()
|
||||
token = secrets.token_urlsafe(18)
|
||||
self.links[token] = Link(entity, box, self.clock(), still, cover)
|
||||
self.links[token] = Link(entity, box, self.clock(), still, cover, live)
|
||||
return token
|
||||
|
||||
async def serve(self, token, etag=None):
|
||||
@@ -339,6 +466,13 @@ class CameraFeed:
|
||||
link.used = now
|
||||
if link.still:
|
||||
return (304, None, link.etag) if etag == link.etag else (200, link.still, link.etag)
|
||||
if link.live:
|
||||
# Always the whole strip, never a 304: ESPHome's http_request logs a 304 as a failed request and raises its
|
||||
# error flag every time, and a page of slow cameras (a radar every five minutes) would do that every 15 s.
|
||||
# The strip is small; the screen draws its squares again either way. The tag still goes out as an ETag:
|
||||
# online_image warns at every load about a missing one.
|
||||
found = await self.live(*link.live)
|
||||
return (503, None, '') if found is None else (200, found[1], found[0])
|
||||
found = await self.cover(link.entity, *link.cover) if link.cover else await self.frame(link.entity, link.box)
|
||||
if found is None:
|
||||
return 503, None, ''
|
||||
@@ -352,7 +486,9 @@ def web_app(feed):
|
||||
|
||||
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 {})}
|
||||
# Last-Modified with every picture: online_image warns at every load about a missing one (it never sends
|
||||
# If-Modified-Since on its own; only the ETag decides a 304 here).
|
||||
headers = {'Cache-Control': 'no-cache', 'Last-Modified': formatdate(usegmt=True), **({'ETag': etag} if etag else {})}
|
||||
if status == 200:
|
||||
return web.Response(body=body, content_type=CONTENT_TYPE, headers=headers)
|
||||
if status == 304:
|
||||
|
||||
@@ -13,13 +13,14 @@ import zipfile
|
||||
|
||||
from i18n import t
|
||||
import tile_icons
|
||||
from core import (ALERT_CAMERA_FIELD, ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
|
||||
from core import (ALERT_ACTION_FIELD, 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, FULL_PAGE_MIN_FIRMWARE, MAX_PAGES, MAX_SLOTS, MAX_TILES, PAGE_TILE_REPEAT_MIN_FIRMWARE,
|
||||
CONTROLS, DISPLAYS, FULL_PAGE_MIN_FIRMWARE, LIVE_MIN_FIRMWARE, MAX_PAGES, MAX_SLOTS, MAX_TILES, PAGE_TILE_REPEAT_MIN_FIRMWARE,
|
||||
SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS, TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE,
|
||||
SETTING_ENTITIES_MIN_FIRMWARE, DARK_MODE_MIN_FIRMWARE, PAGE_BUTTONS_MIN_FIRMWARE)
|
||||
# Full-page tiles, navigation tiles and forty-eight tiles per screen.
|
||||
FULL_PAGE_VERSION = '.'.join(str(part) for part in FULL_PAGE_MIN_FIRMWARE)
|
||||
LIVE_VERSION = '.'.join(str(part) for part in LIVE_MIN_FIRMWARE)
|
||||
# The same navigation tile on several pages (app 0.2.78).
|
||||
PAGE_TILE_REPEAT_VERSION = '.'.join(str(part) for part in PAGE_TILE_REPEAT_MIN_FIRMWARE)
|
||||
|
||||
@@ -99,7 +100,7 @@ actions:
|
||||
| `from_page`, `from_slot` | Only for `esp_screens_move_tile`: which copy of a navigation tile that is on several pages to move (see below). |
|
||||
| `size` | `single`, `wide` or `full` (the whole page; firmware {FULL_PAGE_VERSION} or newer). |
|
||||
| `controls` | What you can operate on the tile itself (see below). |
|
||||
| `display` | How the tile draws itself (see below). |
|
||||
| `display` | How the tile draws itself (see below). A camera or image tile on a Guition takes `live` (firmware {LIVE_VERSION} or newer): a small live picture in the icon's place, with `refresh` 15 or 30 (seconds). |
|
||||
| `icon`, `color` | An icon from the list further down, and one of the pastel colors. |
|
||||
| `tap` | What a tap does: `auto`, `detail` (open the card), `toggle`, `action` or `none`. `toggle` works for anything Home Assistant can toggle for that entity, such as a light, a cover (open, close, or stop while it moves) or a speaker that turns on and off. Holding the tile still opens its card. |
|
||||
| `action`, `data` | Perform action: an action Home Assistant offers for the tile's own entity, such as `cover.set_cover_position`, with `data` for its fields (`position: 50`). Giving `action` sets `tap` to `action`. The target is always the tile's entity. Only actions and fields Home Assistant lists for that entity are accepted; the answer says what is missing. |
|
||||
@@ -197,6 +198,8 @@ actions:
|
||||
|
||||
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.
|
||||
|
||||
It also takes `{ALERT_ACTION_FIELD[0]}` (app 0.2.91): {ALERT_ACTION_FIELD[2]} Example: `{ALERT_ACTION_FIELD[0]}: {ALERT_ACTION_FIELD[3]}`, or `action: light.turn_off` with `data: {{entity_id: light.hall}}`. Use an action Home Assistant lists; give `button_text` a word that says what the button does ("Open", "Turn off"). The per-screen actions have no such field either.
|
||||
|
||||
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
|
||||
|
||||
@@ -34,6 +34,9 @@ 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)
|
||||
# A live picture on a camera tile ("display": "live", app 0.2.91): firmware from here asks for the page's strip.
|
||||
LIVE_MIN_FIRMWARE = (0, 2, 77)
|
||||
LIVE_REFRESH = (15, 30) # the paces a live tile may choose, in seconds; the first is the default
|
||||
# The media card with its cover (app 0.2.77): firmware from here draws it and asks for the cover.
|
||||
COVER_MIN_FIRMWARE = (0, 2, 64)
|
||||
# Forty-eight tiles (one per slot), a tile over the whole page and the screen.page tile (firmware 0.2.62+).
|
||||
@@ -46,7 +49,7 @@ FIRST_MAX_TILES = 10
|
||||
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.76'
|
||||
FIRMWARE_VERSION = '0.2.77'
|
||||
# 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.
|
||||
@@ -85,7 +88,8 @@ def backgrounds():
|
||||
return {name: {**item, 'label': t(f'addon.labels.backgrounds.{name}')} for name, item in TILE_BACKGROUNDS.items()}
|
||||
|
||||
# Display modes per domain; everything else offers standard and watch (large value).
|
||||
DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard', 'watch', 'graph'), 'screen': ('digital', 'analog'), 'sun': ('standard', 'watch', 'sunpath')}
|
||||
DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard', 'watch', 'graph'), 'screen': ('digital', 'analog'), 'sun': ('standard', 'watch', 'sunpath'),
|
||||
'camera': ('standard', 'live'), 'image': ('standard', 'live')}
|
||||
# Displays that only work on a double-width card.
|
||||
WIDE_ONLY = ('forecast', 'sunpath')
|
||||
|
||||
@@ -482,6 +486,8 @@ def min_firmware(layout):
|
||||
return PAGE_TILE_REPEAT_MIN_FIRMWARE
|
||||
if len(layout['tiles']) > LEGACY_MAX_TILES or any(is_full(t) or page_target(t['entity']) for t in layout['tiles']):
|
||||
return FULL_PAGE_MIN_FIRMWARE
|
||||
if any(t.get('options', {}).get('display') == 'live' for t in layout['tiles']):
|
||||
return LIVE_MIN_FIRMWARE
|
||||
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']):
|
||||
@@ -509,7 +515,7 @@ TILE_RESULT_EVENT = 'esp_screens_tile_result'
|
||||
# pastel background.
|
||||
TILE_EVENT_OPTIONS = {'size': 'size', 'controls': 'controls', 'display': 'display', 'icon': 'icon',
|
||||
'color': 'background', 'background': 'background', 'tap': 'tap', 'inline': 'inline',
|
||||
'history_hours': 'history_hours'}
|
||||
'history_hours': 'history_hours', 'refresh': 'refresh'}
|
||||
TILE_SIZES = {'full': 'full', 'fullscreen': 'full', 'full screen': 'full', 'full-screen': 'full', 'page': 'full', 'whole page': 'full',
|
||||
'wide': 'wide', 'double': 'wide', 'large': 'wide', 'big': 'wide',
|
||||
'single': 'single', 'small': 'single', 'normal': 'single'}
|
||||
@@ -590,7 +596,7 @@ def tile_options(data, current=None):
|
||||
if key not in data or data[key] in (None, ''):
|
||||
continue
|
||||
value = data[key]
|
||||
if name == 'history_hours':
|
||||
if name in ('history_hours', 'refresh'):
|
||||
options[name] = int(value) if str(value).isdigit() else value
|
||||
elif name == 'size':
|
||||
options[name] = TILE_SIZES.get(loose(value), str(value))
|
||||
@@ -896,7 +902,7 @@ def validate_layout(data, stored=False):
|
||||
continue
|
||||
if 'options' in tile:
|
||||
options = tile['options']
|
||||
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls', 'action'}:
|
||||
if not isinstance(options, dict) or set(options) - {'tap', 'display', 'inline', 'history_hours', 'background', 'size', 'icon', 'controls', 'action', 'refresh'}:
|
||||
raise ValueError(t('addon.errors.layout.unknown_settings'))
|
||||
# A navigation tile (screen.page_<n>, firmware 0.2.62+) has a name, an icon, a colour and a width; never the page.
|
||||
if page_target(tile['entity']):
|
||||
@@ -931,6 +937,12 @@ def validate_layout(data, stored=False):
|
||||
raise ValueError(t('addon.errors.layout.no_mini_slider'))
|
||||
if 'history_hours' in options and (type(options['history_hours']) is not int or options['history_hours'] not in (1,6,24)):
|
||||
raise ValueError(t('addon.errors.layout.history_hours'))
|
||||
# A live picture's pace (app 0.2.91) belongs to the live display; another display leaves a stale one behind.
|
||||
if options.get('display') == 'live':
|
||||
if 'refresh' in options and (type(options['refresh']) is not int or options['refresh'] not in LIVE_REFRESH):
|
||||
raise ValueError(t('addon.errors.layout.refresh'))
|
||||
elif 'refresh' in options:
|
||||
options = {key: value for key, value in options.items() if key != 'refresh'}
|
||||
if options.get('display') == 'watch' and options.get('inline') == 'slider':
|
||||
raise ValueError(t('addon.errors.layout.watch_or_slider'))
|
||||
if 'controls' in options:
|
||||
@@ -1398,6 +1410,11 @@ ALERT_SUGGESTED_ICONS = ('doorbell', 'bell', 'bell-ring', 'alert-outline', 'alar
|
||||
# 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')
|
||||
# An action behind the button (app 0.2.91): the event names a Home Assistant action, with data for its fields, that the
|
||||
# app performs when the button is pressed on any screen, once per alert. Not an argument of show_alert either: the
|
||||
# screen only reports the press (ALERT_EVENT, action "ok") and the app does the rest, so every firmware from 0.2.31 has it.
|
||||
ALERT_ACTION_FIELD = ('action', 'Action', 'A Home Assistant action, such as script.open_gate or light.turn_off, performed once when the button is pressed on any screen. `data` gives its fields (entity_id, brightness, ...). A timeout or a new alert leaves it unperformed. Only through the esp_screens_show_alert event.', 'script.open_gate')
|
||||
ALERT_ACTION_MAX_BYTES = 4096
|
||||
# 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
|
||||
@@ -1423,6 +1440,8 @@ def alert_reference():
|
||||
for name, kind, _, _, value in ALERT_FIELDS],
|
||||
'camera': {'name': camera, 'label': t(f'addon.alerts.fields.{camera}.label'), 'help': t(f'addon.alerts.fields.{camera}.help'),
|
||||
'example': ALERT_CAMERA_FIELD[3]},
|
||||
'action': {'name': ALERT_ACTION_FIELD[0], 'label': t(f'addon.alerts.fields.{ALERT_ACTION_FIELD[0]}.label'),
|
||||
'help': t(f'addon.alerts.fields.{ALERT_ACTION_FIELD[0]}.help'), 'example': ALERT_ACTION_FIELD[3]},
|
||||
'limits': ALERT_LIMITS,
|
||||
'colors': [{'name': name, 'label': item['label'], 'color': item['color']} for name, item in backgrounds().items() if item['color']],
|
||||
'suggested_icons': [{'name': name, 'cp': tile_icons.GLYPHS[name]} for name in ALERT_SUGGESTED_ICONS],
|
||||
@@ -1482,6 +1501,22 @@ def alert_camera(data):
|
||||
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_action(data):
|
||||
"""((action, data), usable): the alert's `action` and `data` when they name a Home Assistant action with a mapping of
|
||||
fields; (None, True) without one, (None, False) when it is unusable."""
|
||||
value = data.get(ALERT_ACTION_FIELD[0]) if isinstance(data, dict) else None
|
||||
if value is None or value == '':
|
||||
return None, True
|
||||
fields = data.get('data')
|
||||
if not isinstance(value, str) or not ACTION_NAME.fullmatch(value.strip()) or len(value) > 64 or (fields is not None and not isinstance(fields, dict)):
|
||||
return None, False
|
||||
try:
|
||||
if len(json.dumps(fields or {}, ensure_ascii=False, allow_nan=False).encode()) > ALERT_ACTION_MAX_BYTES:
|
||||
return None, False
|
||||
except (TypeError, ValueError):
|
||||
return None, False
|
||||
return (value.strip(), dict(fields or {})), True
|
||||
|
||||
def alert_targets(screens):
|
||||
"""(ready, skipped): the paired screens that can show an alert now, and the others with the reason, in English for the
|
||||
log (the editor shows it in its own language, app 0.2.90).
|
||||
|
||||
@@ -170,6 +170,9 @@ def capabilities(entity_id, actions, state, services):
|
||||
displays.append('forecast')
|
||||
elif domain == 'sun':
|
||||
displays.append('sunpath')
|
||||
elif domain in ('camera', 'image'):
|
||||
# A live picture on the tile (app 0.2.91): the editor offers it on a Guition, the only board that draws images.
|
||||
displays.append('live')
|
||||
return {
|
||||
'toggle': TOGGLE.format(domain=domain) in actions,
|
||||
'inline': domain in INLINE and _fits(INLINE[domain], actions, attributes, services),
|
||||
|
||||
@@ -19,7 +19,7 @@ import tile_icons
|
||||
from updates import Updater
|
||||
|
||||
from aiohttp import ClientError, ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, backgrounds, builtin_name, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import ALERT_EVENT, BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, entity_id, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TRANSPORT_MIN_FIRMWARE, alert_action, alert_camera, alert_data, alert_reference, alert_service, alert_targets, backgrounds, builtin_name, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, 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, state_word
|
||||
from core import PAGE_TILE_REPEAT_MIN_FIRMWARE, SLOTS_PER_PAGE, firmware_features, packed_slots, run_tile_event, screen_firmware, version_text
|
||||
import header_bar
|
||||
@@ -215,8 +215,9 @@ class HomeAssistant:
|
||||
# how the app talks to it.
|
||||
self.esphome_services |= body.get('domain') == 'esphome'
|
||||
self.services_changed.set()
|
||||
elif event.get('event_type') in BROADCAST_EVENTS:
|
||||
# Queued, not handled here: the calls wait for results this reader has to deliver.
|
||||
elif event.get('event_type') in BROADCAST_EVENTS or event.get('event_type') == ALERT_EVENT:
|
||||
# Queued, not handled here: the calls wait for results this reader has to deliver. A screen's own
|
||||
# report of an alert's end (app 0.2.91) goes the same way: the button may have an action behind it.
|
||||
self.broadcasts.put_nowait((event['event_type'], body))
|
||||
elif event.get('event_type') in TILE_EVENTS:
|
||||
self.tile_events.put_nowait((event['event_type'], body))
|
||||
@@ -382,7 +383,7 @@ class HomeAssistant:
|
||||
await self.request('subscribe_events', event_type='esphome.screen_history')
|
||||
await self.request('subscribe_events', event_type='esphome.screen_camera')
|
||||
await self.request('subscribe_events', event_type=light_effects.OPTIONS_EVENT)
|
||||
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS, *SERVICE_EVENTS):
|
||||
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, ALERT_EVENT, *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')}
|
||||
await self.registries()
|
||||
@@ -631,6 +632,8 @@ class Manager:
|
||||
fetch_cover=lambda entity: self.ha.media_image(entity),
|
||||
picture=lambda entity: self.ha.media_picture(entity))
|
||||
self.alert_cameras = {}
|
||||
# The action behind an alert's button (app 0.2.91): node -> (key, action, data) of the alert on that screen.
|
||||
self.alert_actions = {}
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text())
|
||||
# Versioned persistent data. Never silently overwrite an unknown schema.
|
||||
@@ -1629,6 +1632,10 @@ class Manager:
|
||||
inbox = self.aliases.get(request.get('inbox'), request.get('inbox'))
|
||||
entity = request.get('entity')
|
||||
screen = self.screen(inbox) if isinstance(inbox, str) else None
|
||||
# The live pictures of a page's camera tiles (app 0.2.91, firmware 0.2.77+): one strip for all of them.
|
||||
if 'tiles' in request:
|
||||
await self.answer_live(inbox, screen, request)
|
||||
return
|
||||
# A media player's cover (app 0.2.77, firmware 0.2.64+) comes the same way, at the size the card asks for.
|
||||
cover = camera_feed.cover_request(request) if camera_feed.cover_supported(entity) else None
|
||||
if cover:
|
||||
@@ -1643,6 +1650,34 @@ class Manager:
|
||||
await self.ha.send(inbox, message, action)
|
||||
LOG.info('%s %s on %s%s', 'Cover of' if cover else 'Camera', entity, screen['name'], '' if message['u'] else ': no image')
|
||||
|
||||
async def answer_live(self, inbox, screen, request):
|
||||
"""The strip for a page's live camera tiles: every tile the screen names must be a camera tile of its layout
|
||||
set to a live picture; the pace of each comes from that tile's own setting."""
|
||||
live = camera_feed.live_request(request)
|
||||
if not live or not camera_feed.can_show_live(screen) or not screen.get('online'):
|
||||
return
|
||||
action = self.transport(inbox, screen)
|
||||
if not action:
|
||||
return
|
||||
entities, size, grounds = live
|
||||
tiles = {tile['entity']: tile.get('options') or {} for tile in self.layouts.get(inbox, {}).get('tiles', [])}
|
||||
if any(entity not in tiles or tiles[entity].get('display') != 'live' for entity in entities):
|
||||
LOG.info('Live pictures for %s: not the live camera tiles of %s', ', '.join(entities), screen['name'])
|
||||
return
|
||||
paces = [tiles[entity].get('refresh', camera_feed.LIVE_REFRESH[0]) for entity in entities]
|
||||
url, listing = '', ','.join(entities)
|
||||
base = await camera_feed.base_url(self.ha.request)
|
||||
if base:
|
||||
found = await self.camera.live(entities, size, grounds, paces)
|
||||
if found:
|
||||
listing = ','.join(found[2])
|
||||
token = self.camera.link(listing, (size, size), live=(entities, size, grounds, paces))
|
||||
url = f'{base}/camera/{token}.bmp'
|
||||
else:
|
||||
LOG.warning('Live pictures: no address for this app on the LAN; set SCREEN_CAMERA_URL')
|
||||
await self.ha.send(inbox, {'v': 1, 'op': 'camera', 't': 'live', 'e': listing, 'u': url}, action)
|
||||
LOG.info('Live pictures of %s on %s%s', ', '.join(entities), screen['name'], '' if url else ': no image')
|
||||
|
||||
async def cover_message(self, entity, size, background):
|
||||
"""The screen message with a link to a media player's cover at `size` with `background` behind the corners,
|
||||
or an empty link when the player shows no picture (the card keeps its placeholder)."""
|
||||
@@ -1678,6 +1713,9 @@ class Manager:
|
||||
camera, usable = alert_camera(data) if event_type == BROADCAST_SHOW else ('', True)
|
||||
if not usable:
|
||||
unusable.append('camera')
|
||||
button, usable = alert_action(data) if event_type == BROADCAST_SHOW else (None, True)
|
||||
if not usable:
|
||||
unusable.append('action')
|
||||
if unusable:
|
||||
LOG.warning('%s: unusable %s left empty', event_type, ', '.join(unusable))
|
||||
ready, skipped = alert_targets(self.screens())
|
||||
@@ -1694,6 +1732,13 @@ class Manager:
|
||||
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)]
|
||||
# The button's action waits on every screen that shows this alert; a dismissal forgets it everywhere.
|
||||
for screen in ready:
|
||||
self.alert_actions.pop(screen['node'], None)
|
||||
if button and shown:
|
||||
key = secrets.token_hex(4)
|
||||
for screen in shown:
|
||||
self.alert_actions[screen['node']] = (key, *button)
|
||||
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)}
|
||||
@@ -1758,10 +1803,31 @@ class Manager:
|
||||
while queue is not None:
|
||||
event_type, data = await queue.get()
|
||||
try:
|
||||
await self.broadcast(event_type, data)
|
||||
if event_type == ALERT_EVENT:
|
||||
await self.alert_ended(data)
|
||||
else:
|
||||
await self.broadcast(event_type, data)
|
||||
except Exception as error:
|
||||
LOG.warning('%s failed (%s)', event_type, type(error).__name__)
|
||||
|
||||
async def alert_ended(self, data):
|
||||
"""A screen reports how its alert ended (esphome.screen_alert). The button performs the action the alert
|
||||
came with, once for the alert however many screens showed it; any other ending forgets it on that screen."""
|
||||
node = data.get('screen') if isinstance(data, dict) else None
|
||||
pending = self.alert_actions.pop(node, None) if isinstance(node, str) else None
|
||||
if pending is None:
|
||||
return
|
||||
key, action, fields = pending
|
||||
for other in [n for n, (k, _, _) in self.alert_actions.items() if k == key]:
|
||||
del self.alert_actions[other]
|
||||
if data.get('action') != 'ok':
|
||||
return
|
||||
try:
|
||||
await self.ha.call(action, fields)
|
||||
LOG.info('Alert button on %s: %s performed', node, action)
|
||||
except Exception as error:
|
||||
LOG.warning('Alert button on %s: %s failed (%s)', node, action, type(error).__name__)
|
||||
|
||||
def create_app(manager, development=False):
|
||||
csrf = secrets.token_urlsafe(32)
|
||||
@web.middleware
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>ESP Screens</title>
|
||||
<script type="module" crossorigin src="./assets/index-CLkdYPyY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B7AxjXTR.css">
|
||||
<script type="module" crossorigin src="./assets/index-BwlmhXLI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DQxVdm_5.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: ESP Screen Manager
|
||||
version: "0.2.90"
|
||||
version: "0.2.91"
|
||||
slug: esp_screen_manager
|
||||
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"builtin_action": "Eine eingebaute Karte kann keine Aktion ausführen.",
|
||||
"no_mini_slider": "Diese Entität unterstützt keinen kleinen Schieberegler.",
|
||||
"history_hours": "Verlauf: Wähle 1, 6 oder 24 Stunden.",
|
||||
"refresh": "Livebild: alle 15 oder 30 Sekunden auffrischen.",
|
||||
"watch_or_slider": "Wähle „Großer Wert“ oder den kleinen Schieberegler.",
|
||||
"no_direct_control": "Diese Entität unterstützt diese direkte Steuerung nicht.",
|
||||
"position": "Ungültige Kachelposition; lade die Verwaltungsseite neu.",
|
||||
@@ -739,6 +740,10 @@
|
||||
"camera": {
|
||||
"label": "Kamera",
|
||||
"help": "Eine Kamera- oder Bild-Entität. Ein Guition mit Firmware 0.2.57+ zeigt ihr Bild von diesem Moment oben über die ganze Breite der Karte; ein Tippen darauf öffnet die Kamera im Vollbild. Andere Bildschirme zeigen die Meldung ohne Bild. Nur über das Ereignis esp_screens_show_alert."
|
||||
},
|
||||
"action": {
|
||||
"label": "Aktion",
|
||||
"help": "Eine Home-Assistant-Aktion wie script.open_gate oder light.turn_off, einmal ausgeführt, sobald die Taste auf einem der Bildschirme gedrückt wird. `data` gibt ihre Felder an (entity_id, brightness, ...). Bei einem Timeout oder einem neuen Alarm bleibt sie aus. Nur über das Ereignis esp_screens_show_alert."
|
||||
}
|
||||
},
|
||||
"endings": {
|
||||
@@ -1266,15 +1271,22 @@
|
||||
"forecast": "Wettervorhersage",
|
||||
"graph": "Diagramm",
|
||||
"sunpath": "Sonnenbahn",
|
||||
"live": "Livebild",
|
||||
"live_hint": "Ein kleines Bild der Kamera anstelle des Symbols, im Takt unten aufgefrischt, solange die Seite zu sehen ist. Ein Tipp öffnet sie weiterhin bildschirmfüllend.",
|
||||
"live_needs_firmware": "Ein Livebild braucht Firmware 0.2.77 oder neuer: Nach dem Speichern beim Bildschirm auf Aktualisieren drücken.",
|
||||
"no_graph": "Home Assistant hat für diese Entität keine Zahlen, daher bleibt das Diagramm leer. Wähle eine andere Anzeige.",
|
||||
"no_forecast": "Dieser Wetterdienst hat in Home Assistant keine Tagesvorhersage. Wähle eine andere Anzeige."
|
||||
},
|
||||
"refresh": {
|
||||
"label": "Auffrischen",
|
||||
"seconds": "Alle {n} s"
|
||||
},
|
||||
"size": {
|
||||
"label": "Größe",
|
||||
"single": "Normal",
|
||||
"wide": "Doppelt breit",
|
||||
"full": "Ganze Seite",
|
||||
"full_hint": "Ganze Seite: eine große Taste, die im eingeschalteten Zustand leuchtet; am unteren Rand sitzen ein Schieberegler, Bedienelemente oder ein Diagramm.",
|
||||
"full_hint": "Ganze Seite: eine große Taste in der eigenen Farbe der Kachel; am unteren Rand sitzen ein Schieberegler, Bedienelemente oder ein Diagramm.",
|
||||
"needs_firmware": "Eine ganzseitige Kachel braucht Firmware 0.2.62: Drücke zuerst beim Bildschirm auf „Aktualisieren“."
|
||||
},
|
||||
"page": {
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"builtin_action": "A built-in card can't perform an action.",
|
||||
"no_mini_slider": "This entity doesn't support a mini-slider.",
|
||||
"history_hours": "History: choose 1, 6, or 24 hours.",
|
||||
"refresh": "Live picture: refresh every 15 or 30 seconds.",
|
||||
"watch_or_slider": "Choose large value or mini-slider.",
|
||||
"no_direct_control": "This entity doesn't support that direct control.",
|
||||
"position": "Invalid tile position; refresh the management page.",
|
||||
@@ -739,6 +740,10 @@
|
||||
"camera": {
|
||||
"label": "Camera",
|
||||
"help": "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."
|
||||
},
|
||||
"action": {
|
||||
"label": "Action",
|
||||
"help": "A Home Assistant action, such as script.open_gate or light.turn_off, performed once when the button is pressed on any screen. `data` gives its fields (entity_id, brightness, ...). A timeout or a new alert leaves it unperformed. Only through the esp_screens_show_alert event."
|
||||
}
|
||||
},
|
||||
"endings": {
|
||||
@@ -1266,15 +1271,22 @@
|
||||
"forecast": "Weather forecast",
|
||||
"graph": "Graph",
|
||||
"sunpath": "Sun path",
|
||||
"live": "Live picture",
|
||||
"live_hint": "A small picture of the camera in the icon's place, refreshed at the pace below while the page is on screen. A tap still opens it full screen.",
|
||||
"live_needs_firmware": "A live picture needs firmware 0.2.77 or newer: press Update on the screen after saving.",
|
||||
"no_graph": "Home Assistant has no numbers for this entity, so the graph stays empty. Choose another display.",
|
||||
"no_forecast": "This weather service has no daily forecast in Home Assistant. Choose another display."
|
||||
},
|
||||
"refresh": {
|
||||
"label": "Refresh",
|
||||
"seconds": "Every {n} s"
|
||||
},
|
||||
"size": {
|
||||
"label": "Size",
|
||||
"single": "Normal",
|
||||
"wide": "Double-width",
|
||||
"full": "Full page",
|
||||
"full_hint": "Full page: one big button that lights up while on; a slider, controls or a graph sit at the bottom of it.",
|
||||
"full_hint": "Full page: one big button in the tile's own colour; a slider, controls or a graph sit at the bottom of it.",
|
||||
"needs_firmware": "A full-page tile needs firmware 0.2.62: press Update on the screen first."
|
||||
},
|
||||
"page": {
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"builtin_action": "Una tarjeta integrada no puede realizar acciones.",
|
||||
"no_mini_slider": "Esta entidad no admite un control deslizante pequeño.",
|
||||
"history_hours": "Historial: elige 1, 6 o 24 horas.",
|
||||
"refresh": "Imagen en vivo: actualiza cada 15 o 30 segundos.",
|
||||
"watch_or_slider": "Elige valor grande o control deslizante pequeño.",
|
||||
"no_direct_control": "Esta entidad no admite ese control directo.",
|
||||
"position": "Posición de mosaico no válida; recarga la página de gestión.",
|
||||
@@ -739,6 +740,10 @@
|
||||
"camera": {
|
||||
"label": "Cámara",
|
||||
"help": "Una entidad de cámara o de imagen. Un Guition con firmware 0.2.57+ muestra su imagen de ese momento en la parte superior de la tarjeta; al tocarla se abre la cámara a pantalla completa. Las demás pantallas muestran la alerta sin ella. Solo mediante el evento esp_screens_show_alert."
|
||||
},
|
||||
"action": {
|
||||
"label": "Acción",
|
||||
"help": "Una acción de Home Assistant, como script.open_gate o light.turn_off, realizada una vez cuando se pulsa el botón en cualquier pantalla. `data` da sus campos (entity_id, brightness, ...). Un tiempo agotado o una nueva alerta la deja sin realizar. Solo mediante el evento esp_screens_show_alert."
|
||||
}
|
||||
},
|
||||
"endings": {
|
||||
@@ -1266,15 +1271,22 @@
|
||||
"forecast": "Previsión del tiempo",
|
||||
"graph": "Gráfico",
|
||||
"sunpath": "Recorrido del sol",
|
||||
"live": "Imagen en vivo",
|
||||
"live_hint": "Una imagen pequeña de la cámara en el lugar del icono, actualizada al ritmo de abajo mientras la página está en pantalla. Un toque la sigue abriendo a pantalla completa.",
|
||||
"live_needs_firmware": "Una imagen en vivo necesita el firmware 0.2.77 o más reciente: pulsa Actualizar en la pantalla después de guardar.",
|
||||
"no_graph": "Home Assistant no tiene valores numéricos para esta entidad, así que el gráfico queda vacío. Elige otra presentación.",
|
||||
"no_forecast": "Este servicio meteorológico no tiene previsión diaria en Home Assistant. Elige otra presentación."
|
||||
},
|
||||
"refresh": {
|
||||
"label": "Actualizar",
|
||||
"seconds": "Cada {n} s"
|
||||
},
|
||||
"size": {
|
||||
"label": "Tamaño",
|
||||
"single": "Normal",
|
||||
"wide": "Doble ancho",
|
||||
"full": "Página completa",
|
||||
"full_hint": "Página completa: un botón grande que se ilumina mientras está encendido; en la parte inferior va un control deslizante, unos controles o un gráfico.",
|
||||
"full_hint": "Página completa: un botón grande en el color del propio mosaico; en la parte inferior va un control deslizante, unos controles o un gráfico.",
|
||||
"needs_firmware": "Un mosaico de página completa necesita el firmware 0.2.62: pulsa antes Actualizar en la lista de pantallas."
|
||||
},
|
||||
"page": {
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"builtin_action": "Une tuile intégrée à l'écran ne peut pas effectuer d'action.",
|
||||
"no_mini_slider": "Cette entité ne prend pas en charge le petit curseur.",
|
||||
"history_hours": "Historique : choisissez 1, 6 ou 24 heures.",
|
||||
"refresh": "Image en direct : rafraîchir toutes les 15 ou 30 secondes.",
|
||||
"watch_or_slider": "Choisissez grande valeur ou petit curseur.",
|
||||
"no_direct_control": "Cette entité ne prend pas en charge cette commande directe.",
|
||||
"position": "Position de tuile non valide ; actualisez la page de gestion.",
|
||||
@@ -739,6 +740,10 @@
|
||||
"camera": {
|
||||
"label": "Caméra",
|
||||
"help": "Une entité caméra ou image. Un Guition avec le firmware 0.2.57+ affiche son image du moment en haut de la carte ; la toucher ouvre la caméra en plein écran. Les autres écrans affichent l'alerte sans elle. Uniquement via l'événement esp_screens_show_alert."
|
||||
},
|
||||
"action": {
|
||||
"label": "Action",
|
||||
"help": "Une action Home Assistant, comme script.open_gate ou light.turn_off, exécutée une fois quand le bouton est pressé sur n'importe quel écran. `data` donne ses champs (entity_id, brightness, ...). Un délai dépassé ou une nouvelle alerte la laisse sans effet. Uniquement via l'événement esp_screens_show_alert."
|
||||
}
|
||||
},
|
||||
"endings": {
|
||||
@@ -1266,15 +1271,22 @@
|
||||
"forecast": "Prévisions météo",
|
||||
"graph": "Graphique",
|
||||
"sunpath": "Course du soleil",
|
||||
"live": "Image en direct",
|
||||
"live_hint": "Une petite image de la caméra à la place de l'icône, rafraîchie au rythme ci-dessous tant que la page est affichée. Un appui l'ouvre toujours en plein écran.",
|
||||
"live_needs_firmware": "Une image en direct demande le firmware 0.2.77 ou plus récent : appuyez sur Mettre à jour sur l'écran après l'enregistrement.",
|
||||
"no_graph": "Home Assistant n'a pas de valeurs numériques pour cette entité : le graphique reste donc vide. Choisissez un autre affichage.",
|
||||
"no_forecast": "Ce service météo n'a pas de prévisions journalières dans Home Assistant. Choisissez un autre affichage."
|
||||
},
|
||||
"refresh": {
|
||||
"label": "Rafraîchir",
|
||||
"seconds": "Toutes les {n} s"
|
||||
},
|
||||
"size": {
|
||||
"label": "Taille",
|
||||
"single": "Normale",
|
||||
"wide": "Double largeur",
|
||||
"full": "Pleine page",
|
||||
"full_hint": "Pleine page : un grand bouton qui s'illumine quand l'entité est allumée ; un curseur, des commandes ou un graphique se placent en bas.",
|
||||
"full_hint": "Pleine page : un grand bouton dans la couleur de la tuile ; un curseur, des commandes ou un graphique se placent en bas.",
|
||||
"needs_firmware": "Une tuile pleine page nécessite le firmware 0.2.62 : appuyez d'abord sur Mettre à jour pour cet écran."
|
||||
},
|
||||
"page": {
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"builtin_action": "Un riquadro integrato nello schermo non può eseguire un'azione.",
|
||||
"no_mini_slider": "Questa entità non supporta il piccolo cursore.",
|
||||
"history_hours": "Storico: scegli 1, 6 o 24 ore.",
|
||||
"refresh": "Immagine dal vivo: aggiorna ogni 15 o 30 secondi.",
|
||||
"watch_or_slider": "Scegli valore grande o piccolo cursore.",
|
||||
"no_direct_control": "Questa entità non supporta questo controllo diretto.",
|
||||
"position": "Posizione del riquadro non valida; ricarica la pagina di gestione.",
|
||||
@@ -739,6 +740,10 @@
|
||||
"camera": {
|
||||
"label": "Telecamera",
|
||||
"help": "Un'entità telecamera o immagine. Un Guition con firmware 0.2.57+ mostra la sua immagine di quel momento in cima alla scheda; un tocco apre la telecamera a schermo intero. Gli altri schermi mostrano l'avviso senza immagine. Solo tramite l'evento esp_screens_show_alert."
|
||||
},
|
||||
"action": {
|
||||
"label": "Azione",
|
||||
"help": "Un'azione di Home Assistant, come script.open_gate o light.turn_off, eseguita una volta quando il pulsante viene premuto su uno qualsiasi degli schermi. `data` fornisce i suoi campi (entity_id, brightness, ...). Un timeout o un nuovo avviso la lascia non eseguita. Solo tramite l'evento esp_screens_show_alert."
|
||||
}
|
||||
},
|
||||
"endings": {
|
||||
@@ -1266,15 +1271,22 @@
|
||||
"forecast": "Previsioni meteo",
|
||||
"graph": "Grafico",
|
||||
"sunpath": "Percorso del sole",
|
||||
"live": "Immagine dal vivo",
|
||||
"live_hint": "Una piccola immagine della telecamera al posto dell'icona, aggiornata al ritmo qui sotto finché la pagina è sullo schermo. Un tocco la apre ancora a schermo intero.",
|
||||
"live_needs_firmware": "Un'immagine dal vivo richiede il firmware 0.2.77 o più recente: premi Aggiorna sullo schermo dopo il salvataggio.",
|
||||
"no_graph": "Home Assistant non ha valori numerici per questa entità, quindi il grafico resta vuoto. Scegli un'altra visualizzazione.",
|
||||
"no_forecast": "Questo servizio meteo non ha previsioni giornaliere in Home Assistant. Scegli un'altra visualizzazione."
|
||||
},
|
||||
"refresh": {
|
||||
"label": "Aggiorna",
|
||||
"seconds": "Ogni {n} s"
|
||||
},
|
||||
"size": {
|
||||
"label": "Dimensione",
|
||||
"single": "Normale",
|
||||
"wide": "Larghezza doppia",
|
||||
"full": "Pagina intera",
|
||||
"full_hint": "Pagina intera: un grande pulsante che si illumina quando è acceso; in basso trovano posto un cursore, i comandi o un grafico.",
|
||||
"full_hint": "Pagina intera: un grande pulsante nel colore del riquadro; in basso trovano posto un cursore, i comandi o un grafico.",
|
||||
"needs_firmware": "Un riquadro a pagina intera richiede il firmware 0.2.62: premi prima Aggiorna per questo schermo."
|
||||
},
|
||||
"page": {
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"builtin_action": "Een ingebouwde kaart kan geen actie uitvoeren.",
|
||||
"no_mini_slider": "Deze entiteit ondersteunt geen kleine slider.",
|
||||
"history_hours": "Geschiedenis: kies 1, 6 of 24 uur.",
|
||||
"refresh": "Livebeeld: ververs elke 15 of 30 seconden.",
|
||||
"watch_or_slider": "Kies een grote waarde of een kleine slider, niet allebei.",
|
||||
"no_direct_control": "Deze entiteit ondersteunt die directe bediening niet.",
|
||||
"position": "Ongeldige tegelpositie; vernieuw de beheerpagina.",
|
||||
@@ -739,6 +740,10 @@
|
||||
"camera": {
|
||||
"label": "Camera",
|
||||
"help": "Een camera- of afbeeldingsentiteit. Een Guition met firmware 0.2.57+ toont het beeld van dat moment bovenaan de kaart; een tik erop opent de camera schermvullend. Andere schermen tonen de alert zonder beeld. Alleen via de gebeurtenis esp_screens_show_alert."
|
||||
},
|
||||
"action": {
|
||||
"label": "Actie",
|
||||
"help": "Een Home Assistant-actie, zoals script.open_gate of light.turn_off, eenmaal uitgevoerd zodra de knop op een van de schermen wordt ingedrukt. `data` geeft de velden (entity_id, brightness, ...). Bij een time-out of een nieuwe alert blijft hij achterwege. Alleen via de gebeurtenis esp_screens_show_alert."
|
||||
}
|
||||
},
|
||||
"endings": {
|
||||
@@ -1266,15 +1271,22 @@
|
||||
"forecast": "Weersverwachting",
|
||||
"graph": "Grafiek",
|
||||
"sunpath": "Zonnebaan",
|
||||
"live": "Livebeeld",
|
||||
"live_hint": "Een klein beeld van de camera op de plek van het icoon, ververst in het tempo hieronder zolang de pagina in beeld is. Een tik opent hem nog steeds schermvullend.",
|
||||
"live_needs_firmware": "Een livebeeld heeft firmware 0.2.77 of nieuwer nodig: druk na het opslaan op Bijwerken bij het scherm.",
|
||||
"no_graph": "Home Assistant heeft geen getallen voor deze entiteit, dus de grafiek blijft leeg. Kies een andere weergave.",
|
||||
"no_forecast": "Deze weerdienst heeft geen dagverwachting in Home Assistant. Kies een andere weergave."
|
||||
},
|
||||
"refresh": {
|
||||
"label": "Verversen",
|
||||
"seconds": "Elke {n} s"
|
||||
},
|
||||
"size": {
|
||||
"label": "Grootte",
|
||||
"single": "Normaal",
|
||||
"wide": "Dubbelbreed",
|
||||
"full": "Hele pagina",
|
||||
"full_hint": "Hele pagina: één grote knop die oplicht zolang hij aan is; onderaan staat een slider, bediening of grafiek.",
|
||||
"full_hint": "Hele pagina: één grote knop in de eigen kleur van de tile; onderaan staat een slider, bediening of grafiek.",
|
||||
"needs_firmware": "Een paginavullende tegel heeft firmware 0.2.62 nodig: druk eerst bij het scherm op Bijwerken."
|
||||
},
|
||||
"page": {
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"builtin_action": "Wbudowana karta nie może wykonać akcji.",
|
||||
"no_mini_slider": "Ta encja nie obsługuje małego suwaka.",
|
||||
"history_hours": "Historia: wybierz 1, 6 lub 24 godziny.",
|
||||
"refresh": "Obraz na żywo: odświeżaj co 15 lub 30 sekund.",
|
||||
"watch_or_slider": "Wybierz dużą wartość albo mały suwak.",
|
||||
"no_direct_control": "Ta encja nie obsługuje takiego bezpośredniego sterowania.",
|
||||
"position": "Nieprawidłowa pozycja kafelka; odśwież stronę zarządzania.",
|
||||
@@ -739,6 +740,10 @@
|
||||
"camera": {
|
||||
"label": "Kamera",
|
||||
"help": "Encja kamery lub obrazu. Guition z firmware 0.2.57+ pokazuje jej zdjęcie z tej chwili u góry karty; dotknięcie go otwiera kamerę na pełnym ekranie. Inne ekrany pokazują alert bez zdjęcia. Tylko przez zdarzenie esp_screens_show_alert."
|
||||
},
|
||||
"action": {
|
||||
"label": "Akcja",
|
||||
"help": "Akcja Home Assistant, na przykład script.open_gate lub light.turn_off, wykonywana raz po naciśnięciu przycisku na dowolnym ekranie. `data` podaje jej pola (entity_id, brightness, ...). Po upływie czasu lub przy nowym alercie pozostaje niewykonana. Tylko przez zdarzenie esp_screens_show_alert."
|
||||
}
|
||||
},
|
||||
"endings": {
|
||||
@@ -1266,15 +1271,22 @@
|
||||
"forecast": "Prognoza pogody",
|
||||
"graph": "Wykres",
|
||||
"sunpath": "Tor słońca",
|
||||
"live": "Obraz na żywo",
|
||||
"live_hint": "Mały obraz z kamery w miejscu ikony, odświeżany w tempie poniżej, dopóki strona jest na ekranie. Dotknięcie nadal otwiera go na pełnym ekranie.",
|
||||
"live_needs_firmware": "Obraz na żywo wymaga firmware 0.2.77 lub nowszego: po zapisaniu naciśnij Aktualizuj przy ekranie.",
|
||||
"no_graph": "Home Assistant nie ma wartości liczbowych dla tej encji, więc wykres pozostanie pusty. Wybierz inny widok.",
|
||||
"no_forecast": "Ta usługa pogodowa nie ma prognozy dziennej w Home Assistant. Wybierz inny widok."
|
||||
},
|
||||
"refresh": {
|
||||
"label": "Odświeżanie",
|
||||
"seconds": "Co {n} s"
|
||||
},
|
||||
"size": {
|
||||
"label": "Rozmiar",
|
||||
"single": "Normalny",
|
||||
"wide": "Podwójna szerokość",
|
||||
"full": "Cała strona",
|
||||
"full_hint": "Cała strona: jeden duży przycisk, który świeci, gdy encja jest włączona; suwak, przyciski lub wykres są na jego dole.",
|
||||
"full_hint": "Cała strona: jeden duży przycisk w kolorze kafelka; suwak, przyciski lub wykres są na jego dole.",
|
||||
"needs_firmware": "Kafelek na całą stronę wymaga wersji firmware 0.2.62: najpierw naciśnij „Aktualizuj” przy tym ekranie."
|
||||
},
|
||||
"page": {
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"builtin_action": "Um cartão integrado não pode executar ações.",
|
||||
"no_mini_slider": "Esta entidade não suporta um controlo deslizante pequeno.",
|
||||
"history_hours": "Histórico: escolha 1, 6 ou 24 horas.",
|
||||
"refresh": "Imagem ao vivo: atualiza a cada 15 ou 30 segundos.",
|
||||
"watch_or_slider": "Escolha valor grande ou controlo deslizante pequeno.",
|
||||
"no_direct_control": "Esta entidade não suporta esse controlo direto.",
|
||||
"position": "Posição de mosaico inválida; recarregue a página de gestão.",
|
||||
@@ -739,6 +740,10 @@
|
||||
"camera": {
|
||||
"label": "Câmara",
|
||||
"help": "Uma entidade de câmara ou de imagem. Um Guition com firmware 0.2.57+ mostra a imagem desse momento no topo do cartão; um toque nela abre a câmara em ecrã inteiro. Os outros ecrãs mostram o alerta sem ela. Só através do evento esp_screens_show_alert."
|
||||
},
|
||||
"action": {
|
||||
"label": "Ação",
|
||||
"help": "Uma ação do Home Assistant, como script.open_gate ou light.turn_off, executada uma vez quando o botão é premido em qualquer ecrã. `data` dá os seus campos (entity_id, brightness, ...). Um tempo esgotado ou um novo alerta deixa-a por executar. Só através do evento esp_screens_show_alert."
|
||||
}
|
||||
},
|
||||
"endings": {
|
||||
@@ -1266,15 +1271,22 @@
|
||||
"forecast": "Previsão do tempo",
|
||||
"graph": "Gráfico",
|
||||
"sunpath": "Trajetória do sol",
|
||||
"live": "Imagem ao vivo",
|
||||
"live_hint": "Uma pequena imagem da câmara no lugar do ícone, atualizada ao ritmo abaixo enquanto a página está no ecrã. Um toque continua a abri-la em ecrã inteiro.",
|
||||
"live_needs_firmware": "Uma imagem ao vivo precisa do firmware 0.2.77 ou mais recente: prima Atualizar no ecrã depois de guardar.",
|
||||
"no_graph": "O Home Assistant não tem valores numéricos para esta entidade, por isso o gráfico fica vazio. Escolha outra apresentação.",
|
||||
"no_forecast": "Este serviço meteorológico não tem previsão diária no Home Assistant. Escolha outra apresentação."
|
||||
},
|
||||
"refresh": {
|
||||
"label": "Atualizar",
|
||||
"seconds": "A cada {n} s"
|
||||
},
|
||||
"size": {
|
||||
"label": "Tamanho",
|
||||
"single": "Normal",
|
||||
"wide": "Largura dupla",
|
||||
"full": "Página inteira",
|
||||
"full_hint": "Página inteira: um botão grande que se ilumina enquanto está ligado; em baixo fica um controlo deslizante, controlos ou um gráfico.",
|
||||
"full_hint": "Página inteira: um botão grande na cor do próprio mosaico; em baixo ficam um deslizador, controlos ou um gráfico.",
|
||||
"needs_firmware": "Um mosaico de página inteira precisa do firmware 0.2.62: prima primeiro Atualizar na lista de ecrãs."
|
||||
},
|
||||
"page": {
|
||||
|
||||
@@ -11,8 +11,8 @@ import unittest
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'screen_manager/app'))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from core import (ALERT_FIELDS, ALERT_MAX_TIMEOUT, BROADCAST_DISMISS, BROADCAST_EVENTS, BROADCAST_SHOW, # noqa: E402
|
||||
alert_data, alert_reference, alert_targets)
|
||||
from core import (ALERT_EVENT, ALERT_FIELDS, ALERT_MAX_TIMEOUT, BROADCAST_DISMISS, BROADCAST_EVENTS, BROADCAST_SHOW, # noqa: E402
|
||||
alert_action, alert_data, alert_reference, alert_targets)
|
||||
|
||||
HAS_AIOHTTP = importlib.util.find_spec('aiohttp') is not None
|
||||
if HAS_AIOHTTP:
|
||||
@@ -57,6 +57,21 @@ class AlertData(unittest.TestCase):
|
||||
self.assertEqual(alert_reference()['broadcast'], {'show': BROADCAST_SHOW, 'dismiss': BROADCAST_DISMISS})
|
||||
|
||||
|
||||
class AlertAction(unittest.TestCase):
|
||||
"""An action behind the button (app 0.2.91): the event names it, the app performs it on the press."""
|
||||
|
||||
def test_the_action_and_its_data(self):
|
||||
self.assertEqual(alert_action({'title': 'Gate', 'action': 'script.open_gate'}), (('script.open_gate', {}), True))
|
||||
self.assertEqual(alert_action({'action': ' light.turn_off ', 'data': {'entity_id': 'light.hall', 'transition': 2}}),
|
||||
(('light.turn_off', {'entity_id': 'light.hall', 'transition': 2}), True))
|
||||
for data in ({}, {'action': ''}, {'action': None}, None, 'text'):
|
||||
self.assertEqual(alert_action(data), (None, True), data)
|
||||
for data in ({'action': 'open the gate'}, {'action': 42}, {'action': 'script.x', 'data': 'no'}, {'action': 'script.x', 'data': ['a']},
|
||||
{'action': 'script.x', 'data': {'big': 'x' * 5000}}, {'action': 'script.x', 'data': {'nan': float('nan')}}):
|
||||
self.assertEqual(alert_action(data), (None, False), data)
|
||||
self.assertEqual(alert_reference()['action']['name'], 'action')
|
||||
|
||||
|
||||
class AlertTargets(unittest.TestCase):
|
||||
def test_only_screens_that_can_show_it_now(self):
|
||||
screens = [{'name': 'Kitchen', 'node': 'kitchen', 'online': True, 'firmware': '0.2.31'},
|
||||
@@ -91,6 +106,12 @@ def fake_ha():
|
||||
self.changed = asyncio.Event()
|
||||
self.broadcasts = asyncio.Queue()
|
||||
self.calls, self.failing = [], set()
|
||||
self.subscriptions = []
|
||||
|
||||
async def request(self, kind, **data):
|
||||
if kind == 'subscribe_events':
|
||||
self.subscriptions.append(data.get('event_type'))
|
||||
return {}
|
||||
|
||||
async def call(self, action, data):
|
||||
self.calls.append((action, data))
|
||||
@@ -139,6 +160,59 @@ class Broadcast(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(any('unusable timeout left empty' in line for line in logs.output), logs.output)
|
||||
self.assertIn('1 of 3 screens (not: Attic firmware 0.2.30; Hall ConnectionError)', logs.output[-1])
|
||||
|
||||
async def test_the_button_performs_the_alert_action_once(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
with self.assertLogs('screen_manager', 'INFO'):
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Gate', 'button_text': 'Open', 'action': 'script.open_gate', 'data': {'seconds': 5}})
|
||||
self.assertEqual(sorted(m.alert_actions), ['hall', 'kitchen-screen'])
|
||||
self.assertNotIn('action', m.ha.calls[0][1], 'show_alert keeps its seven fields')
|
||||
m.ha.calls.clear()
|
||||
# Hall presses the button: the action runs; Kitchen pressing it later runs nothing again.
|
||||
with self.assertLogs('screen_manager', 'INFO') as logs:
|
||||
await m.alert_ended({'action': 'ok', 'title': 'Gate', 'screen': 'hall'})
|
||||
self.assertEqual(m.ha.calls, [('script.open_gate', {'seconds': 5})])
|
||||
self.assertIn('script.open_gate performed', logs.output[-1])
|
||||
self.assertEqual(m.alert_actions, {})
|
||||
await m.alert_ended({'action': 'ok', 'title': 'Gate', 'screen': 'kitchen-screen'})
|
||||
await m.alert_ended({'action': 'ok', 'title': 'Other', 'screen': 'unknown'})
|
||||
self.assertEqual(len(m.ha.calls), 1)
|
||||
# A timeout, a new alert or a dismissal leaves it unperformed.
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Gate', 'action': 'script.open_gate'})
|
||||
await m.alert_ended({'action': 'timeout', 'title': 'Gate', 'screen': 'hall'})
|
||||
self.assertNotIn('hall', m.alert_actions)
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Plain'})
|
||||
self.assertEqual(m.alert_actions, {})
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Gate', 'action': 'script.open_gate'})
|
||||
await m.broadcast(BROADCAST_DISMISS, {})
|
||||
self.assertEqual(m.alert_actions, {})
|
||||
m.ha.calls.clear()
|
||||
# An action Home Assistant refuses is logged, not fatal; an unusable action is named and left out.
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Gate', 'action': 'script.open_gate'})
|
||||
m.ha.failing.add('script.open_gate')
|
||||
with self.assertLogs('screen_manager', 'WARNING') as logs:
|
||||
await m.alert_ended({'action': 'ok', 'title': 'Gate', 'screen': 'hall'})
|
||||
self.assertIn('script.open_gate failed (ConnectionError)', logs.output[-1])
|
||||
with self.assertLogs('screen_manager', 'INFO') as logs:
|
||||
await m.broadcast(BROADCAST_SHOW, {'title': 'Gate', 'action': 'open the gate'})
|
||||
self.assertTrue(any('unusable action left empty' in line for line in logs.output), logs.output)
|
||||
self.assertEqual(m.alert_actions, {})
|
||||
|
||||
async def test_the_reports_of_the_screens_go_through_the_alert_loop(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
task = asyncio.create_task(m.alert_loop())
|
||||
m.ha.broadcasts.put_nowait((BROADCAST_SHOW, {'title': 'Gate', 'action': 'script.open_gate'}))
|
||||
m.ha.broadcasts.put_nowait((ALERT_EVENT, {'action': 'ok', 'title': 'Gate', 'screen': 'hall'}))
|
||||
with self.assertLogs('screen_manager', 'INFO'):
|
||||
for _ in range(50):
|
||||
if ('script.open_gate', {}) in m.ha.calls:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
task.cancel()
|
||||
self.assertIn(('script.open_gate', {}), m.ha.calls)
|
||||
self.assertIn("ALERT_EVENT, *TILE_EVENTS", (ROOT / 'screen_manager/app/server.py').read_text(), 'subscribed on connect')
|
||||
|
||||
async def test_the_loop_keeps_the_order_and_survives_errors(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m = Manager(fake_ha(), Path(tmp) / 'screens.json')
|
||||
@@ -173,11 +247,12 @@ class Broadcast(unittest.IsolatedAsyncioTestCase):
|
||||
ha = HomeAssistant(None, 'http://ha/api', 'token')
|
||||
ha.ws = Socket([{'type': 'event', 'event': {'event_type': BROADCAST_SHOW, 'data': {'title': 'Mail!'}}},
|
||||
{'type': 'event', 'event': {'event_type': 'call_service', 'data': {}}},
|
||||
{'type': 'event', 'event': {'event_type': ALERT_EVENT, 'data': {'action': 'ok', 'title': 'Mail!', 'screen': 'hall'}}},
|
||||
{'type': 'event', 'event': {'event_type': BROADCAST_DISMISS, 'data': {}}}])
|
||||
with self.assertRaises(ConnectionError):
|
||||
await ha.read()
|
||||
self.assertEqual([ha.broadcasts.get_nowait() for _ in range(ha.broadcasts.qsize())],
|
||||
[(BROADCAST_SHOW, {'title': 'Mail!'}), (BROADCAST_DISMISS, {})])
|
||||
[(BROADCAST_SHOW, {'title': 'Mail!'}), (ALERT_EVENT, {'action': 'ok', 'title': 'Mail!', 'screen': 'hall'}), (BROADCAST_DISMISS, {})])
|
||||
self.assertIn('*BROADCAST_EVENTS', (ROOT / 'screen_manager/app/server.py').read_text(), 'subscribed on connect')
|
||||
|
||||
|
||||
|
||||
+132
-1
@@ -1,6 +1,7 @@
|
||||
"""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 contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
@@ -86,7 +87,10 @@ class Rules(unittest.TestCase):
|
||||
'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)
|
||||
# Three images: the camera full screen (and the cover), the alert's picture, the live tiles' strip (app 0.2.91).
|
||||
self.assertEqual(PROFILE.count('if (url != current) {'), 3)
|
||||
for needle in (' - id: tile_image', 'runtime_tiles::live_loaded(cached);', 'runtime_tiles::live_failed();', 'runtime_tiles::camera_live.load'):
|
||||
self.assertIn(needle, PROFILE, needle)
|
||||
# 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 = profiles.text('home-like-2432s028.yaml')
|
||||
@@ -428,5 +432,132 @@ class App(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(ha.camera_requests.get_nowait(), {'inbox': 'text.d1_tiles', 'entity': 'camera.max'})
|
||||
|
||||
|
||||
class LiveTiles(unittest.TestCase):
|
||||
"""A live picture on a camera tile (app 0.2.91, firmware 0.2.77): the page's tiles as one strip."""
|
||||
|
||||
def test_the_live_display_and_its_pace_are_tile_settings(self):
|
||||
from core import LIVE_MIN_FIRMWARE, LIVE_REFRESH
|
||||
self.assertEqual((camera_feed.LIVE_MIN_FIRMWARE, camera_feed.LIVE_REFRESH), (LIVE_MIN_FIRMWARE, LIVE_REFRESH))
|
||||
layout = validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.front_door', 'name': '', 'options': {'display': 'live', 'refresh': 30}},
|
||||
{'entity': 'image.doorbell', 'name': '', 'options': {'display': 'live'}}]})
|
||||
self.assertEqual(min_firmware(layout), LIVE_MIN_FIRMWARE)
|
||||
self.assertEqual(layout['tiles'][0]['options'], {'display': 'live', 'refresh': 30})
|
||||
# The icon again: the pace goes with the live display.
|
||||
plain = validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.front_door', 'name': '', 'options': {'display': 'standard', 'refresh': 30}}]})
|
||||
self.assertEqual(plain['tiles'][0]['options'], {'display': 'standard'})
|
||||
self.assertEqual(min_firmware(plain), CAMERA_MIN_FIRMWARE)
|
||||
for options in ({'display': 'live', 'refresh': 20}, {'display': 'live', 'refresh': '15'}, {'display': 'live', 'refresh': True}):
|
||||
with self.assertRaises(ValueError, msg=options):
|
||||
validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.front_door', 'name': '', 'options': options}]})
|
||||
with self.assertRaises(ValueError):
|
||||
validate_layout({'title': 'Hall', 'tiles': [{'entity': 'light.hall', 'name': '', 'options': {'display': 'live'}}]})
|
||||
# A tile event may ask for it too.
|
||||
from core import tile_options
|
||||
self.assertEqual(tile_options({'display': 'live', 'refresh': '30'}), {'display': 'live', 'refresh': 30})
|
||||
# The editor learns the choice from the capabilities.
|
||||
import ha_catalogue
|
||||
self.assertEqual(ha_catalogue.capabilities('camera.front_door', [], {}, {})['displays'], ['standard', 'watch', 'live'])
|
||||
self.assertNotIn('live', ha_catalogue.capabilities('light.hall', [], {}, {})['displays'])
|
||||
|
||||
def test_which_screens_draw_live_pictures(self):
|
||||
self.assertTrue(camera_feed.can_show_live({'board': 'guition', 'firmware': '0.2.77'}))
|
||||
for screen in ({'board': 'guition', 'firmware': '0.2.76'}, {'board': 'cyd', 'firmware': '0.2.77'}, None):
|
||||
self.assertFalse(camera_feed.can_show_live(screen), screen)
|
||||
|
||||
def test_the_request_names_the_tiles_their_size_and_their_grounds(self):
|
||||
self.assertEqual(camera_feed.live_request({'tiles': 'camera.front_door,image.doorbell', 'size': '54', 'bg': 'FFFFFF,fadadd'}),
|
||||
(['camera.front_door', 'image.doorbell'], 54, [0xFFFFFF, 0xFADADD]))
|
||||
for bad in ({'tiles': 'camera.a', 'size': '54'}, {'tiles': 'camera.a,light.b', 'size': '54', 'bg': 'FFFFFF,FFFFFF'},
|
||||
{'tiles': 'camera.a,camera.a', 'size': '54', 'bg': 'FFFFFF,FFFFFF'}, {'tiles': 'camera.a', 'size': '54', 'bg': 'FFFFFF,FFFFFF'},
|
||||
{'tiles': 'camera.a', 'size': '8', 'bg': 'FFFFFF'}, {'tiles': 'camera.a', 'size': 'x', 'bg': 'FFFFFF'},
|
||||
{'tiles': ','.join(f'camera.c{i}' for i in range(7)), 'size': '54', 'bg': ','.join(['FFFFFF'] * 7)}, {'tiles': '', 'size': '54', 'bg': ''}):
|
||||
self.assertIsNone(camera_feed.live_request(bad), bad)
|
||||
|
||||
@unittest.skipUnless(HAS_PIL, 'Pillow')
|
||||
def test_the_strip_is_one_square_per_tile_with_rounded_corners_over_its_own_ground(self):
|
||||
from PIL import Image
|
||||
out = camera_feed.encode_live([picture('JPEG', (1920, 1080)), None, picture('PNG', (100, 100), 'RGBA')], 54, [0xFFFFFF, 0x1A1A1A, 0x00FF00])
|
||||
with Image.open(io.BytesIO(out)) as image:
|
||||
self.assertEqual((image.format, image.size, image.mode), ('BMP', (54, 162), 'RGB'))
|
||||
self.assertEqual(image.getpixel((0, 0)), (255, 255, 255)) # the first square's corner: its ground
|
||||
self.assertEqual(image.getpixel((27, 27))[::2], (200, 90)) # its middle: the picture (JPEG, a shade off)
|
||||
self.assertEqual(image.getpixel((27, 54 + 27)), (26, 26, 26)) # no picture: a plain square of its ground
|
||||
self.assertEqual(image.getpixel((0, 108)), (0, 255, 0)) # the third square's corner: its ground
|
||||
|
||||
@unittest.skipUnless(HAS_PIL, 'Pillow')
|
||||
def test_each_load_fetches_the_cameras_whose_pace_has_passed(self):
|
||||
clock = [1000.0]
|
||||
fetched = []
|
||||
|
||||
async def fetch(entity):
|
||||
fetched.append((entity, clock[0]))
|
||||
return picture('JPEG', (640, 360))
|
||||
|
||||
async def run():
|
||||
feed = camera_feed.CameraFeed(fetch, clock=lambda: clock[0])
|
||||
tiles, grounds, paces = ['camera.a', 'camera.b'], [0xFFFFFF, 0xFFFFFF], [15, 30]
|
||||
token = feed.link(','.join(tiles), (54, 54), live=(tiles, 54, grounds, paces))
|
||||
status, body, etag = await feed.serve(token)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(sorted(e for e, _ in fetched), ['camera.a', 'camera.b'], 'the first load waits for both')
|
||||
# 15 s later the page loads again: only the 15 s camera is fetched again. The strip comes whole each
|
||||
# time, never a 304, whatever ETag the screen offers: ESPHome's http_request logs a 304 as an error (seen
|
||||
# on the bench Guition with a radar camera).
|
||||
clock[0] += 15
|
||||
again = await feed.serve(token, etag)
|
||||
self.assertEqual((again[0], again[2] == etag), (200, True))
|
||||
self.assertEqual([e for e, at in fetched if at == 1015.0], ['camera.a'])
|
||||
clock[0] += 15
|
||||
await feed.serve(token, etag)
|
||||
self.assertEqual(sorted(e for e, at in fetched if at == 1030.0), ['camera.a', 'camera.b'])
|
||||
# Nobody loading means nothing fetched.
|
||||
clock[0] += 60
|
||||
self.assertEqual(len([e for e, at in fetched if at > 1030.0]), 0)
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_AIOHTTP and HAS_PIL, 'Run using .venv-portal/bin/python for server tests')
|
||||
class LiveApp(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
camera_feed.base_url.__defaults__[0].clear()
|
||||
|
||||
async def test_a_screen_asks_for_the_live_tiles_of_its_page(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha(picture('JPEG', (1920, 1080)))
|
||||
ha.states['sensor.d1_fw']['state'] = '0.2.77'
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
m.layouts['text.d1_tiles'] = validate_layout({'title': 'Hall', 'tiles': [
|
||||
{'entity': 'camera.max', 'name': '', 'options': {'display': 'live'}},
|
||||
{'entity': 'camera.garden', 'name': '', 'options': {'display': 'live', 'refresh': 30}},
|
||||
{'entity': 'camera.shed', 'name': ''}]})
|
||||
with self.assertLogs('screen_manager', 'INFO'):
|
||||
await m.answer_camera({'inbox': 'text.d1_tiles', 'tiles': 'camera.max,camera.garden', 'size': '54', 'bg': 'FFFFFF,FADADD'})
|
||||
(_, 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', 'live', 'camera.max,camera.garden'))
|
||||
token = message['u'].rsplit('/', 1)[1][:-4]
|
||||
self.assertEqual(m.camera.links[token].live, (['camera.max', 'camera.garden'], 54, [0xFFFFFF, 0xFADADD], [15, 30]))
|
||||
ha.log.clear()
|
||||
# A tile that shows its icon, a camera off the layout, a screen on older firmware, a CYD: no strip.
|
||||
for request in ({'inbox': 'text.d1_tiles', 'tiles': 'camera.max,camera.shed', 'size': '54', 'bg': 'FFFFFF,FFFFFF'},
|
||||
{'inbox': 'text.d1_tiles', 'tiles': 'camera.other', 'size': '54', 'bg': 'FFFFFF'},
|
||||
{'inbox': 'text.d3_tiles', 'tiles': 'camera.max', 'size': '54', 'bg': 'FFFFFF'},
|
||||
{'inbox': 'text.d2_tiles', 'tiles': 'camera.max', 'size': '54', 'bg': 'FFFFFF'}):
|
||||
with self.assertLogs('screen_manager', 'INFO') if request['inbox'] == 'text.d1_tiles' and 'shed' in request['tiles'] else contextlib.nullcontext():
|
||||
await m.answer_camera(request)
|
||||
self.assertEqual([entry for entry in ha.log if entry[0] == 'send'], [])
|
||||
|
||||
async def test_a_camera_without_a_picture_keeps_its_icon(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ha = fake_ha(None)
|
||||
ha.states['sensor.d1_fw']['state'] = '0.2.77'
|
||||
m = Manager(ha, Path(tmp) / 'screens.json')
|
||||
m.layouts['text.d1_tiles'] = validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.max', 'name': '', 'options': {'display': 'live'}}]})
|
||||
with self.assertLogs('screen_manager', 'INFO') as logs:
|
||||
await m.answer_camera({'inbox': 'text.d1_tiles', 'tiles': 'camera.max', 'size': '54', 'bg': 'FFFFFF'})
|
||||
(_, _, message), = [entry for entry in ha.log if entry[0] == 'send']
|
||||
self.assertEqual((message['t'], message['e'], message['u']), ('live', 'camera.max', ''))
|
||||
self.assertIn('no image', logs.output[-1])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -63,6 +63,21 @@ int main() {
|
||||
feed.finish(10, true);
|
||||
assert(!feed.should_load(REFRESH_MS - 1) && feed.should_load(REFRESH_MS + 1));
|
||||
|
||||
// The live pictures of a page's camera tiles (firmware 0.2.77+) load at the tiles' own pace instead of REFRESH_MS.
|
||||
Feed tiles;
|
||||
tiles.open("camera.front_door,camera.garden", false, 15000);
|
||||
tiles.ask(0);
|
||||
tiles.link("http://h/camera/tiles.bmp");
|
||||
tiles.start(100);
|
||||
tiles.finish(400, true);
|
||||
assert(!tiles.should_load(100 + 15000 - 1) && tiles.should_load(100 + 15000));
|
||||
assert(tiles.every == 15000 && feed.every == REFRESH_MS);
|
||||
// Without a picture (an empty link) they ask again at that pace, not every ASK_AGAIN_MS.
|
||||
tiles.open("camera.front_door", false, 30000);
|
||||
tiles.ask(0);
|
||||
tiles.link("");
|
||||
assert(tiles.empty && !tiles.should_ask(ASK_AGAIN_MS) && !tiles.should_ask(30000 - 1) && tiles.should_ask(30000));
|
||||
|
||||
// An album cover (firmware 0.2.64+) loads once per link: it stays on screen, never refreshed on a clock.
|
||||
Feed cover;
|
||||
cover.open("media_player.office", true);
|
||||
|
||||
@@ -192,7 +192,7 @@ class Editor(unittest.TestCase):
|
||||
def test_the_mockup_polls_live_values_and_draws_them(self):
|
||||
self.assertIn('getJson(`states?${query}`)', self.store)
|
||||
self.assertIn('if (!document.hidden && state.layout && state.tab === "layout" && route.value === "") loadStates();', self.store)
|
||||
for marker in ('liveOf(props.tile.entity)', ':class="{ lit: isOn }"', ':style="sliderStyle"', "class=\"tog\" :class=\"{ off: !on }\""):
|
||||
for marker in ('liveOf(props.tile.entity)', 'lit: isOn', ':style="sliderStyle"', "class=\"tog\" :class=\"{ off: !on }\""):
|
||||
self.assertIn(marker, self.page, marker)
|
||||
|
||||
def test_identify_and_the_test_alert_have_their_buttons(self):
|
||||
|
||||
@@ -194,6 +194,10 @@ class Covers(unittest.TestCase):
|
||||
class Answer(unittest.TestCase):
|
||||
"""The app's answer to a screen's request, through Manager.answer_camera with a Home Assistant stand-in."""
|
||||
|
||||
def setUp(self):
|
||||
# base_url keeps the last address for ten minutes; another test's Home Assistant may have left one behind.
|
||||
camera_feed.base_url.__defaults__[0].clear()
|
||||
|
||||
class HA:
|
||||
online = True
|
||||
|
||||
|
||||
@@ -188,8 +188,8 @@ static void test_slider_colours() {
|
||||
setpoint.state = "55.0"; assert(setpoint.slider_active());
|
||||
}
|
||||
struct RunSliderColours { RunSliderColours() { test_slider_colours(); } } run_slider_colours;
|
||||
// Home Assistant's stateActive() (firmware 0.2.71+): what it calls inactive is grey on the screen, and only a tile
|
||||
// that can be off lights up over a whole page, while it is on.
|
||||
// Home Assistant's stateActive() (firmware 0.2.71+): what it calls inactive is grey on the screen. (`lights` was the
|
||||
// full-page tint of firmware 0.2.62 to 0.2.76, gone since 0.2.77: the cases stay as a record of the rule.)
|
||||
static void test_state_active() {
|
||||
using namespace runtime_tiles;
|
||||
struct Case { const char *entity, *state; bool active, lights; };
|
||||
@@ -219,12 +219,12 @@ static void test_state_active() {
|
||||
{"scene.a", "unavailable", false, false}}) {
|
||||
Tile t; t.entity = c.entity; t.state = c.state; t.received = true;
|
||||
assert(t.active() == c.active);
|
||||
assert(t.lights_up() == c.lights);
|
||||
(void) c.lights;
|
||||
}
|
||||
// Nothing received yet is inactive; a built-in card has no state, is always active and never lit.
|
||||
{ Tile t; t.entity = "light.a"; t.state = "on"; assert(!t.active() && !t.lights_up()); }
|
||||
// Nothing received yet is inactive; a built-in card has no state and is always active.
|
||||
{ Tile t; t.entity = "light.a"; t.state = "on"; assert(!t.active()); }
|
||||
for (const char *entity : {"screen.clock", "screen.settings", "screen.page_2"}) {
|
||||
Tile t; t.entity = entity; assert(t.active() && !t.lights_up());
|
||||
Tile t; t.entity = entity; assert(t.active());
|
||||
}
|
||||
// A closed blind: grey card, coloured slider.
|
||||
{ Tile t; t.entity = "cover.a"; t.state = "closed"; t.received = true; assert(!t.active() && t.slider_active()); }
|
||||
|
||||
@@ -59,7 +59,10 @@ class StateColours(unittest.TestCase):
|
||||
|
||||
def test_the_tile_greys_and_lights_up_by_home_assistants_rule(self):
|
||||
self.assertIn('uint32_t accent=tile_controls::accent(t);', TILES)
|
||||
self.assertIn('bool lit=w.full && on && t.lights_up() && available && !t.transparent;', TILES)
|
||||
# A full-page card is white or its own pastel like every other card (firmware 0.2.77+; 0.2.62-0.2.76 tinted it).
|
||||
self.assertNotIn('lights_up()', TILES)
|
||||
self.assertNotIn('lights_up', MODEL)
|
||||
self.assertIn('set_color(w.tile,LV_STYLE_BG_COLOR,lv_color_hex(theme::surface(t.background)));', TILES)
|
||||
self.assertIn('const uint32_t fill=slider_on?accent:theme::STATE_OFF;', TILES)
|
||||
self.assertNotIn('domain_accent', TILES)
|
||||
self.assertIn('frontend src/common/entity/state_active.ts', MODEL)
|
||||
|
||||
@@ -130,7 +130,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<span class="lead"><span class="big">{{ clockText(clock24, now) }}</span><span class="nm">{{ name }}</span></span>
|
||||
</template>
|
||||
<template v-else-if="full">
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="ic mdi" :class="{ lit: isOn, thumb: display === 'live' }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="lead">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
|
||||
@@ -150,7 +150,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
</template>
|
||||
<template v-else-if="wide">
|
||||
<span class="lead">
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="ic mdi" :class="{ lit: isOn, thumb: display === 'live' }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
|
||||
@@ -178,7 +178,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<!-- As the screen draws it: the icon on the left, the name and the value beside it. A watch
|
||||
card puts the name on top and the big value under it; the small slider runs underneath. -->
|
||||
<span class="head" :class="{ top: display === 'watch' }">
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="ic mdi" :class="{ lit: isOn, thumb: display === 'live' }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
|
||||
|
||||
@@ -49,14 +49,18 @@ const displays = computed(() => {
|
||||
if (domain.value === "weather" && (!c || c.displays.includes("forecast") || display.value === "forecast")) keys.push("forecast");
|
||||
if (domain.value === "sensor" && (!c || c.displays.includes("graph") || display.value === "graph")) keys.push("graph");
|
||||
if (domain.value === "sun") keys.push("sunpath");
|
||||
// A live picture on a camera tile (app 0.2.91): the picker only offers cameras on a Guition, the board that draws images.
|
||||
if (["camera", "image"].includes(domain.value)) keys.push("live");
|
||||
return keys.map((key) => [key, t(`editor.tile.display.${key}`)] as [string, string]);
|
||||
});
|
||||
const displayHint = computed(() => {
|
||||
const c = caps.value;
|
||||
if (c && display.value === "graph" && !c.displays.includes("graph")) return t("editor.tile.display.no_graph");
|
||||
if (c && display.value === "forecast" && !c.displays.includes("forecast")) return t("editor.tile.display.no_forecast");
|
||||
if (display.value === "live") return t(supports(0, 2, 77) ? "editor.tile.display.live_hint" : "editor.tile.display.live_needs_firmware");
|
||||
return "";
|
||||
});
|
||||
const refresh = computed(() => current("refresh", 15) as number);
|
||||
const size = computed(() => current("size", "single") as string);
|
||||
const catalogue = computed(() => state.inventory.controls?.[domain.value]);
|
||||
const controls = computed(() => current("controls", size.value === "full" ? "none" : catalogue.value?.default) as string);
|
||||
@@ -124,7 +128,11 @@ function inspect() {
|
||||
<div v-else class="f">
|
||||
<span class="f-label">{{ t("editor.tile.display.label") }}</span>
|
||||
<Segmented :choices="displays" :value="display" @pick="(v) => setTileOption(tile, 'display', v)" />
|
||||
<small v-if="displayHint" class="warn">{{ displayHint }}</small>
|
||||
<small v-if="displayHint" :class="{ warn: display !== 'live' || !supports(0, 2, 77) }">{{ displayHint }}</small>
|
||||
</div>
|
||||
<div v-if="display === 'live'" class="f">
|
||||
<span class="f-label">{{ t("editor.tile.refresh.label") }}</span>
|
||||
<Segmented :choices="[15, 30].map((seconds) => [seconds, t('editor.tile.refresh.seconds', { n: seconds })] as [number, string])" :value="refresh" @pick="(v) => setTileOption(tile, 'refresh', Number(v))" />
|
||||
</div>
|
||||
<div class="f">
|
||||
<span class="f-label">{{ t("editor.tile.size.label") }}</span>
|
||||
|
||||
@@ -173,6 +173,10 @@ a:hover { text-decoration: underline; }
|
||||
.tile .tog.off { background: #c9ccd1; }
|
||||
.tile .tog.off::after { right: auto; left: 2px; }
|
||||
.tile .ic.lit { color: #d9930f; }
|
||||
/* A live camera tile (app 0.2.91): the picture's rounded square stands in for the icon on the mockup. */
|
||||
.tile .ic.thumb { width: 28px; height: 28px; border-radius: 7px; background: #d7dbe1; display: grid; place-items: center; font-size: 15px; color: #5a5f66; }
|
||||
.tile.full .ic.thumb { width: 54px; height: 54px; border-radius: 12px; font-size: 26px; }
|
||||
.device.cyd .tile .ic.thumb { width: 22px; height: 22px; font-size: 12px; }
|
||||
.tile .big { font-size: 19px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.05; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tile .big small { display: inline; font-size: 10.5px; font-weight: 500; color: #5a5f66; margin-left: 2px; }
|
||||
.device.cyd .tile .big { font-size: 16px; }
|
||||
|
||||
@@ -183,6 +183,38 @@ describe("several tiles that go to the same page in the library (firmware 0.2.65
|
||||
});
|
||||
});
|
||||
|
||||
describe("TileInspector: a live picture on a camera tile (app 0.2.91)", () => {
|
||||
const row = (wrapper: ReturnType<typeof mount>, label: string) =>
|
||||
wrapper.findAll(".f").find((f) => f.find(".f-label").exists() && f.find(".f-label").text() === label)!;
|
||||
const choices = (wrapper: ReturnType<typeof mount>, label: string) => row(wrapper, label).findAll(".seg button").map((b) => b.text());
|
||||
it("offers the live picture for a camera, with its pace once chosen, and says which firmware it needs", async () => {
|
||||
state.inventory.entities.push({ id: "camera.front", name: "Front", state: "idle", area: "Hall" } as any);
|
||||
const tile: Tile = { entity: "camera.front", name: "", slot: 0 };
|
||||
state.layout!.tiles.push(tile);
|
||||
const drawer = mount(TileInspector, { props: { tile } });
|
||||
expect(choices(drawer, "Display")).toEqual(["Name and status", "Large value", "Live picture"]);
|
||||
expect(row(drawer, "Refresh")).toBeUndefined();
|
||||
await row(drawer, "Display").findAll(".seg button")[2].trigger("click");
|
||||
expect(tile.options).toEqual({ display: "live" });
|
||||
expect(choices(drawer, "Refresh")).toEqual(["Every 15 s", "Every 30 s"]);
|
||||
expect(row(drawer, "Refresh").find('[aria-pressed="true"]').text()).toBe("Every 15 s");
|
||||
expect(row(drawer, "Display").find("small").text()).toMatch(/firmware 0\.2\.77/);
|
||||
expect(row(drawer, "Display").find("small").classes()).toContain("warn");
|
||||
await row(drawer, "Refresh").findAll(".seg button")[1].trigger("click");
|
||||
expect(tile.options).toEqual({ display: "live", refresh: 30 });
|
||||
Object.assign(state.inventory.screens[0], { firmware: "0.2.77" });
|
||||
await drawer.vm.$nextTick();
|
||||
expect(row(drawer, "Display").find("small").text()).toMatch(/icon's place/);
|
||||
expect(row(drawer, "Display").find("small").classes()).not.toContain("warn");
|
||||
// A light has no such choice.
|
||||
const lamp = mount(TileInspector, { props: { tile: { entity: "light.a", name: "", slot: 1 } } });
|
||||
expect(choices(lamp, "Display")).not.toContain("Live picture");
|
||||
// The mockup draws the picture's rounded square instead of the icon.
|
||||
const card = mount(TileCard, { props: { tile, slot: 0 } });
|
||||
expect(card.find(".ic").classes()).toContain("thumb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TileInspector: pages (app 0.2.78)", () => {
|
||||
const row = (wrapper: ReturnType<typeof mount>, label: string) =>
|
||||
wrapper.findAll(".f").find((f) => f.find(".f-label").exists() && f.find(".f-label").text() === label)!;
|
||||
|
||||
Reference in New Issue
Block a user