Skip to content
Snippets Groups Projects
Unverified Commit 23a1a807 authored by Pascal Reeb's avatar Pascal Reeb Committed by GitHub
Browse files

Add callback support to nuki (#88346)

* feat(nuki): add callback support

* fix(nuki): add webhook_enabled to tests

* remove callback choice, add repair if it's https

* black

* fix(nuki): implemented feedback from pvizeli and frenck

* remove unneded test change

* remove issue_registry and http check

* remove unneded response

* add await to executor_job
parent 2fd872b2
No related branches found
No related tags found
No related merge requests found
...@@ -3,9 +3,11 @@ from __future__ import annotations ...@@ -3,9 +3,11 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus
import logging import logging
from typing import Generic, TypeVar from typing import Generic, TypeVar
from aiohttp import web
import async_timeout import async_timeout
from pynuki import NukiBridge, NukiLock, NukiOpener from pynuki import NukiBridge, NukiLock, NukiOpener
from pynuki.bridge import InvalidCredentialsException from pynuki.bridge import InvalidCredentialsException
...@@ -13,10 +15,18 @@ from pynuki.device import NukiDevice ...@@ -13,10 +15,18 @@ from pynuki.device import NukiDevice
from requests.exceptions import RequestException from requests.exceptions import RequestException
from homeassistant import exceptions from homeassistant import exceptions
from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform from homeassistant.const import (
from homeassistant.core import HomeAssistant CONF_HOST,
CONF_PORT,
CONF_TOKEN,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.network import get_url
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import (
CoordinatorEntity, CoordinatorEntity,
DataUpdateCoordinator, DataUpdateCoordinator,
...@@ -46,6 +56,29 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp ...@@ -46,6 +56,29 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp
return bridge.locks, bridge.openers return bridge.locks, bridge.openers
def _register_webhook(bridge: NukiBridge, entry_id: str, url: str) -> bool:
# Register HA URL as webhook if not already
callbacks = bridge.callback_list()
for item in callbacks["callbacks"]:
if entry_id in item["url"]:
if item["url"] == url:
return True
bridge.callback_remove(item["id"])
if bridge.callback_add(url)["success"]:
return True
return False
def _remove_webhook(bridge: NukiBridge, entry_id: str) -> None:
# Remove webhook if set
callbacks = bridge.callback_list()
for item in callbacks["callbacks"]:
if entry_id in item["url"]:
bridge.callback_remove(item["id"])
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the Nuki entry.""" """Set up the Nuki entry."""
...@@ -88,6 +121,63 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ...@@ -88,6 +121,63 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
sw_version=info["versions"]["firmwareVersion"], sw_version=info["versions"]["firmwareVersion"],
) )
async def handle_webhook(
hass: HomeAssistant, webhook_id: str, request: web.Request
) -> web.Response:
"""Handle webhook callback."""
try:
data = await request.json()
except ValueError:
return web.Response(status=HTTPStatus.BAD_REQUEST)
locks = hass.data[DOMAIN][entry.entry_id][DATA_LOCKS]
openers = hass.data[DOMAIN][entry.entry_id][DATA_OPENERS]
devices = [x for x in locks + openers if x.nuki_id == data["nukiId"]]
if len(devices) == 1:
devices[0].update_from_callback(data)
coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR]
coordinator.async_set_updated_data(None)
return web.Response(status=HTTPStatus.OK)
webhook.async_register(
hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True
)
async def _stop_nuki(_: Event):
"""Stop and remove the Nuki webhook."""
webhook.async_unregister(hass, entry.entry_id)
try:
async with async_timeout.timeout(10):
await hass.async_add_executor_job(
_remove_webhook, bridge, entry.entry_id
)
except InvalidCredentialsException as err:
raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err
except RequestException as err:
raise UpdateFailed(f"Error communicating with Bridge: {err}") from err
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki)
)
webhook_url = webhook.async_generate_path(entry.entry_id)
hass_url = get_url(
hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False
)
url = f"{hass_url}{webhook_url}"
try:
async with async_timeout.timeout(10):
await hass.async_add_executor_job(
_register_webhook, bridge, entry.entry_id, url
)
except InvalidCredentialsException as err:
raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err
except RequestException as err:
raise UpdateFailed(f"Error communicating with Bridge: {err}") from err
coordinator = NukiCoordinator(hass, bridge, locks, openers) coordinator = NukiCoordinator(hass, bridge, locks, openers)
hass.data[DOMAIN][entry.entry_id] = { hass.data[DOMAIN][entry.entry_id] = {
...@@ -107,6 +197,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ...@@ -107,6 +197,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload the Nuki entry.""" """Unload the Nuki entry."""
webhook.async_unregister(hass, entry.entry_id)
try:
async with async_timeout.timeout(10):
await hass.async_add_executor_job(
_remove_webhook,
hass.data[DOMAIN][entry.entry_id][DATA_BRIDGE],
entry.entry_id,
)
except InvalidCredentialsException as err:
raise UpdateFailed(
f"Unable to remove callback. Invalid credentials for Bridge: {err}"
) from err
except RequestException as err:
raise UpdateFailed(
f"Unable to remove callback. Error communicating with Bridge: {err}"
) from err
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
"name": "Nuki", "name": "Nuki",
"codeowners": ["@pschmitt", "@pvizeli", "@pree"], "codeowners": ["@pschmitt", "@pvizeli", "@pree"],
"config_flow": true, "config_flow": true,
"dependencies": ["webhook"],
"dhcp": [ "dhcp": [
{ {
"hostname": "nuki_bridge_*" "hostname": "nuki_bridge_*"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment