Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The nuki component."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from dataclasses import dataclass
7 from http import HTTPStatus
8 import logging
9 
10 from aiohttp import web
11 from pynuki import NukiBridge, NukiLock, NukiOpener
12 from pynuki.bridge import InvalidCredentialsException
13 from requests.exceptions import RequestException
14 
15 from homeassistant import exceptions
16 from homeassistant.components import webhook
17 from homeassistant.config_entries import ConfigEntry
18 from homeassistant.const import (
19  CONF_HOST,
20  CONF_PORT,
21  CONF_TOKEN,
22  EVENT_HOMEASSISTANT_STOP,
23  Platform,
24 )
25 from homeassistant.core import Event, HomeAssistant
26 from homeassistant.helpers import device_registry as dr, issue_registry as ir
27 from homeassistant.helpers.network import NoURLAvailableError, get_url
28 from homeassistant.helpers.update_coordinator import UpdateFailed
29 
30 from .const import CONF_ENCRYPT_TOKEN, DEFAULT_TIMEOUT, DOMAIN
31 from .coordinator import NukiCoordinator
32 from .helpers import NukiWebhookException, parse_id
33 
34 _LOGGER = logging.getLogger(__name__)
35 
36 PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR]
37 
38 
39 @dataclass(slots=True)
41  """Class to hold Nuki data."""
42 
43  coordinator: NukiCoordinator
44  bridge: NukiBridge
45  locks: list[NukiLock]
46  openers: list[NukiOpener]
47 
48 
49 def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOpener]]:
50  return bridge.locks, bridge.openers
51 
52 
53 async def _create_webhook(
54  hass: HomeAssistant, entry: ConfigEntry, bridge: NukiBridge
55 ) -> None:
56  # Create HomeAssistant webhook
57  async def handle_webhook(
58  hass: HomeAssistant, webhook_id: str, request: web.Request
59  ) -> web.Response:
60  """Handle webhook callback."""
61  try:
62  data = await request.json()
63  except ValueError:
64  return web.Response(status=HTTPStatus.BAD_REQUEST)
65 
66  entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id]
67  locks = entry_data.locks
68  openers = entry_data.openers
69 
70  devices = [x for x in locks + openers if x.nuki_id == data["nukiId"]]
71  if len(devices) == 1:
72  devices[0].update_from_callback(data)
73 
74  coordinator = entry_data.coordinator
75  coordinator.async_set_updated_data(None)
76 
77  return web.Response(status=HTTPStatus.OK)
78 
79  webhook.async_register(
80  hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True
81  )
82 
83  webhook_url = webhook.async_generate_path(entry.entry_id)
84 
85  try:
86  hass_url = get_url(
87  hass,
88  allow_cloud=False,
89  allow_external=False,
90  allow_ip=True,
91  require_ssl=False,
92  )
93  except NoURLAvailableError:
94  webhook.async_unregister(hass, entry.entry_id)
96  f"Error registering URL for webhook {entry.entry_id}: "
97  "HomeAssistant URL is not available"
98  ) from None
99 
100  url = f"{hass_url}{webhook_url}"
101 
102  if hass_url.startswith("https"):
103  ir.async_create_issue(
104  hass,
105  DOMAIN,
106  "https_webhook",
107  is_fixable=False,
108  severity=ir.IssueSeverity.WARNING,
109  translation_key="https_webhook",
110  translation_placeholders={
111  "base_url": hass_url,
112  "network_link": "https://my.home-assistant.io/redirect/network/",
113  },
114  )
115  else:
116  ir.async_delete_issue(hass, DOMAIN, "https_webhook")
117 
118  try:
119  async with asyncio.timeout(10):
120  await hass.async_add_executor_job(
121  _register_webhook, bridge, entry.entry_id, url
122  )
123  except InvalidCredentialsException as err:
124  webhook.async_unregister(hass, entry.entry_id)
125  raise NukiWebhookException(
126  f"Invalid credentials for Bridge: {err}"
127  ) from err
128  except RequestException as err:
129  webhook.async_unregister(hass, entry.entry_id)
130  raise NukiWebhookException(
131  f"Error communicating with Bridge: {err}"
132  ) from err
133 
134 
135 def _register_webhook(bridge: NukiBridge, entry_id: str, url: str) -> bool:
136  # Register HA URL as webhook if not already
137  callbacks = bridge.callback_list()
138  for item in callbacks["callbacks"]:
139  if entry_id in item["url"]:
140  if item["url"] == url:
141  return True
142  bridge.callback_remove(item["id"])
143 
144  if bridge.callback_add(url)["success"]:
145  return True
146 
147  return False
148 
149 
150 def _remove_webhook(bridge: NukiBridge, entry_id: str) -> None:
151  # Remove webhook if set
152  callbacks = bridge.callback_list()
153  for item in callbacks["callbacks"]:
154  if entry_id in item["url"]:
155  bridge.callback_remove(item["id"])
156 
157 
158 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
159  """Set up the Nuki entry."""
160 
161  hass.data.setdefault(DOMAIN, {})
162 
163  # Migration of entry unique_id
164  if isinstance(entry.unique_id, int):
165  new_id = parse_id(entry.unique_id)
166  params = {"unique_id": new_id}
167  if entry.title == entry.unique_id:
168  params["title"] = new_id
169  hass.config_entries.async_update_entry(entry, **params)
170 
171  try:
172  bridge = await hass.async_add_executor_job(
173  NukiBridge,
174  entry.data[CONF_HOST],
175  entry.data[CONF_TOKEN],
176  entry.data[CONF_PORT],
177  entry.data.get(CONF_ENCRYPT_TOKEN, True),
178  DEFAULT_TIMEOUT,
179  )
180 
181  locks, openers = await hass.async_add_executor_job(_get_bridge_devices, bridge)
182  except InvalidCredentialsException as err:
183  raise exceptions.ConfigEntryAuthFailed from err
184  except RequestException as err:
185  raise exceptions.ConfigEntryNotReady from err
186 
187  # Device registration for the bridge
188  info = bridge.info()
189  bridge_id = parse_id(info["ids"]["hardwareId"])
190  dev_reg = dr.async_get(hass)
191  dev_reg.async_get_or_create(
192  config_entry_id=entry.entry_id,
193  identifiers={(DOMAIN, bridge_id)},
194  manufacturer="Nuki Home Solutions GmbH",
195  name=f"Nuki Bridge {bridge_id}",
196  model="Hardware Bridge",
197  sw_version=info["versions"]["firmwareVersion"],
198  serial_number=parse_id(info["ids"]["hardwareId"]),
199  )
200 
201  try:
202  await _create_webhook(hass, entry, bridge)
203  except NukiWebhookException as err:
204  _LOGGER.warning("Error registering HomeAssistant webhook: %s", err)
205 
206  async def _stop_nuki(_: Event):
207  """Stop and remove the Nuki webhook."""
208  webhook.async_unregister(hass, entry.entry_id)
209  try:
210  async with asyncio.timeout(10):
211  await hass.async_add_executor_job(
212  _remove_webhook, bridge, entry.entry_id
213  )
214  except InvalidCredentialsException as err:
215  _LOGGER.error(
216  "Error unregistering webhook, invalid credentials for bridge: %s", err
217  )
218  except RequestException as err:
219  _LOGGER.error("Error communicating with bridge: %s", err)
220 
221  entry.async_on_unload(
222  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki)
223  )
224 
225  coordinator = NukiCoordinator(hass, bridge, locks, openers)
226  hass.data[DOMAIN][entry.entry_id] = NukiEntryData(
227  coordinator=coordinator,
228  bridge=bridge,
229  locks=locks,
230  openers=openers,
231  )
232 
233  # Fetch initial data so we have data when entities subscribe
234  await coordinator.async_refresh()
235 
236  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
237 
238  return True
239 
240 
241 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
242  """Unload the Nuki entry."""
243  webhook.async_unregister(hass, entry.entry_id)
244  entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id]
245 
246  try:
247  async with asyncio.timeout(10):
248  await hass.async_add_executor_job(
249  _remove_webhook,
250  entry_data.bridge,
251  entry.entry_id,
252  )
253  except InvalidCredentialsException as err:
254  raise UpdateFailed(
255  f"Unable to remove callback. Invalid credentials for Bridge: {err}"
256  ) from err
257  except RequestException as err:
258  raise UpdateFailed(
259  f"Unable to remove callback. Error communicating with Bridge: {err}"
260  ) from err
261 
262  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
263  if unload_ok:
264  hass.data[DOMAIN].pop(entry.entry_id)
265 
266  return unload_ok
tuple[list[NukiLock], list[NukiOpener]] _get_bridge_devices(NukiBridge bridge)
Definition: __init__.py:49
None _remove_webhook(NukiBridge bridge, str entry_id)
Definition: __init__.py:150
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:241
None _create_webhook(HomeAssistant hass, ConfigEntry entry, NukiBridge bridge)
Definition: __init__.py:55
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:158
bool _register_webhook(NukiBridge bridge, str entry_id, str url)
Definition: __init__.py:135
web.Response handle_webhook(HomeAssistant hass, str webhook_id, web.Request request)
Definition: __init__.py:51
str get_url(HomeAssistant hass, *bool require_current_request=False, bool require_ssl=False, bool require_standard_port=False, bool require_cloud=False, bool allow_internal=True, bool allow_external=True, bool allow_cloud=True, bool|None allow_ip=None, bool|None prefer_external=None, bool prefer_cloud=False)
Definition: network.py:131