-
Notifications
You must be signed in to change notification settings - Fork 38.3k
Add new integration Loqed #70080
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add new integration Loqed #70080
Changes from 7 commits
a03f488
e13ef49
17c53d7
4e798c6
309cbf5
83a63de
dd8afa6
2ff25b2
d1ac007
c870299
28c83e9
877d047
f306935
8f3be3d
3d1a1ed
1463956
ad110ed
f3cee5d
37077d2
14348ca
e6a9eb7
e6a42b5
82a5105
9daa3fe
c2a3bd3
2f1a095
6967347
faa8c11
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| """The loqed integration.""" | ||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
|
|
||
| from aiohttp.web import Request | ||
| import async_timeout | ||
| from loqedAPI import loqed | ||
|
|
||
| from homeassistant.components import webhook | ||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import CONF_WEBHOOK_ID, Platform | ||
| from homeassistant.core import HomeAssistant, callback | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator | ||
|
|
||
| from .const import CONF_COORDINATOR, CONF_LOCK, CONF_WEBHOOK_INDEX, DOMAIN | ||
|
|
||
| PLATFORMS: list[str] = [Platform.LOCK, Platform.SENSOR] | ||
|
|
||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @callback | ||
| async def _handle_webhook( | ||
| hass: HomeAssistant, webhook_id: str, request: Request | ||
| ) -> None: | ||
| """Handle incoming Loqed messages.""" | ||
| _LOGGER.debug("Callback received: %s", str(request.headers)) | ||
|
frenck marked this conversation as resolved.
Outdated
|
||
| received_ts = request.headers["TIMESTAMP"] | ||
| received_hash = request.headers["HASH"] | ||
| body = await request.text() | ||
|
|
||
| _LOGGER.debug("Callback body: %s", body) | ||
|
|
||
| entry = next( | ||
| entry | ||
| for entry in hass.data[DOMAIN].values() | ||
| if entry[CONF_WEBHOOK_ID] == webhook_id | ||
| ) | ||
|
frenck marked this conversation as resolved.
Outdated
|
||
| lock: loqed.Lock = entry[CONF_LOCK] | ||
| coordinator: LoqedDataCoordinator = entry[CONF_COORDINATOR] | ||
|
|
||
| event_data = await lock.receiveWebhook(body, received_hash, received_ts) | ||
| if "error" in event_data: | ||
| _LOGGER.warning("Incorrect callback received:: %s", event_data) | ||
| return | ||
|
|
||
| coordinator.async_set_updated_data(event_data) | ||
|
|
||
|
|
||
| async def _ensure_webhooks( | ||
| hass: HomeAssistant, webhook_id: str, lock: loqed.Lock | ||
| ) -> int: | ||
| webhook.async_register(hass, DOMAIN, "Loqed", webhook_id, _handle_webhook) | ||
| webhook_url = webhook.async_generate_url(hass, webhook_id) | ||
| _LOGGER.info("Webhook URL: %s", webhook_url) | ||
|
|
||
| webhooks = await lock.getWebhooks() | ||
|
|
||
| webhook_index = next((x["id"] for x in webhooks if x["url"] == webhook_url), None) | ||
|
|
||
| if not webhook_index: | ||
| await lock.registerWebhook(webhook_url) | ||
| webhooks = await lock.getWebhooks() | ||
| webhook_index = next(x["id"] for x in webhooks if x["url"] == webhook_url) | ||
|
|
||
| _LOGGER.info("Webhook got index %s", webhook_index) | ||
|
|
||
| return int(webhook_index) | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Set up loqed from a config entry.""" | ||
| websession = async_get_clientsession(hass) | ||
| host = entry.data["host"] | ||
| apiclient = loqed.APIClient(websession, f"http://{host}") | ||
|
frenck marked this conversation as resolved.
|
||
| api = loqed.LoqedAPI(apiclient) | ||
|
|
||
| lock = await api.async_get_lock( | ||
| entry.data["api_key"], | ||
| entry.data["bkey"], | ||
| entry.data["key_id"], | ||
| entry.data["host"], | ||
| ) | ||
| webhook_id = entry.data[CONF_WEBHOOK_ID] | ||
| webhook_index = await _ensure_webhooks(hass, webhook_id, lock) | ||
| coordinator = LoqedDataCoordinator(hass, api) | ||
| await coordinator.async_config_entry_first_refresh() | ||
|
|
||
| hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { | ||
|
frenck marked this conversation as resolved.
Outdated
|
||
| CONF_WEBHOOK_ID: webhook_id, | ||
| CONF_LOCK: lock, | ||
| CONF_COORDINATOR: coordinator, | ||
| CONF_WEBHOOK_INDEX: webhook_index, | ||
| } | ||
|
|
||
| hass.config_entries.async_setup_platforms(entry, PLATFORMS) | ||
|
frenck marked this conversation as resolved.
Outdated
|
||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| data = hass.data[DOMAIN][entry.entry_id] | ||
| webhook.async_unregister(hass, data[CONF_WEBHOOK_ID]) | ||
| lock: loqed.Lock = data[CONF_LOCK] | ||
|
|
||
| unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) | ||
| if unload_ok: | ||
|
frenck marked this conversation as resolved.
Outdated
|
||
| hass.data[DOMAIN].pop(entry.entry_id) | ||
|
|
||
| try: | ||
| await lock.deleteWebhook(data[CONF_WEBHOOK_INDEX]) | ||
| except Exception: # pylint: disable=broad-except | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should avoid catching broad exceptions, can we make this more specific?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That would need some improvements in the library.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed |
||
| _LOGGER.exception("Failed to delete webhook") | ||
| return False | ||
|
|
||
| return unload_ok | ||
|
|
||
|
|
||
| class LoqedDataCoordinator(DataUpdateCoordinator): | ||
|
frenck marked this conversation as resolved.
Outdated
frenck marked this conversation as resolved.
Outdated
|
||
| """Data update coordinator for the loqed platform.""" | ||
|
|
||
| def __init__(self, hass: HomeAssistant, api: loqed.LoqedAPI) -> None: | ||
| """Initialize the Loqed Data Update coordinator.""" | ||
| super().__init__(hass, _LOGGER, name="Loqed sensors") | ||
| self._api = api | ||
|
|
||
| async def _async_update_data(self) -> dict[str, str]: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we make the return type more specific? As in, having it to return an actual instance of something, a dataclass, or at least a typed dictionary?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The data can be any of three message types. I could narrow it don but it would be a lot of optionals
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed |
||
| """Fetch data from API endpoint.""" | ||
| async with async_timeout.timeout(10): | ||
| return await self._api.async_get_lock_details() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| """Config flow for loqed integration.""" | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import logging | ||
| from typing import Any | ||
|
|
||
| import aiohttp | ||
| from loqedAPI import loqed | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant import config_entries | ||
| from homeassistant.components import webhook | ||
| from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.data_entry_flow import FlowResult | ||
| from homeassistant.exceptions import HomeAssistantError | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
|
|
||
| from .const import DOMAIN | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| async def validate_input( | ||
| hass: HomeAssistant, data: dict[str, Any], zeroconf_host: str | None | ||
| ) -> dict[str, Any]: | ||
| """Validate the user input allows us to connect.""" | ||
|
|
||
| newdata = data | ||
| json_config = json.loads(data["config"]) | ||
| newdata["ip"] = json_config["bridge_ip"] | ||
| newdata["host"] = json_config["bridge_mdns_hostname"] | ||
| newdata["bkey"] = json_config["bridge_key"] | ||
| newdata["key_id"] = int(json_config["lock_key_local_id"]) | ||
| newdata["api_key"] = json_config["lock_key_key"] | ||
|
|
||
| if zeroconf_host is not None and zeroconf_host != newdata["host"]: | ||
| raise InvalidAuth( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not really an invalid authentication? No authentication is done here.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The json blob the user has to copy contains the hostname of the lock, if it doesn't match it means the user pasted in the wrong json blob
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should never ask a user to pass a JSON blob as input.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The lock needs 4 parameters: Api key, key Index, client secret. And a hostname. Not copying the json blob means copying more data with a higher risk of errors. If you prefer, we could change it, but it would require some more rework in the library
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not ask the user for their username and password and let Home Assistant fetch the data itself? Asking a user to log in, copy a JSON blob, or other things with names they probably have no idea what they mean isn't really friendly.
That you have at this point of the configuration flow already 😉
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Logging in with username / password can also be protected with a 2FA. I wouldn't know how to deal with that.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't they have OAuth support? That would make everything way easier, allowing to import device data.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. They have oauth, but they prefer not to use it as it would require them to have the signing key for the lock on their servers (or so I'm told) which they prefer not to because of security
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like Oauth is in the works. But still not soonly realised
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OAuth is there. This is all outdated |
||
| f"Got config for {newdata['host']} while configuring {zeroconf_host} " | ||
| ) | ||
|
|
||
| # 1. Checking loqed-connection | ||
| try: | ||
| session = async_get_clientsession(hass) | ||
|
|
||
| apiclient = loqed.APIClient(session, "http://" + newdata["ip"]) | ||
|
frenck marked this conversation as resolved.
Outdated
|
||
| api = loqed.LoqedAPI(apiclient) | ||
| lock = await api.async_get_lock( | ||
| newdata["api_key"], newdata["bkey"], newdata["key_id"], newdata["host"] | ||
| ) | ||
| newdata["id"] = lock.id | ||
| # checking getWebooks to check the bridgeKey | ||
| await lock.getWebhooks() | ||
| except (aiohttp.ClientError): | ||
| _LOGGER.error("HTTP Connection error to loqed lock") | ||
| raise CannotConnect from aiohttp.ClientError | ||
|
frenck marked this conversation as resolved.
Outdated
|
||
| return newdata | ||
|
|
||
|
|
||
| class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for loqed.""" | ||
|
|
||
| VERSION = 2 | ||
|
frenck marked this conversation as resolved.
Outdated
|
||
|
|
||
| def __init__(self) -> None: | ||
| """Initialize the ConfigFlow for the LOQED integration.""" | ||
| self._host: str | None = None | ||
|
frenck marked this conversation as resolved.
Outdated
|
||
|
|
||
| async def async_step_zeroconf(self, discovery_info) -> FlowResult: | ||
|
frenck marked this conversation as resolved.
Outdated
|
||
| """Handle zeroconf discovery.""" | ||
| self._host = discovery_info.hostname.rstrip(".") | ||
|
|
||
| session = async_get_clientsession(self.hass) | ||
| apiclient = loqed.APIClient(session, f"http://{self._host}") | ||
| api = loqed.LoqedAPI(apiclient) | ||
| lock_data = await api.async_get_lock_details() | ||
|
|
||
| # Check if already exists | ||
| await self.async_set_unique_id(lock_data["bridge_mac_wifi"]) | ||
| self._abort_if_unique_id_configured() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this call update data? For example, register a changed hostname? There is a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if the lock can change hostname. It's a unique Id for as far as I can tell
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The hostname would not be guaranteed. mDNS collisions, router changes, IP addresses instead of names. And all of them can change regardless of the device and software of the lock. The
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added updates parameter |
||
| return await self.async_step_user() | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> FlowResult: | ||
| """Show userform to user.""" | ||
| user_data_schema = vol.Schema( | ||
| { | ||
| vol.Required("config"): str, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What would this string be?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This config has been simplified to only the required config string
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm sorry, I'm not following that? Wat does it mean?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oops, my comment got a bit out of context due to all the rebasing and refactoring. I tried to explain the process in the documentation pr. We expect the user to log into the LOQED Webapp, create an API token and copy the JSON config object that the Webapp provides and paste it into this field.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should not ask the user to copy JSON blobs. That is an unacceptable user input IMHO.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gone |
||
| } | ||
| ) | ||
| self.context["title_placeholders"] = {CONF_HOST: self._host} | ||
|
frenck marked this conversation as resolved.
Outdated
|
||
| if user_input is None: | ||
| return self.async_show_form( | ||
| step_id="user", | ||
| data_schema=user_data_schema, | ||
| description_placeholders={ | ||
| CONF_HOST: self._host, | ||
| "config_url": "https://app.loqed.com/API-Config", | ||
| }, | ||
| ) | ||
|
|
||
| errors = {} | ||
|
|
||
| try: | ||
| info = await validate_input(self.hass, user_input, self._host) | ||
| except CannotConnect: | ||
| errors["base"] = "cannot_connect" | ||
| except InvalidAuth: | ||
| errors["base"] = "invalid_auth" | ||
| except Exception: # pylint: disable=broad-except | ||
| _LOGGER.exception("Unexpected exception") | ||
| errors["base"] = "unknown" | ||
| else: | ||
| await self.async_set_unique_id(info["id"]) | ||
| self._abort_if_unique_id_configured() | ||
|
|
||
| return self.async_create_entry( | ||
| title="LOQED Touch Smart Lock", | ||
| data=(user_input | {CONF_WEBHOOK_ID: webhook.async_generate_id()}), | ||
| ) | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", data_schema=user_data_schema, errors=errors | ||
| ) | ||
|
|
||
|
|
||
| class CannotConnect(HomeAssistantError): | ||
| """Error to indicate we cannot connect.""" | ||
|
|
||
|
|
||
| class InvalidAuth(HomeAssistantError): | ||
| """Error to indicate there is invalid auth.""" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| """Constants for the loqed integration.""" | ||
|
|
||
|
|
||
| DOMAIN = "loqed" | ||
| CONF_WEBHOOK_INDEX = "webhook_index" | ||
| CONF_COORDINATOR = "coordinator" | ||
| CONF_LOCK = "lock" |
Uh oh!
There was an error while loading. Please reload this page.