From 8778763a3ee2a3e4cbb4bf711e43d9849fd0582a Mon Sep 17 00:00:00 2001 From: Daniel Schall Date: Wed, 27 Dec 2023 12:19:25 -0800 Subject: [PATCH] Synchronize and cache Generic Camera still image fetching (#105821) --- homeassistant/components/generic/camera.py | 53 ++++++++++---- tests/components/generic/test_camera.py | 83 ++++++++++++++++++++++ 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 9ffd873efd64..f4c02a2ab9f7 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,7 +1,9 @@ """Support for IP Cameras.""" from __future__ import annotations +import asyncio from collections.abc import Mapping +from datetime import datetime, timedelta import logging from typing import Any @@ -129,6 +131,8 @@ class GenericCamera(Camera): """A generic implementation of an IP camera.""" _last_image: bytes | None + _last_update: datetime + _update_lock: asyncio.Lock def __init__( self, @@ -172,6 +176,8 @@ class GenericCamera(Camera): self._last_url = None self._last_image = None + self._last_update = datetime.min + self._update_lock = asyncio.Lock() self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, @@ -198,22 +204,39 @@ class GenericCamera(Camera): if url == self._last_url and self._limit_refetch: return self._last_image - try: - async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) - response = await async_client.get( - url, auth=self._auth, follow_redirects=True, timeout=GET_IMAGE_TIMEOUT - ) - response.raise_for_status() - self._last_image = response.content - except httpx.TimeoutException: - _LOGGER.error("Timeout getting camera image from %s", self._name) - return self._last_image - except (httpx.RequestError, httpx.HTTPStatusError) as err: - _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) - return self._last_image + async with self._update_lock: + if ( + self._last_image is not None + and url == self._last_url + and self._last_update + timedelta(0, self._attr_frame_interval) + > datetime.now() + ): + return self._last_image - self._last_url = url - return self._last_image + try: + update_time = datetime.now() + async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) + response = await async_client.get( + url, + auth=self._auth, + follow_redirects=True, + timeout=GET_IMAGE_TIMEOUT, + ) + response.raise_for_status() + self._last_image = response.content + self._last_update = update_time + + except httpx.TimeoutException: + _LOGGER.error("Timeout getting camera image from %s", self._name) + return self._last_image + except (httpx.RequestError, httpx.HTTPStatusError) as err: + _LOGGER.error( + "Error getting new camera image from %s: %s", self._name, err + ) + return self._last_image + + self._last_url = url + return self._last_image @property def name(self): diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 8bfd0a66dd5e..70746f70c9ae 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -1,9 +1,11 @@ """The tests for generic camera component.""" import asyncio +from datetime import timedelta from http import HTTPStatus from unittest.mock import patch import aiohttp +from freezegun.api import FrozenDateTimeFactory import httpx import pytest import respx @@ -49,6 +51,7 @@ async def test_fetching_url( "username": "user", "password": "pass", "authentication": "basic", + "framerate": 20, } }, ) @@ -63,10 +66,87 @@ async def test_fetching_url( body = await resp.read() assert body == fakeimgbytes_png + # sleep .1 seconds to make cached image expire + await asyncio.sleep(0.1) + resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 2 +@respx.mock +async def test_image_caching( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + fakeimgbytes_png, +) -> None: + """Test that the image is cached and not fetched more often than the framerate indicates.""" + respx.get("http://example.com").respond(stream=fakeimgbytes_png) + + framerate = 5 + await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + "authentication": "basic", + "framerate": framerate, + } + }, + ) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # time is frozen, image should have come from cache + assert respx.calls.call_count == 1 + + # advance time by 150ms + freezer.tick(timedelta(seconds=0.150)) + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # Only 150ms have passed, image should still have come from cache + assert respx.calls.call_count == 1 + + # advance time by another 150ms + freezer.tick(timedelta(seconds=0.150)) + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # 300ms have passed, now we should have fetched a new image + assert respx.calls.call_count == 2 + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # Still only 300ms have passed, should have returned the cached image + assert respx.calls.call_count == 2 + + @respx.mock async def test_fetching_without_verify_ssl( hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png @@ -468,6 +548,7 @@ async def test_timeout_cancelled( "still_image_url": "http://example.com", "username": "user", "password": "pass", + "framerate": 20, } }, ) @@ -497,6 +578,8 @@ async def test_timeout_cancelled( ] for total_calls in range(2, 4): + # sleep .1 seconds to make cached image expire + await asyncio.sleep(0.1) resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == total_calls assert resp.status == HTTPStatus.OK