Synchronize and cache Generic Camera still image fetching (#105821)
This commit is contained in:
parent
5545883400
commit
8778763a3e
2 changed files with 121 additions and 15 deletions
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue