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
+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__':