Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a03f488
Added new integration Loqed
Apr 15, 2022
e13ef49
Adds rudimentary tests. Some cleanup
mikewoudenberg May 25, 2022
17c53d7
Moves lock initialization to config_setup
mikewoudenberg May 27, 2022
4e798c6
Cleans up config data to only required data
mikewoudenberg May 27, 2022
309cbf5
Relocates webhook, adds sensors and adds tests
mikewoudenberg May 31, 2022
83a63de
Adds webhook dependency, adds missing tests
mikewoudenberg Jun 1, 2022
dd8afa6
Disables sensors that are not really reliable/useful
mikewoudenberg Jun 1, 2022
2ff25b2
Fixes tests
mikewoudenberg Jun 8, 2022
d1ac007
Reworks data coordinator, corrects strings, fixes tests
mikewoudenberg Aug 8, 2022
c870299
Removes sensor component for first PR
mikewoudenberg Aug 8, 2022
28c83e9
Initializes lock with sane defaults
mikewoudenberg Aug 8, 2022
877d047
Updates stale comment
mikewoudenberg Aug 9, 2022
f306935
Allows updates of hostname, types coordinator message, removes context
mikewoudenberg Aug 15, 2022
8f3be3d
Handles coordinator updates that get triggered manually
mikewoudenberg Sep 27, 2022
3d1a1ed
Removes unused keys
mikewoudenberg Sep 28, 2022
1463956
Renames default name
mikewoudenberg Sep 28, 2022
ad110ed
Moves to OAuth based flow
mikewoudenberg Nov 22, 2022
f3cee5d
Uses improved LoqedAPI library
mikewoudenberg Nov 24, 2022
37077d2
Updates manifest to match new lib version
mikewoudenberg Nov 24, 2022
14348ca
Uses hostname and sets unique id for manual setups
mikewoudenberg Jan 9, 2023
e6a9eb7
Uses identifier instead of ip
mikewoudenberg Jan 15, 2023
e6a42b5
Adds correct branding
mikewoudenberg Jan 16, 2023
82a5105
Moves back to token based flow
mikewoudenberg Jun 12, 2023
9daa3fe
Completes tests
mikewoudenberg Jun 14, 2023
c2a3bd3
Removes incorrect executable flags
mikewoudenberg Jun 14, 2023
2f1a095
Removes incorrect executable flag
mikewoudenberg Jun 14, 2023
6967347
Uses latest cloud_loqed api
mikewoudenberg Jun 16, 2023
faa8c11
Fixes tests to match picking correct key for manipulating lock
mikewoudenberg Jun 19, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,8 @@ build.json @home-assistant/supervisor
/tests/components/logi_circle/ @evanjd
/homeassistant/components/lookin/ @ANMalko @bdraco
/tests/components/lookin/ @ANMalko @bdraco
/homeassistant/components/loqed/ @cpolhout
/tests/components/loqed/ @cpolhout
/homeassistant/components/lovelace/ @home-assistant/frontend
/tests/components/lovelace/ @home-assistant/frontend
/homeassistant/components/luci/ @mzdrale
Expand Down
133 changes: 133 additions & 0 deletions homeassistant/components/loqed/__init__.py
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]
Comment thread
frenck marked this conversation as resolved.
Outdated


_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))
Comment thread
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
)
Comment thread
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}")
Comment thread
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] = {
Comment thread
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)
Comment thread
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:
Comment thread
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should avoid catching broad exceptions, can we make this more specific?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would need some improvements in the library.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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):
Comment thread
frenck marked this conversation as resolved.
Outdated
Comment thread
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]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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()
134 changes: 134 additions & 0 deletions homeassistant/components/loqed/config_flow.py
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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not really an invalid authentication? No authentication is done here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

@frenck frenck Aug 17, 2022

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

And a hostname.

That you have at this point of the configuration flow already 😉

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like Oauth is in the works. But still not soonly realised

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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"])
Comment thread
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
Comment thread
frenck marked this conversation as resolved.
Outdated
return newdata


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for loqed."""

VERSION = 2
Comment thread
frenck marked this conversation as resolved.
Outdated

def __init__(self) -> None:
"""Initialize the ConfigFlow for the LOQED integration."""
self._host: str | None = None
Comment thread
frenck marked this conversation as resolved.
Outdated

async def async_step_zeroconf(self, discovery_info) -> FlowResult:
Comment thread
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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 updates parameter to this call that can update an existing entry accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 updates parameter allows updating the host whenever it occurs. The general best practice is to do that.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would this string be?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This config has been simplified to only the required config string

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry, I'm not following that? Wat does it mean?
What kind of input do we expect a user to provide here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gone

}
)
self.context["title_placeholders"] = {CONF_HOST: self._host}
Comment thread
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."""
7 changes: 7 additions & 0 deletions homeassistant/components/loqed/const.py
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"
Loading