# Camera images on a screen App 0.2.66 with firmware 0.2.57 shows camera images on every board with the memory (PSRAM) for them: the 4-inch, 7-inch (JC1060P470 and V2) and 10.1-inch Guition, and the 4B, 4.3-inch and 7-inch Waveshare. The CYD, the Waveshare 3.5-inch and the Hosyond 4-inch cannot (see below). Which boards can is `camera` in `screen_manager/app/boards.json`, worked out from whether the board file includes `packages/features/camera.yaml`. The numbers further down were measured on the 4-inch Guition. - **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, and a spinner until the first image is there (firmware 0.2.73). The image is refreshed every four seconds while it is open. It is not video: ESPHome has no video decoder. A P4 board plays the camera live instead (see [Live on the P4 boards](#live-on-the-p4-boards)). - **An alert with a picture.** Add `camera: camera.front_door` to the `esp_screens_show_alert` event, and `screen: hallway_screen` for one screen only (app 0.2.133). The screen's own actions `esphome._show_alert` and `_show_alert_choice` have no `camera`: Home Assistant makes every field of an ESPHome action required, so a new field would break every automation that calls it. The event also takes the second button (`button2_text` and the rest, app 0.3.8, firmware 0.3.3). The card shows the picture of that moment (it stays that picture), with the card's round corners (firmware 0.2.73); a tap on it opens the camera full screen over the alert, and Back returns to the alert. Since firmware 0.2.103 the card makes room for the picture in the picture's own proportions: a wide camera across the top of the card, a square or standing doorbell camera on the left of the words where the glass is wide and low (the button stays on the right). Until the picture arrives the card shows a camera icon where a 16:9 picture would go, and then takes the picture's shape. The picture keeps one size in millimetres on every board, at most about 58 x 45 mm, so a big screen shows it as a phone-sized picture, not a poster. - **How the picture travels.** The app fetches the camera's snapshot once for all screens, reads its size from the file's header (turned the way its EXIF says), works out for each screen the frame its card makes for those proportions (the same rule as the firmware, `screen_manager/app/ alert_layout.py`) and scales the snapshot once per frame size to exactly that frame. Screens that draw the same frame share one picture; frames of different sizes are made at the same time, from the same snapshot, and each screen gets its own link to its own size. The frame is worked out from the density and look the screen reports itself, so an Override YAML that changes `DISPLAY_DPI` is followed. The screen gets a BMP that is its frame, pixel for pixel: about 350 KB at the standard look's density, up to about 430 KB for a square picture on the denser 4.3-inch. Firmware before 0.2.103 has one fixed frame and gets the picture fitted into it. 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), and neither have the Waveshare 3.5-inch and the Hosyond 4-inch (no PSRAM). They show the alert without the picture, and the app refuses a camera tile on them ("This screen cannot show camera pictures", `server.py` and `page_service.py`). ## The album cover on the media card App 0.2.77 with firmware 0.2.64 uses the same road for a media player's picture. The media card (a tap on a media player's tile, or a media tile of size *Full page*) shows the album cover of what plays, with the title, the artist and the album, a progress bar and the keys under it. - The screen asks Tessera Screen Manager for the cover with the size it draws it at and the colour behind it (the event `esphome.screen_camera` with `size` and `bg`). The app fetches the picture through Home Assistant's own proxy for the player (`/api/media_player_proxy/...`), cuts it square, sizes it, rounds the corners over that colour and serves it on port 8098 as a BMP, like a camera image. - Home Assistant's token goes to that proxy only (app 0.4.15). A player whose picture lies on the internet or on a server in the house hands out the picture's own address and the proxy beside it, and the app takes the proxy, so Home Assistant fetches the picture itself. A player that hands out an address without a proxy gets its picture fetched from the internet only, never from an address in the house, and without following a redirect. - A cover is fetched once per picture: the state carries a short mark of the picture, and the screen asks again only when the mark changes (a new track), when the card opens again or when the page turns back to a full-page media tile. Nothing polls. - A player without a picture (a radio station, a player that is off) keeps the player's icon in the cover's place; the app answers with an empty link and the screen stops asking. - A board without camera pictures (CYD, Waveshare 3.5, Hosyond 4-inch) has no place for a cover on its media card or on a media tile over the whole page (firmware 0.46.0): the title, the bar and the keys stand together in the middle and take the room, with shuffle and repeat where they fit. Before, an empty square with the player's icon stood where a cover never came. - One picture loads at a time, the most urgent first (see "One route for every picture" below). An alert closes an open card and its cover. Under a media tile of size *Full page* the alert's picture goes first: a cover already on its way finishes, and the tile's cover follows the alert's picture. - A new track keeps the card's cover and colour until the next cover is here, then changes both at once. A media tile over a whole page on a kept page has its next cover fetched ahead while the screen is in use, after everything on the glass. ## A live picture on a camera tile App 0.2.91 with firmware 0.2.77 puts the camera on the tile itself: **Display → Live picture** in the tile's settings, with a pace of 15 or 30 seconds (`refresh`; 5 and 10 seconds too from app 0.3.13). The picture refreshes while that page is on the screen, and a tap still opens the camera full screen. From app 0.3.13 with firmware 0.3.7 the picture fills the whole tile on every size (see [A camera that fills its tile](#a-camera-that-fills-its-tile)). Older firmware shows a small square of the camera's view in the icon's place of a single, double-width or full-page tile: the middle of the snapshot cut square with the tile's rounded corners, delivered as described below. - **A picture per tile** (firmware 0.52.0, GitHub #183). Every tile with a picture asks for its own: a live camera, a media tile's album cover, a favourite and a map. The screen asks Tessera Screen Manager with the event `esphome.screen_camera` as for a page of one tile: `tiles` the entity, `idx` the tile's index (the app prepares the picture the way that tile's settings say), `bg` the colour behind its corners, `dark` the look, and `atlas` one frame at the top left with the picture's size in pixels, its corner radius and its shade (`components/smart_display/tile_picture.h`). The app answers with a link to a BMP of exactly that frame and the question's number, by which the screen finds the tile. Every app since 0.3.8 answers this question, so a new screen works with an older app too. - **A plugin tile's places** (plugin API 0.10, docs/PLUGINS.md, "Pictures on a plugin tile") ask the same question with an atlas of several frames, one per place, and `src` and `fit` beside it: one picture for a tile of eight posters, each place its piece of it. - **Each at its own pace.** A camera tile loads its next picture when its own pace has passed, 5 to 30 seconds. The app fetches the camera just before that load, at the pace of the quickest of its tiles (as for the full view below), so the picture on a tile is a fraction of a second old when it comes, not a whole pace. A new track replaces the cover of its tile and sends no camera again; a page of covers alone loads once per track. The picture comes whole every time, never as a 304. Nobody loading means nothing fetched, as with the camera full screen. - **No picture smaller for another.** The size cap (below) holds for each picture alone, so a page of five 2 × 2 cameras on the 10-inch glass shows every one at the full size of its tile, whatever else the page holds. - **After the other pictures.** One picture loads at a time, the most urgent first: the alert's picture, the camera full screen, an open card's cover, then the tiles on the glass, and last what is fetched ahead for a page out of sight, which breaks off for them. Of the tiles, one that has no picture yet goes first, then the one whose picture is oldest, so cameras at the same pace take turns. Asking goes at once for all tiles of the page; a download waits for the finger to leave the glass and the pages to stand still. In standby it keeps loading while the tiles are seen (firmware 0.40.0+, `runtime_tiles::tiles_seen`): no screensaver over them, the clock included, and the glass at 5 % or more. A screensaver or a darker glass stops it until the screen wakes. Dark mode (other colours behind the corners) or a changed tile asks for a new picture. - **Kept pages.** On a board with PSRAM (`picture_store.h`) every tile's picture is kept under its own key, so turning back shows them at once, and each camera loads its next picture at its own pace. A cover, a favourite or a map of a kept page is fetched ahead while the screen is in use; a camera of a kept page is not. - **Until the next one comes** a card keeps the picture it shows of the same place: the last track's cover, a map before someone moved. Until the first picture arrives, a camera card shows a spinner. - **A camera without a picture** keeps its icon: the app answers with an empty link. - **A media tile's album cover** (app 0.2.92, firmware 0.2.78): **Display → Album cover** on a single or double-width media player tile. The app fetches the player's picture only when its address changes (a new track); the screen asks again the moment the picture's mark in the player's state changes. A player without a picture keeps its icon; the tile over the whole page keeps the card's big cover. - The tiles' pictures come through the third of the screen's three downloads (`picture_fetch::live`, decoded into PSRAM), bound in `packages/features/camera.yaml`, which every board with camera pictures includes; the CYD, the Waveshare 3.5 and the Hosyond have none. - **Older firmware** (0.51 and before) asks for all the pictures of a page at once, and the app still answers it that way: one image spanning them all, from the top left of the first picture to the bottom right of the last (firmware 0.3.1+), or a strip of squares, top to bottom, before firmware 0.3.7. That image was under one cap for the whole page, so on a large glass every picture of a page came smaller as soon as one more tile had a picture. ### A camera that fills its tile From app 0.3.13 with firmware 0.3.7, a live camera fills the whole card on every size: a single tile, a double-width one, a 1 × 2 or 2 × 2 tile and a tile over the whole page. Two cameras that film in 16:9 fit side by side on the 4-inch Guition, one single tile each. App 0.3.8 with firmware 0.3.3 did this on 1 × 2 and 2 × 2 tiles only. Two settings appear in the tile's settings with the live picture: - **Picture**: **Fill the tile** (`fit` left out, the default) cuts the picture to the card, the way a photo fills a frame. **Whole picture** (`fit: contain`) shows all of it, with black above and below or at the sides. - **On the picture**: **Name** (`overlay` left out, the default) writes the tile's name at the bottom in white. **Nothing** (`overlay: none`) leaves the picture alone. The screen does none of this work. The screen asks for each picture at its card's exact size in pixels (`atlas`, see above), and the app answers with a BMP that already has its crop or its black bars, its rounded corners and, under the name, a soft shade that keeps white text readable on a bright picture. The screen draws that image as it is. A smaller picture does not load faster: the picture already has exactly as many pixels as the card shows. What sets the pace is the refresh the tile chose and the time the camera takes to answer. On the 4-inch Guition two single tiles side by side are about 25 KB each per refresh, and a tile over the whole page about 160 KB. A refresh every 5 seconds works there, but the larger the picture and the slower the Wi-Fi, the longer the screen spends reading it. From app 0.4.13 with firmware 0.9.0 no picture is larger than 1024 pixels either way or 1.25 MB once the screen has decoded it, on a board with 8 MB of PSRAM. Since firmware 0.52.0 that cap holds for each tile's picture alone, and a board with more memory takes larger pictures: one picture may be a third of its picture store, up to 2048 pixels a side (`picture_store::cap_for`). The P4 boards with 32 MB keep 6 MB of pictures, so 2 MB a picture: the 10-inch glass gets a camera over its whole page, its full view, a map and the screensaver at its own 1280 x 800. The screen says so with `cap` (the bytes) when it asks for a picture of the whole glass, and the app honours up to 4 MB (`camera_feed.picture_cap`); an older app keeps the common cap. A picture that is still smaller than its place sits in the middle of its card on a dark ground, so the name under it stays readable. A screen with firmware 0.3.3 or newer gets its live pictures in 8-bit colour: a palette of the picture's own 256 colours, dithered so a shade stays smooth. That is a third of the bytes of a 24-bit BMP (a 2 × 2 card on the 4-inch Guition: about 100 KB instead of 300 KB), at about the quality of the screen's own 16-bit colour. The screen decodes a BMP while it downloads, so fewer bytes means a picture that arrives sooner. Preparing the palette costs Home Assistant a few milliseconds per picture, also on a Raspberry Pi. Older firmware keeps 24-bit pictures. The camera's state ("Idle") is not written on the picture. Until the first picture arrives, the tile shows a spinner; a camera that has no picture for Home Assistant (a camera that only streams) keeps its icon and name as any tile does. Firmware before 0.3.7 ignores both settings on a single, double-width or full-page tile and keeps the small picture in the icon's place there. ## 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 Tessera 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: full screen at most the board's canvas (`camera.full` in boards.json: 480×480 on the 4-inch Guition, 800×480 on the Waveshare 4.3, 5 and 7, 1024×600 on the JC1060P470), an alert card at its frame (`camera.thumb`, 392×220 on the 4-inch Guition), proportions kept. Neither is ever larger than the cap above (`camera_feed.PICTURE_MAX_SIDE` and `PICTURE_MAX_BYTES`): the JC8012P4A1's 1280×800 canvas gets a full-screen picture of at most 1024×640, shown in the middle. 3. It serves the result as an uncompressed BMP on **port 8098** under a random link, and sends the link to the screen. The screen downloads it in a task of its own, beside its main loop (`components/smart_display/picture_fetch.cpp`, firmware 0.49.0+), and decodes the rows as they arrive straight into the pixels LVGL draws: touch, drawing and Home Assistant never wait for the network. Before, ESPHome's `online_image` downloaded in the main loop, 16 KB a turn, each turn waiting for its chunk; a page of album covers held the glass still for two to three seconds and a camera picture for the better part of one. A screen on another network or VLAN than Home Assistant needs to reach this port on Home Assistant's host; without it a camera tile shows its name and state but no picture, and the screen's log says `no connection to` the link's address. The full screen, the alert and the cover are 24-bit; live tile pictures go to firmware 0.3.3+ in 8-bit (see above). While a camera is open, the screen loads its link every four seconds, one image at a time. Each load sets the camera's next fetch for just before the next load: four seconds on, less the time the camera took to answer last time and half a second (`camera_feed.CameraFeed.ahead`), so the next load finds a snapshot a fraction of a second old and the picture changes at the screen's steady pace. A load whose fetch is still on its way waits up to 1.5 seconds for it, then takes the last snapshot. (Firmware before 0.52.0 got the snapshot fetched at the load before, a picture four seconds old; 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, beyond the one fetch the last load set. 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 (16 KB per round of its main loop since firmware 0.2.73, 4 KB before). 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 bench Guition it comes in about 1.8 s (2.8 s with 4 KB). ## Live on the P4 boards A camera opened full screen on a board with an ESP32-P4 (the reTerminal D1001, the M5Stack Tab5, the Waveshare ESP32-P4 panel and the Guition P4 boards) plays live, at the camera's own pace, about a tenth of a second behind the camera. Every other board keeps the still of every four seconds above. A map, an image entity and the screensaver stay stills on a P4 too. **How it travels.** The screen asks for the camera as for a still, with one key more: `live`, the room below its top bar in pixels (`1280x715`). An app that knows streams answers with a link on its camera port that ends in `.mjpeg`; an older app answers with a still, and the screen shows stills. The link serves `multipart/x-mixed-replace`: one baseline JPEG a part, each with its Content-Length, over plain HTTP/1.0. The app makes the next picture once the last one has left its host, always of the newest frame the camera gave, so a slow Wi-Fi gets fewer pictures and never older ones. At most four screens stream at once. **Where the pictures come from** (`screen_manager/app/live_feed.py`). Most of the work lands on Home Assistant's host, often a Raspberry Pi, so each kind of camera takes the cheapest road: - A camera with a stream of its own (an RTSP camera through Generic Camera, ONVIF, Reolink, UniFi Protect and others) comes through Home Assistant's WebRTC: its built-in go2rtc relays the camera's H.264 as it is, and the app receives it with aiortc and decodes it with PyAV. The P4 cannot decode H.264 (its hardware encodes it, and Espressif's software decoder takes only the baseline profile most cameras do not send), so Home Assistant's host does, once per camera however many screens watch it. The JPEG is made straight from the decoder's YUV with FFmpeg's encoder, at the camera's own size where that fits the room, or scaled down to it. Until the first frame (WebRTC waits for the camera's next key frame, a few seconds on some cameras) the screen gets the camera's still. - Any other camera (a snapshot camera, an ESPHome camera, a picture an integration draws) gives its snapshot, asked again as soon as the last one came, at most 15 a second. A JPEG the P4 decodes as it is (baseline, at most 1920 pixels a side) goes out untouched, which costs the app nothing; anything else is made into one. - A camera whose WebRTC does not start within 20 seconds sends its snapshots instead. **What it costs Home Assistant's host.** Decoding costs what the camera's resolution costs, so give a camera's tile the camera's low-resolution stream where the integration offers one (most do, as a second camera entity). Measured on a PC (2026-10-10, an EZVIZ camera): its 512 x 288 stream at 8 frames a second took 2% of one core for everything (receiving, decoding, JPEGs, sending), its 1920 x 1080 stream at 15 frames a second about 15%. A Raspberry Pi 4 core is several times slower, so on a Pi the low-resolution stream is the one to take. A JPEG from YUV took 1 ms for 512 x 288 and 7 ms for 1080p scaled to 720p, where Pillow from RGB took 3 and 19. **On the screen** (`components/smart_display/live_view.h`). A task of its own reads the stream, decodes each picture with the P4's hardware JPEG decoder and has the PPA scale it to fill the room and turn it the way LVGL turns its own drawing, straight into the panel's frame buffer. LVGL would take 200 ms to draw such a picture, with touch waiting meanwhile; past LVGL the main loop is not involved at all. The back key and the name stay LVGL's, on the view's black above the picture, and while the camera streams LVGL draws nothing in the picture's room (a card on the page behind it that changes would otherwise paint the view's black over the picture until its next frame). A stream that breaks is asked for again; after three breaks the view shows the stills of every board. The panel's frame buffer is found as ESP-IDF makes the panel for ESPHome's display (a linker wrap of `esp_lcd_new_panel_dpi`), so every display platform on a P4 serves. Measured on the reTerminal D1001 (2026-10-10): | The camera's stream | Pictures | Decoding | Scaling and turning | |---|---|---|---| | 512 x 288, scaled 2.4 times by the screen | 8 a second, the camera's pace | 2 ms | 15 ms | | 1920 x 1080, sent as 1264 x 704 | 15 a second, the camera's pace | 10 ms | 48 ms | Home Assistant's LL-HLS was 0.6 to 1.2 seconds behind the same camera and came in bursts of a second; its WebRTC 0.12. **Live tiles.** A camera tile with **Display → Live picture** has one more pace on a P4: **Live**, its first and default one, so a camera tile on a P4 is live unless its pace says otherwise. The editor offers it for a screen that streams (its hello says `live`, or, while it is offline, a P4 board with pictures); any other screen refreshes such a tile at 15 seconds, which is also what firmware before it makes of the pace (0). A live tile streams while it is on the glass, the pages stand still and nothing lies over them (a card, the camera full screen, an alert, the settings, the screensaver); until its first live picture, and whenever it does not stream, it shows its last still as every camera tile does. It asks for its stream with its picture's question and `live`, the card's size; the app sends its pictures plain at that size (cut to fill it, or whole, as the tile says: the link ends in `?fit=contain` for a whole one), and the screen lays over each of them what the app bakes into a still: the card's rounded corners over the page, the shade under the name (the bottom 42 %, to 150/255 black) and the name as LVGL draws it. A camera opened full screen from a live tile has its first picture in about 0.3 seconds instead of five: the tile's streams stop, and the app's source of the camera, still running, has the newest frame ready. The other way round, the cameras of a screen's live tiles keep running in the app for as long as that screen's full view streams (`LiveFeed.hold`), so its tiles are live again within a tenth of a second when it closes, rather than after the seconds a WebRTC session takes to start. Measured on the reTerminal D1001 with the EZVIZ: a 2 x 2 tile of its substream at 8 pictures a second (decoding 2 ms, laying out 32 ms), a single tile of its main stream at 15 (1 ms, 11 ms), beside each other. **The camera's sound.** On a P4 with a speaker (a board that includes `features/audio.yaml`) the full view plays the camera's sound, with a key at the right of its top bar that mutes it; muted stays muted for every camera until the screen starts again. The screen asks with `sound`; the app takes the sound through Home Assistant's WebRTC too, makes it 16-bit mono at 16 kHz and sends it in the same stream as parts of their own (`audio/x-s16le`), as it comes, never waiting for a picture's turn. On the screen the sound goes to the board's media speaker (`ts_media_speaker`), whose resampler makes it the bus's rate, whatever the board's: nothing in a board file is needed for it, the volume under Extras holds for it, an announcement plays over it, and on a board whose microphone and speaker share one bus the microphone pauses while it plays, as for any sound. A live tile on a board with a speaker asks with `sound` too: the app then takes the sound along in the tile's own WebRTC session (and drops it while nobody listens), so the full view opened from that tile has its sound at once. **Nothing of LVGL's under a live picture.** A live picture writes past LVGL, so it stops the moment anything LVGL draws lies over its room, and starts again once nothing does. What counts follows LVGL's own order of drawing (`drawn_over` in `runtime_tiles.h`): on the way from the tile or the camera view up to its screen or layer, every object that comes after it (a card opened on the page itself, as a lamp's colour card is); for a tile, anything on the top layer (the update's progress, an alert, the camera full screen, the screensaver) and its page no longer on the glass; and anything on the system layer. A plain, clear container is not in the way by itself, only what it holds. No list of overlays: one that comes later is covered by the same rule. Beside it, every card over the page (the detail card, the colour card, the effects and lamps pages) is one notion, `card_open()`, which stops the live tiles of the page at once. The check runs on the board's 50 ms interval and as LVGL starts every drawing (`LV_EVENT_REFR_START`, `live_stop_covered`), so it also holds for a drawing the main loop does not run for: an update holds the loop and has its progress drawn at once. **Nothing of LVGL's over a live picture.** While a picture streams, LVGL must not draw its room: what it has there (the full view's black, a tile's still with its spinner and name) would cover the live picture until its next one. So the list of what LVGL is about to draw is cut against where the picture of every stream lies (a tile's whole card, a full view's picture as large as fits its room; beside a picture of another shape LVGL draws the room's black itself), once, as LVGL starts drawing (`LV_EVENT_RENDER_START`, `keep_out` in `live_view.cpp`). That one place catches every way LVGL comes to draw there: a card that changes beside a tile, an area queued before a stream's first picture, two areas LVGL joined into one, and the whole glass, which LVGL draws when more areas come in one frame than its list holds. Each piece goes through the display's own rounding, as LVGL's own areas do. When a picture lies elsewhere or at another size (a doorbell's 16:9 snapshot first, then its 4:3 stream), LVGL draws where the last one lay again, so nothing of it stays beside the new one. When a stream ends, LVGL draws its room again. **The panel's share of the memory bus.** The DSI bridge reads the frame buffer from PSRAM without pause, a frame every 16 ms. When another DMA holds the bus too long (the PPA scaling and turning a picture, the JPEG decoder, the DMA2D that copies what LVGL drew), the bridge runs dry and the panel shows a frame of blue, its colour for no data; ESP-IDF only logs that from its interrupt, on the UART. A tester with a Guition 10.1-inch P4 (pixel clock 70 MHz) saw such flashes while a camera streamed. So the firmware does two things on a P4 (`live_view.cpp`, "The panel's share of the memory bus"). It counts: the bridge's underrun flag is read every 50 ms and the count stands in every stream report (`the panel ran dry N times since boot`), so a log says whether a change helped. And it shortens every PPA burst on the screen from ESP-IDF's 128 bytes to 64, the streams' own clients and ESPHome's LVGL (which turns every flush of a turned glass with the PPA) alike, through a linker wrap of `ppa_register_client`: between shorter bursts the bus turns to the bridge. LVGL took the same way for the same underrun under its own PPA draw unit (lvgl/lvgl #9590). A copy takes a little longer for it. The reTerminal D1001 (pixel clock 80 MHz) counts none, with or without the change. **One writer on the glass at a time.** LVGL holds the glass for the whole of each refresh (from `LV_EVENT_REFR_START` to `LV_EVENT_REFR_READY`: deciding what to draw, drawing it, flushing it to the panel) and a stream's task holds it for each copy of a picture, one recursive mutex in `live_view.cpp` (`glass`). Without it a stream's first picture could land while LVGL was still drawing the still and the name it had decided on before the stream armed, and that drawing then lay over the picture until the next one: a tile's name stood over its live picture after the camera full screen closed (2026-10-12, the D1001). `close()` takes the same lock, so once it returns nothing of the stream writes the glass. **A live picture stays where its card was.** A stream writes at the room its card had when it asked. The moment that card lies elsewhere (a page animated, a layout made anew) the tile stops before LVGL draws, LVGL draws the old room again, and the tile asks again from where its card is now (`live_stop_covered`). **The network of a P4.** Every P4 board reaches its Wi-Fi through an ESP32-C6 over SDIO, where a packet's round trip takes long enough that ESP-IDF's default TCP receive window (5,760 bytes) held a download at about 0.7 MB/s. The P4 boards build with a window of 64 KB (`components/smart_display/__init__.py`, keyed on the chip): about 2.6 MB/s, for the live camera and for every picture. The other boards keep the default: the window holds for every connection, and a board without PSRAM has no room for it. ## Network - **Home Assistant OS:** the app publishes port 8098 on the Home Assistant host. When another app already uses 8098, pick another port in the app's network settings: the app sends the screens links on the port you chose (app 0.4.24+). The screens need to reach Home Assistant's address on it. - **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 (at most 392 x 300 = 235 KB decoded since firmware 0.2.103, never more than the screen's own full-screen picture). 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. In standby only the screensaver's picture loads, or the tiles' pictures while the dimmed tiles are seen (see the live pictures above). - The first image (firmware 0.2.73, measured 2026-09-19 with an EZVIZ camera): the screen asks when the camera opens and loads the link as soon as it comes. About 2 s when the app still has the camera's last snapshot (it keeps one for 30 s after the last load), 4.5 s when it must ask Home Assistant first, of which 2.4 s is the camera's own snapshot. Firmware 0.2.72 took 3.4 s and 5.8 s. - Wi-Fi without power save (firmware 0.2.74 sets `power_save_mode: none` itself) makes the screen wait on its Wi-Fi less often while an image comes in: a loop held over 50 ms in one of eleven opens, against five of twelve with power save. ## One route for every picture Every picture on a screen goes the same way (firmware 0.52.0, `components/smart_display/picture_loader.h`): the camera full screen and the screensaver, an alert's picture, the media card's cover, a player's library, each tile's own picture, and the cover of a media tile over a whole page (on the glass, and fetched ahead for a kept page). Each of them only says, every quarter second, what it wants to see: a key that names the picture, the download slot it comes through, how much it matters, and how the app is asked for it. One loader decides for all of them: - **The store says what is there.** Whether a picture is had is read from the store every round, never from a flag an owner keeps, so a picture that left the store (to make room) is asked for again at once. - **One download at a time, the most urgent first:** an alert, the full view and the screensaver, the media card, the library, the tiles on the glass, and last a picture fetched ahead for a page out of sight. Of equal ones, a picture not yet there goes before a refresh, then the oldest picture. Something on the glass breaks off a download for a page out of sight; a download nobody wants any more breaks off too. Pages turned fast, a card opened and closed before its picture came: nothing is left behind, and the page that stays loads first. - **Asking is free, loading waits.** Asking the app is an event and goes at once; a download waits for the finger to leave the glass and the pages to stand still. A picture fetched ahead asks only once nothing on the glass waits. - **One rule for cleaning up.** A picture its owner moved on from (another track, another focus on a map, someone on a map who moved) goes once nothing draws it, and so does the cover of a track its player no longer plays. What an owner still holds stays, so a page that comes back has its pictures at once; when the store is full, by bytes or by places, the picture used longest ago that nothing draws makes way (`picture_store.h`). The screen's log says what the store holds whenever that changes (`store: 5 pictures, 578 of 1638 KB (cover tile tile cover tile), 0 to go`). ## For developers - `screen_manager/app/camera_feed.py`: fetching, sizing, links and the port; `CameraFeed.live` makes a tile's picture (with `tile_art.encode`) and an older screen's strip (`encode_live`); `Manager.answer_live` in `server.py` checks the tiles against the layout. - `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/picture_loader.h`: the one route every picture goes (`tests/test_picture_loader.cpp` drives it through fast page turns, cards closed before their picture, priorities, pictures that left the store, refreshes, answers without a picture and failures). - `components/smart_display/tile_picture.h`: a tile's own picture, its frame, its key, the question and how an answer finds its tile, and the parts of a plugin tile's picture (`tests/test_tile_picture.cpp`). - `components/smart_display/runtime_tiles.h`: what each picture wants (`pictures_round` and the `*_want` functions beside it), the full-screen view, the alert picture, the tiles' pictures (`card_picture_wants`, `tile_ask`, `tile_picture_place`), the store (`picture_of`, `pictures_collect`) and the `camera` message. - `components/smart_display/picture_fetch.h` and `picture_fetch.cpp`: the download beside the main loop, one task per picture, and the BMP decoder (`tests/test_picture_fetch.cpp` checks the link, the answer's head and the decoder). - `packages/features/camera.yaml` (included by every board with camera pictures): the three pictures bound to their cards (the camera full screen and the cover, the alert's picture, the tiles' own pictures), the 50 ms hand-off of a finished download, the `LV_USE_IMAGE` flag, 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. - `screen_manager/app/live_feed.py`: a camera live on a P4, its links, the source per camera (WebRTC or snapshots), the stream and a tile's pace (`live_pace`) (`tests/test_live_feed.py`); `components/smart_display/live_view.h` and `live_view.cpp`: the screen's streams, up to seven at once (`tests/test_live_view.cpp` checks the stream's framing, where a picture lies on a turned panel, how it is scaled, cut into a card and what is laid over it); `runtime_tiles.h` (`live_tiles_round`, `live_tile_answer`, `live_tick`): which tiles stream and when. - Every step of every picture is in the screen's log at DEBUG (`logger: level: DEBUG` in a screen's Override YAML), under the tag `picture`: who wants which picture, the question and its answer, the download, the store keeping, renewing, retiring and freeing each copy (and why), a picture put on its card on the glass or on a kept page, and an answer to an older question dropped. The `store:` line at INFO sums it up whenever it changes; "0 to go" means no copy waits to be freed.