Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Reolink integration for HomeAssistant."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from datetime import timedelta
7 import logging
8 
9 from reolink_aio.api import RETRY_ATTEMPTS
10 from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
11 
12 from homeassistant.config_entries import ConfigEntryState
13 from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
14 from homeassistant.core import HomeAssistant
15 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
16 from homeassistant.helpers import (
17  config_validation as cv,
18  device_registry as dr,
19  entity_registry as er,
20 )
21 from homeassistant.helpers.device_registry import format_mac
22 from homeassistant.helpers.typing import ConfigType
23 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
24 
25 from .const import CONF_USE_HTTPS, DOMAIN
26 from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin
27 from .host import ReolinkHost
28 from .services import async_setup_services
29 from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch
30 
31 _LOGGER = logging.getLogger(__name__)
32 
33 PLATFORMS = [
34  Platform.BINARY_SENSOR,
35  Platform.BUTTON,
36  Platform.CAMERA,
37  Platform.LIGHT,
38  Platform.NUMBER,
39  Platform.SELECT,
40  Platform.SENSOR,
41  Platform.SIREN,
42  Platform.SWITCH,
43  Platform.UPDATE,
44 ]
45 DEVICE_UPDATE_INTERVAL = timedelta(seconds=60)
46 FIRMWARE_UPDATE_INTERVAL = timedelta(hours=12)
47 NUM_CRED_ERRORS = 3
48 
49 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
50 
51 
52 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
53  """Set up Reolink shared code."""
54 
56  return True
57 
58 
60  hass: HomeAssistant, config_entry: ReolinkConfigEntry
61 ) -> bool:
62  """Set up Reolink from a config entry."""
63  host = ReolinkHost(hass, config_entry.data, config_entry.options)
64 
65  try:
66  await host.async_init()
67  except (UserNotAdmin, CredentialsInvalidError, PasswordIncompatible) as err:
68  await host.stop()
69  raise ConfigEntryAuthFailed(err) from err
70  except (
71  ReolinkException,
72  ReolinkError,
73  ) as err:
74  await host.stop()
75  raise ConfigEntryNotReady(
76  f"Error while trying to setup {host.api.host}:{host.api.port}: {err!s}"
77  ) from err
78  except BaseException:
79  await host.stop()
80  raise
81 
82  config_entry.async_on_unload(
83  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop)
84  )
85 
86  # update the port info if needed for the next time
87  if (
88  host.api.port != config_entry.data[CONF_PORT]
89  or host.api.use_https != config_entry.data[CONF_USE_HTTPS]
90  ):
91  _LOGGER.warning(
92  "HTTP(s) port of Reolink %s, changed from %s to %s",
93  host.api.nvr_name,
94  config_entry.data[CONF_PORT],
95  host.api.port,
96  )
97  data = {
98  **config_entry.data,
99  CONF_PORT: host.api.port,
100  CONF_USE_HTTPS: host.api.use_https,
101  }
102  hass.config_entries.async_update_entry(config_entry, data=data)
103 
104  async def async_device_config_update() -> None:
105  """Update the host state cache and renew the ONVIF-subscription."""
106  async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
107  try:
108  await host.update_states()
109  except CredentialsInvalidError as err:
110  host.credential_errors += 1
111  if host.credential_errors >= NUM_CRED_ERRORS:
112  await host.stop()
113  raise ConfigEntryAuthFailed(err) from err
114  raise UpdateFailed(str(err)) from err
115  except ReolinkError as err:
116  host.credential_errors = 0
117  raise UpdateFailed(str(err)) from err
118 
119  host.credential_errors = 0
120 
121  async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
122  await host.renew()
123 
124  if host.api.new_devices and config_entry.state == ConfigEntryState.LOADED:
125  # Their are new cameras/chimes connected, reload to add them.
126  hass.async_create_task(
127  hass.config_entries.async_reload(config_entry.entry_id)
128  )
129 
130  async def async_check_firmware_update() -> None:
131  """Check for firmware updates."""
132  async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)):
133  try:
134  await host.api.check_new_firmware(host.firmware_ch_list)
135  except ReolinkError as err:
136  if host.starting:
137  _LOGGER.debug(
138  "Error checking Reolink firmware update at startup "
139  "from %s, possibly internet access is blocked",
140  host.api.nvr_name,
141  )
142  return
143 
144  raise UpdateFailed(
145  f"Error checking Reolink firmware update from {host.api.nvr_name}, "
146  "if the camera is blocked from accessing the internet, "
147  "disable the update entity"
148  ) from err
149  finally:
150  host.starting = False
151 
152  device_coordinator = DataUpdateCoordinator(
153  hass,
154  _LOGGER,
155  config_entry=config_entry,
156  name=f"reolink.{host.api.nvr_name}",
157  update_method=async_device_config_update,
158  update_interval=DEVICE_UPDATE_INTERVAL,
159  )
160  firmware_coordinator = DataUpdateCoordinator(
161  hass,
162  _LOGGER,
163  config_entry=config_entry,
164  name=f"reolink.{host.api.nvr_name}.firmware",
165  update_method=async_check_firmware_update,
166  update_interval=FIRMWARE_UPDATE_INTERVAL,
167  )
168 
169  # If camera WAN blocked, firmware check fails and takes long, do not prevent setup
170  config_entry.async_create_background_task(
171  hass,
172  firmware_coordinator.async_refresh(),
173  f"Reolink firmware check {config_entry.entry_id}",
174  )
175  # Fetch initial data so we have data when entities subscribe
176  try:
177  await device_coordinator.async_config_entry_first_refresh()
178  except BaseException:
179  await host.stop()
180  raise
181 
182  config_entry.runtime_data = ReolinkData(
183  host=host,
184  device_coordinator=device_coordinator,
185  firmware_coordinator=firmware_coordinator,
186  )
187 
188  migrate_entity_ids(hass, config_entry.entry_id, host)
189 
190  await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
191 
192  config_entry.async_on_unload(
193  config_entry.add_update_listener(entry_update_listener)
194  )
195 
196  return True
197 
198 
200  hass: HomeAssistant, config_entry: ReolinkConfigEntry
201 ) -> None:
202  """Update the configuration of the host entity."""
203  await hass.config_entries.async_reload(config_entry.entry_id)
204 
205 
207  hass: HomeAssistant, config_entry: ReolinkConfigEntry
208 ) -> bool:
209  """Unload a config entry."""
210  host: ReolinkHost = config_entry.runtime_data.host
211 
212  await host.stop()
213 
214  return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
215 
216 
218  hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry
219 ) -> bool:
220  """Remove a device from a config entry."""
221  host: ReolinkHost = config_entry.runtime_data.host
222  (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host)
223 
224  if is_chime:
225  await host.api.get_state(cmd="GetDingDongList")
226  chime = host.api.chime(ch)
227  if (
228  chime is None
229  or chime.connect_state is None
230  or chime.connect_state < 0
231  or chime.channel not in host.api.channels
232  ):
233  _LOGGER.debug(
234  "Removing Reolink chime %s with id %s, "
235  "since it is not coupled to %s anymore",
236  device.name,
237  ch,
238  host.api.nvr_name,
239  )
240  return True
241 
242  # remove the chime from the host
243  await chime.remove()
244  await host.api.get_state(cmd="GetDingDongList")
245  if chime.connect_state < 0:
246  _LOGGER.debug(
247  "Removed Reolink chime %s with id %s from %s",
248  device.name,
249  ch,
250  host.api.nvr_name,
251  )
252  return True
253 
254  _LOGGER.warning(
255  "Cannot remove Reolink chime %s with id %s, because it is still connected "
256  "to %s, please first remove the chime "
257  "in the reolink app",
258  device.name,
259  ch,
260  host.api.nvr_name,
261  )
262  return False
263 
264  if not host.api.is_nvr or ch is None:
265  _LOGGER.warning(
266  "Cannot remove Reolink device %s, because it is not a camera connected "
267  "to a NVR/Hub, please remove the integration entry instead",
268  device.name,
269  )
270  return False # Do not remove the host/NVR itself
271 
272  if ch not in host.api.channels:
273  _LOGGER.debug(
274  "Removing Reolink device %s, "
275  "since no camera is connected to NVR channel %s anymore",
276  device.name,
277  ch,
278  )
279  return True
280 
281  await host.api.get_state(cmd="GetChannelstatus") # update the camera_online status
282  if not host.api.camera_online(ch):
283  _LOGGER.debug(
284  "Removing Reolink device %s, "
285  "since the camera connected to channel %s is offline",
286  device.name,
287  ch,
288  )
289  return True
290 
291  _LOGGER.warning(
292  "Cannot remove Reolink device %s on channel %s, because it is still connected "
293  "to the NVR/Hub, please first remove the camera from the NVR/Hub "
294  "in the reolink app",
295  device.name,
296  ch,
297  )
298  return False
299 
300 
302  hass: HomeAssistant, config_entry_id: str, host: ReolinkHost
303 ) -> None:
304  """Migrate entity IDs if needed."""
305  device_reg = dr.async_get(hass)
306  devices = dr.async_entries_for_config_entry(device_reg, config_entry_id)
307  ch_device_ids = {}
308  for device in devices:
309  (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host)
310 
311  if host.api.supported(None, "UID") and device_uid[0] != host.unique_id:
312  if ch is None:
313  new_device_id = f"{host.unique_id}"
314  else:
315  new_device_id = f"{host.unique_id}_{device_uid[1]}"
316  new_identifiers = {(DOMAIN, new_device_id)}
317  device_reg.async_update_device(device.id, new_identifiers=new_identifiers)
318 
319  if ch is None or is_chime:
320  continue # Do not consider the NVR itself or chimes
321 
322  ch_device_ids[device.id] = ch
323  if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(ch):
324  if host.api.supported(None, "UID"):
325  new_device_id = f"{host.unique_id}_{host.api.camera_uid(ch)}"
326  else:
327  new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}"
328  new_identifiers = {(DOMAIN, new_device_id)}
329  existing_device = device_reg.async_get_device(identifiers=new_identifiers)
330  if existing_device is None:
331  device_reg.async_update_device(
332  device.id, new_identifiers=new_identifiers
333  )
334  else:
335  _LOGGER.warning(
336  "Reolink device with uid %s already exists, "
337  "removing device with uid %s",
338  new_device_id,
339  device_uid,
340  )
341  device_reg.async_remove_device(device.id)
342 
343  entity_reg = er.async_get(hass)
344  entities = er.async_entries_for_config_entry(entity_reg, config_entry_id)
345  for entity in entities:
346  # Can be removed in HA 2025.1.0
347  if entity.domain == "update" and entity.unique_id in [
348  host.unique_id,
349  format_mac(host.api.mac_address),
350  ]:
351  entity_reg.async_update_entity(
352  entity.entity_id, new_unique_id=f"{host.unique_id}_firmware"
353  )
354  continue
355 
356  if host.api.supported(None, "UID") and not entity.unique_id.startswith(
357  host.unique_id
358  ):
359  new_id = f"{host.unique_id}_{entity.unique_id.split("_", 1)[1]}"
360  entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id)
361 
362  if entity.device_id in ch_device_ids:
363  ch = ch_device_ids[entity.device_id]
364  id_parts = entity.unique_id.split("_", 2)
365  if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch):
366  new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}"
367  existing_entity = entity_reg.async_get_entity_id(
368  entity.domain, entity.platform, new_id
369  )
370  if existing_entity is None:
371  entity_reg.async_update_entity(
372  entity.entity_id, new_unique_id=new_id
373  )
374  else:
375  _LOGGER.warning(
376  "Reolink entity with unique_id %s already exists, "
377  "removing device with unique_id %s",
378  new_id,
379  entity.unique_id,
380  )
381  entity_reg.async_remove(entity.entity_id)
None async_setup_services(HomeAssistant hass)
Definition: __init__.py:72