Home Assistant Unofficial Reference 2024.12.1
camera.py
Go to the documentation of this file.
1 """Provide animated GIF loops of Buienradar imagery."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from datetime import datetime, timedelta
7 import logging
8 
9 import aiohttp
10 import voluptuous as vol
11 
12 from homeassistant.components.camera import Camera
13 from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE
14 from homeassistant.core import HomeAssistant
15 from homeassistant.helpers.aiohttp_client import async_get_clientsession
16 from homeassistant.helpers.entity_platform import AddEntitiesCallback
17 from homeassistant.util import dt as dt_util
18 
19 from . import BuienRadarConfigEntry
20 from .const import CONF_DELTA, DEFAULT_COUNTRY, DEFAULT_DELTA, DEFAULT_DIMENSION
21 
22 _LOGGER = logging.getLogger(__name__)
23 
24 # Maximum range according to docs
25 DIM_RANGE = vol.All(vol.Coerce(int), vol.Range(min=120, max=700))
26 
27 # Multiple choice for available Radar Map URL
28 SUPPORTED_COUNTRY_CODES = ["NL", "BE"]
29 
30 
32  hass: HomeAssistant,
33  entry: BuienRadarConfigEntry,
34  async_add_entities: AddEntitiesCallback,
35 ) -> None:
36  """Set up buienradar radar-loop camera component."""
37  config = entry.data
38  options = entry.options
39 
40  country = options.get(
41  CONF_COUNTRY_CODE, config.get(CONF_COUNTRY_CODE, DEFAULT_COUNTRY)
42  )
43 
44  delta = options.get(CONF_DELTA, config.get(CONF_DELTA, DEFAULT_DELTA))
45 
46  latitude = config.get(CONF_LATITUDE, hass.config.latitude)
47  longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
48 
49  async_add_entities([BuienradarCam(latitude, longitude, delta, country)])
50 
51 
53  """A camera component producing animated buienradar radar-imagery GIFs.
54 
55  Rain radar imagery camera based on image URL taken from [0].
56 
57  [0]: https://www.buienradar.nl/overbuienradar/gratis-weerdata
58  """
59 
60  _attr_entity_registry_enabled_default = False
61  _attr_name = "Buienradar"
62 
63  def __init__(
64  self, latitude: float, longitude: float, delta: float, country: str
65  ) -> None:
66  """Initialize the component.
67 
68  This constructor must be run in the event loop.
69  """
70  super().__init__()
71 
72  # dimension (x and y) of returned radar image
73  self._dimension_dimension = DEFAULT_DIMENSION
74 
75  # time a cached image stays valid for
76  self._delta_delta = delta
77 
78  # country location
79  self._country_country = country
80 
81  # Condition that guards the loading indicator.
82  #
83  # Ensures that only one reader can cause an http request at the same
84  # time, and that all readers are notified after this request completes.
85  #
86  # invariant: this condition is private to and owned by this instance.
87  self._condition_condition = asyncio.Condition()
88 
89  self._last_image_last_image: bytes | None = None
90  # value of the last seen last modified header
91  self._last_modified_last_modified: str | None = None
92  # loading status
93  self._loading_loading = False
94  # deadline for image refresh - self.delta after last successful load
95  self._deadline_deadline: datetime | None = None
96 
97  self._attr_unique_id_attr_unique_id = f"{latitude:2.6f}{longitude:2.6f}"
98 
99  def __needs_refresh(self) -> bool:
100  if not (self._delta_delta and self._deadline_deadline and self._last_image_last_image):
101  return True
102 
103  return dt_util.utcnow() > self._deadline_deadline
104 
105  async def __retrieve_radar_image(self) -> bool:
106  """Retrieve new radar image and return whether this succeeded."""
107  session = async_get_clientsession(self.hasshass)
108 
109  url = (
110  f"https://api.buienradar.nl/image/1.0/RadarMap{self._country}"
111  f"?w={self._dimension}&h={self._dimension}"
112  )
113 
114  if self._last_modified_last_modified:
115  headers = {"If-Modified-Since": self._last_modified_last_modified}
116  else:
117  headers = {}
118 
119  try:
120  async with session.get(
121  url, timeout=aiohttp.ClientTimeout(total=5), headers=headers
122  ) as res:
123  res.raise_for_status()
124 
125  if res.status == 304:
126  _LOGGER.debug("HTTP 304 - success")
127  return True
128 
129  if last_modified := res.headers.get("Last-Modified"):
130  self._last_modified_last_modified = last_modified
131 
132  self._last_image_last_image = await res.read()
133  _LOGGER.debug("HTTP 200 - Last-Modified: %s", last_modified)
134 
135  return True
136  except (TimeoutError, aiohttp.ClientError) as err:
137  _LOGGER.error("Failed to fetch image, %s", type(err))
138  return False
139 
141  self, width: int | None = None, height: int | None = None
142  ) -> bytes | None:
143  """Return a still image response from the camera.
144 
145  Uses asyncio conditions to make sure only one task enters the critical
146  section at the same time. Otherwise, two http requests would start
147  when two tabs with Home Assistant are open.
148 
149  The condition is entered in two sections because otherwise the lock
150  would be held while doing the http request.
151 
152  A boolean (_loading) is used to indicate the loading status instead of
153  _last_image since that is initialized to None.
154 
155  For reference:
156  * :func:`asyncio.Condition.wait` releases the lock and acquires it
157  again before continuing.
158  * :func:`asyncio.Condition.notify_all` requires the lock to be held.
159  """
160  if not self.__needs_refresh__needs_refresh():
161  return self._last_image_last_image
162 
163  # get lock, check iff loading, await notification if loading
164  async with self._condition_condition:
165  # cannot be tested - mocked http response returns immediately
166  if self._loading_loading:
167  _LOGGER.debug("already loading - waiting for notification")
168  await self._condition_condition.wait()
169  return self._last_image_last_image
170 
171  # Set loading status **while holding lock**, makes other tasks wait
172  self._loading_loading = True
173 
174  try:
175  now = dt_util.utcnow()
176  was_updated = await self.__retrieve_radar_image__retrieve_radar_image()
177  # was updated? Set new deadline relative to now before loading
178  if was_updated:
179  self._deadline_deadline = now + timedelta(seconds=self._delta_delta)
180 
181  return self._last_image_last_image
182  finally:
183  # get lock, unset loading status, notify all waiting tasks
184  async with self._condition_condition:
185  self._loading_loading = False
186  self._condition_condition.notify_all()
bytes|None async_camera_image(self, int|None width=None, int|None height=None)
Definition: camera.py:142
None __init__(self, float latitude, float longitude, float delta, str country)
Definition: camera.py:65
None async_setup_entry(HomeAssistant hass, BuienRadarConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: camera.py:35
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)