Show who is at the door: camera images on the Guition (0.2.66)
An alert from esp_screens_show_alert takes a `camera` (a camera.* or image.* entity): a Guition with firmware 0.2.57 shows that moment's picture across the top of the card, and a tap opens the camera full screen. A camera or image tile opens the same full-screen view, refreshed every four seconds at a steady pace. ESP Screen Manager fetches the snapshot with its own access, sizes it for the screen and serves a 24-bit BMP under a random, short-lived link on port 8098; the screen loads it with ESPHome's online_image and holds no Home Assistant token. BMP because ESPHome decodes it per chunk, where a full-screen JPEG held the loop 0.6 s. The CYD keeps its alerts without the picture and gets no camera tiles. The Guition also builds with ESPHome 2026.8+ again: backlight_fade.h derived from ESPHome's LEDC output, which 2026.8 made final, so the backlight now dims through ESPHome's own light transition. Tested on the bench boards and a local add-on on the Home Assistant Yellow; docs/TEST_RESULTS_0266.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
"""Camera images on a Guition screen (app 0.2.66, firmware 0.2.57).
|
||||
|
||||
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
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
DOMAINS = ('camera', 'image')
|
||||
MIN_FIRMWARE = (0, 2, 57)
|
||||
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 packages/guition.yaml (tests/test_camera.py).
|
||||
BOXES = {'guition': {'full': (480, 480), 'thumb': (392, 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 version(text):
|
||||
try:
|
||||
parts = tuple(int(part) for part in str(text or '').split('.'))
|
||||
except ValueError:
|
||||
return ()
|
||||
return parts if len(parts) == 3 else ()
|
||||
|
||||
|
||||
def can_show(screen):
|
||||
"""A paired Guition with firmware that draws camera images."""
|
||||
return bool(screen) and screen.get('board') in BOXES and version(screen.get('firmware')) >= MIN_FIRMWARE
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class Watch:
|
||||
"""One camera: its last snapshot, the sizes made of it and the fetch on its way."""
|
||||
|
||||
def __init__(self, now):
|
||||
self.used = now
|
||||
self.raw, self.digest = None, ''
|
||||
self.frames = {}
|
||||
self.failures = 0
|
||||
self.retry_at = 0.0
|
||||
self.task = None
|
||||
|
||||
|
||||
class Link:
|
||||
__slots__ = ('entity', 'box', 'still', 'etag', 'used', 'lifetime')
|
||||
|
||||
def __init__(self, entity, box, now, still=None):
|
||||
self.entity, self.box, self.used = entity, box, now
|
||||
self.still = still
|
||||
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):
|
||||
self.fetch, self.clock = fetch, clock
|
||||
self.watches, self.links = {}, {}
|
||||
|
||||
# ----- 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.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]
|
||||
|
||||
# ----- 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):
|
||||
"""A new random token for one camera at one size; `still` makes it one fixed image (an alert's)."""
|
||||
self.prune()
|
||||
token = secrets.token_urlsafe(18)
|
||||
self.links[token] = Link(entity, box, self.clock(), still)
|
||||
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)
|
||||
found = 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'))
|
||||
headers = {'Cache-Control': 'no-cache', **({'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']
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
import tile_icons
|
||||
from core import (ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK_ICON, ALERT_FIELDS, ALERT_LIMITS, ALERT_MAX_TIMEOUT,
|
||||
from core import (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, MAX_PAGES, SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS,
|
||||
TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE, SETTING_ENTITIES_MIN_FIRMWARE,
|
||||
@@ -181,6 +181,8 @@ actions:
|
||||
|---|---|---|---|
|
||||
{fields}
|
||||
|
||||
The event for every screen takes one more field, `{ALERT_CAMERA_FIELD[0]}`: {ALERT_CAMERA_FIELD[2]} Example: `{ALERT_CAMERA_FIELD[0]}: {ALERT_CAMERA_FIELD[3]}`. Use a real `camera.*` or `image.*` entity from this Home Assistant (a doorbell integration usually has one); the per-screen actions have no such field.
|
||||
|
||||
Text limits are in bytes; an accented letter takes two. Keep the title short: a screen shows about twenty characters of it on one line and ends a longer title with an ellipsis. Put details in the subtitle.
|
||||
|
||||
## Colors
|
||||
|
||||
@@ -9,15 +9,18 @@ import secrets
|
||||
|
||||
import tile_icons
|
||||
|
||||
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen'.split())
|
||||
DOMAINS = frozenset('light switch input_boolean scene script climate vacuum fan cover sensor binary_sensor input_select select number input_number weather media_player button input_button sun timer person screen camera image'.split())
|
||||
# Built-in cards without a Home Assistant entity; firmware 0.2.14+ renders them.
|
||||
BUILTIN = {'screen.clock': 'Clock', 'screen.settings': 'Settings'}
|
||||
NEW_DOMAINS = frozenset('sun timer person screen'.split())
|
||||
# A camera or an image entity opens full screen on a Guition with firmware 0.2.57+ (camera_feed.py).
|
||||
CAMERA_DOMAINS = frozenset(('camera', 'image'))
|
||||
CAMERA_MIN_FIRMWARE = (0, 2, 57)
|
||||
WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
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.56'
|
||||
FIRMWARE_VERSION = '0.2.57'
|
||||
# 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.
|
||||
@@ -308,7 +311,7 @@ HEADER_SHOWS = ('always', 'active')
|
||||
|
||||
def header_entity(value):
|
||||
return (isinstance(value, str) and len(value) <= 120 and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value) is not None
|
||||
and value.split('.')[0] in (DOMAINS - {'screen'}) | HEADER_ONLY_DOMAINS)
|
||||
and value.split('.')[0] in (DOMAINS - {'screen'} - CAMERA_DOMAINS) | HEADER_ONLY_DOMAINS)
|
||||
|
||||
def header_items(layout):
|
||||
"""Items the top bar shows: the stored ones, else what firmware before the top bar drew (the clock)."""
|
||||
@@ -350,6 +353,8 @@ def validate_header(data):
|
||||
|
||||
def min_firmware(layout):
|
||||
"""Oldest firmware that still accepts this layout; None when any version works."""
|
||||
if any(t['entity'].split('.')[0] in CAMERA_DOMAINS for t in layout['tiles']):
|
||||
return CAMERA_MIN_FIRMWARE
|
||||
if any(t['entity'] == 'screen.settings' for t in layout['tiles']):
|
||||
return (0, 2, 44)
|
||||
if any(t.get('options', {}).get('background') == 'none' for t in layout['tiles']):
|
||||
@@ -865,8 +870,9 @@ def extras(tile, states, forecast=None, tz=None, hourly=None, now=None, device=N
|
||||
if hours:
|
||||
result['hours'] = hours
|
||||
return result or None
|
||||
if domain in ('scene', 'script', 'button', 'input_button'):
|
||||
# When it last ran: scripts report last_triggered; scenes and buttons carry the time as their state.
|
||||
if domain in ('scene', 'script', 'button', 'input_button', 'image'):
|
||||
# When it last ran: scripts report last_triggered; scenes and buttons carry the time as their state, and so does
|
||||
# an image entity (when its picture last changed).
|
||||
last = epoch(attrs.get('last_triggered') if domain == 'script' else states.get(tile['entity'], {}).get('state'))
|
||||
return {'last': last} if last else None
|
||||
if domain == 'sun':
|
||||
@@ -981,6 +987,9 @@ ALERT_LIMITS = {'cyd': {'title': 48, 'subtitle': 160, 'button_text': 12}, 'guiti
|
||||
ALERT_SUGGESTED_ICONS = ('doorbell', 'bell', 'bell-ring', 'alert-outline', 'alarm-light', 'lock', 'lock-open-variant', 'door-open',
|
||||
'window-closed-variant', 'motion-sensor', 'cctv', 'smoke-detector', 'water-alert', 'fire', 'mailbox', 'car',
|
||||
'account', 'account-group', 'washing-machine', 'robot-vacuum', 'timer-outline', 'check')
|
||||
# Not an argument of show_alert: the app sends the image itself to screens that can draw it (app 0.2.66, a Guition with
|
||||
# firmware 0.2.57+) and leaves it out for the others. (name, label, explanation, example) like ALERT_FIELDS.
|
||||
ALERT_CAMERA_FIELD = ('camera', 'Camera', 'A camera or image entity. A Guition with firmware 0.2.57+ shows its picture of that moment across the top of the card; a tap on it opens the camera full screen. Other screens show the alert without it. Only through the esp_screens_show_alert event.', 'camera.front_door')
|
||||
# The firmware's MAX_TIMEOUT_SECONDS.
|
||||
ALERT_MAX_TIMEOUT = 86400
|
||||
# One alert for every screen (app 0.2.45): an automation fires one of these Home Assistant events and the
|
||||
@@ -999,6 +1008,7 @@ def alert_reference():
|
||||
'endings': [{'action': action, 'label': label} for action, label in ALERT_ENDINGS],
|
||||
'fallback_icon': ALERT_FALLBACK_ICON, 'fallback_cp': tile_icons.GLYPHS[ALERT_FALLBACK_ICON],
|
||||
'fields': [{'name': name, 'type': kind, 'label': label, 'help': help_, 'example': example} for name, kind, label, help_, example in ALERT_FIELDS],
|
||||
'camera': dict(zip(('name', 'label', 'help', 'example'), ALERT_CAMERA_FIELD)),
|
||||
'limits': ALERT_LIMITS,
|
||||
'colors': [{'name': name, 'label': item['label'], 'color': item['color']} for name, item in TILE_BACKGROUNDS.items() if item['color']],
|
||||
'suggested_icons': [{'name': name, 'cp': tile_icons.GLYPHS[name]} for name in ALERT_SUGGESTED_ICONS],
|
||||
@@ -1050,6 +1060,14 @@ def alert_data(data):
|
||||
unusable.append(name)
|
||||
return service, unusable
|
||||
|
||||
def alert_camera(data):
|
||||
"""(entity, usable): the alert's `camera` field when it names a camera or image entity; ('', True) without one."""
|
||||
value = data.get(ALERT_CAMERA_FIELD[0]) if isinstance(data, dict) else None
|
||||
if value is None or value == '':
|
||||
return '', True
|
||||
usable = isinstance(value, str) and re.fullmatch(r'[a-z0-9_]+\.[a-z0-9_]+', value.strip()) is not None and value.strip().split('.')[0] in CAMERA_DOMAINS
|
||||
return (value.strip(), True) if usable else ('', False)
|
||||
|
||||
def alert_targets(screens):
|
||||
"""(ready, skipped): the paired screens that can show an alert now, and the others with the reason.
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import math
|
||||
import camera_feed
|
||||
import claude_skill
|
||||
from firmware import Firmware
|
||||
import tile_icons
|
||||
from updates import Updater
|
||||
|
||||
from aiohttp import ClientError, ClientSession, ClientTimeout, WSMsgType, web
|
||||
from core import BROADCAST_EVENTS, BUILTIN, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import BROADCAST_EVENTS, BROADCAST_SHOW, BUILTIN, CAMERA_DOMAINS, SETTINGS_BESIDE_BLOCK, TILE_EVENTS, TILE_RESULT_EVENT, apply_tile_event, layout_snapshot, match_screen, HEADER_MIN_FIRMWARE, NAME_TILE_SETTINGS, TILE_BACKGROUNDS, TRANSPORT_MIN_FIRMWARE, alert_camera, alert_data, alert_reference, alert_service, alert_targets, controls_catalogue, device_prefixes, discover, discover_screens, encode, extras, forecast_kinds, header_items, inbox_prefix, message_action, min_firmware, pack_slots, packets, revision, screen_items, state_message, validate_header, validate_layout, validate_settings
|
||||
from core import SETTING_ENTITIES, SETTING_RULES, setting_action, setting_entities, setting_from_state
|
||||
import header_bar
|
||||
import history_card
|
||||
@@ -107,6 +108,8 @@ class HomeAssistant:
|
||||
self.services_changed = asyncio.Event()
|
||||
# History a detail card asks for when it opens (firmware 0.2.51+): {inbox, entity, hours}, for Manager.card_history_loop.
|
||||
self.history_requests = asyncio.Queue()
|
||||
# A camera a screen opens full screen (firmware 0.2.57+): {inbox, entity}, for Manager.camera_loop.
|
||||
self.camera_requests = asyncio.Queue()
|
||||
|
||||
async def request(self, kind, **data):
|
||||
if self.ws is None or self.ws.closed:
|
||||
@@ -142,6 +145,8 @@ class HomeAssistant:
|
||||
self.changed.set()
|
||||
elif event.get('event_type') == 'esphome.screen_history':
|
||||
self.history_requests.put_nowait(body)
|
||||
elif event.get('event_type') == 'esphome.screen_camera':
|
||||
self.camera_requests.put_nowait(body)
|
||||
elif event.get('event_type') == 'state_changed':
|
||||
eid = body.get('entity_id')
|
||||
if body.get('new_state'):
|
||||
@@ -205,6 +210,7 @@ class HomeAssistant:
|
||||
await self.request('subscribe_events', event_type='state_changed')
|
||||
await self.request('subscribe_events', event_type='esphome.screen_setting')
|
||||
await self.request('subscribe_events', event_type='esphome.screen_history')
|
||||
await self.request('subscribe_events', event_type='esphome.screen_camera')
|
||||
for event_type in (*REGISTRY_EVENTS, *BROADCAST_EVENTS, *TILE_EVENTS, *SERVICE_EVENTS):
|
||||
await self.request('subscribe_events', event_type=event_type)
|
||||
self.states = {s['entity_id']: s for s in await self.request('get_states')}
|
||||
@@ -287,6 +293,21 @@ class HomeAssistant:
|
||||
domain, service = action.split('.', 1)
|
||||
await self.request('call_service', domain=domain, service=service, service_data=data)
|
||||
|
||||
async def camera_image(self, entity):
|
||||
"""The picture of a camera or image entity as Home Assistant hands it to its own frontend."""
|
||||
path = 'camera_proxy' if entity.startswith('camera.') else 'image_proxy'
|
||||
async with self.session.get(f'{self.base}/{path}/{entity}', headers={'Authorization': 'Bearer ' + self.token},
|
||||
timeout=ClientTimeout(total=camera_feed.FETCH_SECONDS)) as response:
|
||||
response.raise_for_status()
|
||||
if (response.content_length or 0) > camera_feed.MAX_SNAPSHOT_BYTES:
|
||||
raise ValueError('image too large')
|
||||
raw = bytearray()
|
||||
async for chunk in response.content.iter_chunked(65536):
|
||||
raw += chunk
|
||||
if len(raw) > camera_feed.MAX_SNAPSHOT_BYTES:
|
||||
raise ValueError('image too large')
|
||||
return bytes(raw)
|
||||
|
||||
async def fire(self, event_type, data):
|
||||
"""One Home Assistant event of our own, such as the answer to a tile event."""
|
||||
await self.request('fire_event', event_type=event_type, event_data=data)
|
||||
@@ -395,6 +416,10 @@ class Manager:
|
||||
self.retry_at, self.retries, self.no_answers = {}, {}, set()
|
||||
# History for detail cards (firmware 0.2.51+): (entity, hours) -> (monotonic, message), and the fetches under way.
|
||||
self.card_histories, self.card_history_fetches = {}, {}
|
||||
# Camera images (firmware 0.2.57+): the feed behind the camera port, and the cameras of recent alerts
|
||||
# (entity -> monotonic time) that a screen may open full screen without a tile.
|
||||
self.camera = camera_feed.CameraFeed(lambda entity: self.ha.camera_image(entity))
|
||||
self.alert_cameras = {}
|
||||
if self.path.exists():
|
||||
raw = json.loads(self.path.read_text())
|
||||
# Versioned persistent data. Never silently overwrite an unknown schema.
|
||||
@@ -794,6 +819,9 @@ class Manager:
|
||||
if screen is None:
|
||||
raise ValueError("This isn't a paired ESP screen. Refresh the overview.")
|
||||
layout = validate_layout(data)
|
||||
# A CYD has no memory for camera images, whatever its firmware; say so before asking for an update.
|
||||
if any(t['entity'].split('.')[0] in CAMERA_DOMAINS for t in layout['tiles']) and screen.get('board') not in camera_feed.BOXES:
|
||||
raise ValueError('Camera images need a Guition screen.')
|
||||
needed = self.needs_firmware(inbox, layout, screen)
|
||||
if needed:
|
||||
raise ValueError(f"Install screen firmware {needed} or newer first for these tiles.")
|
||||
@@ -1160,19 +1188,101 @@ class Manager:
|
||||
except Exception as error:
|
||||
LOG.warning('Refreshing history failed (%s)', type(error).__name__)
|
||||
|
||||
# ----- Camera images (firmware 0.2.57+) -----
|
||||
async def camera_loop(self):
|
||||
"""Answer the cameras screens open full screen, a few at a time."""
|
||||
limit = asyncio.Semaphore(4)
|
||||
|
||||
async def answer(request):
|
||||
async with limit:
|
||||
try:
|
||||
await self.answer_camera(request)
|
||||
except Exception as error:
|
||||
LOG.warning('Camera for %s was not sent (%s)', request.get('entity') if isinstance(request, dict) else '?', type(error).__name__)
|
||||
queue = getattr(self.ha, 'camera_requests', None)
|
||||
while queue is not None:
|
||||
request = await queue.get()
|
||||
asyncio.ensure_future(answer(request))
|
||||
|
||||
def camera_allowed(self, inbox, entity):
|
||||
"""A camera on the screen's own layout, or one a recent alert showed."""
|
||||
seen = self.alert_cameras.get(entity)
|
||||
if seen is not None and time.monotonic() - seen < camera_feed.STILL_SECONDS:
|
||||
return True
|
||||
return entity in {tile['entity'] for tile in self.layouts.get(inbox, {}).get('tiles', [])}
|
||||
|
||||
async def camera_message(self, entity, view, board, still=None):
|
||||
"""The screen message for one camera view: a link to its image, or an empty link when there is none."""
|
||||
url = ''
|
||||
base = await camera_feed.base_url(self.ha.request)
|
||||
if base:
|
||||
box = camera_feed.BOXES[board][view]
|
||||
if view == 'thumb':
|
||||
token = self.camera.link(entity, box, still) if still else None
|
||||
else:
|
||||
token = self.camera.link(entity, box) if await self.camera.frame(entity, box) else None
|
||||
url = f'{base}/camera/{token}.bmp' if token else ''
|
||||
else:
|
||||
LOG.warning('Camera images: no address for this app on the LAN; set SCREEN_CAMERA_URL')
|
||||
return {'v': 1, 'op': 'camera', 't': 'alert' if view == 'thumb' else 'full', 'e': entity, 'u': url}
|
||||
|
||||
async def answer_camera(self, request):
|
||||
"""One screen's request: a camera it may show, on a screen that draws camera images."""
|
||||
if not isinstance(request, dict):
|
||||
return
|
||||
inbox = self.aliases.get(request.get('inbox'), request.get('inbox'))
|
||||
entity = request.get('entity')
|
||||
screen = self.screen(inbox) if isinstance(inbox, str) else None
|
||||
if not camera_feed.supported(entity) or not camera_feed.can_show(screen) or not screen.get('online'):
|
||||
return
|
||||
action = self.transport(inbox, screen)
|
||||
if not action or not self.camera_allowed(inbox, entity):
|
||||
return
|
||||
message = await self.camera_message(entity, 'full', screen['board'])
|
||||
await self.ha.send(inbox, message, action)
|
||||
LOG.info('Camera %s on %s%s', entity, screen['name'], '' if message['u'] else ': no image')
|
||||
|
||||
async def alert_images(self, camera, screens):
|
||||
"""The picture of an alert's camera at that moment, on every screen that draws it."""
|
||||
self.alert_cameras[camera] = time.monotonic()
|
||||
for old in [entity for entity, moment in self.alert_cameras.items() if time.monotonic() - moment > camera_feed.STILL_SECONDS]:
|
||||
del self.alert_cameras[old]
|
||||
boards = {}
|
||||
for screen in screens:
|
||||
boards.setdefault(screen['board'], []).append(screen)
|
||||
for board, group in boards.items():
|
||||
found = await self.camera.frame(camera, camera_feed.BOXES[board]['thumb'], fresh=False, now=True)
|
||||
message = await self.camera_message(camera, 'thumb', board, found[1] if found else None)
|
||||
results = await asyncio.gather(*(self.ha.send(screen['id'], message, self.transport(screen['id'], screen)) for screen in group),
|
||||
return_exceptions=True)
|
||||
failed = sum(isinstance(result, BaseException) for result in results)
|
||||
LOG.info('Alert image of %s: %d of %d screens%s', camera, len(group) - failed, len(group), '' if message['u'] else ' (no image)')
|
||||
|
||||
async def broadcast(self, event_type, data):
|
||||
"""An alert event for every screen: the matching action on each screen that can show it, all at once."""
|
||||
action = BROADCAST_EVENTS[event_type]
|
||||
service_data, unusable = alert_data(data) if action == 'show_alert' else ({}, [])
|
||||
camera, usable = alert_camera(data) if event_type == BROADCAST_SHOW else ('', True)
|
||||
if not usable:
|
||||
unusable.append('camera')
|
||||
if unusable:
|
||||
LOG.warning('%s: unusable %s left empty', event_type, ', '.join(unusable))
|
||||
ready, skipped = alert_targets(self.screens())
|
||||
# A screen that draws the image hears about it before the alert, so the card opens with room for it.
|
||||
viewers = [screen for screen in ready if camera and camera_feed.can_show(screen) and self.transport(screen['id'], screen)]
|
||||
if viewers:
|
||||
pending = {'v': 1, 'op': 'camera', 't': 'alert', 'e': camera, 'u': ''}
|
||||
await asyncio.gather(*(self.ha.send(screen['id'], pending, self.transport(screen['id'], screen)) for screen in viewers),
|
||||
return_exceptions=True)
|
||||
results = await asyncio.gather(*(self.ha.call(alert_service(screen['node'], action), service_data) for screen in ready),
|
||||
return_exceptions=True)
|
||||
failed = [(screen, type(result).__name__) for screen, result in zip(ready, results) if isinstance(result, BaseException)]
|
||||
notes = [f"{screen['name']} {reason}" for screen, reason in skipped + failed]
|
||||
LOG.info('%s: %d of %d screens%s', event_type, len(ready) - len(failed), len(ready) + len(skipped),
|
||||
f" (not: {'; '.join(notes)})" if notes else '')
|
||||
shown = [screen for screen, result in zip(ready, results) if not isinstance(result, BaseException)]
|
||||
if viewers and any(screen in shown for screen in viewers):
|
||||
await self.alert_images(camera, [screen for screen in viewers if screen in shown])
|
||||
return {'sent': len(ready) - len(failed), 'skipped': len(skipped), 'failed': len(failed)}
|
||||
|
||||
async def tile_event(self, event_type, data):
|
||||
@@ -1416,10 +1526,19 @@ async def main():
|
||||
runner = web.AppRunner(create_app(manager, development), access_log=None)
|
||||
await runner.setup()
|
||||
await web.TCPSite(runner, '127.0.0.1' if development else '0.0.0.0', 8099).start()
|
||||
# Camera images for the screens: their own port on the LAN, not the ingress page (docs/CAMERA.md).
|
||||
cameras = web.AppRunner(camera_feed.web_app(manager.camera), access_log=None)
|
||||
await cameras.setup()
|
||||
try:
|
||||
await web.TCPSite(cameras, '0.0.0.0', camera_feed.port()).start()
|
||||
except OSError as error:
|
||||
# Everything else still works; only camera images stay away.
|
||||
LOG.error('Camera images are off: port %d is not available (%s)', camera_feed.port(), error)
|
||||
try:
|
||||
await asyncio.gather(ha.run(), manager.run(), manager.history_loop(), manager.updates.run(),
|
||||
manager.alert_loop(), manager.tile_loop(), manager.card_history_loop())
|
||||
manager.alert_loop(), manager.tile_loop(), manager.card_history_loop(), manager.camera_loop())
|
||||
finally:
|
||||
await cameras.cleanup()
|
||||
await runner.cleanup()
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -806,6 +806,8 @@ const domains = {
|
||||
sun: ["Sun", "☼", "#c86620", "#ffebdc"],
|
||||
timer: ["Timer", "⏱", "#008577", "#def3ed"],
|
||||
person: ["Person", "☺", "#2f7d32", "#e1f2e2"],
|
||||
camera: ["Camera", "◧", "#3d4a57", "#e6ebf0"],
|
||||
image: ["Image", "◧", "#3d4a57", "#e6ebf0"],
|
||||
};
|
||||
const displayNames = { standard: "standard", watch: "large value", forecast: "weather forecast", graph: "graph", digital: "digital clock", analog: "analog clock", sunpath: "sun path" };
|
||||
// Same rule as the add-on: only a wide card in the standard layout shows direct controls;
|
||||
@@ -2009,8 +2011,11 @@ function renderResults() {
|
||||
if (!layout) return;
|
||||
const query = $("#search").value.toLocaleLowerCase();
|
||||
const chosen = new Set(layout.tiles.map((t) => t.entity));
|
||||
// Camera images need a Guition (app 0.2.66); the add-on refuses them for other screens too.
|
||||
const guition = inventory.screens.find((s) => s.id === selected)?.board === "guition";
|
||||
const matches = [...(inventory.builtin || []), ...inventory.entities].filter(
|
||||
(e) =>
|
||||
(guition || !["camera", "image"].includes(e.id.split(".")[0])) &&
|
||||
(!filter || e.id.startsWith(filter + ".") ||
|
||||
({switch:"input_boolean", number:"input_number", select:"input_select", weather:"sun"}[filter] === e.id.split(".")[0])) &&
|
||||
`${e.name} ${e.id} ${e.device} ${e.area}`
|
||||
@@ -2612,6 +2617,8 @@ function alertWaitYaml(action) {
|
||||
function alertAllYaml() {
|
||||
const fields = inventory.alerts?.fields || [];
|
||||
const lines = fields.map((f) => ` ${f.name}: ${f.type === "string" ? (/^[a-z][a-z0-9-]*$/.test(f.example) ? f.example : yamlString(f.example)) : String(f.example)}`);
|
||||
const camera = inventory.alerts?.camera;
|
||||
if (camera) lines.push(` # ${camera.name}: ${camera.example} # a Guition shows its picture on the card`);
|
||||
return `event: ${inventory.alerts?.broadcast?.show || "esp_screens_show_alert"}\nevent_data:\n${lines.join("\n")}`;
|
||||
}
|
||||
function renderAlertBroadcast() {
|
||||
@@ -2751,6 +2758,16 @@ function renderAlertTables() {
|
||||
row.append(...cells);
|
||||
table.append(row);
|
||||
}
|
||||
// The camera image (app 0.2.66): a field of the event for every screen, not of a screen's own action.
|
||||
if (alerts.camera) {
|
||||
const row = node("tr"), name = node("td"), example = node("td");
|
||||
name.append(node("code", alerts.camera.name), node("small", alerts.camera.label));
|
||||
example.append(node("code", alerts.camera.example));
|
||||
const cells = [name, node("td", "entity"), node("td", alerts.camera.help), example, node("td", "Guition, firmware 0.2.57+")];
|
||||
cells.forEach((cell, index) => cell.dataset.label = ["Field", "Type", "What it does", "Example", "Limit"][index]);
|
||||
row.append(...cells);
|
||||
table.append(row);
|
||||
}
|
||||
const endings = $("#alerts-ending-table");
|
||||
endings.replaceChildren();
|
||||
const endHead = node("tr");
|
||||
|
||||
@@ -235,7 +235,7 @@ GLYPHS = {**{name: codepoint for name, (codepoint, _) in ICONS.items()}, **dict(
|
||||
# Mirrors runtime_tiles::icon_for() so the editor mockup shows what the screen draws.
|
||||
DEFAULTS = {'light': 'lightbulb', 'climate': 'air-conditioner', 'vacuum': 'robot-vacuum', 'fan': 'fan',
|
||||
'cover': 'window-shutter', 'scene': 'sofa', 'script': 'sofa', 'sensor': 'gauge', 'binary_sensor': 'gauge',
|
||||
'timer': 'timer-outline', 'person': 'account', 'screen': 'clock-outline'}
|
||||
'timer': 'timer-outline', 'person': 'account', 'camera': 'cctv', 'image': 'cctv', 'screen': 'clock-outline'}
|
||||
# The cards the screen brings itself: one icon per entity, not per domain.
|
||||
BUILTIN_TILES = {'screen.clock': 'clock-outline', 'screen.settings': 'cog'}
|
||||
FALLBACK = 'power'
|
||||
|
||||
Reference in New Issue
Block a user