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
+84
View File
@@ -0,0 +1,84 @@
# Camera images on a Guition
App 0.2.66 with firmware 0.2.57 shows camera images on a Guition 4848S040:
- **A camera tile.** Add a `camera.*` or `image.*` entity as a tile. A tap opens the image full
screen, with the round back key at the top left like every card. The image is refreshed every
four seconds while it is open. It is not video: ESPHome has no video decoder.
- **An alert with a picture.** Add `camera: camera.front_door` to the `esp_screens_show_alert`
event. The card shows the picture of that moment across its top (it stays that picture); a tap
on it opens the camera full screen over the alert, and Back returns to the alert.
The CYD has no memory for images (a 320×180 image needs 115 KB in one piece, the CYD's largest
free block is about 45 KB). It shows the alert without the picture, and the editor doesn't offer
camera tiles for it.
## A doorbell
```yaml
triggers:
- trigger: state
entity_id: event.front_door_ding
actions:
- event: esp_screens_show_alert
event_data:
title: Someone is at the door
icon: doorbell
camera: camera.front_door
timeout: 60
```
Any `camera.*` entity works, and so does an `image.*` entity, such as the snapshot a doorbell or
motion integration keeps of its last event.
## How the image gets to the screen
The screen never talks to Home Assistant about images, and it never holds a Home Assistant token.
1. The screen asks ESP Screen Manager for a camera (the event `esphome.screen_camera`), or the app
sends the picture of an alert by itself.
2. The app fetches the snapshot from Home Assistant with its own access (the same pictures the
Home Assistant frontend shows), and makes it exactly as large as the screen draws it: at most
480×480 full screen, 392×220 on an alert card, proportions kept.
3. It serves the result as an uncompressed 24-bit BMP on **port 8098** under a random link, and
sends the link to the screen. ESPHome's `online_image` loads it.
While a camera is open, the screen loads its link every four seconds, one image at a time. The app
serves the last snapshot at once and starts fetching the next one, so each load gets a picture one
load younger: the picture changes at the screen's steady pace. (A fetch on its own clock next to the
screen's made the picture change after 1.5 s one time and 6 s the next.) A slow camera makes the
images older, never the screen slower, and nothing queues up. Nobody loading means nothing fetched.
A link that nobody loads for two minutes stops working; an alert's picture stays for half an hour.
**Why BMP.** ESPHome decodes a BMP piece by piece while it downloads (4 KB per round of its main
loop). A JPEG of the full screen took 0.6 s in one piece on the Guition, during which the screen
missed taps. A full-screen BMP is about 390 KB; on the LAN that takes one to three seconds.
## Network
- **Home Assistant OS:** the app publishes port 8098 on the Home Assistant host. Keep it at 8098 in
the app's network settings; the screens need to reach Home Assistant's address on that port.
- **Docker** (docs/DOCKER.md): the container uses the host network, so 8098 is open as is. When
the screens reach the host under another address, set `SCREEN_CAMERA_URL`, for example
`http://192.168.1.20:8098`.
- The images travel unencrypted over the LAN, like the screens' other HTTP traffic. Links are
random, short-lived, and only issued for a camera on that screen's tiles or in a recent alert.
## Memory and speed (Guition, measured 2026-09-17)
- Full screen: 480×270 RGB565 is 259 KB of PSRAM; the alert picture 172 KB. Both are freed when
the camera or the alert closes. A new alert closes an open camera first.
- The internal heap stays level while a camera refreshes: 77.2 KB free after 91 images in six minutes,
and PSRAM unchanged.
- Standby, **Back to page 1** and a new layout close the camera; nothing loads in standby.
## For developers
- `screen_manager/app/camera_feed.py`: fetching, sizing, links and the port.
- `components/smart_display/camera_view.h`: when to ask for a link and when to load again
(`tests/test_camera_view.cpp`).
- `components/smart_display/runtime_tiles.h`: the full-screen view, the alert picture and the
`camera` message.
- `guition-4848s040.yaml`: the two `online_image` components, the alert frame, and the diagnostic
action `preview_camera` (an entity opens it, an empty entity closes it).
- `tests/test_camera.py`: the app side and the words both sides share.
+7 -1
View File
@@ -80,6 +80,10 @@ under **Settings → Devices & services** in Home Assistant, and then choose the
ESPHome folder. `data/build` and `data/platformio` are caches.
- **Claude:** **Install for Claude Code** is meant for the Claude Code app on Home Assistant
OS. Use **Download for claude.ai** instead.
- **Camera images (Guition, app 0.2.66+):** the screens load camera pictures from port **8098**
of this host, on all its addresses, so keep that port open to the screens. The app uses Home
Assistant's own LAN address; when the screens reach this host under another one, set
`SCREEN_CAMERA_URL` (see below). The links are random and short-lived. [docs/CAMERA.md](CAMERA.md)
## Updating
@@ -96,8 +100,10 @@ After an update, ESP Screens shows per screen whether newer firmware is availabl
| Setting | What it does |
| --- | --- |
| `SCREEN_DEV: "1"` | Runs the app outside the Supervisor: it reads the token from `HA_TOKEN_FILE` and only listens on 127.0.0.1 |
| `SCREEN_DEV: "1"` | Runs the app outside the Supervisor: it reads the token from `HA_TOKEN_FILE` and serves its page on 127.0.0.1 only (camera images still on port 8098 for the screens) |
| `HA_API` | Home Assistant's address, ending in `/api` |
| `HA_TOKEN_FILE` | The token file inside the container (the `ha_token` secret) |
| `ESPHOME_CONFIG` | The ESPHome folder inside the container |
| `SCREEN_DATA` | Layouts, update settings, and build caches |
| `SCREEN_CAMERA_URL` | Optional: where screens load camera images, such as `http://192.168.1.20:8098`; by default Home Assistant's own LAN address |
| `SCREEN_CAMERA_PORT` | Optional: the camera image port instead of 8098 |
+4 -4
View File
@@ -44,10 +44,10 @@ four pages; navigation disappears at six or fewer. The current interface has a
light gray background, white cards, and colored domain icons. From 0.2.10, you
can choose a pastel background with dark text per tile. Standby starts
after ten minutes without touch by default and is adjustable in the management page.
From 0.2.13, the backlight dims via the LEDC hardware fader (`backlight_fade.h`,
1.5 s to standby, 80 ms to wake): the full LVGL redraw on standby no longer
interrupts the transition. ESPHome's light state is synced after the fade,
so the entity in HA and later transitions stay correct.
The backlight dims through ESPHome's own light transition (1.5 s to standby, 80 ms to
wake). Firmware 0.2.13-0.2.56 used the LEDC hardware fader (`backlight_fade.h`) instead;
0.2.57 went back to ESPHome's transition, because that fader reached into ESPHome's LEDC
output, which ESPHome 2026.8 no longer allows.
The existing tile actions, climate control, and vacuum card are preserved.
Every card type works in all twenty positions.
+45
View File
@@ -176,6 +176,51 @@ icons sit off-center in the browser); run `generate_packages.py` afterward.
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys.
### Compatibility 0.2.66 / firmware 0.2.57
Camera images on the Guition (docs/CAMERA.md). Storage, keys, preferences and the settings block are unchanged; the
layout message is unchanged apart from two tile domains, and the `show_alert` action keeps its seven fields.
- New tile domains `camera` and `image` in `core.DOMAINS` and the firmware's `valid_entity()`. `min_firmware()` asks
0.2.57 for them, and `Manager.save` refuses them for a screen whose board is not in `camera_feed.BOXES` (the CYD)
before it asks for firmware, so a CYD never hears "update first". The editor hides them for other boards; the top
bar refuses them (`header_entity`). An `image` tile carries `x.last` like a scene. Older firmware never gets these
tiles because of `min_firmware`.
- New protocol, all optional: the screen fires `esphome.screen_camera` {inbox, entity} when a camera opens; the app
answers through `screen_message` with op `camera` {t: `full`|`alert`, e, u}. An empty `u` means no image. For an
alert the app sends op `camera` with `t: alert` and an empty `u` to screens that can show it (`camera_feed.can_show`:
a Guition on 0.2.57+) BEFORE `show_alert`, so the card opens with room, and the link after it. `show_alert` has no
camera field on purpose: Home Assistant requires every field of an ESPHome action, so a new field would break every
automation calling `esphome.<screen>_show_alert` directly. The camera is only a field of the
`esp_screens_show_alert` event (`core.alert_camera`, `ALERT_CAMERA_FIELD`).
- The app publishes port 8098 (`config.yaml` `ports`) and serves `GET /camera/<token>.bmp` without the ingress guard.
Tokens are 24 random URL-safe characters, per camera and size; live links expire 120 s after their last use, an
alert's still after 30 min; at most 64 links. Only a camera on the screen's layout or in an alert of the last
30 min gets one. The address is Home Assistant's default adapter from the `network` websocket command, else the
host of `network/url`; `SCREEN_CAMERA_URL` / `SCREEN_CAMERA_PORT` override it (Docker). A port that cannot be
bound logs an error and leaves the rest of the app running.
- Images are 24-bit BMP, not JPEG: ESPHome 2026.6.2 decodes a JPEG in one loop call (623 ms for 480x270 on the
Guition) and a BMP per downloaded chunk (`buffer_size: 4096`). Boxes: full 480x480, alert 392x220, proportions kept.
- Fetching follows the screen: serving a live link starts the next snapshot fetch (`CameraFeed.frame(fresh=True)`),
one at a time per camera, so the picture changes at the screen's pace. The screen loads every 4 s start to start with
at least 800 ms free after a load (`camera_view.h`), never under a finger and never in standby. An alert fetches its
own snapshot (`now=True`), never one kept from an earlier view. A failing camera is asked again after 5-30 s.
- Firmware (Guition profile only): `http_request` (4 s timeout, no TLS), two top-level `online_image`s
(`camera_image`, `alert_image`, the form 2026.6.2 knows; 2026.7+ reads it with a deprecation note), a hidden LVGL
`image` seed so `LV_USE_IMAGE` is compiled, the `alert_image_frame` in the alert card, `preview_camera` for
diagnostics and renders, theme roles `CAMERA_PAGE/INK/NOTE` and paint `camera`. The load hooks only call `set_url()`
for a new link, since it forgets the ETag. `runtime_tiles.h` builds the full view on `lv_layer_top()`; closing
frees the image on the next tick. `LV_USE_IMAGE` guards the drawing, so the CYD (no image widget) still compiles;
the CYD profile binds no hooks and never opens a camera.
- Swipes, the settings hold and Back to page 1 treat an open camera like an open card; `close_cards` (standby,
Back to page 1, a new layout) closes it, and a new alert closes it first.
- `guition_diagnostics.h` snapshots composite the top layer, so `capture_ui.py` shows alerts and the camera.
- `backlight_fade.h` is gone. It derived from `esphome::ledc::LEDCOutput` to read its channel and bit depth, and
ESPHome 2026.8 declared that class `final`, so the Guition package no longer compiled in ESPHome Device Builder
2026.8+ (the app's own ESPHome 2026.6.2 still built it). `set_backlight` now only calls ESPHome's light transition
(1.5 s to standby, 80 ms to wake); a new call interrupts a running transition as ESPHome does. Both profiles and the
camera code were compiled with ESPHome 2026.6.2 and 2026.8.1 for this release.
### Compatibility 0.2.65 / firmware 0.2.56
Both board profiles and the editor page; the app raises `FIRMWARE_VERSION`. Storage, tile protocol, preferences, keys
+95
View File
@@ -0,0 +1,95 @@
# Test results app 0.2.66 / firmware 0.2.57 (2026-09-17)
Camera images on the Guition, asked for by Max: an alert that shows who is at the door, and a camera tile that opens a
refreshed image full screen. Plus the Guition building again with ESPHome 2026.8+. See CHANGELOG 0.2.66, docs/CAMERA.md
and the compatibility note in docs/RELEASING.md. Built on 0.2.65 (`9567ab5`).
## Design decisions, with the measurement behind each
- **The add-on serves the image, the screen holds no token.** Home Assistant's camera access token changes every five
minutes and a long-lived token on every screen opens all of Home Assistant. The app fetches with its own access and
serves a random, short-lived link on port 8098 (Max chose a LAN port over a file in `/config/www`).
- **BMP, not JPEG.** On the bench Guition (Wi-Fi -70 dBm) ESPHome 2026.6.2 decoded a 480x270 JPEG in one loop call:
`online_image took a long time for an operation (623 ms)`, long enough to lose a tap. A 24-bit BMP is decoded per
4 KB as it downloads; the longest single loop operation over a six-minute soak was 353 ms, typically under 170 ms.
The full-screen BMP is 388,854 bytes and downloads in 2.7-3.3 s over that Wi-Fi (the same from the Mac and from the
Home Assistant Yellow, so the screen's Wi-Fi is the limit).
- **A steady rhythm.** Max saw the picture change after 3, 5, 7 s. The EZVIZ camera answered every snapshot in a
steady 2.4 s (10 fetches: 2.36-2.56 s), so the cause was the app fetching on its own 2.6 s clock next to the screen's
3.75 s. Now each load starts the next fetch and the screen loads every 4 s start to start: pulling every 4 s against
the real camera gave camera timestamps 5, 4, 4, 4, 4, 3, 4, 5, 3, 5 s apart (the camera stamps whole seconds).
- **The alert keeps the picture of its moment** (Max's choice); a tap opens the camera now. An alert fetches its own
snapshot, never one kept from a view in the last 30 s (found in review).
- **Alert design** (Max): the picture across the top of the card, 392x220, title and subtitle under it.
- **CYD: no images.** 320x180 RGB565 needs 115 KB in one piece; the CYD's largest free block is 41-53 KB.
## Automated
- Python (`.venv-portal`, with Pillow 12.2.0 as in the ESPHome image): 337 tests OK (334 before the review fixes).
New `tests/test_camera.py` (23): the rules (domains, firmware, board, header, alert field), BMP encoding of JPEG, PNG
with alpha, GIF and portrait snapshots (24 bits, no compression, exact size), links (304, expiry, bounds, stills),
fetching per load, a failing camera's pause, an alert's own snapshot, the HTTP port, the broadcast order (announce,
alert, link) with and without an image, requests a screen may and may not make, camera tiles only on a Guition, the
words firmware and app share, and the profile's `set_url` guard. `test_wake_sleep.py` counts `preview_camera` among
the writers of `last_use_ms`; `tile_icons` knows the cctv default.
- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I.`): 17/17 PASS, new `tests/test_camera_view.cpp` (asking, loading
rhythm, the gap after a slow load, failures, an empty link).
- `generate_packages.py --check` green. No token or Home Assistant URL in tracked files.
- Code review (high) found three issues, all fixed with tests: the alert picture could be a snapshot kept from a view
up to 30 s earlier; a busy camera port stopped the whole app; `set_url()` on every load threw away the ETag, so an
unchanged picture was downloaded again instead of answering 304.
## Builds
Easy profiles with the generated packages and local components (ESPHome 2026.6.2, the app's own):
| Profile | RAM | Flash | vs 0.2.65 |
|---|---|---|---|
| CYD | 23.8% (77,940 B) | 77.6% (1,423,815 B) | +368 B RAM, +2.6 KB flash |
| Guition | 25.5% (83,516 B) | 20.5% (1,664,915 B) | +2.6 KB RAM, +140 KB flash (http_request, online_image, BMP decoder) |
(0.2.65's numbers came from check profiles with other device names, a few bytes apart.)
ESPHome 2026.8.1 (ESPHome Device Builder on Max's Home Assistant), separate build folders: both packages compile.
Before this release the Guition package failed there: `backlight_fade.h` derived from `esphome::ledc::LEDCOutput`,
which ESPHome 2026.8 made `final`. With Max's agreement the hardware fader is gone and the backlight uses ESPHome's own
transition. Warnings left for later: top-level `online_image:` is removed in ESPHome 2027.1.0 (use
`image: - platform: online_image` once min_version is 2026.7+), and `platformio_options: build_flags` in 2026.12.0.
## Hardware (bench, Max's home Home Assistant, HA 2026.9.2 on a Home Assistant Yellow)
The app ran as a local add-on built from this tree on the Yellow (store add-on stopped meanwhile); both screens OTA.
| Check | Result |
|---|---|
| Guition boots 0.2.57 (compiled 15:22:34), inbox Synced, `sensor.guition_wallbox_screen_firmware` 0.2.57 | yes |
| `diagnostics/run_ui_test.py` on the Guition | PASS: 10 page checks and 50 overlay render cycles |
| Sleep and Wake from Home Assistant twice, ESPHome transition | no long operations logged; Max watched: "dimmen gaat mooi" |
| Alert with `camera: camera.max` through the real event | picture after 1.8-1.9 s (258,774 B, 391x220) |
| An `image.*` entity as the alert camera | loads (392x98: that entity's own 400x100 picture) |
| Alert without camera, Guition and CYD | the old card, unchanged |
| Tap on the alert picture (Max) | camera full screen; Back returns to the alert |
| Camera tile tapped (Max), refresh | seven images, 2.73-2.79 s downloads each, Back closes it |
| Camera open, then an alert with a camera | camera closes (PSRAM back to 6.09 MB), alert picture loads |
| Camera opened over an alert | both images held: PSRAM 5.66 MB free; close keeps the alert |
| Six-minute soak, 91 images | internal heap level at 77.2 KB free, PSRAM unchanged, no errors |
| Five real alerts in a row (Max testing) | all shown, pictures 0.9-1.9 s, no failed download |
| First image after the add-on started | one `ESP_ERR_HTTP_CONNECT`; the alert picture now retries three times |
| Home Assistant Yellow while a camera refreshes | add-on CPU 0-1.5 %, 51 MB |
| CYD boots 0.2.57 (compiled 15:23:49), inbox Synced | yes |
| Alert with a camera on the CYD | shown without the picture, OK closes it |
| Camera tile saved for the CYD | refused: "Camera images need a Guition screen." |
Internal heap from the screens' own sensors, five minutes after the release build booted (the Guition's window held the
UI self test, Sleep/Wake twice, a camera view and an alert with a picture; the CYD's a sync and an alert):
| Screen | Free | Largest block | Lowest since boot | Before (earlier today) |
|---|---|---|---|---|
| Guition 0.2.57 | 80,108 B | 34,816 B | 56,548 B | 0.2.55 after hours of use: 83,172 / 40,960 / 72,472 B |
| CYD 0.2.57 | 114,024 B | 57,344 B | 104,884 B | 0.2.54 after hours of use: 104,424 / 49,152 / 85,600 B |
PSRAM on the Guition stays near 6.1 MB free with nothing open, 5.9 MB with a camera and 5.7 MB with a camera over an
alert. The Guition's lowest point is lower than this morning's after a UI self test plus camera and alert in the same
five minutes; the soak kept the heap level, so it is headroom used at once, not a leak.
Not tested: the Docker route (no Linux Docker host here); a doorbell integration (Max has none, `camera.max` stood in).
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB