Show the album cover on a media tile, in the same strip as the live camera pictures (0.2.92)

A single or double-width media player tile takes Display -> Album cover
on a Guition: the cover of what plays in the icon's place, like Home
Assistant's own tile. It rides in the page's strip of 0.2.91: the request
and the answer are the same, a media player is simply among the tiles.
The app fetches a player's picture only when its address changes; a page
with a camera loads at the camera's pace with the cover reused, a page of
media tiles alone loads once, and a new track (the picture's mark in the
state, part of the page's wish) asks again at once. A player without a
picture keeps its icon and is not asked for; the double-width tile keeps
its controls; the tile over the whole page keeps the card's big cover.
Firmware 0.2.78.

Host harness 31 checks (docs/TEST_RESULTS_0292.md). CYD firmware
1,633,760 B, 89.0 %.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-20 13:41:21 +02:00
parent 306636434b
commit efbab04b39
45 changed files with 352 additions and 85 deletions
+1 -1
View File
@@ -75,7 +75,7 @@ https://github.com/user-attachments/assets/f9f6a933-d388-4c8b-9c9a-011dfa37617b
<img src="docs/images/guition-media-full.png" width="32%" alt="A media player over the whole page: the cover at the left, the track, the bar and the keys beside it, the volume row along the bottom">
<img src="docs/images/guition-dark-media.png" width="32%" alt="The media card in dark mode: the same cover and keys on a black page">
</p>
<p align="center"><sub>Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. A lamp with modes (a WLED) gets an effects page with a drum picker, named and filled from what Home Assistant lists for it. The media card shows what plays with its album cover, fetched by ESP Screens like a camera picture (<a href="docs/CAMERA.md">how</a>). Rendered from the firmware's own LVGL code with a demo home.</sub></p>
<p align="center"><sub>Tap a tile to switch it, hold it for the full card. Keys and sliders right on the tile, pastel colors, a clock, the weather and history you can read with a finger. A lamp with modes (a WLED) gets an effects page with a drum picker, named and filled from what Home Assistant lists for it. The media card shows what plays with its album cover, fetched by ESP Screens like a camera picture, and a media tile can show that cover in the icon's place (<a href="docs/CAMERA.md">how</a>). Rendered from the firmware's own LVGL code with a demo home.</sub></p>
<p align="center">
<img src="docs/images/guition-camera-tiles.png" width="32%" alt="Cameras as tiles on the Guition: the front door camera, the garden camera live, the doorbell's last ring, next to the door, the porch light and the garage door">
<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">
+4 -2
View File
@@ -311,8 +311,10 @@ the icon's place: in the tile's settings choose **Display → Live picture** and
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).
A media player tile can show its **album cover** the same way (app 0.2.92, firmware 0.2.78):
**Display → Album cover** puts the cover of what plays in the icon's place, refreshed when the track
changes, with the tile's controls kept. 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
+5
View File
@@ -217,6 +217,11 @@ struct Tile {
// 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"); }
// A media player's album cover in the icon's place (firmware 0.2.78+): "display": "cover" on a single or double-width
// tile, while the player has a picture; the tile over the whole page keeps the card's big cover.
bool cover_tile() const { return display == "cover" && domain() == "media_player" && !full && !extra().media_picture.empty(); }
// A tile that draws its picture out of the page's strip (runtime_tiles.h, live_*).
bool pictured() const { return live() || cover_tile(); }
// 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.
+20 -17
View File
@@ -247,7 +247,7 @@ 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+).
// A camera tile's live picture (firmware 0.2.77+) or a media tile's album cover (0.2.78+) in the icon's place.
lv_obj_t *picture{};
};
constexpr unsigned POINT_BUFFER = 128;
@@ -4177,15 +4177,16 @@ 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; };
// ---- Live pictures on camera tiles (firmware 0.2.77+) and album covers on media tiles (0.2.78+) ----
// A camera tile with "display": "live" shows a small picture of its camera in the icon's place, a media tile with
// "display": "cover" the album cover of what plays. The pictured 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 camera, 15 or 30 s, in the
// board's third online_image; a page of covers alone loads once. 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, another look or another track
// (the picture's mark in the media state) changes what is wanted: the strip is dropped and asked for again.
struct LiveWish { std::string entities, grounds, marks; int size = 0; uint32_t every = 15000; bool cameras = false; };
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
@@ -4221,14 +4222,15 @@ inline LiveWish live_wanted() {
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 += ','; }
if (!t.pictured()) continue;
if (!want.entities.empty()) { want.entities += ','; want.grounds += ','; want.marks += ','; }
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 (t.cover_tile()) want.marks += t.extra().media_picture;
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);
if (t.live()) { want.cameras = true; want.every = std::min<uint32_t>(want.every, t.refresh * 1000u); }
}
return want;
}
@@ -4259,7 +4261,7 @@ inline void live_release() {
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;
lv_image_dsc_t *src = t.pictured() ? live_ready(t.entity, size, square) : nullptr;
if (src) {
if (!w.picture) {
w.picture = lv_image_create(w.tile);
@@ -4301,10 +4303,11 @@ inline void live_request() {
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) {
if (want.entities != live_wish.entities || want.grounds != live_wish.grounds || want.marks != live_wish.marks || want.size != live_wish.size) {
live_wish = want;
live_release();
if (!want.entities.empty()) live.open(want.entities, false, want.every);
// Covers alone load once per link (a new track is a new wish); a camera sets the pace.
if (!want.entities.empty()) live.open(want.entities, !want.cameras, 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
@@ -4325,7 +4328,7 @@ inline void live_loaded(bool cached) {
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);
if (w.tile && !lv_obj_has_flag(w.tile, LV_OBJ_FLAG_HIDDEN) && w.index < model.count && model.tiles[w.index].pictured()) refresh_tile(w.index);
}
inline void live_failed() {
if (!live.loading) return;
+6
View File
@@ -60,6 +60,12 @@ corners, and refreshes it while that page is on the screen. A tap still opens th
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.
- **A media tile's album cover** (app 0.2.92, firmware 0.2.78) rides in the same strip: **Display →
Album cover** on a single or double-width media player tile. The app fetches the player's picture
only when its address changes (a new track), so a page with a camera and a Sonos loads its strip at
the camera's pace with the cover reused, and a page of media tiles alone loads once. The screen asks
for the page again the moment a player's picture mark in its state changes. A player without a
picture keeps its icon; the tile over the whole page keeps the card's big cover.
- 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.
+18
View File
@@ -219,6 +219,24 @@ 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.92 / firmware 0.2.78
One thing: the album cover on a single or double-width media tile, in the strip of 0.2.91. No protocol change: the
strip request (`tiles`, `size`, `bg`) and the answer (op `camera`, `t: "live"`) are the same; a media player may now be
among the tiles.
- **Tile setting** `display: cover` on a `media_player` tile (`DISPLAYS`, `COVER_TILE_MIN_FIRMWARE` = 0.2.78 in
`core.py`; `min_firmware` asks for it). `resolve_controls` and the editor's `effectiveControls` treat `cover` as the
standard layout, so a double-width tile keeps its controls. Firmware before 0.2.78 draws a `cover` tile as standard.
- **Firmware**: `Tile::cover_tile()` (display cover, media player, not full, with a picture mark in the extras) and
`Tile::pictured()` (live or cover_tile) drive `live_wanted()`; the wish carries the media pictures' marks (`marks`),
so a new track is a new wish (strip dropped and asked for again), and `cameras`: without a camera the feed opens
with `once` (one load per link), with one it keeps the fastest camera's pace. The tile over the whole page keeps the
card's cover through `cover_want` as before.
- **App**: `CameraFeed.cover_raw()` (split out of `cover()`) fetches a player's picture when its address changed;
`live_one()` takes that road for a media player, a pace of 0. `Manager.answer_live` accepts a media tile only with
`display: cover`. `tests/test_camera.py` (LiveTiles, LiveApp), `web/tests/components.spec.ts`.
### 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
+50
View File
@@ -0,0 +1,50 @@
# Test results app 0.2.92 / firmware 0.2.78 (2026-09-20)
One ask, right after 0.2.91: the album cover on a media player tile, the way Home Assistant's own tile shows it, on the
same road as the live camera pictures. "Even via dezelfde endpoint, toch?" Yes.
## What changed, and why it is small
- **The strip takes media players.** The page's request (`tiles`, `size`, `bg`) and the answer (op `camera`,
`t: "live"`) are the same as in 0.2.91; a media player tile with `display: cover` is simply among the tiles. The
app tells the two apart by domain: a camera gets a snapshot at its pace, a media player its `entity_picture`,
fetched again only when that address changes (the same fetch as the media card's cover, `CameraFeed.cover_raw`).
- **Two clocks in one download.** The screen loads the strip at the pace of its fastest camera; the app decides per
entity whether anything new is fetched from Home Assistant. A page with a 15 s camera and a Sonos loads its strip
every 15 s with the cover reused; a page of media tiles alone has no clock and loads once. A new track changes the
picture's mark in the player's state, which is part of the page's wish, so the screen asks again at once.
- **Only players with a picture ask.** A radio station or a player that is off has no `entity_picture`; such a tile is
not in the request and keeps its icon. The tile over the whole page keeps the card's big cover through the older
route; a double-width tile keeps its controls (the cover is the standard layout with a picture).
## Host harness (Mac, host build of the Guition package, real add-on code)
`live_host.py` of 0.2.91 with a Sonos tile (cover) on the camera page and a page of two media tiles (a wide one with
the volume control and a radio without a picture); 31 checks, all passing:
- page 1 asks for its three live tiles and the Sonos together, with the four colours; the strip loads and every tile
shows its own square (offsets 0, −54, −108, −162), the Sonos' cover where its icon was;
- the 15 s pace, the 30 s camera fetched every other load, the camera full screen and a card pausing the strip, a
page turn and dark mode asking again: as in 0.2.91, now with the media tile in the list;
- the page of media tiles asks for the one player with a picture only, shows its cover beside the volume slider
while the radio keeps its icon, loads once (no load in 17 s), and asks again the moment the player's picture
address changes, loading the strip once more;
- a player without a picture, a media tile that shows its icon and a media tile over the whole page are not in the
strip; the full-page tile still asks for its own cover.
Found on the way: the harness first expected the radio in the request; the firmware leaves a player without a
picture out, which is the better answer (no request for nothing).
## Bench Guition (192.168.146.136, firmware 0.2.78 from this tree over OTA, local add-on 0.2.92 on the Yellow)
- The Sonos in the living room (single tile, Display → Album cover) on page 1: as long as every Sonos played "TV"
(no `entity_picture`) the tile kept its icon and the screen asked for nothing. The moment a track with art started,
the state's picture mark reached the screen, the page asked for its strip (`Live pictures of
media_player.sonos_woonkamer on Guition Wallbox` in the add-on's log) and the tile showed the cover in the icon's
place (`capture_ui.py`, dark mode). A tap opened the media card with its big cover as before (69 KB, the older
route; its 63-150 ms loop warnings while decoding are the same as in 0.2.77). Camera.max and Buienradar keep their
live pictures beside it on page 8.
## Builds
`tools/check.sh` and `tools/check.sh --firmware` with ESPHome 2026.9.0: CYD 1,633,760 B, 89.0 % of the update slot (32 B more than 0.2.91); Guition 2,058,656 B.
+1 -1
View File
@@ -14,7 +14,7 @@
# fonts/materialdesignicons-webfont.ttf (Apache 2.0, github.com/Templarian/MaterialDesign-Webfont).
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.77"
SCREEN_FIRMWARE_VERSION: "0.2.78"
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
+7
View File
@@ -1,3 +1,10 @@
## 0.2.92 (firmware 0.2.78)
The album cover on a media tile, the way Home Assistant's own tile shows it.
- **The album cover in the icon's place.** On a Guition, a single or double-width media player tile can show the cover of what plays instead of its icon: in the tile's settings choose **Display → Album cover**. It travels the same road as the live camera pictures of 0.2.91, in the one strip a page shares: a page with a camera and a Sonos loads its strip at the camera's pace and reuses the cover until the track changes; a page of media tiles alone loads once, and again the moment a new track brings another picture. A player without a picture (a radio station, a player that is off) keeps its icon; the double-width tile keeps its volume or playback controls. The tile over the whole page keeps the card's big cover. Nothing loads in standby.
- Needs firmware 0.2.78: press **Update** on the screen. Includes everything from 0.2.91. CYD firmware: 1,633,760 bytes, 89.0 % of the update slot (32 bytes more than 0.2.91).
## 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.
+18 -4
View File
@@ -40,6 +40,10 @@ COVER_RADIUS_SHARE = 12 # the corner is a twelfth of the size, at least 4 px (m
# 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)
# A media tile's album cover in the same strip (app 0.2.92, firmware 0.2.78): "display": "cover" on a single or wide
# media tile. A cover is fetched when the player's picture address changes, never on a clock; the screen asks for the
# page again when the picture's mark in the player's state changes.
COVER_TILE_MIN_FIRMWARE = (0, 2, 78)
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
@@ -103,7 +107,7 @@ def live_request(request):
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):
if not LIVE_SIZES[0] <= size <= LIVE_SIZES[1] or not all(supported(e) or cover_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]
@@ -352,6 +356,8 @@ class CameraFeed:
# 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):
if cover_supported(entity):
return await self.cover_raw(entity, wait)
watch = self.watch(entity)
if watch.raw is None:
self.refresh(entity, watch)
@@ -408,9 +414,9 @@ class CameraFeed:
if watch.failures in (1, 30):
LOG.info('No cover from %s (%s)', entity, type(error).__name__)
async def cover(self, entity, size, background, wait=FIRST_FRAME_SECONDS):
"""(etag, BMP) of the player's cover at `size` with `background` behind its corners, or None when the player
shows no picture or it cannot be fetched."""
async def cover_raw(self, entity, wait=FIRST_FRAME_SECONDS):
"""The player's picture as Home Assistant hands it out, fetched again only when its address changed; None when
the player shows no picture or it cannot be fetched (yet)."""
if self.fetch_cover is None or self.picture is None:
return None
picture = self.picture(entity)
@@ -429,6 +435,14 @@ class CameraFeed:
return None
if watch.picture != picture or watch.raw is None:
return None
return watch.raw
async def cover(self, entity, size, background, wait=FIRST_FRAME_SECONDS):
"""(etag, BMP) of the player's cover at `size` with `background` behind its corners, or None when the player
shows no picture or it cannot be fetched."""
if await self.cover_raw(entity, wait) is None:
return None
watch = self.watch(entity)
key = ('cover', size, background)
cached = watch.frames.get(key)
if cached is None or cached[0] != watch.digest:
+3 -2
View File
@@ -15,12 +15,13 @@ from i18n import t
import tile_icons
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, LIVE_MIN_FIRMWARE, MAX_PAGES, MAX_SLOTS, MAX_TILES, PAGE_TILE_REPEAT_MIN_FIRMWARE,
CONTROLS, COVER_TILE_MIN_FIRMWARE, 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)
COVER_TILE_VERSION = '.'.join(str(part) for part in COVER_TILE_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)
@@ -100,7 +101,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). 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). |
| `display` | How the tile draws itself (see below). On a Guition a camera or image tile takes `live` (firmware {LIVE_VERSION} or newer): a small live picture in the icon's place, with `refresh` 15 or 30 (seconds); a media player tile takes `cover` (firmware {COVER_TILE_VERSION} or newer): the album cover of what plays in the icon's place. |
| `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. |
+8 -3
View File
@@ -37,6 +37,8 @@ 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
# A media tile's album cover in the icon's place ("display": "cover", app 0.2.92): the same strip, firmware from here.
COVER_TILE_MIN_FIRMWARE = (0, 2, 78)
# 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+).
@@ -49,7 +51,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.77'
FIRMWARE_VERSION = '0.2.78'
# 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.
@@ -89,7 +91,7 @@ def backgrounds():
# 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'),
'camera': ('standard', 'live'), 'image': ('standard', 'live')}
'camera': ('standard', 'live'), 'image': ('standard', 'live'), 'media_player': ('standard', 'watch', 'cover')}
# Displays that only work on a double-width card.
WIDE_ONLY = ('forecast', 'sunpath')
@@ -188,7 +190,8 @@ def resolve_controls(tile):
"""Control set a card shows on the screen, or None: only wide cards in the standard layout have room for one."""
options = tile.get('options', {})
domain = tile['entity'].split('.')[0]
if domain not in CONTROLS or options.get('size') not in ('wide', 'full') or options.get('display', 'standard') != 'standard' or options.get('inline') == 'slider':
# The album cover in the icon's place (app 0.2.92) is the standard layout with a picture: the controls stay.
if domain not in CONTROLS or options.get('size') not in ('wide', 'full') or options.get('display', 'standard') not in ('standard', 'cover') or options.get('inline') == 'slider':
return None
# A full-page card is one big button unless a control was chosen for it; a wide card shows its usual one.
choice = options.get('controls', 'none' if options.get('size') == 'full' else CONTROLS[domain][0][0])
@@ -486,6 +489,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') == 'cover' for t in layout['tiles']):
return COVER_TILE_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']):
+3
View File
@@ -173,6 +173,9 @@ def capabilities(entity_id, actions, state, services):
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')
elif domain == 'media_player':
# The album cover in the icon's place (app 0.2.92), a Guition again.
displays.append('cover')
return {
'toggle': TOGGLE.format(domain=domain) in actions,
'inline': domain in INLINE and _fits(INLINE[domain], actions, attributes, services),
+6 -3
View File
@@ -1661,10 +1661,13 @@ class Manager:
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'])
# A camera tile set to a live picture, or a media tile set to its cover (app 0.2.92).
wanted = {'live', 'cover'}
if any(entity not in tiles or tiles[entity].get('display') not in wanted
or (tiles[entity].get('display') == 'cover') != camera_feed.cover_supported(entity) for entity in entities):
LOG.info('Live pictures for %s: not the pictured tiles of %s', ', '.join(entities), screen['name'])
return
paces = [tiles[entity].get('refresh', camera_feed.LIVE_REFRESH[0]) for entity in entities]
paces = [tiles[entity].get('refresh', camera_feed.LIVE_REFRESH[0]) if tiles[entity].get('display') == 'live' else 0 for entity in entities]
url, listing = '', ','.join(entities)
base = await camera_feed.base_url(self.ha.request)
if base:
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
+1 -1
View File
@@ -5,7 +5,7 @@
<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-BwlmhXLI.js"></script>
<script type="module" crossorigin src="./assets/index-plSjhIie.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DQxVdm_5.css">
</head>
<body>
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.91"
version: "0.2.92"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+6 -1
View File
@@ -1241,7 +1241,9 @@
"graph": "Diagramm",
"digital": "Digitaluhr",
"analog": "Analoguhr",
"sunpath": "Sonnenbahn"
"sunpath": "Sonnenbahn",
"live": "Livebild",
"cover": "Albumcover"
},
"sizes": {
"single": "normal",
@@ -1274,6 +1276,9 @@
"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.",
"cover": "Albumcover",
"cover_hint": "Das Albumcover des laufenden Titels anstelle des Symbols, wie auf der eigenen Kachel von Home Assistant; ein Player ohne Bild behält sein Symbol. Ein Tipp öffnet weiterhin die Karte.",
"cover_needs_firmware": "Das Albumcover auf der Kachel braucht Firmware 0.2.78 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."
},
+6 -1
View File
@@ -1241,7 +1241,9 @@
"graph": "graph",
"digital": "digital clock",
"analog": "analog clock",
"sunpath": "sun path"
"sunpath": "sun path",
"live": "live picture",
"cover": "album cover"
},
"sizes": {
"single": "normal",
@@ -1274,6 +1276,9 @@
"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.",
"cover": "Album cover",
"cover_hint": "The album cover of what plays in the icon's place, like Home Assistant's own tile; a player without a picture keeps its icon. A tap still opens the card.",
"cover_needs_firmware": "The album cover on the tile needs firmware 0.2.78 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."
},
+6 -1
View File
@@ -1241,7 +1241,9 @@
"graph": "gráfico",
"digital": "reloj digital",
"analog": "reloj analógico",
"sunpath": "recorrido del sol"
"sunpath": "recorrido del sol",
"live": "imagen en vivo",
"cover": "carátula"
},
"sizes": {
"single": "normal",
@@ -1274,6 +1276,9 @@
"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.",
"cover": "Carátula",
"cover_hint": "La carátula de lo que suena en el lugar del icono, como en el propio mosaico de Home Assistant; un reproductor sin imagen conserva su icono. Un toque sigue abriendo la tarjeta.",
"cover_needs_firmware": "La carátula en el mosaico necesita el firmware 0.2.78 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."
},
+6 -1
View File
@@ -1241,7 +1241,9 @@
"graph": "graphique",
"digital": "horloge numérique",
"analog": "horloge analogique",
"sunpath": "course du soleil"
"sunpath": "course du soleil",
"live": "image en direct",
"cover": "pochette"
},
"sizes": {
"single": "normale",
@@ -1274,6 +1276,9 @@
"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.",
"cover": "Pochette",
"cover_hint": "La pochette de ce qui joue à la place de l'icône, comme la tuile de Home Assistant ; un lecteur sans image garde son icône. Un appui ouvre toujours la carte.",
"cover_needs_firmware": "La pochette sur la tuile demande le firmware 0.2.78 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."
},
+6 -1
View File
@@ -1241,7 +1241,9 @@
"graph": "grafico",
"digital": "orologio digitale",
"analog": "orologio analogico",
"sunpath": "percorso del sole"
"sunpath": "percorso del sole",
"live": "immagine dal vivo",
"cover": "copertina"
},
"sizes": {
"single": "normale",
@@ -1274,6 +1276,9 @@
"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.",
"cover": "Copertina",
"cover_hint": "La copertina di ciò che suona al posto dell'icona, come nel riquadro di Home Assistant; un lettore senza immagine mantiene la sua icona. Un tocco apre ancora la scheda.",
"cover_needs_firmware": "La copertina sul riquadro richiede il firmware 0.2.78 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."
},
+6 -1
View File
@@ -1241,7 +1241,9 @@
"graph": "grafiek",
"digital": "digitale klok",
"analog": "analoge klok",
"sunpath": "zonnebaan"
"sunpath": "zonnebaan",
"live": "livebeeld",
"cover": "albumhoes"
},
"sizes": {
"single": "normaal",
@@ -1274,6 +1276,9 @@
"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.",
"cover": "Albumhoes",
"cover_hint": "De albumhoes van wat er speelt op de plek van het icoon, zoals de eigen tile van Home Assistant; een speler zonder afbeelding houdt zijn icoon. Een tik opent nog steeds de kaart.",
"cover_needs_firmware": "De albumhoes op de tile heeft firmware 0.2.78 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."
},
+6 -1
View File
@@ -1241,7 +1241,9 @@
"graph": "wykres",
"digital": "zegar cyfrowy",
"analog": "zegar analogowy",
"sunpath": "tor słońca"
"sunpath": "tor słońca",
"live": "obraz na żywo",
"cover": "okładka albumu"
},
"sizes": {
"single": "normalny",
@@ -1274,6 +1276,9 @@
"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.",
"cover": "Okładka albumu",
"cover_hint": "Okładka albumu tego, co gra, w miejscu ikony, jak na kafelku Home Assistant; odtwarzacz bez obrazu zachowuje ikonę. Dotknięcie nadal otwiera kartę.",
"cover_needs_firmware": "Okładka albumu na kafelku wymaga firmware 0.2.78 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."
},
+6 -1
View File
@@ -1241,7 +1241,9 @@
"graph": "gráfico",
"digital": "relógio digital",
"analog": "relógio analógico",
"sunpath": "trajetória do sol"
"sunpath": "trajetória do sol",
"live": "imagem ao vivo",
"cover": "capa do álbum"
},
"sizes": {
"single": "normal",
@@ -1274,6 +1276,9 @@
"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.",
"cover": "Capa do álbum",
"cover_hint": "A capa do álbum do que está a tocar no lugar do ícone, como no próprio mosaico do Home Assistant; um leitor sem imagem mantém o seu ícone. Um toque continua a abrir o cartão.",
"cover_needs_firmware": "A capa do álbum no mosaico precisa do firmware 0.2.78 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."
},
+82
View File
@@ -326,6 +326,13 @@ def fake_ha(image=None):
if image is None:
raise ConnectionError('500')
return image
async def media_image(self, entity):
self.log.append(('fetch', entity))
return picture('PNG', (300, 300))
def media_picture(self, entity):
return '/api/media_player_proxy/%s?token=a&cache=1' % entity if entity == 'media_player.sonos' else ''
return HA()
@@ -459,6 +466,21 @@ class LiveTiles(unittest.TestCase):
self.assertEqual(ha_catalogue.capabilities('camera.front_door', [], {}, {})['displays'], ['standard', 'watch', 'live'])
self.assertNotIn('live', ha_catalogue.capabilities('light.hall', [], {}, {})['displays'])
def test_a_media_tile_may_show_its_cover_in_the_icons_place(self):
from core import COVER_TILE_MIN_FIRMWARE, resolve_controls
import ha_catalogue
self.assertEqual(camera_feed.COVER_TILE_MIN_FIRMWARE, COVER_TILE_MIN_FIRMWARE)
layout = validate_layout({'title': 'Hall', 'tiles': [{'entity': 'media_player.sonos', 'name': '', 'options': {'display': 'cover', 'size': 'wide'}}]})
self.assertEqual(min_firmware(layout), COVER_TILE_MIN_FIRMWARE)
# The cover is the standard layout with a picture: a wide tile keeps its controls.
self.assertEqual(resolve_controls(layout['tiles'][0]), 'volume')
self.assertIn('cover', ha_catalogue.capabilities('media_player.sonos', [], {}, {})['displays'])
with self.assertRaises(ValueError):
validate_layout({'title': 'Hall', 'tiles': [{'entity': 'camera.front_door', 'name': '', 'options': {'display': 'cover'}}]})
# A media player may be asked for in the strip; the request keeps its shape.
self.assertEqual(camera_feed.live_request({'tiles': 'camera.front_door,media_player.sonos', 'size': '54', 'bg': 'FFFFFF,FFFFFF'}),
(['camera.front_door', 'media_player.sonos'], 54, [0xFFFFFF, 0xFFFFFF]))
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):
@@ -515,6 +537,45 @@ class LiveTiles(unittest.TestCase):
self.assertEqual(len([e for e, at in fetched if at > 1030.0]), 0)
asyncio.run(run())
@unittest.skipUnless(HAS_PIL, 'Pillow')
def test_a_media_tile_in_the_strip_is_fetched_once_per_picture(self):
clock = [1000.0]
fetched, pictures = [], {'media_player.sonos': '/api/media_player_proxy/media_player.sonos?token=a&cache=1'}
async def fetch(entity):
fetched.append((entity, clock[0]))
return picture('JPEG', (640, 360))
async def fetch_cover(entity):
fetched.append((entity, clock[0]))
return picture('PNG', (300, 300))
async def run():
feed = camera_feed.CameraFeed(fetch, clock=lambda: clock[0], fetch_cover=fetch_cover, picture=lambda e: pictures.get(e, ''))
tiles, grounds, paces = ['camera.a', 'media_player.sonos', 'media_player.radio'], [0xFFFFFF] * 3, [15, 0, 0]
token = feed.link(','.join(tiles), (54, 54), live=(tiles, 54, grounds, paces))
status, body, etag = await feed.serve(token)
self.assertEqual(status, 200)
from PIL import Image
with Image.open(io.BytesIO(body)) as image:
self.assertEqual(image.size, (54, 162))
self.assertEqual(image.getpixel((27, 54 + 27))[::2], (200, 90), 'the cover in the second square')
self.assertEqual(image.getpixel((27, 108 + 27)), (255, 255, 255), 'a radio without a picture: a plain square')
found = await feed.live(tiles, 54, grounds, paces)
self.assertEqual(found[2], ['camera.a', 'media_player.sonos', ''], 'the answer names the tiles with a picture')
# Every 15 s the camera again, the cover not: its address did not change.
for _ in range(3):
clock[0] += 15
await feed.serve(token)
self.assertEqual(len([e for e, _ in fetched if e == 'media_player.sonos']), 1)
self.assertEqual(len([e for e, _ in fetched if e == 'camera.a']), 4)
# A new track: another address, fetched at the next load.
pictures['media_player.sonos'] = '/api/media_player_proxy/media_player.sonos?token=a&cache=2'
clock[0] += 15
await feed.serve(token)
self.assertEqual(len([e for e, _ in fetched if e == 'media_player.sonos']), 2)
asyncio.run(run())
@unittest.skipUnless(HAS_AIOHTTP and HAS_PIL, 'Run using .venv-portal/bin/python for server tests')
class LiveApp(unittest.IsolatedAsyncioTestCase):
@@ -546,6 +607,27 @@ class LiveApp(unittest.IsolatedAsyncioTestCase):
await m.answer_camera(request)
self.assertEqual([entry for entry in ha.log if entry[0] == 'send'], [])
async def test_a_media_tile_asks_with_the_cameras_of_its_page(self):
with tempfile.TemporaryDirectory() as tmp:
ha = fake_ha(picture('JPEG', (1920, 1080)))
ha.states['sensor.d1_fw']['state'] = '0.2.78'
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': 'media_player.sonos', 'name': '', 'options': {'display': 'cover', 'size': 'wide'}},
{'entity': 'media_player.radio', 'name': '', 'options': {'display': 'cover'}},
{'entity': 'media_player.tv', 'name': ''}]})
with self.assertLogs('screen_manager', 'INFO'):
await m.answer_camera({'inbox': 'text.d1_tiles', 'tiles': 'camera.max,media_player.sonos,media_player.radio', 'size': '54', 'bg': 'FFFFFF,FFFFFF,FADADD'})
(_, inbox, message), = [entry for entry in ha.log if entry[0] == 'send']
self.assertEqual((message['t'], message['e']), ('live', 'camera.max,media_player.sonos,'), 'the radio has no picture')
token = message['u'].rsplit('/', 1)[1][:-4]
self.assertEqual(m.camera.links[token].live[3], [15, 0, 0], 'a cover has no pace')
ha.log.clear()
# A media tile that shows its icon is not in the strip.
await m.answer_camera({'inbox': 'text.d1_tiles', 'tiles': 'camera.max,media_player.tv', 'size': '54', 'bg': 'FFFFFF,FFFFFF'})
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)
+3 -3
View File
@@ -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, thumb: display === 'live' }">{{ glyph(tileIconCp(tile)) }}</span>
<span class="ic mdi" :class="{ lit: isOn, thumb: display === 'live' || display === 'cover' }">{{ 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, thumb: display === 'live' }">{{ glyph(tileIconCp(tile)) }}</span>
<span class="ic mdi" :class="{ lit: isOn, thumb: display === 'live' || display === 'cover' }">{{ 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, thumb: display === 'live' }">{{ glyph(tileIconCp(tile)) }}</span>
<span class="ic mdi" :class="{ lit: isOn, thumb: display === 'live' || display === 'cover' }">{{ glyph(tileIconCp(tile)) }}</span>
<span class="tx">
<span class="nm">{{ name }}</span>
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
+5 -2
View File
@@ -4,7 +4,7 @@ import { computed, toRaw } from "vue";
import { t } from "../i18n";
import { domainInfo, entriesOf, MAX_PAGES, pageCount, pageOf, pageTarget, SLIDER_DOMAINS, TOGGLE_BEFORE } from "../model/layout";
import { glyph } from "../model/topbar";
import { automaticIcon, closeInspector, entityName, fullPage, markDirty, moveTileToPage, removeTile, retargetPageTile, setTileOption, state, supports, tileIconCp } from "../store";
import { automaticIcon, closeInspector, entityName, fullPage, isGuition, markDirty, moveTileToPage, removeTile, retargetPageTile, setTileOption, state, supports, tileIconCp } from "../store";
import type { Tile } from "../types";
import ActionPicker from "./ActionPicker.vue";
import IconPicker from "./IconPicker.vue";
@@ -51,6 +51,8 @@ const displays = computed(() => {
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");
// The album cover on a media tile (app 0.2.92), on a Guition too; the tile over the whole page has the card's big cover.
if (domain.value === "media_player" && (isGuition.value || display.value === "cover") && size.value !== "full") keys.push("cover");
return keys.map((key) => [key, t(`editor.tile.display.${key}`)] as [string, string]);
});
const displayHint = computed(() => {
@@ -58,6 +60,7 @@ const displayHint = computed(() => {
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");
if (display.value === "cover") return t(supports(0, 2, 78) ? "editor.tile.display.cover_hint" : "editor.tile.display.cover_needs_firmware");
return "";
});
const refresh = computed(() => current("refresh", 15) as number);
@@ -128,7 +131,7 @@ 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: display !== 'live' || !supports(0, 2, 77) }">{{ displayHint }}</small>
<small v-if="displayHint" :class="{ warn: !(display === 'live' && supports(0, 2, 77)) && !(display === 'cover' && supports(0, 2, 78)) }">{{ displayHint }}</small>
</div>
<div v-if="display === 'live'" class="f">
<span class="f-label">{{ t("editor.tile.refresh.label") }}</span>
+2 -2
View File
@@ -149,7 +149,7 @@ export const newTile = (id: string): Tile => ({ entity: id, name: "", slot: -1,
// without a choice the domain's first control set applies to a wide card, none to a full one.
export function effectiveControls(tile: Tile, inventory: Inventory): string | null {
const domain = tile.entity.split(".")[0], catalogue = inventory.controls?.[domain], o = tile.options || {};
if (!catalogue || !["wide", "full"].includes(o.size as string) || (o.display || "standard") !== "standard" || o.inline === "slider") return null;
if (!catalogue || !["wide", "full"].includes(o.size as string) || !["standard", "cover"].includes((o.display || "standard") as string) || o.inline === "slider") return null;
const choice = o.controls ?? (o.size === "full" ? "none" : catalogue.default);
return choice === "none" ? null : choice;
}
@@ -177,7 +177,7 @@ export function tileLimit(firmware: string | undefined | null) {
return versionAtLeast(firmware, "0.2.62") ? MAX_TILES : versionAtLeast(firmware, "0.2.7") ? 20 : 10;
}
// What a tile shows and how big it is, in a few words (editor.displays, editor.sizes); a key it doesn't know stays as it is.
export const DISPLAYS = ["standard", "watch", "forecast", "graph", "digital", "analog", "sunpath"];
export const DISPLAYS = ["standard", "watch", "forecast", "graph", "digital", "analog", "sunpath", "live", "cover"];
export const displayName = (display: string) => (DISPLAYS.includes(display) ? t(`editor.displays.${display}`) : display);
export const sizeName = (size: string | undefined) => t(`editor.sizes.${SIZES.includes(size as Size) ? size : "single"}`);
export const TOGGLE_BEFORE = ["light", "switch", "input_boolean", "fan", "media_player", "climate"];
+25
View File
@@ -213,6 +213,31 @@ describe("TileInspector: a live picture on a camera tile (app 0.2.91)", () => {
const card = mount(TileCard, { props: { tile, slot: 0 } });
expect(card.find(".ic").classes()).toContain("thumb");
});
it("offers the album cover for a media player on a Guition, not on a full-page tile, and keeps its controls", async () => {
Object.assign(state.inventory.screens[0], { firmware: "0.2.78" });
state.inventory.entities.push({ id: "media_player.sonos", name: "Sonos", state: "playing", area: "Hall" } as any);
(state.inventory as any).controls.media_player = { default: "volume", choices: [{ key: "volume", label: "Volume" }, { key: "none", label: "None" }] };
const tile: Tile = { entity: "media_player.sonos", name: "", slot: 0, options: { size: "wide" } };
state.layout!.tiles.push(tile);
const drawer = mount(TileInspector, { props: { tile } });
expect(choices(drawer, "Display")).toEqual(["Name and status", "Large value", "Album cover"]);
await row(drawer, "Display").findAll(".seg button")[2].trigger("click");
expect(tile.options).toEqual({ size: "wide", display: "cover" });
expect(row(drawer, "Display").find("small").text()).toMatch(/icon's place/);
expect(row(drawer, "Refresh")).toBeUndefined();
expect(row(drawer, "Direct control on the tile")).toBeDefined();
const card = mount(TileCard, { props: { tile, slot: 0 } });
expect(card.find(".ic").classes()).toContain("thumb");
expect(card.find(".range").exists()).toBe(true);
// A CYD gets no such choice; a tile over the whole page keeps the card's big cover.
Object.assign(state.inventory.screens[0], { board: "cyd" });
tile.options = { size: "single" };
await drawer.vm.$nextTick();
expect(choices(mount(TileInspector, { props: { tile } }), "Display")).toEqual(["Name and status", "Large value"]);
Object.assign(state.inventory.screens[0], { board: "guition" });
tile.options = { size: "full" };
expect(choices(mount(TileInspector, { props: { tile } }), "Display")).toEqual(["Name and status", "Large value"]);
});
});
describe("TileInspector: pages (app 0.2.78)", () => {