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:
Max Gramser
2026-09-17 15:31:11 +02:00
parent 9567ab591a
commit d60a0f122b
31 changed files with 1847 additions and 117 deletions
+13
View File
@@ -1,3 +1,16 @@
## 0.2.66 (firmware 0.2.57)
Camera images on the Guition: see who is at the door.
- **An alert with a picture.** Add `camera: camera.front_door` to the `esp_screens_show_alert` event and a Guition shows the camera's picture of that moment across the top of the alert card, with the title and subtitle under it. A tap on the picture opens the camera full screen over the alert; Back returns to the alert. Any `camera.*` or `image.*` entity works. Other screens show the same alert without the picture.
- **A camera tile.** Add a `camera.*` or `image.*` entity as a tile on a Guition. A tap opens the image full screen, with the round back key at the top left like on every card, and the image refreshes every four seconds, at a steady pace, while it is open. It is not video: ESPHome has no video decoder. Standby and **Back to page 1** close it, and a new alert closes it before it opens.
- **How it works.** The screen never gets a Home Assistant token. ESP Screen Manager fetches the snapshot, makes it exactly as large as the screen draws it and serves it on a new port, **8098**, under a random link that stops working when nobody uses it. On Home Assistant OS the app opens that port itself; keep it at 8098. docs/CAMERA.md has the details, Docker included.
- **Smooth while it loads.** The image arrives as an uncompressed BMP, which the Guition decodes piece by piece while it downloads; a JPEG held the screen for more than half a second per image, long enough to miss a tap. The app fetches the camera's next snapshot each time the screen loads one, so a new picture follows every four seconds instead of after one and a half seconds one time and six the next. A slow camera makes the picture older, never the screen slower.
- **The alert's picture is the moment it rang.** It stays as it was when the alert came in; tap it for the camera now.
- **The Guition builds with ESPHome 2026.8 and newer.** Its firmware no longer uses a backlight fader of its own, which reached into ESPHome's LEDC output; ESPHome 2026.8 closed that off, so **Install** in ESPHome Device Builder failed for a Guition. Standby now dims through ESPHome's own light transition, like any light. Updating from ESP Screens was not affected.
- **The Claude skill and the Alerts cheatsheet** describe the `camera` field. Install the skill again from Settings → Claude to get it.
- Needs firmware 0.2.57 on the Guition: press **Update** on the screen. The CYD gets 0.2.57 too, without camera images: it has no memory for them.
## 0.2.65 (firmware 0.2.56)
Wake lights the screen, and ESP Screens lets you pick the screen.
+283
View File
@@ -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']
+3 -1
View File
@@ -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
+23 -5
View File
@@ -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.
+121 -2
View File
@@ -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__':
+17
View File
@@ -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");
+1 -1
View File
@@ -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'
+6 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.65"
version: "0.2.66"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
@@ -10,6 +10,11 @@ init: false
homeassistant_api: true
ingress: true
ingress_port: 8099
# Camera images for Guition screens (docs/CAMERA.md): the screens load them from this port on the LAN.
ports:
8098/tcp: 8098
ports_description:
8098/tcp: Camera images for the screens (keep 8098)
panel_icon: mdi:monitor-dashboard
panel_title: ESP Screens
panel_admin: true