ADR-0049: A Cameras popup — a still image of each Home Assistant camera, refreshed while the popup is open¶
Page en anglais
Cette page n'existe qu'en anglais.
Status: Proposed (2026-10-09; draft PR, not tried on a tablet nor with a real camera when written). Date: 2026-10-09
Context¶
A tester (discussion #278, Tab5 with a battery, Tapo cameras) asked to see his cameras on the tablet. The author's choice: no live video (no decoder for H.264 on this firmware, a stream would hold the Wi-Fi and the main loop), but a still image of each camera, refreshed every few seconds, only while the popup is open.
What Home Assistant offers, read in homeassistant/components/camera/__init__.py (branch dev, 2026-10-09):
- every camera has an
entity_pictureattribute,/api/camera_proxy/<entity_id>?token=<token>; the token is rotated every 5 minutes (TOKEN_CHANGE_INTERVAL) and the previous one stays valid (access_tokens, adeque(maxlen=2)): a URL read at any time works for at least 5 more minutes; CameraImageViewreadswidthandheightfrom the query and, only when both are given and the image is a JPEG, scales it down with TurboJPEG by the smallest factor of 1/8 … 7/8 that stays at or above both sizes (scale_jpeg_camera_image, quality 75). Without TurboJPEG the image comes unscaled.
On the tablet, ESPHome 2026.9 has online_image (already used for the image of the voice answer, tab5-assist.yaml): JPEG through JPEGDEC, decoded in one call once the whole file is downloaded, resize: WxH keeping the proportions, buffers from RAMAllocator (PSRAM first), online_image.release to free the image. Its update() waits for the HTTP headers synchronously (the http_request client of the assistant: 12 s timeout).
Decision¶
- One popup, one page per camera (
ui_components/cameras_popup.yaml,Tab5/ecran/tab5_cameras.cpp), shared chrome (ADR-0009, header iconcctvU+F07AE), registered as « Caméras » (ADR-0013): a 960 × 540 image (16:9) in a glass frame, the camera's name and « Image de 14:32:05 » under it, one pagination dot per camera (pagination_afficher(), the dots of the home page). A swipe changes camera through the shared paged-popup brick (pages_brancher(), ADR-0046). The names are not in the title bar like the Lights popup: eight names would get 95 px each; the name sits under the image instead. Messages throughtr(): « En attente de Home Assistant », « Aucune caméra choisie », « Chargement... », « Image indisponible », « Adresse de Home Assistant inconnue », « Plus d'image depuis … ». - One image at a time. One image for all cameras, downloaded and decoded off the main loop since 2026-10-10 (« Off the main loop » below; before: one
online_image); the next download starts 5 s after the previous one ended (tab5_cameras_attente,mode: restart: a single pending callback), the camera shown only. When the popup is closed (cross, the 45 s inactivity return, another screen), the next callback — or the end of the download in flight — releases the memory and nothing more is requested. - Contract: a minor addition (
contrat/contrat.yaml1.2.0). Eventesphome.tab5_cameras(no data), sent at the opening, every 4 minutes while open and after a failed image (30 s at most often); actiontab5_maj_cameras(adresse, cameras):cameras= « nom|image;… » in the blueprint's order, eight at most,image= the camera'sentity_picture(or a full URL);adresse= the base URL of Home Assistant as the tablet must call it, empty for the default. Payload reading is pure C++ intab5_parse(section 10:cameras_lire(),camera_url(),ha_base_depuis_hote()), tested bytools/test_parse.cppand fuzzed bytools/fuzz/fuzz_parse.cpp(;selector). - The URL. A relative
entity_pictureis prefixed with the blueprint's « Adresse de Home Assistant » when given, otherwise withhttp://<address of the API client>:8123— the address Home Assistant connects to the tablet from (on_client_connected, client « Home Assistant »; IPv6 in brackets, a zone%eth0refused). The tablet addswidth=960&height=540to a/api/camera_proxy/URL so that HA sends at most a 3/8 or 1/2 scaled image (a 2560 × 1440 camera arrives as 960 × 540, a 1920 × 1080 one as 960 × 540, a 2304 × 1296 one as 1152 × 648, then shown reduced by LVGL,lv_image_set_scale(); a smaller image is shown at its own size, never enlarged). A URL with a space or a control character is refused (it would break the HTTP request). - Token rotation without a loop in HA. The tablet asks again every 4 minutes (< 5 minutes, the rotation), so the URL it holds is always valid; a 401 (or any failed image) asks again too. The blueprint answers each request with one action, from the
entity_pictureof the moment: no script, no package — the Énergie model (ADR-0028) keeps a script looping in HA because it pushes live values; here the tablet already polls the image, a loop on the HA side would only duplicate the timer. - Opening. Option « Caméras » at the end of the « Aller à l'écran » select (
Ecran::CAMERAS, index 18; ARCADE moves to 19, which is not stored): Home Assistant can open it, for instance from a doorbell automation, with no new service. Gesture codecamerasat the end ofkCodesGestes(index 23, stored in NVS) and of the blueprint's gesture lists (ADR-0039); glyphcctvinmdi_font_70,mdi_font_26(top buttons and their mini icons) andmdi_font_32(header). Always available (ecran_disponible()): the tablet does not know the cameras before it asks; an empty section answers with an empty list and the popup says so. - Navigation wheel (asked by the author on 2026-10-09, after the first draft): « Caméras » at the end of the « Agenda ▸ » family of the wheel (ADR-0042), icon
cctvadded tomdi_font_36(RoueIcone::CAMERAS). « Appareils ▸ » would fit better, but it is full (six choices,kRoueChoix), as is the first ring (six buttons,kRoueBoutons); raisingkRoueChoixwould change the second ring of every wheel (lamp colours, climate carousel). Moving an existing entry was not asked. Always offered, like Météo: the tablet does not know the cameras before asking (the list is asked at the opening, never at boot), so hiding the button would need a push from Home Assistant at each connection and automation reload, guarded by the firmware version; without cameras, the popup says « Aucune caméra choisie ». - Blueprint. Section « Caméras · Cameras » (collapsed):
cameras_liste(entities of domaincamera, multiple, reorder) andcameras_adresse(text, empty by default). No real value, no placeholder (ADR-0024): the example address in the description ishttps://ha.example.com(a reserved example domain;.localnames are not relied on, their resolution by the tablet is not checked).
Rejected¶
- A tile for a camera. A new tile type (
cam) would have to be added to the tile model, the blueprint's tile domains, the room pages and the tile popups; the popup with the select and a gesture covers the need for a fraction of the cost. It can come later, opening this popup on the camera. - Hiding the wheel button when no camera is chosen — see the navigation wheel above.
- Live video or MJPEG (
/api/camera_proxy_stream): a never-ending response would hold the main loop ofonline_image; no H.264/H.265 decoder. - HA pushing the image itself (base64 in a service call): an ESPHome API string is not meant for 100 kB payloads, and HA would push while nobody looks.
- A script and a package in HA re-pushing on token change (the request's first idea): it works, but needs one more file to install and a loop per open popup, for what a 4-minute request of the tablet does.
Costs (estimated, not measured on the tablet)¶
- Memory. The decoded image: width × height × 2 bytes in PSRAM (614 400 for a 640 × 480 camera, 1 036 800 at 960 × 540) while the popup is open, released at closing. The download buffer grows to the size of the JPEG (the whole file is needed to decode; about 40 kB for a 640 × 480 camera, measured on 2026-10-10) and stays allocated after
release()(an ESPHome limit:DownloadBufferonly grows). JPEGDEC's working state comes with the decoder (size not checked). - Main loop. While HA prepares the snapshot, the loop waits for the headers (from a few hundred ms for a camera with a still-image URL to several seconds for one that HA reads through ffmpeg or a stream, up to the 12 s timeout); then the JPEG is decoded in one call, pixel by pixel through ESPHome's draw callback — measured on 2026-10-10 with a 640 × 480 camera (M5Stack AtomS3R-CAM, HA over https): about 0.6 s waiting for HA, then 1.45 s of decoding with the former
resize: 960x540, which made ESPHome enlarge the image to 720 × 540 pixel by pixel (the same for an 11 kB and a 45 kB JPEG). Hence noresize:since then: decoding fell to 1.0 s (same camera, 36 kB JPEG), about 1.6 s frozen per image instead of 2 s. What remains is ESPHome's pixel-by-pixel draw callback. The screen does not refresh and touch is not read during that time, once every 5 s or more; the 5 s gap is the knob. - A camera offline (or a Home Assistant that does not answer) freezes the screen up to the 12 s timeout of
http_requestat each try. After a failure the next try of the page shown waits 10 s, then 30 s, then 60 s (kEchecsDelaisMs), back to 10 s after an image, a page change or a new opening. A download neither finished nor failed after 60 s is dropped withonline_image.release(which closes the connection), so that a late image cannot land on another camera's page. - Logs. On a failed download, ESPHome's
online_imagelogs the URL at the ERROR level, token included (valid 5 to 10 minutes, readable by whoever reads the tablet's logs). The tablet checks the URL beforeset_url(camera_url()) and never logs it itself; the ERROR line of ESPHome is not avoided. - Formats. Progressive JPEG is refused by ESPHome's decoder (« Image indisponible »); a camera whose
entity_pictureis not a JPEG (a PNG) fails the same way.
2026-10-10 — Off the main loop¶
The first real test (a 640 × 480 camera, M5Stack AtomS3R-CAM, Home Assistant over https) measured what « Costs » above only estimated: with online_image, every image froze the screen about 2 s, then 1.6 s once the resize: was gone (#439). ESPHome's online_image downloads and decodes in the main loop by design, so the only way out was to stop using it here.
- A FreeRTOS task (
Tab5/ecran/tab5_cameras_charge.cpp, the first task of the firmware), pinned to core 0 (ESPHome'sloopTaskruns on core 1), priority 1, 12 kB stack in internal RAM (not PSRAM: a flash write stops the cache), created at the first image and kept. It sleeps until the main loop hands it a URL. - The download: ESP-IDF's
esp_http_client, 12 s timeout likeassist_http, the connection kept open from one image to the next (no TLS handshake per image); a kept connection that Home Assistant closed in between gets one more try on a new one. HTTPS without certificate check relies onCONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY, whichassist_http(verify_ssl: false) already sets. The URL is never logged (it carries the token): a failure logs the HTTP status or the error name only. - The decoding: the ESP32-P4 hardware JPEG decoder (
esp_driver_jpeg, already built by ESPHome), straight to little-endian RGB565 — LVGL's own format, so no conversion. It writes whole blocks: the line pitch is the width rounded up to 8 or 16 pixels (lv_image_dsc_t.header.stride). Baseline JPEG only (progressive refused, as before); 1280 × 736 pixels at most. - Two image buffers (
jpeg_alloc_decoder_mem, PSRAM): the task writes into the one not shown; the main loop swaps them when it takes the image. State shared through one atomic (free → busy → ready / failed → free); while busy, the main loop touches nothing of the loader. The main loop polls every 100 ms (kSondeMs, the sametab5_cameras_attentescript), 1 s while the popup is closed (to release at the end). - The image widget is created in C++ (
creer_image()), because ESPHome'simage:widget requires a fixed source; two descriptors are used in turn. - After a failure the image shown stays valid (the task wrote into the other buffer): it stays on screen with « Plus d'image depuis … ». The 10 / 30 / 60 s spacing after failures is kept (a camera offline still costs Home Assistant a request), but no longer freezes anything. The 60 s « lost download » path is gone: the task always ends (12 s timeout, at most two tries, 2 s decoder timeout).
- Memory: 2 × width × height × 2 bytes of PSRAM while the popup is open (2 × 600 kB for a 640 × 480 camera), plus the JPEG; all of it, and the connection, released at closing. The task and the decoder stay (12 kB of internal RAM for the stack).
- Measured on the tablet the same day, same camera (12 images of 37-39 kB): Home Assistant ~0.4 s per image over the kept connection (0.6 s for the first one), hardware decoding 3-12 ms (1.0 s with
online_image), no camera « took a long time » warning left in the main loop, worst loop of the minute 217 ms instead of 1 020 ms, one image every 5.4 s. Stack: 9 860 of the 12 288 bytes never used. Closing the popup six times during a download and an unreachable Home Assistant (ESP_ERR_HTTP_CONNECT, next try 10 s later): no crash, no reboot, internal RAM back to the same level after each closing. - Off the tablet (
tab5-rendu-host.yaml) a stub never finishes: the popup stays on « Chargement... », as it did with the mutedonline_image.
2026-10-10 — By room¶
ADR-0057 extends this popup: sixteen cameras, each with its Home Assistant area and offline time (« nom|image|pièce|depuis », same action, same variables), a room column from two rooms, the swipe and the dots inside the chosen room, the choice kept in NVS, the last image of each camera kept while the popup is open (the loader gives its buffer to the screen instead of swapping two), and a per-camera retry schedule. What is above about the URL, the token rotation, the opening and the loader still holds.
Consequences¶
- The author decides whether 5 s between images and the 45 s auto-close (the popup is a popup: no touch for 45 s closes it, a doorbell image included) are right.
- A Home Assistant behind a reverse proxy, on another port or in https needs the « Adresse de Home Assistant » field (
verify_ssl: falseon the tablet's HTTP client already). - Two tablets on the same automation are not a case: one automation per tablet (the blueprint's rule).