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:
Max Gramser
2026-09-20 12:47:44 +02:00
parent 8a81ff4d93
commit 306636434b
55 changed files with 1133 additions and 126 deletions
+143 -7
View File
@@ -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: