9857cc3bda
A board file for the Elecrow CrowPanel 7.0" (DIS08070H, ESP32-S3-WROOM-1-N4R8): 800x480 through mipi_rgb, GT911 on the ESP32-S3's USB pins (elecrow_usb_phy.h frees them, behind a flag only this board sets), the Guition's vertical sizes across a wider page, no rotation. Its 4 MB of flash take the CYD's budget: a partition table of the component's (smart_display: partitions: 4mb_wide_slots, 1,984 KB slots), the icon fonts at 2 bits a pixel and the TLS client only bring the firmware to 1,865,440 bytes, 91.8 % of a slot, with camera images kept. docs/ELECROW.md has the numbers and the trade. The add-on reads a large board's name from the type sensor's value, the editor offers the board under New screen and draws the wide preview, tools/check.sh builds it with the budget, render_topbar and capture_ui take the panel's size. Firmware 0.2.79; the CYD image is unchanged (1,633,760 bytes). This fork builds from gittea.trippedonmy.name/Xandrossian/homeassistant_espscreen: the packages, the fonts, the component and the add-on repository point there instead of GitHub.
554 lines
28 KiB
Python
554 lines
28 KiB
Python
"""Camera images on a Guition screen (app 0.2.66, firmware 0.2.57), and on an Elecrow CrowPanel 7.0" (app 0.2.93).
|
|
|
|
A screen never asks Home Assistant for a camera itself. This app fetches the snapshot with its own token (a
|
|
camera's access token changes every five minutes), makes it exactly as large as the screen shows it and serves
|
|
it as an uncompressed 24-bit BMP on its own port on the LAN, where the screen's ESPHome `online_image` loads it.
|
|
ESPHome decodes a BMP piece by piece while it downloads; a JPEG of the full screen held the Guition's main loop for
|
|
0.6 s (measured 2026-09-17), long enough to lose a tap. On the LAN the larger file costs nothing that matters.
|
|
|
|
Links are random and short-lived, and only a camera on the screen's layout or in a recent alert gets one. A screen
|
|
that loads a link gets the last snapshot at once, however long the camera takes to answer (ESPHome's HTTP client waits
|
|
in the screen's main loop, where touch and drawing wait with it), and the load starts fetching the next snapshot.
|
|
"""
|
|
import asyncio
|
|
from email.utils import formatdate
|
|
import hashlib
|
|
import io
|
|
import logging
|
|
import os
|
|
import re
|
|
import secrets
|
|
import time
|
|
|
|
from core import screen_firmware
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
DOMAINS = ('camera', 'image')
|
|
MIN_FIRMWARE = (0, 2, 57)
|
|
# Album covers on the media card (app 0.2.77, firmware 0.2.64): the same port serves a media player's picture, square,
|
|
# at the size the screen asks for, with the rounded corners baked in over the colour the screen names (the card's
|
|
# page or the tile's own colour), so the screen draws it as it is. A cover is fetched when the screen loads its link
|
|
# and again only when Home Assistant's picture changes; the screen asks for a new link then.
|
|
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)
|
|
# 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
|
|
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
|
|
# A link to a live camera nobody used for this long is forgotten; the screen asks for a new one.
|
|
LINK_SECONDS = 120
|
|
# An alert's still stays loadable this long, and its camera may be opened full screen that long.
|
|
STILL_SECONDS = 1800
|
|
FIRST_FRAME_SECONDS = 8
|
|
FETCH_SECONDS = 15
|
|
MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024
|
|
MAX_LINKS = 64
|
|
# What the screens load (online_image `format: BMP`).
|
|
CONTENT_TYPE = 'image/bmp'
|
|
# The pixel box per board and view; the image keeps its proportions inside it. Equal to the CAMERA_*
|
|
# substitutions of the board's file under packages/boards/ (tests/test_camera.py).
|
|
BOXES = {'guition': {'full': (480, 480), 'thumb': (392, 220)}, 'elecrow': {'full': (800, 480), 'thumb': (532, 220)}}
|
|
|
|
|
|
def supported(entity):
|
|
"""True for a camera or image entity id."""
|
|
return (isinstance(entity, str) and len(entity) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', entity) is not None
|
|
and entity.split('.')[0] in DOMAINS)
|
|
|
|
|
|
def can_show(screen):
|
|
"""A paired Guition with firmware that draws camera images (the version feature gates go by, core.screen_firmware)."""
|
|
return bool(screen) and screen.get('board') in BOXES and (screen_firmware(screen) or (0, 0, 0)) >= MIN_FIRMWARE
|
|
|
|
|
|
def cover_supported(entity):
|
|
"""True for a media player entity id: its cover can be served."""
|
|
return (isinstance(entity, str) and len(entity) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', entity) is not None
|
|
and entity.split('.')[0] in COVER_DOMAINS)
|
|
|
|
|
|
def can_show_cover(screen):
|
|
"""A paired Guition with firmware that draws the media card's cover."""
|
|
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) 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]
|
|
|
|
|
|
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."""
|
|
try:
|
|
size = int(request.get('size'))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
background = request.get('bg')
|
|
if not COVER_SIZES[0] <= size <= COVER_SIZES[1] or not isinstance(background, str) or not re.fullmatch(r'[0-9A-Fa-f]{6}', background):
|
|
return None
|
|
return size, int(background, 16)
|
|
|
|
|
|
def fit(size, box):
|
|
"""The largest size with the proportions of `size` that fits `box`, at least one pixel each way."""
|
|
width, height = size
|
|
scale = min(box[0] / width, box[1] / height)
|
|
return max(1, min(box[0], round(width * scale))), max(1, min(box[1], round(height * scale)))
|
|
|
|
|
|
def encode(raw, box):
|
|
"""Any snapshot Home Assistant hands out (JPEG, PNG, GIF, WebP) as a 24-bit BMP that fits `box`, the size of the
|
|
screen's buffer."""
|
|
from PIL import Image, ImageOps
|
|
with Image.open(io.BytesIO(raw)) as source:
|
|
# A JPEG decodes at a half, quarter or eighth of its size while that is still larger than the box.
|
|
source.draft('RGB', box)
|
|
image = ImageOps.exif_transpose(source)
|
|
if image.mode in ('RGBA', 'LA', 'P', 'PA'):
|
|
image = image.convert('RGBA')
|
|
ground = Image.new('RGB', image.size)
|
|
ground.paste(image, mask=image.getchannel('A'))
|
|
image = ground
|
|
elif image.mode != 'RGB':
|
|
image = image.convert('RGB')
|
|
size = fit(image.size, box)
|
|
if image.size != size:
|
|
image = image.resize(size, Image.Resampling.LANCZOS)
|
|
out = io.BytesIO()
|
|
image.save(out, 'BMP')
|
|
return out.getvalue()
|
|
|
|
|
|
def encode_cover(raw, size, background):
|
|
"""A media player's picture as a square 24-bit BMP of `size` pixels with rounded corners, the corners filled with
|
|
`background` (0xRRGGBB): what the screen draws on its card without any work of its own. A picture that is not
|
|
square is cut to its middle."""
|
|
from PIL import Image, ImageDraw, ImageOps
|
|
with Image.open(io.BytesIO(raw)) as source:
|
|
source.draft('RGB', (size, size))
|
|
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'))
|
|
image = ground
|
|
elif image.mode != 'RGB':
|
|
image = image.convert('RGB')
|
|
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 // COVER_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)
|
|
out = io.BytesIO()
|
|
ground.save(out, 'BMP')
|
|
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. `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
|
|
self.raw, self.digest = None, ''
|
|
self.picture, self.fetching = None, None
|
|
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', 'live')
|
|
|
|
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
|
|
|
|
|
|
class CameraFeed:
|
|
def __init__(self, fetch, clock=time.monotonic, fetch_cover=None, picture=None):
|
|
"""`fetch(entity)` gives a camera's snapshot; `fetch_cover(entity)` a media player's picture and
|
|
`picture(entity)` the address that picture has in its state right now ('' without one)."""
|
|
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
|
|
# picture exactly one load younger and the picture changes in the screen's own steady rhythm. A fetch that ran on
|
|
# its own clock next to the screen's made the picture change after 1.5 s one time and after 6 s the next (measured
|
|
# 2026-09-17 with an EZVIZ camera that takes a steady 2.4 s per snapshot). Nobody loading means nothing fetched.
|
|
def watch(self, entity):
|
|
now = self.clock()
|
|
for old in [e for e, w in self.watches.items() if now - w.used > WATCH_SECONDS and (w.task is None or w.task.done())]:
|
|
del self.watches[old]
|
|
watch = self.watches.get(entity)
|
|
if watch is None:
|
|
watch = self.watches[entity] = Watch(now)
|
|
watch.used = now
|
|
return watch
|
|
|
|
def refresh(self, entity, watch):
|
|
"""Start fetching the next snapshot, unless one is on its way or the camera failed a moment ago."""
|
|
if (watch.task is None or watch.task.done()) and self.clock() >= watch.retry_at:
|
|
watch.fetched_at = self.clock()
|
|
watch.task = asyncio.ensure_future(self.fetch_one(entity, watch))
|
|
|
|
async def fetch_one(self, entity, watch):
|
|
try:
|
|
raw = await asyncio.wait_for(self.fetch(entity), FETCH_SECONDS)
|
|
if not raw:
|
|
raise ValueError('empty image')
|
|
digest = hashlib.sha1(raw).hexdigest()
|
|
if digest != watch.digest:
|
|
watch.raw, watch.digest, watch.frames = raw, digest, {}
|
|
if watch.failures:
|
|
LOG.info('Camera %s answers again', entity)
|
|
watch.failures = 0
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as error:
|
|
watch.failures += 1
|
|
# A camera that fails is asked again after a pause, up to half a minute.
|
|
watch.retry_at = self.clock() + min(30, 5 * watch.failures)
|
|
if watch.failures in (1, 30):
|
|
LOG.info('No image from %s (%s)', entity, type(error).__name__)
|
|
|
|
async def frame(self, entity, box, wait=FIRST_FRAME_SECONDS, fresh=True, now=False):
|
|
"""(etag, BMP) of the camera's last snapshot at `box`, or None when it has none (yet). The first snapshot is
|
|
waited for; `fresh` starts fetching the next one for the next load. `now` (an alert) waits for a snapshot whose
|
|
fetch starts now or is already on its way, never one kept from an earlier load."""
|
|
watch = self.watch(entity)
|
|
if now:
|
|
if watch.task is None or watch.task.done():
|
|
watch.retry_at = 0.0
|
|
self.refresh(entity, watch)
|
|
try:
|
|
await asyncio.wait_for(asyncio.shield(watch.task), wait)
|
|
except asyncio.TimeoutError:
|
|
return None
|
|
if watch.failures:
|
|
return None
|
|
elif watch.raw is None:
|
|
self.refresh(entity, watch)
|
|
if watch.task is not None and not watch.task.done():
|
|
try:
|
|
await asyncio.wait_for(asyncio.shield(watch.task), wait)
|
|
except asyncio.TimeoutError:
|
|
return None
|
|
raw, digest = watch.raw, watch.digest
|
|
if raw is None:
|
|
return None
|
|
if fresh:
|
|
self.refresh(entity, watch)
|
|
cached = watch.frames.get(box)
|
|
if cached is None or cached[0] != digest:
|
|
try:
|
|
image = await asyncio.get_running_loop().run_in_executor(None, encode, raw, box)
|
|
except Exception as error:
|
|
LOG.info('The image of %s cannot be read (%s)', entity, type(error).__name__)
|
|
return None
|
|
cached = watch.frames[box] = (digest, image)
|
|
return f'"{digest[:16]}-{box[0]}x{box[1]}"', cached[1]
|
|
|
|
# ----- 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):
|
|
if cover_supported(entity):
|
|
return await self.cover_raw(entity, 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
|
|
# screen loads again only when the state's picture mark changed. So a load waits for the picture of this moment.
|
|
async def fetch_cover_one(self, entity, watch, picture):
|
|
try:
|
|
raw = await asyncio.wait_for(self.fetch_cover(entity), FETCH_SECONDS)
|
|
if not raw:
|
|
raise ValueError('empty picture')
|
|
digest = hashlib.sha1(raw).hexdigest()
|
|
if digest != watch.digest:
|
|
watch.raw, watch.digest, watch.frames = raw, digest, {}
|
|
watch.picture = picture
|
|
if watch.failures:
|
|
LOG.info('The cover of %s comes again', entity)
|
|
watch.failures = 0
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as error:
|
|
watch.failures += 1
|
|
if watch.failures in (1, 30):
|
|
LOG.info('No cover from %s (%s)', entity, type(error).__name__)
|
|
|
|
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)
|
|
if not picture:
|
|
return None
|
|
watch = self.watch(entity)
|
|
if watch.picture != picture or watch.raw is None:
|
|
if watch.task is None or watch.task.done() or watch.fetching != picture:
|
|
if watch.task is not None and not watch.task.done():
|
|
watch.task.cancel()
|
|
watch.fetching = picture
|
|
watch.task = asyncio.ensure_future(self.fetch_cover_one(entity, watch, picture))
|
|
try:
|
|
await asyncio.wait_for(asyncio.shield(watch.task), wait)
|
|
except asyncio.TimeoutError:
|
|
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:
|
|
try:
|
|
image = await asyncio.get_running_loop().run_in_executor(None, encode_cover, watch.raw, size, background)
|
|
except Exception as error:
|
|
LOG.info('The cover of %s cannot be read (%s)', entity, type(error).__name__)
|
|
return None
|
|
cached = watch.frames[key] = (watch.digest, image)
|
|
return f'"{watch.digest[:16]}-c{size}-{background:06X}"', cached[1]
|
|
|
|
# ----- links -----
|
|
def prune(self):
|
|
now = self.clock()
|
|
for token in [token for token, link in self.links.items() if now - link.used > link.lifetime]:
|
|
del self.links[token]
|
|
while len(self.links) >= MAX_LINKS:
|
|
del self.links[min(self.links, key=lambda token: self.links[token].used)]
|
|
|
|
def link(self, entity, box, still=None, 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, `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, live)
|
|
return token
|
|
|
|
async def serve(self, token, etag=None):
|
|
"""(HTTP status, BMP or None, etag) for one request of a screen."""
|
|
link = self.links.get(token)
|
|
now = self.clock()
|
|
if link is None or now - link.used > link.lifetime:
|
|
self.links.pop(token, None)
|
|
return 404, None, ''
|
|
link.used = now
|
|
if link.still:
|
|
return (304, None, link.etag) if etag == link.etag else (200, link.still, link.etag)
|
|
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, ''
|
|
tag, image = found
|
|
return (304, None, tag) if etag == tag else (200, image, tag)
|
|
|
|
|
|
def web_app(feed):
|
|
"""The camera port: GET /camera/<token>.bmp and nothing else, open to the LAN like the screens are."""
|
|
from aiohttp import web
|
|
|
|
async def image(request):
|
|
status, body, etag = await feed.serve(request.match_info['token'], request.headers.get('If-None-Match'))
|
|
# 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:
|
|
return web.Response(status=304, headers=headers)
|
|
return web.Response(status=status, text='No image' if status == 503 else 'Unknown link', headers=headers)
|
|
|
|
app = web.Application(client_max_size=1024)
|
|
app.router.add_get(r'/camera/{token:[A-Za-z0-9_-]{16,64}}.bmp', image)
|
|
return app
|
|
|
|
|
|
def port():
|
|
try:
|
|
value = int(os.environ.get('SCREEN_CAMERA_PORT', PORT))
|
|
except ValueError:
|
|
return PORT
|
|
return value if 0 < value < 65536 else PORT
|
|
|
|
|
|
async def base_url(request, cache={}):
|
|
"""http://<address>:<port> where screens reach this app: SCREEN_CAMERA_URL when set, else Home Assistant's
|
|
own LAN address (the add-on's port is published on the host). `request` is HomeAssistant.request."""
|
|
override = os.environ.get('SCREEN_CAMERA_URL', '').rstrip('/')
|
|
if override:
|
|
return override
|
|
now = time.monotonic()
|
|
if cache.get('url') and now - cache.get('at', 0) < 600:
|
|
return cache['url']
|
|
host = None
|
|
try:
|
|
network = await request('network')
|
|
for adapter in (network or {}).get('adapters', []):
|
|
addresses = [item.get('address') for item in adapter.get('ipv4') or [] if item.get('address')]
|
|
if adapter.get('default') and addresses:
|
|
host = addresses[0]
|
|
break
|
|
except Exception as error:
|
|
LOG.info('Reading the network adapters failed (%s)', type(error).__name__)
|
|
if host is None:
|
|
try:
|
|
from urllib.parse import urlparse
|
|
host = urlparse((await request('network/url') or {}).get('internal') or '').hostname
|
|
except Exception as error:
|
|
LOG.info('Reading the internal URL failed (%s)', type(error).__name__)
|
|
if not host:
|
|
return None
|
|
cache.update(url=f'http://{host}:{port()}', at=now)
|
|
return cache['url']
|