Files
esphome/tests/integration/test_snapshot_display.py
T

182 lines
7.7 KiB
Python

"""Integration test for the snapshot display and the file writing shared with other displays."""
from __future__ import annotations
import asyncio
import io
from pathlib import Path
from aioesphomeapi import LogLevel
from PIL import Image, UnidentifiedImageError
import pytest
from .bmp_utils import Bmp, capture_when_drawn, wait_for_bmp
from .types import APIClientConnectedFactory, RunCompiledFunction
WIDTH = 101
HEIGHT = 64
ANIMATION_FRAMES = 5
# The fixture asks for 20 frames a second, and a GIF counts time in milliseconds here.
ANIMATION_FRAME_MS = 50
NOISE_WIDTH = 200
NOISE_HEIGHT = 150
NOISE_FRAMES = 3
# Part of the message the writer logs when it will not write over a file that is already there.
REFUSAL_MESSAGE = b"not overwriting"
# Part of the message logged when an animation is asked for while another is still being recorded.
BUSY_MESSAGE = b"Already recording"
async def wait_for_gif(path: Path, frames: int, timeout: float = 5.0) -> Image.Image:
"""Wait for a complete animated GIF with the given number of frames and return it.
The file exists from the moment the recording starts and grows a frame at a time, so keep
reading until it holds all of them and ends with the GIF trailer.
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
try:
data = path.read_bytes()
# Open the bytes just read, so the trailer check and the decode see the same file.
image = Image.open(io.BytesIO(data))
if data.endswith(b";") and image.n_frames == frames:
# Decoding every frame proves the compressed data is all there and valid.
for frame in range(frames):
image.seek(frame)
image.load()
image.seek(0)
return image
except (FileNotFoundError, UnidentifiedImageError, OSError, EOFError):
pass
await asyncio.sleep(0.05)
raise AssertionError(
f"no complete {frames} frame GIF appeared at {path} within {timeout}s"
)
def bmp_as_image(bmp: Bmp) -> Image.Image:
"""The picture in a decoded BMP, as an RGB image."""
return Image.frombytes(
"RGB", (bmp.width, bmp.height), bmp.pixels, "raw", "BGR", 0, -1
)
@pytest.mark.asyncio
async def test_snapshot_display(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A display with no screen draws into memory and writes what it drew to a file."""
snapshot_dir = tmp_path / "snapshots"
# The device reads this when it writes a file; the subprocess inherits our environment, so it
# must be set before the binary is launched.
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
async with run_compiled(yaml_config), api_client_connected() as client:
_, services = await client.list_entities_services()
service = next(s for s in services if s.name == "take_snapshot")
async def take(name: str) -> None:
await client.execute_service(service, {"name": name})
animation_service = next(s for s in services if s.name == "take_animation")
# The test card is drawn in several colours, so once it is on the screen the picture is
# not one flat shade. Capturing until that is true waits out the first update rather than
# racing it.
image, capture = await capture_when_drawn(take, snapshot_dir)
assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24)
# Asking for frames records a GIF: every frame is the size of the display and lasts as long
# as the frame rate says. The test card does not move, so every frame is the picture
# captured above.
await client.execute_service(animation_service, {"name": "movie"})
movie = await wait_for_gif(snapshot_dir / "movie.gif", ANIMATION_FRAMES)
assert movie.size == (WIDTH, HEIGHT)
expected = bmp_as_image(image)
for frame in range(ANIMATION_FRAMES):
movie.seek(frame)
assert movie.info["duration"] == ANIMATION_FRAME_MS
assert movie.convert("RGB").tobytes() == expected.tobytes()
# A picture of 256 colours in no pattern makes a long run of compression codes, so recording
# it passes the point where the code width grows and where the dictionary is started over.
# The picture uses no more than 256 colours, so it is stored exactly and every frame must
# come back identical to the one captured as a BMP.
noise_service = next(s for s in services if s.name == "take_noise_snapshot")
noise_animation = next(s for s in services if s.name == "take_noise_animation")
async def take_noise(name: str) -> None:
await client.execute_service(noise_service, {"name": name})
noise, _ = await capture_when_drawn(take_noise, snapshot_dir, "noisedrawn")
assert (noise.width, noise.height) == (NOISE_WIDTH, NOISE_HEIGHT)
noise_expected = bmp_as_image(noise)
assert (
len(noise_expected.getcolors(NOISE_WIDTH * NOISE_HEIGHT)) > 200
) # a busy picture
await client.execute_service(noise_animation, {"name": "noise"})
noise_movie = await wait_for_gif(
snapshot_dir / "noise.gif", NOISE_FRAMES, timeout=15.0
)
for frame in range(NOISE_FRAMES):
noise_movie.seek(frame)
assert noise_movie.convert("RGB").tobytes() == noise_expected.tobytes()
# An extension is only added when there is not one already, whatever its case.
await take("UPPER.BMP")
await wait_for_bmp(snapshot_dir / "UPPER.BMP")
# A name that tries to lead somewhere else is cut back to one harmless name in the
# snapshot directory.
await take("../escape")
await wait_for_bmp(snapshot_dir / ".._escape.bmp")
# The device says so when it refuses a request. Waiting for that message is the only way to
# tell a refusal from a request the device has not got to yet: an unchanged file, or a file
# that never appears, would look the same on a busy machine.
refused = asyncio.Event()
busy = asyncio.Event()
def on_log(msg) -> None:
if REFUSAL_MESSAGE in msg.message:
refused.set()
if BUSY_MESSAGE in msg.message:
busy.set()
client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
# A display records one animation at a time. A second request made while the first is still
# running is refused and leaves no file, and the first one carries on to the end.
slow_animation = next(s for s in services if s.name == "take_slow_animation")
await client.execute_service(slow_animation, {"name": "slow"})
await client.execute_service(slow_animation, {"name": "second"})
await asyncio.wait_for(busy.wait(), timeout=10.0)
assert not (snapshot_dir / "second.gif").exists()
await wait_for_gif(snapshot_dir / "slow.gif", 3)
# A second capture under a name already used must fail rather than write over the first.
before = capture.read_bytes()
await take(capture.name)
await asyncio.wait_for(refused.wait(), timeout=10.0)
assert capture.read_bytes() == before
# Nothing beyond what was asked for, leaving out however many captures it took to wait
# for the first frame.
written = sorted(
p.name for p in snapshot_dir.iterdir() if "drawn-" not in p.name
)
assert written == [
".._escape.bmp",
"UPPER.BMP",
"movie.gif",
"noise.gif",
"slow.gif",
]