Synchronize and cache Generic Camera still image fetching (#105821)

This commit is contained in:
Daniel Schall 2023-12-27 12:19:25 -08:00 committed by GitHub
parent 5545883400
commit 8778763a3e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 121 additions and 15 deletions

View file

@ -1,7 +1,9 @@
"""Support for IP Cameras.""" """Support for IP Cameras."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Mapping from collections.abc import Mapping
from datetime import datetime, timedelta
import logging import logging
from typing import Any from typing import Any
@ -129,6 +131,8 @@ class GenericCamera(Camera):
"""A generic implementation of an IP camera.""" """A generic implementation of an IP camera."""
_last_image: bytes | None _last_image: bytes | None
_last_update: datetime
_update_lock: asyncio.Lock
def __init__( def __init__(
self, self,
@ -172,6 +176,8 @@ class GenericCamera(Camera):
self._last_url = None self._last_url = None
self._last_image = None self._last_image = None
self._last_update = datetime.min
self._update_lock = asyncio.Lock()
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, identifier)}, identifiers={(DOMAIN, identifier)},
@ -198,22 +204,39 @@ class GenericCamera(Camera):
if url == self._last_url and self._limit_refetch: if url == self._last_url and self._limit_refetch:
return self._last_image return self._last_image
try: async with self._update_lock:
async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) if (
response = await async_client.get( self._last_image is not None
url, auth=self._auth, follow_redirects=True, timeout=GET_IMAGE_TIMEOUT and url == self._last_url
) and self._last_update + timedelta(0, self._attr_frame_interval)
response.raise_for_status() > datetime.now()
self._last_image = response.content ):
except httpx.TimeoutException: return self._last_image
_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 try:
return self._last_image 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 @property
def name(self): def name(self):

View file

@ -1,9 +1,11 @@
"""The tests for generic camera component.""" """The tests for generic camera component."""
import asyncio import asyncio
from datetime import timedelta
from http import HTTPStatus from http import HTTPStatus
from unittest.mock import patch from unittest.mock import patch
import aiohttp import aiohttp
from freezegun.api import FrozenDateTimeFactory
import httpx import httpx
import pytest import pytest
import respx import respx
@ -49,6 +51,7 @@ async def test_fetching_url(
"username": "user", "username": "user",
"password": "pass", "password": "pass",
"authentication": "basic", "authentication": "basic",
"framerate": 20,
} }
}, },
) )
@ -63,10 +66,87 @@ async def test_fetching_url(
body = await resp.read() body = await resp.read()
assert body == fakeimgbytes_png 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") resp = await client.get("/api/camera_proxy/camera.config_test")
assert respx.calls.call_count == 2 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 @respx.mock
async def test_fetching_without_verify_ssl( async def test_fetching_without_verify_ssl(
hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png
@ -468,6 +548,7 @@ async def test_timeout_cancelled(
"still_image_url": "http://example.com", "still_image_url": "http://example.com",
"username": "user", "username": "user",
"password": "pass", "password": "pass",
"framerate": 20,
} }
}, },
) )
@ -497,6 +578,8 @@ async def test_timeout_cancelled(
] ]
for total_calls in range(2, 4): 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") resp = await client.get("/api/camera_proxy/camera.config_test")
assert respx.calls.call_count == total_calls assert respx.calls.call_count == total_calls
assert resp.status == HTTPStatus.OK assert resp.status == HTTPStatus.OK