mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 00:37:53 +00:00
Synchronize and cache Generic Camera still image fetching (#105821)
This commit is contained in:
parent
5545883400
commit
8778763a3e
@ -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):
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user