1
mirror of https://github.com/home-assistant/core synced 2024-07-15 09:42:11 +02:00

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."""
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):

View File

@ -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