Files
homeassistant_espscreen/screen_manager/app/camera_feed.py
T
Xandrossian 9857cc3bda
CI / checks (push) Successful in 12m50s
CI / firmware (add-on) (push) Successful in 18m19s
CI / firmware (min_version) (push) Successful in 17m55s
Add the Elecrow CrowPanel 7.0" as a third board, and take the packages and the add-on from Gitea (0.2.93)
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.
2026-09-20 23:18:01 -07:00

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']