From a03f488b004b6359eb0b9993e083b49b03a705c0 Mon Sep 17 00:00:00 2001 From: cpolhout Date: Fri, 15 Apr 2022 07:50:29 +0000 Subject: [PATCH 01/28] Added new integration Loqed --- CODEOWNERS | 2 + homeassistant/components/loqed/__init__.py | 37 +++ homeassistant/components/loqed/config_flow.py | 152 ++++++++++ homeassistant/components/loqed/const.py | 5 + homeassistant/components/loqed/lock.py | 269 ++++++++++++++++++ homeassistant/components/loqed/manifest.json | 13 + homeassistant/components/loqed/strings.json | 23 ++ .../components/loqed/translations/en.json | 23 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 4 + requirements_all.txt | 3 + 11 files changed, 532 insertions(+) create mode 100644 homeassistant/components/loqed/__init__.py create mode 100644 homeassistant/components/loqed/config_flow.py create mode 100644 homeassistant/components/loqed/const.py create mode 100644 homeassistant/components/loqed/lock.py create mode 100644 homeassistant/components/loqed/manifest.json create mode 100644 homeassistant/components/loqed/strings.json create mode 100644 homeassistant/components/loqed/translations/en.json diff --git a/CODEOWNERS b/CODEOWNERS index c1545d6142944..ad6f5c71fd211 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py new file mode 100644 index 0000000000000..be4b27a49d5fb --- /dev/null +++ b/homeassistant/components/loqed/__init__.py @@ -0,0 +1,37 @@ +"""The loqed integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +PLATFORMS: list[str] = ["lock"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up loqed from a config entry.""" + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data + + # Registers update listener to update config entry when options are updated. + entry.async_on_unload(entry.add_update_listener(update_listener)) + + # Forward the setup to the lock platform + hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, "lock")) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry): + """Handle options update.""" + print("UPDATE LISTENER CALLED") + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py new file mode 100644 index 0000000000000..0b38ace4ce5e2 --- /dev/null +++ b/homeassistant/components/loqed/config_flow.py @@ -0,0 +1,152 @@ +"""Config flow for loqed integration.""" +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +import aiohttp +from loqedAPI import loqed +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import network + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +urlRegex = re.compile( + r"^(?:http|ftp)s?://" # http:// or https:// + r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?| \ + [A-Z0-9-]{s2,}\.?)|" + r"localhost|" # localhost... + r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # ...or ip + r"(?::\d+)?" # optional port + r"(?:/?|[/?]\S+)$", + re.IGNORECASE, +) + + +async def validate_input(hass, data): + """Validate the user input allows us to connect.""" + if len(data["internal_url"]) > 5: + if re.match(urlRegex, data["internal_url"]) is None: + _LOGGER.error("Local HA URL is incorrect %s", data["internal_url"]) + raise ValueError("Local HA URL is incorrect") + 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"] + _LOGGER.debug("Got Info from config: %s", str(newdata)) + + # 1. Checking loqed-connection + try: + async with aiohttp.ClientSession() as session: + apiclient = loqed.APIClient(session, "http://" + newdata["ip"]) + api = loqed.LoqedAPI(apiclient) + lock = await api.async_get_lock( + newdata["api_key"], newdata["bkey"], newdata["key_id"], newdata["name"] + ) + _LOGGER.debug("Lock details retrieved: %s", newdata["ip"]) + newdata["id"] = lock.id + # checking getWebooks to check the bridgeKey + await lock.getWebhooks() + except (aiohttp.ClientError): + _LOGGER.error("HTTP Connection error to loqed lock: %s:%s", lock.name, lock.id) + raise CannotConnect from aiohttp.ClientError + except Exception: + _LOGGER.error("HTTP Connection error to loqed lock: %s:%s", lock.name, lock.id) + raise CannotConnect from aiohttp.ClientError + return newdata + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for loqed.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize configflow.""" + self.host = "LOQED.." + + async def async_step_zeroconf(self, discovery_info) -> FlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("HOST: %s", discovery_info.hostname) + _LOGGER.info("HOST: %s", discovery_info.hostname) + + host = discovery_info.hostname.rstrip(".") + async with aiohttp.ClientSession() as session: + apiclient = loqed.APIClient(session, "http://" + host) + api = loqed.LoqedAPI(apiclient) + lock_data = await api.async_get_lock_details() + + # Check if already exists + id = lock_data["bridge_mac_wifi"] + if await self.async_set_unique_id(id) is not None: + self.async_abort(reason="already_configured") + self.host = host + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Show userform to user.""" + try: + internal_url = network.get_url( + self.hass, allow_internal=True, allow_external=False, allow_ip=True + ) + if internal_url.startswith("172"): + internal_url = "http://:8123" + except network.NoURLAvailableError: + internal_url = "http://:8123" + + STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required("name", default="My Lock"): str, + vol.Required("internal_url", default=internal_url): str, + vol.Required("config"): str, + } + ) + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + if await self.async_set_unique_id(info["id"]) is not None: + _LOGGER.error("Aborting config: This device is already configured") + return self.async_abort(reason="already_configured") + return self.async_create_entry( + title="LOQED Touch Smart Lock", data=user_input + ) + 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" + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/loqed/const.py b/homeassistant/components/loqed/const.py new file mode 100644 index 0000000000000..bb8e8ca5c2212 --- /dev/null +++ b/homeassistant/components/loqed/const.py @@ -0,0 +1,5 @@ +"""Constants for the loqed integration.""" + +DOMAIN = "loqed" +WEBHOOK_PREFIX = "p87yh9pb787yhup" +STATE_OPENING = "opening" diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py new file mode 100644 index 0000000000000..3b01949d45018 --- /dev/null +++ b/homeassistant/components/loqed/lock.py @@ -0,0 +1,269 @@ +"""LOQED lock integration for Home Assistant.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging +import random +import string + +from aiohttp import ClientError +from loqedAPI import loqed +from voluptuous.schema_builder import Undefined + +from homeassistant.components import webhook +from homeassistant.components.lock import SUPPORT_OPEN, LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, STATE_OPENING, WEBHOOK_PREFIX + +LOCK_STATES = { + "opening": STATE_OPENING, + "unlocking": STATE_UNLOCKING, + "locking": STATE_LOCKING, + "latch": STATE_UNLOCKED, + "night_lock": STATE_LOCKED, + "open": STATE_UNLOCKED, + "day_lock": STATE_UNLOCKED, +} + + +WEBHOOK_API_ENDPOINT = "/api/loqed/webhook" +SCAN_INTERVAL = timedelta(seconds=300) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +): + """Set up the Loqed lock platform.""" + data = hass.data[DOMAIN][entry.entry_id] + _LOGGER.debug("Start setting up the Loqed lock: %s", data["id"]) + websession = async_get_clientsession(hass) + host = data["host"] + apiclient = loqed.APIClient(websession, "http://" + host) + api = loqed.LoqedAPI(apiclient) + try: + await api.async_get_lock_details() + except ClientError: + host = data["ip"] + _LOGGER.warning( + "Unable to use the mdns hostname: %s . Trying with IP-address: %s", + data["host"], + data["ip"], + ) + apiclient = loqed.APIClient(websession, "http://" + host) + api = loqed.LoqedAPI(apiclient) + + lock = await api.async_get_lock( + data["api_key"], data["bkey"], data["key_id"], data["name"] + ) + _LOGGER.debug( + "Inititated loqed-lock entity with id: %s and host: %s", lock.id, host + ) + if not lock: + # No locks found; abort setup routine. + _LOGGER.info( + "We cannot connect to the loqed lock, \ + please try to reinstall the integation" + ) + return + async_add_entities([LoqedLock(lock, data["internal_url"], "http://" + host)]) + + +def get_random_string(length): + """Create a rondom ascii string.""" + letters = string.ascii_lowercase + result_str = "".join(random.choice(letters) for i in range(length)) + return result_str + + +class LoqedLock(LockEntity): + """Representation of a loqed lock.""" + + def __init__(self, lock: loqed.Lock, internal_url, lock_url) -> None: + """Initialize the lock.""" + self.lock_url = lock_url + self._lock = lock + self._internal_url = internal_url + self._webhook = "" + self._attr_unique_id = self._lock.id + self._attr_name = self._lock.name + self._attr_supported_features = SUPPORT_OPEN + # self._attr_supported_features = LockEntityFeature.OPEN + self.update_task = None + + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + await self.check_webhook() + + @property + def changed_by(self): + """Return true if lock is locking.""" + return "KeyID " + str(self._lock.last_key_id) + + @property + def bolt_state(self): + """Return true if lock is locking.""" + return self._lock.bolt_state + + @property + def is_locking(self): + """Return true if lock is locking.""" + return LOCK_STATES[self.bolt_state] == STATE_LOCKING + + @property + def is_unlocking(self): + """Return true if lock is unlocking.""" + return LOCK_STATES[self.bolt_state] == STATE_UNLOCKING + + @property + def is_jammed(self): + """Return true if lock is jammed.""" + return LOCK_STATES[self.bolt_state] == STATE_JAMMED + + @property + def is_locked(self): + """Return true if lock is locked.""" + return LOCK_STATES[self.bolt_state] == STATE_LOCKED + + @property + def battery(self): + """Return true if lock is locked.""" + return self._lock.battery_percentage + + @property + def extra_state_attributes(self): + """Extra state attribtues.""" + state_attr = { + "id": self._lock.id, + "bolt_state": self.bolt_state, + "lock_url": self.lock_url, + "webhook_url": self._webhook, + ATTR_BATTERY_LEVEL: self._lock.battery_percentage, + "battery_type": self._lock.battery_type, + "battery_voltage": self._lock.battery_voltage, + "wifi_strength": self._lock.wifi_strength, + "ble_strength": self._lock.ble_strength, + "last_event": self._lock.last_event, + # "party_mode": self._lock.party_mode, + # "guest_access_mode": self._lock.guest_access_mode, + # "twist_assist": self._lock.twist_assist, + # "touch_to_connect": self._lock.touch_to_connect, + # "lock_direction": self._lock.lock_direction, + # "mortise_lock_type": self._lock.mortise_lock_type, + "last_changed_key_id": self._lock.last_key_id, + } + return state_attr + + async def async_lock(self, **kwargs): + """To calls the lock method of the loqed lock.""" + _LOGGER.debug("start lock operation") + await self.async_schedule_update(10) + await self._lock.lock() + + async def async_unlock(self, **kwargs): + """To call the unlock method of the loqed lock.""" + _LOGGER.debug("start unlock operation") + await self.async_schedule_update(10) + await self._lock.unlock() + + async def async_open(self, **kwargs): + """To call the open method of the loqed lock.""" + _LOGGER.debug("start open operation") + await self.async_schedule_update(10) + await self._lock.open() + + async def async_update(self) -> None: + """To update the internal state of the device.""" + _LOGGER.debug("Start update operation") + resp = await self._lock.update() + _LOGGER.debug("Update response: %s", str(resp)) + self._attr_unique_id = self._lock.id + self._attr_name = self._lock.name + _LOGGER.debug("BOLT_STATE after update: %s", self.bolt_state) + self.async_schedule_update_ha_state() + + async def check_webhook(self): + """Check if webhook is configured on both sides.""" + _LOGGER.debug("Start checking webhooks") + webhooks = await self._lock.getWebhooks() + wh_id = Undefined + # Check if hook already registered @loqed + for hook in webhooks: + if hook["url"].startswith( + self._internal_url + "/api/webhook/" + WEBHOOK_PREFIX + ): + url = hook["url"] + wh_id = WEBHOOK_PREFIX + url[-12:] + _LOGGER.debug("Found already configured webhook @loqed: %s", url) + break + if wh_id == Undefined: + wh_id = WEBHOOK_PREFIX + get_random_string(12) + # Registering webhook in Loqed + url = self._internal_url + "/api/webhook/" + wh_id + _LOGGER.debug("Registering webhook @loqed: %s", url) + await self._lock.registerWebhook(url) + # Registering webhook in HASS, when exists same will be used + _LOGGER.debug("Registering webhook in HA") + self._webhook = str(url) + try: + webhook.async_register( + hass=self.hass, + domain=DOMAIN, + name="loqed", + webhook_id=wh_id, + handler=self.async_handle_webhook, + ) + except ValueError: # when already installed + pass + return url + + @callback + async def async_handle_webhook(self, hass, webhook_id, request): + """Handle webhook callback.""" + _LOGGER.debug("Callback received: %s", str(request.headers)) + received_ts = request.headers["TIMESTAMP"] + received_hash = request.headers["HASH"] + body = await request.text() + _LOGGER.debug("Callback body: %s", body) + event_data = await self._lock.receiveWebhook(body, received_hash, received_ts) + if "error" in event_data: + _LOGGER.warning("Incorrect CALLBACK RECEIVED:: %s", event_data) + return + event_type = "LOQED_status_change_to_" + LOCK_STATES[self.bolt_state] + _LOGGER.debug("Firing event:: %s", event_type) + hass.bus.fire(event_type, event_data) + self.async_schedule_update_ha_state(False) + event = event_data["event_type"].strip().lower() + if event.split("_")[0] == "state": + if self.update_task: + self.update_task.cancel() + elif "go_to" in event: + await self.async_schedule_update(12) + + async def async_schedule_update(self, timeout): + """To cancel outstanding async update task and schedules new one.""" + if self.update_task: + self.update_task.cancel() + _LOGGER.debug("PLAN update operation in %s seconds", timeout) + self.update_task = asyncio.create_task(self.async_delayed_update(timeout)) + + async def async_delayed_update(self, timeout): + """Async update task to handle lock update when nno callback.""" + _LOGGER.debug("Start waiting in delayed_update") + await asyncio.sleep(timeout) + await self.async_update() diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json new file mode 100644 index 0000000000000..5009b85ce95ea --- /dev/null +++ b/homeassistant/components/loqed/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "loqed", + "name": "LOQED Touch Smart Lock", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/loqed", + "requirements": ["loqedAPI==2.1.3"], + "ssdp": [], + "homekit": {}, + "dependencies": [], + "codeowners": ["@cpolhout"], + "iot_class": "local_push", + "zeroconf": [{ "type": "_http._tcp.local.", "name": "loqed*" }] +} diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json new file mode 100644 index 0000000000000..89295c4bc8e19 --- /dev/null +++ b/homeassistant/components/loqed/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "description": "Please enter a name for the lock and the internal Home Assistant URL.
Then visit https://app.loqed.com/API-Config and:
* Create an API-key: [add new api-key]
* Copy the [Integration information] value by clicking on the copy button in the detail-page of the just created API-key.", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "internal_url": "Internal Home assistant URL", + "config": "Loqed Confiuguration" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/loqed/translations/en.json b/homeassistant/components/loqed/translations/en.json new file mode 100644 index 0000000000000..851254f13b9fe --- /dev/null +++ b/homeassistant/components/loqed/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, did you copy the complete string with the copy button?", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error", + "already_configured": "Device is already configured" + }, + "step": { + "user": { + "data": { + "name": "Lock-name in Home Assistant", + "internal_url": "Internal Home Assistant URL. Should be accessible in the local network", + "config": "Lock-details: [Integration information] in the api-key details" + }, + "description": "Please enter a name for the lock and the internal Home Assistant URL.
Then visit https://app.loqed.com/API-Config and:
* Create an API-key: [add new api-key]
* Copy the [Integration information] value by clicking on the copy button in the detail-page of the just created API-key." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 96cb74cb3162d..daeee5977e608 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -253,6 +253,7 @@ "locative", "logi_circle", "lookin", + "loqed", "luftdaten", "lutron_caseta", "lyric", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 93ccb404ae455..79ebfd18620d9 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -448,6 +448,10 @@ "domain": "bosch_shc", "name": "bosch shc*", }, + { + "domain": "loqed", + "name": "loqed*" + }, { "domain": "nam", "name": "nam-*", diff --git a/requirements_all.txt b/requirements_all.txt index 16404f700078f..9bafca7e5f3b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1148,6 +1148,9 @@ logi-circle==0.2.3 # homeassistant.components.london_underground london-tube-status==0.5 +# homeassistant.components.loqed +loqedAPI==2.1.3 + # homeassistant.components.luftdaten luftdaten==0.7.4 From e13ef4987b5011474b18328396bc27a9ef0a520c Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Wed, 25 May 2022 21:02:10 +0000 Subject: [PATCH 02/28] Adds rudimentary tests. Some cleanup --- homeassistant/components/loqed/__init__.py | 7 +- homeassistant/components/loqed/config_flow.py | 56 ++++++------- homeassistant/components/loqed/const.py | 1 - homeassistant/components/loqed/lock.py | 19 +---- homeassistant/components/loqed/strings.json | 2 +- requirements_test_all.txt | 3 + tests/components/loqed/__init__.py | 1 + .../loqed/fixtures/integration_config.json | 9 ++ tests/components/loqed/test_config_flow.py | 84 +++++++++++++++++++ 9 files changed, 129 insertions(+), 53 deletions(-) create mode 100644 tests/components/loqed/__init__.py create mode 100644 tests/components/loqed/fixtures/integration_config.json create mode 100644 tests/components/loqed/test_config_flow.py diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index be4b27a49d5fb..4f8384c47f24f 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN -PLATFORMS: list[str] = ["lock"] +PLATFORMS: list[str] = [Platform.LOCK] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -17,8 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Registers update listener to update config entry when options are updated. entry.async_on_unload(entry.add_update_listener(update_listener)) - # Forward the setup to the lock platform - hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, "lock")) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -33,5 +33,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry): """Handle options update.""" - print("UPDATE LISTENER CALLED") await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 0b38ace4ce5e2..129e8a1f2602d 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import network +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -49,22 +50,22 @@ async def validate_input(hass, data): # 1. Checking loqed-connection try: - async with aiohttp.ClientSession() as session: - apiclient = loqed.APIClient(session, "http://" + newdata["ip"]) - api = loqed.LoqedAPI(apiclient) - lock = await api.async_get_lock( - newdata["api_key"], newdata["bkey"], newdata["key_id"], newdata["name"] - ) - _LOGGER.debug("Lock details retrieved: %s", newdata["ip"]) - newdata["id"] = lock.id - # checking getWebooks to check the bridgeKey - await lock.getWebhooks() + session = async_get_clientsession(hass) + + apiclient = loqed.APIClient(session, "http://" + newdata["ip"]) + api = loqed.LoqedAPI(apiclient) + lock = await api.async_get_lock( + newdata["api_key"], newdata["bkey"], newdata["key_id"], newdata["name"] + ) + _LOGGER.debug("Lock details retrieved: %s", newdata["ip"]) + newdata["id"] = lock.id + # checking getWebooks to check the bridgeKey + await lock.getWebhooks() except (aiohttp.ClientError): _LOGGER.error("HTTP Connection error to loqed lock: %s:%s", lock.name, lock.id) raise CannotConnect from aiohttp.ClientError except Exception: - _LOGGER.error("HTTP Connection error to loqed lock: %s:%s", lock.name, lock.id) - raise CannotConnect from aiohttp.ClientError + raise CannotConnect from Exception return newdata @@ -73,14 +74,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize configflow.""" - self.host = "LOQED.." - async def async_step_zeroconf(self, discovery_info) -> FlowResult: """Handle zeroconf discovery.""" _LOGGER.debug("HOST: %s", discovery_info.hostname) - _LOGGER.info("HOST: %s", discovery_info.hostname) host = discovery_info.hostname.rstrip(".") async with aiohttp.ClientSession() as session: @@ -90,9 +86,8 @@ async def async_step_zeroconf(self, discovery_info) -> FlowResult: # Check if already exists id = lock_data["bridge_mac_wifi"] - if await self.async_set_unique_id(id) is not None: - self.async_abort(reason="already_configured") - self.host = host + await self.async_set_unique_id(id) + self._abort_if_unique_id_configured() return await self.async_step_user() async def async_step_user( @@ -108,7 +103,7 @@ async def async_step_user( except network.NoURLAvailableError: internal_url = "http://:8123" - STEP_USER_DATA_SCHEMA = vol.Schema( + user_data_schema = vol.Schema( { vol.Required("name", default="My Lock"): str, vol.Required("internal_url", default=internal_url): str, @@ -117,20 +112,12 @@ async def async_step_user( ) if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) + return self.async_show_form(step_id="user", data_schema=user_data_schema) errors = {} try: info = await validate_input(self.hass, user_input) - if await self.async_set_unique_id(info["id"]) is not None: - _LOGGER.error("Aborting config: This device is already configured") - return self.async_abort(reason="already_configured") - return self.async_create_entry( - title="LOQED Touch Smart Lock", data=user_input - ) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -138,9 +125,16 @@ async def async_step_user( 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 + ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", data_schema=user_data_schema, errors=errors ) diff --git a/homeassistant/components/loqed/const.py b/homeassistant/components/loqed/const.py index bb8e8ca5c2212..90bd767b7312f 100644 --- a/homeassistant/components/loqed/const.py +++ b/homeassistant/components/loqed/const.py @@ -2,4 +2,3 @@ DOMAIN = "loqed" WEBHOOK_PREFIX = "p87yh9pb787yhup" -STATE_OPENING = "opening" diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index 3b01949d45018..a5004bd052571 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -19,6 +19,7 @@ STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, ) @@ -26,7 +27,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, STATE_OPENING, WEBHOOK_PREFIX +from .const import DOMAIN, WEBHOOK_PREFIX LOCK_STATES = { "opening": STATE_OPENING, @@ -47,7 +48,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -): +) -> None: """Set up the Loqed lock platform.""" data = hass.data[DOMAIN][entry.entry_id] _LOGGER.debug("Start setting up the Loqed lock: %s", data["id"]) @@ -102,7 +103,6 @@ def __init__(self, lock: loqed.Lock, internal_url, lock_url) -> None: self._attr_unique_id = self._lock.id self._attr_name = self._lock.name self._attr_supported_features = SUPPORT_OPEN - # self._attr_supported_features = LockEntityFeature.OPEN self.update_task = None async def async_added_to_hass(self) -> None: @@ -140,18 +140,11 @@ def is_locked(self): """Return true if lock is locked.""" return LOCK_STATES[self.bolt_state] == STATE_LOCKED - @property - def battery(self): - """Return true if lock is locked.""" - return self._lock.battery_percentage - @property def extra_state_attributes(self): """Extra state attribtues.""" state_attr = { - "id": self._lock.id, "bolt_state": self.bolt_state, - "lock_url": self.lock_url, "webhook_url": self._webhook, ATTR_BATTERY_LEVEL: self._lock.battery_percentage, "battery_type": self._lock.battery_type, @@ -159,12 +152,6 @@ def extra_state_attributes(self): "wifi_strength": self._lock.wifi_strength, "ble_strength": self._lock.ble_strength, "last_event": self._lock.last_event, - # "party_mode": self._lock.party_mode, - # "guest_access_mode": self._lock.guest_access_mode, - # "twist_assist": self._lock.twist_assist, - # "touch_to_connect": self._lock.touch_to_connect, - # "lock_direction": self._lock.lock_direction, - # "mortise_lock_type": self._lock.mortise_lock_type, "last_changed_key_id": self._lock.last_key_id, } return state_attr diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json index 89295c4bc8e19..dc47fc24ff001 100644 --- a/homeassistant/components/loqed/strings.json +++ b/homeassistant/components/loqed/strings.json @@ -6,7 +6,7 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "internal_url": "Internal Home assistant URL", - "config": "Loqed Confiuguration" + "config": "Loqed Configuration" } } }, diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d2878d421478..530e319341817 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -257,6 +257,9 @@ aiolivisi==0.0.19 # homeassistant.components.lookin aiolookin==1.0.0 +# homeassistant.components.loqed +loqedAPI==2.1.3 + # homeassistant.components.lyric aiolyric==1.0.9 diff --git a/tests/components/loqed/__init__.py b/tests/components/loqed/__init__.py new file mode 100644 index 0000000000000..c40c0f7c8f5df --- /dev/null +++ b/tests/components/loqed/__init__.py @@ -0,0 +1 @@ +"""Tests for the Loqed integration.""" diff --git a/tests/components/loqed/fixtures/integration_config.json b/tests/components/loqed/fixtures/integration_config.json new file mode 100644 index 0000000000000..c4d9f87162c3d --- /dev/null +++ b/tests/components/loqed/fixtures/integration_config.json @@ -0,0 +1,9 @@ +{ + "lock_id": "**REDACTED**", + "lock_key_local_id": 1, + "lock_key_key": "SGFsbG8gd2VyZWxk", + "backend_key": "aGVsbG8gd29ybGQ=", + "bridge_key": "Ym9uam91ciBtb25kZQ==", + "bridge_ip": "192.168.12.34", + "bridge_mdns_hostname": "LOQED-aabbccddeeff.local" +} diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py new file mode 100644 index 0000000000000..4e11a2d5f5f96 --- /dev/null +++ b/tests/components/loqed/test_config_flow.py @@ -0,0 +1,84 @@ +"""Test the Loqed config flow.""" +import json +from unittest.mock import Mock, patch + +from loqedAPI import loqed + +from homeassistant import config_entries +from homeassistant.components.loqed.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import load_fixture + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + loqed_integration_config = load_fixture("loqed/integration_config.json") + + mock_lock = Mock(spec=loqed.Lock, id="Foo") + + with patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock", + return_value=mock_lock, + ), patch( + "homeassistant.components.loqed.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "loqed-abc", + "internal_url": "http://foo.bar.com", + "config": loqed_integration_config, + }, + ) + await hass.async_block_till_done() + + json_config = json.loads(loqed_integration_config) + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "LOQED Touch Smart Lock" + assert result2["data"] == { + "id": "Foo", + "ip": json_config["bridge_ip"], + "host": json_config["bridge_mdns_hostname"], + "bkey": json_config["bridge_key"], + "key_id": int(json_config["lock_key_local_id"]), + "api_key": json_config["lock_key_key"], + "config": loqed_integration_config, + "name": "loqed-abc", + "internal_url": "http://foo.bar.com", + } + mock_lock.getWebhooks.assert_awaited() + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + loqed_integration_config = load_fixture("loqed/integration_config.json") + + with patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "loqed-abc", + "internal_url": "http://foo.bar.com", + "config": loqed_integration_config, + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} From 17c53d72f74dbdc9779895d6466664d37a9a8238 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Fri, 27 May 2022 12:04:14 +0000 Subject: [PATCH 03/28] Moves lock initialization to config_setup --- homeassistant/components/loqed/__init__.py | 17 ++++++- homeassistant/components/loqed/config_flow.py | 9 ++-- homeassistant/components/loqed/lock.py | 46 +++---------------- 3 files changed, 27 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index 4f8384c47f24f..855930b3e407c 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -1,9 +1,12 @@ """The loqed integration.""" from __future__ import annotations +from loqedAPI import loqed + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -13,7 +16,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up loqed from a config entry.""" - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data + websession = async_get_clientsession(hass) + host = entry.data["host"] + apiclient = loqed.APIClient(websession, "http://" + host) + api = loqed.LoqedAPI(apiclient) + + lock = await api.async_get_lock( + entry.data["api_key"], + entry.data["bkey"], + entry.data["key_id"], + entry.data["name"], + ) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = lock # Registers update listener to update config entry when options are updated. entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 129e8a1f2602d..140cad49501b7 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -79,10 +79,11 @@ async def async_step_zeroconf(self, discovery_info) -> FlowResult: _LOGGER.debug("HOST: %s", discovery_info.hostname) host = discovery_info.hostname.rstrip(".") - async with aiohttp.ClientSession() as session: - apiclient = loqed.APIClient(session, "http://" + host) - api = loqed.LoqedAPI(apiclient) - lock_data = await api.async_get_lock_details() + + session = async_get_clientsession(self.hass) + apiclient = loqed.APIClient(session, "http://" + host) + api = loqed.LoqedAPI(apiclient) + lock_data = await api.async_get_lock_details() # Check if already exists id = lock_data["bridge_mac_wifi"] diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index a5004bd052571..91081b4537c78 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -2,17 +2,15 @@ from __future__ import annotations import asyncio -from datetime import timedelta import logging import random import string -from aiohttp import ClientError from loqedAPI import loqed from voluptuous.schema_builder import Undefined from homeassistant.components import webhook -from homeassistant.components.lock import SUPPORT_OPEN, LockEntity +from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -24,7 +22,6 @@ STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, WEBHOOK_PREFIX @@ -41,7 +38,6 @@ WEBHOOK_API_ENDPOINT = "/api/loqed/webhook" -SCAN_INTERVAL = timedelta(seconds=300) _LOGGER = logging.getLogger(__name__) @@ -50,38 +46,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Loqed lock platform.""" - data = hass.data[DOMAIN][entry.entry_id] - _LOGGER.debug("Start setting up the Loqed lock: %s", data["id"]) - websession = async_get_clientsession(hass) - host = data["host"] - apiclient = loqed.APIClient(websession, "http://" + host) - api = loqed.LoqedAPI(apiclient) - try: - await api.async_get_lock_details() - except ClientError: - host = data["ip"] - _LOGGER.warning( - "Unable to use the mdns hostname: %s . Trying with IP-address: %s", - data["host"], - data["ip"], - ) - apiclient = loqed.APIClient(websession, "http://" + host) - api = loqed.LoqedAPI(apiclient) - - lock = await api.async_get_lock( - data["api_key"], data["bkey"], data["key_id"], data["name"] - ) - _LOGGER.debug( - "Inititated loqed-lock entity with id: %s and host: %s", lock.id, host - ) - if not lock: - # No locks found; abort setup routine. - _LOGGER.info( - "We cannot connect to the loqed lock, \ - please try to reinstall the integation" - ) - return - async_add_entities([LoqedLock(lock, data["internal_url"], "http://" + host)]) + lock = hass.data[DOMAIN][entry.entry_id] + + async_add_entities([LoqedLock(lock, entry.data["internal_url"])]) def get_random_string(length): @@ -94,15 +61,14 @@ def get_random_string(length): class LoqedLock(LockEntity): """Representation of a loqed lock.""" - def __init__(self, lock: loqed.Lock, internal_url, lock_url) -> None: + def __init__(self, lock: loqed.Lock, internal_url) -> None: """Initialize the lock.""" - self.lock_url = lock_url self._lock = lock self._internal_url = internal_url self._webhook = "" self._attr_unique_id = self._lock.id self._attr_name = self._lock.name - self._attr_supported_features = SUPPORT_OPEN + self._attr_supported_features = LockEntityFeature.OPEN self.update_task = None async def async_added_to_hass(self) -> None: From 4e798c64daea68e41b0c8864a0a1269a6528eb8d Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Fri, 27 May 2022 15:11:51 +0000 Subject: [PATCH 04/28] Cleans up config data to only required data --- homeassistant/components/loqed/__init__.py | 2 +- homeassistant/components/loqed/config_flow.py | 75 ++++++++----------- homeassistant/components/loqed/lock.py | 3 +- homeassistant/components/loqed/strings.json | 10 +-- .../components/loqed/translations/en.json | 8 +- .../components/loqed/fixtures/status_ok.json | 16 ++++ tests/components/loqed/test_config_flow.py | 55 ++++++++++++-- 7 files changed, 105 insertions(+), 64 deletions(-) create mode 100644 tests/components/loqed/fixtures/status_ok.json diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index 855930b3e407c..9c375a9d6dc39 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data["api_key"], entry.data["bkey"], entry.data["key_id"], - entry.data["name"], + entry.data["host"], ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = lock diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 140cad49501b7..5098c0d47445d 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -3,7 +3,6 @@ import json import logging -import re from typing import Any import aiohttp @@ -11,9 +10,10 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import network from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -21,24 +21,11 @@ _LOGGER = logging.getLogger(__name__) -urlRegex = re.compile( - r"^(?:http|ftp)s?://" # http:// or https:// - r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?| \ - [A-Z0-9-]{s2,}\.?)|" - r"localhost|" # localhost... - r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # ...or ip - r"(?::\d+)?" # optional port - r"(?:/?|[/?]\S+)$", - re.IGNORECASE, -) - - -async def validate_input(hass, data): +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.""" - if len(data["internal_url"]) > 5: - if re.match(urlRegex, data["internal_url"]) is None: - _LOGGER.error("Local HA URL is incorrect %s", data["internal_url"]) - raise ValueError("Local HA URL is incorrect") + newdata = data json_config = json.loads(data["config"]) newdata["ip"] = json_config["bridge_ip"] @@ -46,7 +33,11 @@ async def validate_input(hass, data): newdata["bkey"] = json_config["bridge_key"] newdata["key_id"] = int(json_config["lock_key_local_id"]) newdata["api_key"] = json_config["lock_key_key"] - _LOGGER.debug("Got Info from config: %s", str(newdata)) + + if zeroconf_host is not None and zeroconf_host != newdata["host"]: + raise InvalidAuth( + f"Got config for {newdata['host']} while configuring {zeroconf_host} " + ) # 1. Checking loqed-connection try: @@ -55,14 +46,13 @@ async def validate_input(hass, data): apiclient = loqed.APIClient(session, "http://" + newdata["ip"]) api = loqed.LoqedAPI(apiclient) lock = await api.async_get_lock( - newdata["api_key"], newdata["bkey"], newdata["key_id"], newdata["name"] + newdata["api_key"], newdata["bkey"], newdata["key_id"], newdata["host"] ) - _LOGGER.debug("Lock details retrieved: %s", newdata["ip"]) newdata["id"] = lock.id # checking getWebooks to check the bridgeKey await lock.getWebhooks() except (aiohttp.ClientError): - _LOGGER.error("HTTP Connection error to loqed lock: %s:%s", lock.name, lock.id) + _LOGGER.error("HTTP Connection error to loqed lock") raise CannotConnect from aiohttp.ClientError except Exception: raise CannotConnect from Exception @@ -72,22 +62,23 @@ async def validate_input(hass, data): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for loqed.""" - VERSION = 1 + VERSION = 2 + + def __init__(self) -> None: + """Initialize the ConfigFlow for the LOQED integration.""" + self._host: str | None = None async def async_step_zeroconf(self, discovery_info) -> FlowResult: """Handle zeroconf discovery.""" - _LOGGER.debug("HOST: %s", discovery_info.hostname) - - host = discovery_info.hostname.rstrip(".") + self._host = discovery_info.hostname.rstrip(".") session = async_get_clientsession(self.hass) - apiclient = loqed.APIClient(session, "http://" + host) + apiclient = loqed.APIClient(session, "http://" + self._host) api = loqed.LoqedAPI(apiclient) lock_data = await api.async_get_lock_details() # Check if already exists - id = lock_data["bridge_mac_wifi"] - await self.async_set_unique_id(id) + await self.async_set_unique_id(lock_data["bridge_mac_wifi"]) self._abort_if_unique_id_configured() return await self.async_step_user() @@ -95,30 +86,26 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Show userform to user.""" - try: - internal_url = network.get_url( - self.hass, allow_internal=True, allow_external=False, allow_ip=True - ) - if internal_url.startswith("172"): - internal_url = "http://:8123" - except network.NoURLAvailableError: - internal_url = "http://:8123" - user_data_schema = vol.Schema( { - vol.Required("name", default="My Lock"): str, - vol.Required("internal_url", default=internal_url): str, vol.Required("config"): str, } ) - + self.context["title_placeholders"] = {CONF_HOST: self._host} if user_input is None: - return self.async_show_form(step_id="user", data_schema=user_data_schema) + 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) + info = await validate_input(self.hass, user_input, self._host) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index 91081b4537c78..881480fc67a80 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -22,6 +22,7 @@ STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import network from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, WEBHOOK_PREFIX @@ -48,7 +49,7 @@ async def async_setup_entry( """Set up the Loqed lock platform.""" lock = hass.data[DOMAIN][entry.entry_id] - async_add_entities([LoqedLock(lock, entry.data["internal_url"])]) + async_add_entities([LoqedLock(lock, network.get_url(hass))]) def get_random_string(length): diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json index dc47fc24ff001..755f6e13fb1c4 100644 --- a/homeassistant/components/loqed/strings.json +++ b/homeassistant/components/loqed/strings.json @@ -1,20 +1,18 @@ { "config": { + "flow_title": "LOQED Touch Smartlock setup ({host})", "step": { "user": { - "description": "Please enter a name for the lock and the internal Home Assistant URL.
Then visit https://app.loqed.com/API-Config and:
* Create an API-key: [add new api-key]
* Copy the [Integration information] value by clicking on the copy button in the detail-page of the just created API-key.", + "description": "Visit {config_url} and:
* Create an API-key: [add new api-key]
* Copy the [Integration information] value by clicking on the copy button in the detail-page of the just created API-key.", "data": { - "name": "[%key:common::config_flow::data::name%]", - "internal_url": "Internal Home assistant URL", - "config": "Loqed Configuration" + "config": "Loqed Configuration copied from the API setup page" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/homeassistant/components/loqed/translations/en.json b/homeassistant/components/loqed/translations/en.json index 851254f13b9fe..8af97a49bb658 100644 --- a/homeassistant/components/loqed/translations/en.json +++ b/homeassistant/components/loqed/translations/en.json @@ -1,22 +1,20 @@ { "config": { + "flow_title": "LOQED Touch Smartlock setup ({host})", "abort": { "already_configured": "Device is already configured" }, "error": { "cannot_connect": "Failed to connect, did you copy the complete string with the copy button?", "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error", - "already_configured": "Device is already configured" + "unknown": "Unexpected error" }, "step": { "user": { "data": { - "name": "Lock-name in Home Assistant", - "internal_url": "Internal Home Assistant URL. Should be accessible in the local network", "config": "Lock-details: [Integration information] in the api-key details" }, - "description": "Please enter a name for the lock and the internal Home Assistant URL.
Then visit https://app.loqed.com/API-Config and:
* Create an API-key: [add new api-key]
* Copy the [Integration information] value by clicking on the copy button in the detail-page of the just created API-key." + "description": "Visit {config_url} and:
* Create an API-key: [add new api-key]
* Copy the [Integration information] value by clicking on the copy button in the detail-page of the just created API-key." } } } diff --git a/tests/components/loqed/fixtures/status_ok.json b/tests/components/loqed/fixtures/status_ok.json new file mode 100644 index 0000000000000..4e5362507e654 --- /dev/null +++ b/tests/components/loqed/fixtures/status_ok.json @@ -0,0 +1,16 @@ +{ + "battery_percentage": 78, + "battery_type": "NICKEL_METAL_HYDRIDE", + "battery_type_numeric": 1, + "battery_voltage": 10.37, + "bolt_state": "day_lock", + "bolt_state_numeric": 2, + "bridge_mac_wifi": "***REDACTED***", + "bridge_mac_ble": "***REDACTED***", + "lock_online": 1, + "webhooks_number": 1, + "ip_address": "192.168.42.12", + "up_timestamp": 1653041994, + "wifi_strength": 73, + "ble_strength": 20 +} diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index 4e11a2d5f5f96..a313b7c709018 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -2,10 +2,13 @@ import json from unittest.mock import Mock, patch +import aiohttp from loqedAPI import loqed from homeassistant import config_entries +from homeassistant.components import zeroconf from homeassistant.components.loqed.const import DOMAIN +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM @@ -35,8 +38,6 @@ async def test_form(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "name": "loqed-abc", - "internal_url": "http://foo.bar.com", "config": loqed_integration_config, }, ) @@ -53,8 +54,6 @@ async def test_form(hass: HomeAssistant) -> None: "key_id": int(json_config["lock_key_local_id"]), "api_key": json_config["lock_key_key"], "config": loqed_integration_config, - "name": "loqed-abc", - "internal_url": "http://foo.bar.com", } mock_lock.getWebhooks.assert_awaited() @@ -69,16 +68,58 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: with patch( "loqedAPI.loqed.LoqedAPI.async_get_lock", - side_effect=Exception, + side_effect=aiohttp.ClientError, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "name": "loqed-abc", - "internal_url": "http://foo.bar.com", "config": loqed_integration_config, }, ) assert result2["type"] == RESULT_TYPE_FORM assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_zeroconf_wrong_config(hass: HomeAssistant) -> None: + """Test zeroconf setup errors when provided wrong config.""" + lock_result = json.loads(load_fixture("loqed/status_ok.json")) + loqed_integration_config = load_fixture("loqed/integration_config.json") + + with patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock_details", + return_value=lock_result, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + addresses=["127.0.0.1"], + hostname="LOQED-ffeeddccbbaa.local", + name="mock_name", + port=9123, + properties={}, + type="mock_type", + ), + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert context["title_placeholders"][CONF_HOST] == "LOQED-ffeeddccbbaa.local" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "config": loqed_integration_config, + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} From 309cbf5a09083af4fbf8658ea690447273c058c1 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Tue, 31 May 2022 18:59:15 +0000 Subject: [PATCH 05/28] Relocates webhook, adds sensors and adds tests --- homeassistant/components/loqed/__init__.py | 106 ++++++++- homeassistant/components/loqed/config_flow.py | 10 +- homeassistant/components/loqed/const.py | 5 +- homeassistant/components/loqed/lock.py | 209 +++++------------- homeassistant/components/loqed/sensor.py | 91 ++++++++ tests/components/loqed/conftest.py | 73 ++++++ .../loqed/fixtures/battery_update.json | 6 + .../loqed/fixtures/get_all_webhooks.json | 15 ++ .../loqed/fixtures/lock_going_to_daylock.json | 8 + .../fixtures/lock_going_to_nightlock.json | 8 + .../loqed/fixtures/nightlock_reached.json | 8 + tests/components/loqed/test_config_flow.py | 29 ++- tests/components/loqed/test_init.py | 77 +++++++ tests/components/loqed/test_lock.py | 170 ++++++++++++++ tests/components/loqed/test_sensor.py | 81 +++++++ 15 files changed, 731 insertions(+), 165 deletions(-) create mode 100644 homeassistant/components/loqed/sensor.py create mode 100644 tests/components/loqed/conftest.py create mode 100644 tests/components/loqed/fixtures/battery_update.json create mode 100644 tests/components/loqed/fixtures/get_all_webhooks.json create mode 100644 tests/components/loqed/fixtures/lock_going_to_daylock.json create mode 100644 tests/components/loqed/fixtures/lock_going_to_nightlock.json create mode 100644 tests/components/loqed/fixtures/nightlock_reached.json create mode 100644 tests/components/loqed/test_init.py create mode 100644 tests/components/loqed/test_lock.py create mode 100644 tests/components/loqed/test_sensor.py diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index 9c375a9d6dc39..03bd4aed6829a 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -1,24 +1,81 @@ """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 Platform -from homeassistant.core import HomeAssistant +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)) + 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 + ) + 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) + -from .const import DOMAIN +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) -PLATFORMS: list[str] = [Platform.LOCK] + 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, "http://" + host) + apiclient = loqed.APIClient(websession, f"http://{host}") api = loqed.LoqedAPI(apiclient) lock = await api.async_get_lock( @@ -27,8 +84,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data["key_id"], entry.data["host"], ) - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = lock + 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] = { + CONF_WEBHOOK_ID: webhook_id, + CONF_LOCK: lock, + CONF_COORDINATOR: coordinator, + CONF_WEBHOOK_INDEX: webhook_index, + } # Registers update listener to update config entry when options are updated. entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -39,13 +105,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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: hass.data[DOMAIN].pop(entry.entry_id) + try: + await lock.deleteWebhook(data[CONF_WEBHOOK_INDEX]) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Failed to delete webhook") + return False + return unload_ok async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry): """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) + + +class LoqedDataCoordinator(DataUpdateCoordinator): + """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]: + """Fetch data from API endpoint.""" + async with async_timeout.timeout(10): + return await self._api.async_get_lock_details() diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 5098c0d47445d..b3851121782fa 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -10,7 +10,8 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_HOST +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 @@ -54,8 +55,6 @@ async def validate_input( except (aiohttp.ClientError): _LOGGER.error("HTTP Connection error to loqed lock") raise CannotConnect from aiohttp.ClientError - except Exception: - raise CannotConnect from Exception return newdata @@ -73,7 +72,7 @@ async def async_step_zeroconf(self, discovery_info) -> FlowResult: self._host = discovery_info.hostname.rstrip(".") session = async_get_clientsession(self.hass) - apiclient = loqed.APIClient(session, "http://" + self._host) + apiclient = loqed.APIClient(session, f"http://{self._host}") api = loqed.LoqedAPI(apiclient) lock_data = await api.async_get_lock_details() @@ -118,7 +117,8 @@ async def async_step_user( self._abort_if_unique_id_configured() return self.async_create_entry( - title="LOQED Touch Smart Lock", data=user_input + title="LOQED Touch Smart Lock", + data=(user_input | {CONF_WEBHOOK_ID: webhook.async_generate_id()}), ) return self.async_show_form( diff --git a/homeassistant/components/loqed/const.py b/homeassistant/components/loqed/const.py index 90bd767b7312f..f78728a58a961 100644 --- a/homeassistant/components/loqed/const.py +++ b/homeassistant/components/loqed/const.py @@ -1,4 +1,7 @@ """Constants for the loqed integration.""" + DOMAIN = "loqed" -WEBHOOK_PREFIX = "p87yh9pb787yhup" +CONF_WEBHOOK_INDEX = "webhook_index" +CONF_COORDINATOR = "coordinator" +CONF_LOCK = "lock" diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index 881480fc67a80..01073dd4975a7 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -1,223 +1,132 @@ """LOQED lock integration for Home Assistant.""" from __future__ import annotations -import asyncio -import logging -import random -import string +from typing import Any from loqedAPI import loqed -from voluptuous.schema_builder import Undefined -from homeassistant.components import webhook from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_BATTERY_LEVEL, STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, - STATE_OPENING, + STATE_UNKNOWN, STATE_UNLOCKED, STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import network +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, WEBHOOK_PREFIX +from . import LoqedDataCoordinator +from .const import CONF_COORDINATOR, CONF_LOCK, DOMAIN LOCK_STATES = { - "opening": STATE_OPENING, - "unlocking": STATE_UNLOCKING, - "locking": STATE_LOCKING, "latch": STATE_UNLOCKED, "night_lock": STATE_LOCKED, "open": STATE_UNLOCKED, "day_lock": STATE_UNLOCKED, + "unknown": STATE_UNKNOWN, } +LOCK_GO_TO_STATES = { + "latch": STATE_UNLOCKING, + "night_lock": STATE_LOCKING, + "open": STATE_UNLOCKING, + "day_lock": STATE_UNLOCKING, + "unknown": STATE_UNKNOWN, +} -WEBHOOK_API_ENDPOINT = "/api/loqed/webhook" -_LOGGER = logging.getLogger(__name__) +WEBHOOK_API_ENDPOINT = "/api/loqed/webhook" async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Loqed lock platform.""" - lock = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([LoqedLock(lock, network.get_url(hass))]) + entry_state = hass.data[DOMAIN][entry.entry_id] + lock = entry_state[CONF_LOCK] + coordinator = entry_state[CONF_COORDINATOR] + async_add_entities([LoqedLock(lock, coordinator)]) -def get_random_string(length): - """Create a rondom ascii string.""" - letters = string.ascii_lowercase - result_str = "".join(random.choice(letters) for i in range(length)) - return result_str - -class LoqedLock(LockEntity): +class LoqedLock(CoordinatorEntity[LoqedDataCoordinator], LockEntity): """Representation of a loqed lock.""" - def __init__(self, lock: loqed.Lock, internal_url) -> None: + def __init__(self, lock: loqed.Lock, coordinator: LoqedDataCoordinator) -> None: """Initialize the lock.""" + super().__init__(coordinator) self._lock = lock - self._internal_url = internal_url - self._webhook = "" self._attr_unique_id = self._lock.id self._attr_name = self._lock.name self._attr_supported_features = LockEntityFeature.OPEN - self.update_task = None - - async def async_added_to_hass(self) -> None: - """Entity created.""" - await super().async_added_to_hass() - await self.check_webhook() + self._state = STATE_UNKNOWN + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, lock.id)}, + name="Loqed instance", + ) @property def changed_by(self): """Return true if lock is locking.""" return "KeyID " + str(self._lock.last_key_id) - @property - def bolt_state(self): - """Return true if lock is locking.""" - return self._lock.bolt_state - @property def is_locking(self): """Return true if lock is locking.""" - return LOCK_STATES[self.bolt_state] == STATE_LOCKING + return self._state == STATE_LOCKING @property def is_unlocking(self): """Return true if lock is unlocking.""" - return LOCK_STATES[self.bolt_state] == STATE_UNLOCKING + return self._state == STATE_UNLOCKING @property def is_jammed(self): """Return true if lock is jammed.""" - return LOCK_STATES[self.bolt_state] == STATE_JAMMED + return self._state == STATE_JAMMED @property def is_locked(self): """Return true if lock is locked.""" - return LOCK_STATES[self.bolt_state] == STATE_LOCKED + return self._state == STATE_LOCKED + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock.""" + self._state = STATE_LOCKING + self.async_write_ha_state() - @property - def extra_state_attributes(self): - """Extra state attribtues.""" - state_attr = { - "bolt_state": self.bolt_state, - "webhook_url": self._webhook, - ATTR_BATTERY_LEVEL: self._lock.battery_percentage, - "battery_type": self._lock.battery_type, - "battery_voltage": self._lock.battery_voltage, - "wifi_strength": self._lock.wifi_strength, - "ble_strength": self._lock.ble_strength, - "last_event": self._lock.last_event, - "last_changed_key_id": self._lock.last_key_id, - } - return state_attr - - async def async_lock(self, **kwargs): - """To calls the lock method of the loqed lock.""" - _LOGGER.debug("start lock operation") - await self.async_schedule_update(10) await self._lock.lock() - async def async_unlock(self, **kwargs): - """To call the unlock method of the loqed lock.""" - _LOGGER.debug("start unlock operation") - await self.async_schedule_update(10) + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the lock.""" + self._state = STATE_UNLOCKING + self.async_write_ha_state() + await self._lock.unlock() - async def async_open(self, **kwargs): - """To call the open method of the loqed lock.""" - _LOGGER.debug("start open operation") - await self.async_schedule_update(10) - await self._lock.open() + async def async_open(self, **kwargs: Any) -> None: + """Open the door latch.""" + self._state = STATE_UNLOCKING + self.async_write_ha_state() - async def async_update(self) -> None: - """To update the internal state of the device.""" - _LOGGER.debug("Start update operation") - resp = await self._lock.update() - _LOGGER.debug("Update response: %s", str(resp)) - self._attr_unique_id = self._lock.id - self._attr_name = self._lock.name - _LOGGER.debug("BOLT_STATE after update: %s", self.bolt_state) - self.async_schedule_update_ha_state() - - async def check_webhook(self): - """Check if webhook is configured on both sides.""" - _LOGGER.debug("Start checking webhooks") - webhooks = await self._lock.getWebhooks() - wh_id = Undefined - # Check if hook already registered @loqed - for hook in webhooks: - if hook["url"].startswith( - self._internal_url + "/api/webhook/" + WEBHOOK_PREFIX - ): - url = hook["url"] - wh_id = WEBHOOK_PREFIX + url[-12:] - _LOGGER.debug("Found already configured webhook @loqed: %s", url) - break - if wh_id == Undefined: - wh_id = WEBHOOK_PREFIX + get_random_string(12) - # Registering webhook in Loqed - url = self._internal_url + "/api/webhook/" + wh_id - _LOGGER.debug("Registering webhook @loqed: %s", url) - await self._lock.registerWebhook(url) - # Registering webhook in HASS, when exists same will be used - _LOGGER.debug("Registering webhook in HA") - self._webhook = str(url) - try: - webhook.async_register( - hass=self.hass, - domain=DOMAIN, - name="loqed", - webhook_id=wh_id, - handler=self.async_handle_webhook, - ) - except ValueError: # when already installed - pass - return url + await self._lock.open() @callback - async def async_handle_webhook(self, hass, webhook_id, request): - """Handle webhook callback.""" - _LOGGER.debug("Callback received: %s", str(request.headers)) - received_ts = request.headers["TIMESTAMP"] - received_hash = request.headers["HASH"] - body = await request.text() - _LOGGER.debug("Callback body: %s", body) - event_data = await self._lock.receiveWebhook(body, received_hash, received_ts) - if "error" in event_data: - _LOGGER.warning("Incorrect CALLBACK RECEIVED:: %s", event_data) - return - event_type = "LOQED_status_change_to_" + LOCK_STATES[self.bolt_state] - _LOGGER.debug("Firing event:: %s", event_type) - hass.bus.fire(event_type, event_data) - self.async_schedule_update_ha_state(False) - event = event_data["event_type"].strip().lower() - if event.split("_")[0] == "state": - if self.update_task: - self.update_task.cancel() - elif "go_to" in event: - await self.async_schedule_update(12) - - async def async_schedule_update(self, timeout): - """To cancel outstanding async update task and schedules new one.""" - if self.update_task: - self.update_task.cancel() - _LOGGER.debug("PLAN update operation in %s seconds", timeout) - self.update_task = asyncio.create_task(self.async_delayed_update(timeout)) - - async def async_delayed_update(self, timeout): - """Async update task to handle lock update when nno callback.""" - _LOGGER.debug("Start waiting in delayed_update") - await asyncio.sleep(timeout) - await self.async_update() + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + data = self.coordinator.data + + if "requested_state" in data and data["requested_state"].lower() in LOCK_STATES: + self._state = LOCK_STATES[data["requested_state"].lower()] + self.async_schedule_update_ha_state() + elif "go_to_state" in data and data["go_to_state"].lower() in LOCK_GO_TO_STATES: + self._state = LOCK_GO_TO_STATES[data["go_to_state"].lower()] + self.async_schedule_update_ha_state() + elif "bolt_state" in data and data["bolt_state"] in LOCK_STATES: + self._state = LOCK_STATES[data["bolt_state"]] + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/loqed/sensor.py b/homeassistant/components/loqed/sensor.py new file mode 100644 index 0000000000000..ada12d8f3120d --- /dev/null +++ b/homeassistant/components/loqed/sensor.py @@ -0,0 +1,91 @@ +"""Loqed sensor entities.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import LoqedDataCoordinator +from .const import CONF_COORDINATOR, CONF_LOCK, DOMAIN + +SENSORS: list[SensorEntityDescription] = [ + SensorEntityDescription( + name="Loqed battery status", + key="battery_percentage", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery", + ), + SensorEntityDescription( + name="Loqed wifi signal strength", + key="wifi_strength", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:signal", + ), + SensorEntityDescription( + name="Loqed bluetooth signal strength", + key="ble_strength", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + icon="mdi:signal", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Loqed sensor.""" + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][entry.entry_id][ + CONF_COORDINATOR + ] + mac_address = hass.data[DOMAIN][entry.entry_id][CONF_LOCK].id + + entities = [LoqedSensor(mac_address, sensor, coordinator) for sensor in SENSORS] + async_add_entities(entities) + + +class LoqedSensor(CoordinatorEntity[LoqedDataCoordinator], SensorEntity): + """Class representing a LoqedSensor.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + mac_address: str, + sensor_description: SensorEntityDescription, + coordinator: LoqedDataCoordinator, + ) -> None: + """Initialize the loqed sensor.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mac_address)}, + name="Loqed instance", + ) + self.entity_description = sensor_description + self._attr_unique_id = f"{sensor_description.key}-{mac_address}" + self._attr_native_unit_of_measurement = ( + sensor_description.native_unit_of_measurement + ) + + self._attr_native_value = coordinator.data[sensor_description.key] + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + key = self.entity_description.key + + if key in self.coordinator.data: + self._attr_native_value = self.coordinator.data[key] + self.async_write_ha_state() diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py new file mode 100644 index 0000000000000..9bd22cedfa64e --- /dev/null +++ b/tests/components/loqed/conftest.py @@ -0,0 +1,73 @@ +"""Contains fixtures for Loqed tests.""" + +from collections.abc import AsyncGenerator +import json +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +from loqedAPI import loqed +import pytest + +from homeassistant.components.loqed import DOMAIN +from homeassistant.components.loqed.const import CONF_COORDINATOR +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="config_entry") +def config_entry_fixture() -> MockConfigEntry: + """Mock config entry.""" + + config = load_fixture("loqed/integration_config.json") + json_config = json.loads(config) + return MockConfigEntry( + version=2, + domain=DOMAIN, + data={ + "config": config, + "id": "Foo", + "ip": json_config["bridge_ip"], + "host": json_config["bridge_mdns_hostname"], + "bkey": json_config["bridge_key"], + "key_id": int(json_config["lock_key_local_id"]), + "api_key": json_config["lock_key_key"], + CONF_WEBHOOK_ID: "Webhook_ID", + }, + ) + + +@pytest.fixture(name="lock") +def lock_fixture() -> loqed.Lock: + """Set up a mock implementation of a Lock.""" + webhooks_fixture = json.loads(load_fixture("loqed/get_all_webhooks.json")) + + mock_lock = Mock(spec=loqed.Lock, id="Foo", last_key_id=2) + mock_lock.name = "LOQED smart lock" + mock_lock.getWebhooks = AsyncMock(return_value=webhooks_fixture) + return mock_lock + + +@pytest.fixture(name="integration") +async def integration_fixture( + hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock +) -> AsyncGenerator[MockConfigEntry, None]: + """Set up the loqed integration with a config entry.""" + config: dict[str, Any] = {DOMAIN: {CONF_COORDINATOR: ""}} + config_entry.add_to_hass(hass) + + webhooks_fixture = json.loads(load_fixture("loqed/get_all_webhooks.json")) + + lock_status = json.loads(load_fixture("loqed/status_ok.json")) + + with patch( + "homeassistant.components.webhook.async_generate_url", + return_value=webhooks_fixture[0]["url"], + ), patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_status + ): + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + yield config_entry diff --git a/tests/components/loqed/fixtures/battery_update.json b/tests/components/loqed/fixtures/battery_update.json new file mode 100644 index 0000000000000..be62d7f14f83e --- /dev/null +++ b/tests/components/loqed/fixtures/battery_update.json @@ -0,0 +1,6 @@ +{ + "battery_type": "NICKEL_METAL_HYDRIDE", + "battery_percentage": 88, + "mac_wifi": "**REDACTED**", + "mac_ble": "**REDACTED**" +} diff --git a/tests/components/loqed/fixtures/get_all_webhooks.json b/tests/components/loqed/fixtures/get_all_webhooks.json new file mode 100644 index 0000000000000..991aededb30f3 --- /dev/null +++ b/tests/components/loqed/fixtures/get_all_webhooks.json @@ -0,0 +1,15 @@ +[ + { + "id": 1, + "url": "http://172.17.0.2:8123/api/webhook/Webhook_id", + "trigger_state_changed_open": 1, + "trigger_state_changed_latch": 1, + "trigger_state_changed_night_lock": 1, + "trigger_state_changed_unknown": 1, + "trigger_state_goto_open": 1, + "trigger_state_goto_latch": 1, + "trigger_state_goto_night_lock": 1, + "trigger_battery": 1, + "trigger_online_status": 1 + } +] diff --git a/tests/components/loqed/fixtures/lock_going_to_daylock.json b/tests/components/loqed/fixtures/lock_going_to_daylock.json new file mode 100644 index 0000000000000..a07ba43d25c03 --- /dev/null +++ b/tests/components/loqed/fixtures/lock_going_to_daylock.json @@ -0,0 +1,8 @@ +{ + "go_to_state": "DAY_LOCK", + "go_to_state_numeric": 2, + "mac_wifi": "**REDACTED**", + "mac_ble": "**REDACTED**", + "event_type": "GO_TO_STATE_TWIST_ASSIST_LATCH", + "key_local_id": 255 +} diff --git a/tests/components/loqed/fixtures/lock_going_to_nightlock.json b/tests/components/loqed/fixtures/lock_going_to_nightlock.json new file mode 100644 index 0000000000000..8f7ce2ec02551 --- /dev/null +++ b/tests/components/loqed/fixtures/lock_going_to_nightlock.json @@ -0,0 +1,8 @@ +{ + "go_to_state": "NIGHT_LOCK", + "go_to_state_numeric": 3, + "mac_wifi": "**REDACTED**", + "mac_ble": "**REDACTED**", + "event_type": "GO_TO_STATE_TWIST_ASSIST_LATCH", + "key_local_id": 255 +} diff --git a/tests/components/loqed/fixtures/nightlock_reached.json b/tests/components/loqed/fixtures/nightlock_reached.json new file mode 100644 index 0000000000000..f8ce0d45cae97 --- /dev/null +++ b/tests/components/loqed/fixtures/nightlock_reached.json @@ -0,0 +1,8 @@ +{ + "requested_state": "NIGHT_LOCK", + "requested_state_numeric": 3, + "mac_wifi": "**REDACTED**", + "mac_ble": "**REDACTED**", + "event_type": "STATE_CHANGED_LATCH", + "key_local_id": 255 +} diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index a313b7c709018..c4ab276817a4d 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -8,7 +8,7 @@ from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.components.loqed.const import DOMAIN -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM @@ -27,6 +27,7 @@ async def test_form(hass: HomeAssistant) -> None: loqed_integration_config = load_fixture("loqed/integration_config.json") mock_lock = Mock(spec=loqed.Lock, id="Foo") + webhook_id = "Webhook_ID" with patch( "loqedAPI.loqed.LoqedAPI.async_get_lock", @@ -34,6 +35,8 @@ async def test_form(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.loqed.async_setup_entry", return_value=True, + ), patch( + "homeassistant.components.webhook.async_generate_id", return_value=webhook_id ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -54,6 +57,7 @@ async def test_form(hass: HomeAssistant) -> None: "key_id": int(json_config["lock_key_local_id"]), "api_key": json_config["lock_key_key"], "config": loqed_integration_config, + CONF_WEBHOOK_ID: webhook_id, } mock_lock.getWebhooks.assert_awaited() @@ -81,6 +85,29 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} +async def test_form_unexpected_exception(hass: HomeAssistant) -> None: + """Test we handle cannot unexpected exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + loqed_integration_config = load_fixture("loqed/integration_config.json") + + with patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "config": loqed_integration_config, + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + async def test_zeroconf_wrong_config(hass: HomeAssistant) -> None: """Test zeroconf setup errors when provided wrong config.""" lock_result = json.loads(load_fixture("loqed/status_ok.json")) diff --git a/tests/components/loqed/test_init.py b/tests/components/loqed/test_init.py new file mode 100644 index 0000000000000..47e9566b119fd --- /dev/null +++ b/tests/components/loqed/test_init.py @@ -0,0 +1,77 @@ +"""Tests the init part of the Loqed integration.""" + +import json +from typing import Any +from unittest.mock import AsyncMock, patch + +from loqedAPI import loqed + +from homeassistant.components.loqed.const import ( + CONF_COORDINATOR, + CONF_WEBHOOK_INDEX, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +async def test_webhook_rejects_invalid_message( + hass: HomeAssistant, + hass_client_no_auth, + integration: MockConfigEntry, +): + """Test webhook called with invalid message.""" + await async_setup_component(hass, "http", {"http": {}}) + client = await hass_client_no_auth() + + coordinator = hass.data[DOMAIN][integration.entry_id][CONF_COORDINATOR] + + with patch.object(coordinator, "async_set_updated_data") as mock: + message = load_fixture("loqed/battery_update.json") + timestamp = 1653304609 + await client.post( + f"/api/webhook/{integration.data[CONF_WEBHOOK_ID]}", + data=message, + headers={"timestamp": str(timestamp), "hash": "incorrect hash"}, + ) + + mock.assert_not_called() + + +async def test_setup_webhook_in_bridge( + hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock +): + """Test webhook setup in loqed bridge.""" + config: dict[str, Any] = {DOMAIN: {CONF_COORDINATOR: ""}} + config_entry.add_to_hass(hass) + + lock_status = json.loads(load_fixture("loqed/status_ok.json")) + webhooks_fixture = json.loads(load_fixture("loqed/get_all_webhooks.json")) + lock.getWebhooks = AsyncMock(side_effect=[[], webhooks_fixture]) + + with patch( + "homeassistant.components.webhook.async_generate_url", + return_value=webhooks_fixture[0]["url"], + ), patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_status + ): + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + lock.registerWebhook.assert_called_with(webhooks_fixture[0]["url"]) + + +async def test_unload_entry(hass, integration: MockConfigEntry, lock: loqed.Lock): + """Test successful unload of entry.""" + webhook_index = hass.data[DOMAIN][integration.entry_id][CONF_WEBHOOK_INDEX] + + assert await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + + lock.deleteWebhook.assert_called_with(webhook_index) + assert integration.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/loqed/test_lock.py b/tests/components/loqed/test_lock.py new file mode 100644 index 0000000000000..9cac8a29b9130 --- /dev/null +++ b/tests/components/loqed/test_lock.py @@ -0,0 +1,170 @@ +"""Tests the lock platform of the Loqed integration.""" +import json + +from loqedAPI import loqed + +from homeassistant.components.loqed import LoqedDataCoordinator +from homeassistant.components.loqed.const import CONF_COORDINATOR, DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +async def test_lock_entity( + hass: HomeAssistant, + integration: MockConfigEntry, +) -> None: + """Test the lock entity.""" + entity_id = "lock.loqed_smart_lock" + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNLOCKED + + +async def test_lock_responds_to_updates( + hass: HomeAssistant, integration: MockConfigEntry +) -> None: + """Test the lock responding to updates.""" + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id][ + CONF_COORDINATOR + ] + coordinator.async_set_updated_data( + { + "go_to_state": "DAY_LOCK", + } + ) + + entity_id = "lock.loqed_smart_lock" + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNLOCKING + + +async def test_lock_responds_to_status_updates( + hass: HomeAssistant, integration: MockConfigEntry +) -> None: + """Tests the lock responding to updates.""" + message = json.loads(load_fixture("loqed/lock_going_to_daylock.json")) + + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id][ + CONF_COORDINATOR + ] + coordinator.async_set_updated_data(message) + + entity_id = "lock.loqed_smart_lock" + print(hass.state) + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNLOCKING + + +async def test_lock_responds_to_webhook_calls( + hass: HomeAssistant, integration: MockConfigEntry +) -> None: + """Tests the lock responding to updates.""" + message = json.loads(load_fixture("loqed/nightlock_reached.json")) + + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id][ + CONF_COORDINATOR + ] + coordinator.async_set_updated_data(message) + + entity_id = "lock.loqed_smart_lock" + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_LOCKED + + +async def test_lock_responds_to_bolt_state_updates( + hass: HomeAssistant, integration: MockConfigEntry +) -> None: + """Tests the lock responding to updates.""" + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id][ + CONF_COORDINATOR + ] + coordinator.async_set_updated_data( + { + "bolt_state": "night_lock", + } + ) + + entity_id = "lock.loqed_smart_lock" + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_LOCKED + + +async def test_lock_transition_to_unlocked( + hass: HomeAssistant, integration: MockConfigEntry, lock: loqed.Lock +) -> None: + """Tests the lock transitions to unlocked state.""" + + entity_id = "lock.loqed_smart_lock" + + await hass.services.async_call( + "lock", SERVICE_UNLOCK, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + await hass.async_block_till_done() + lock.unlock.assert_called() + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNLOCKING + + +async def test_lock_transition_to_locked( + hass: HomeAssistant, integration: MockConfigEntry, lock: loqed.Lock +) -> None: + """Tests the lock transitions to locked state.""" + + entity_id = "lock.loqed_smart_lock" + + await hass.services.async_call( + "lock", SERVICE_LOCK, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + await hass.async_block_till_done() + lock.lock.assert_called() + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_LOCKING + + +async def test_lock_transition_to_open( + hass: HomeAssistant, integration: MockConfigEntry, lock: loqed.Lock +) -> None: + """Tests the lock transitions to open state.""" + + entity_id = "lock.loqed_smart_lock" + + await hass.services.async_call( + "lock", SERVICE_OPEN, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + await hass.async_block_till_done() + lock.open.assert_called() + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNLOCKING diff --git a/tests/components/loqed/test_sensor.py b/tests/components/loqed/test_sensor.py new file mode 100644 index 0000000000000..112e4bd6f19df --- /dev/null +++ b/tests/components/loqed/test_sensor.py @@ -0,0 +1,81 @@ +"""Tests the sensor platform of the Loqed integration.""" +from homeassistant.components.loqed import LoqedDataCoordinator +from homeassistant.components.loqed.const import CONF_COORDINATOR, DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_battery_sensor( + hass: HomeAssistant, + integration: MockConfigEntry, +) -> None: + """Test the battery sensor.""" + entity_id = "sensor.loqed_battery_status" + + state = hass.states.get(entity_id) + + assert state + assert state.state == "78" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT + + +async def test_wifi_stength_sensor( + hass: HomeAssistant, + integration: MockConfigEntry, +) -> None: + """Test the wifi signal strength sensor.""" + entity_id = "sensor.loqed_wifi_signal_strength" + + state = hass.states.get(entity_id) + + assert state + assert state.state == "73" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT + + +async def test_ble_strength_sensor( + hass: HomeAssistant, + integration: MockConfigEntry, +) -> None: + """Test the bluetooth signal strength sensor.""" + entity_id = "sensor.loqed_bluetooth_signal_strength" + + state = hass.states.get(entity_id) + + assert state + assert state.state == "20" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS + assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT + + +async def test_battery_sensor_update( + hass: HomeAssistant, integration: MockConfigEntry +) -> None: + """Tests the sensor responding to a coordinator update.""" + + entity_id = "sensor.loqed_battery_status" + + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id][ + CONF_COORDINATOR + ] + coordinator.async_set_updated_data({"battery_percentage": 99}) + + state = hass.states.get(entity_id) + assert state.state == "99" From 83a63de981e3f2c1379b295394918896c3cd8549 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Wed, 1 Jun 2022 08:47:20 +0000 Subject: [PATCH 06/28] Adds webhook dependency, adds missing tests --- homeassistant/components/loqed/__init__.py | 8 ---- homeassistant/components/loqed/manifest.json | 2 +- requirements_all.txt | 3 ++ requirements_test_all.txt | 6 +-- tests/components/loqed/conftest.py | 9 +---- tests/components/loqed/test_init.py | 39 ++++++++++++++++++-- 6 files changed, 44 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index 03bd4aed6829a..cfc4c794493be 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -96,9 +96,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_WEBHOOK_INDEX: webhook_index, } - # Registers update listener to update config entry when options are updated. - entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -122,11 +119,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry): - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - class LoqedDataCoordinator(DataUpdateCoordinator): """Data update coordinator for the loqed platform.""" diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json index 5009b85ce95ea..3a190d96202fc 100644 --- a/homeassistant/components/loqed/manifest.json +++ b/homeassistant/components/loqed/manifest.json @@ -6,7 +6,7 @@ "requirements": ["loqedAPI==2.1.3"], "ssdp": [], "homekit": {}, - "dependencies": [], + "dependencies": ["webhook"], "codeowners": ["@cpolhout"], "iot_class": "local_push", "zeroconf": [{ "type": "_http._tcp.local.", "name": "loqed*" }] diff --git a/requirements_all.txt b/requirements_all.txt index 9bafca7e5f3b3..baf46d9697096 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1151,6 +1151,9 @@ london-tube-status==0.5 # homeassistant.components.loqed loqedAPI==2.1.3 +# homeassistant.components.recorder +lru-dict==1.1.7 + # homeassistant.components.luftdaten luftdaten==0.7.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 530e319341817..713edd4f0eb43 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -257,9 +257,6 @@ aiolivisi==0.0.19 # homeassistant.components.lookin aiolookin==1.0.0 -# homeassistant.components.loqed -loqedAPI==2.1.3 - # homeassistant.components.lyric aiolyric==1.0.9 @@ -880,6 +877,9 @@ life360==5.5.0 # homeassistant.components.logi_circle logi-circle==0.2.3 +# homeassistant.components.loqed +loqedAPI==2.1.3 + # homeassistant.components.luftdaten luftdaten==0.7.4 diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index 9bd22cedfa64e..57fc4fea9fa91 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -34,7 +34,7 @@ def config_entry_fixture() -> MockConfigEntry: "bkey": json_config["bridge_key"], "key_id": int(json_config["lock_key_local_id"]), "api_key": json_config["lock_key_key"], - CONF_WEBHOOK_ID: "Webhook_ID", + CONF_WEBHOOK_ID: "Webhook_id", }, ) @@ -58,14 +58,9 @@ async def integration_fixture( config: dict[str, Any] = {DOMAIN: {CONF_COORDINATOR: ""}} config_entry.add_to_hass(hass) - webhooks_fixture = json.loads(load_fixture("loqed/get_all_webhooks.json")) - lock_status = json.loads(load_fixture("loqed/status_ok.json")) - with patch( - "homeassistant.components.webhook.async_generate_url", - return_value=webhooks_fixture[0]["url"], - ), patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( + with patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_status ): await async_setup_component(hass, DOMAIN, config) diff --git a/tests/components/loqed/test_init.py b/tests/components/loqed/test_init.py index 47e9566b119fd..5221e78bf634c 100644 --- a/tests/components/loqed/test_init.py +++ b/tests/components/loqed/test_init.py @@ -23,12 +23,14 @@ async def test_webhook_rejects_invalid_message( hass: HomeAssistant, hass_client_no_auth, integration: MockConfigEntry, + lock: loqed.Lock, ): """Test webhook called with invalid message.""" await async_setup_component(hass, "http", {"http": {}}) client = await hass_client_no_auth() coordinator = hass.data[DOMAIN][integration.entry_id][CONF_COORDINATOR] + lock.receiveWebhook = AsyncMock(return_value={"error": "invalid hash"}) with patch.object(coordinator, "async_set_updated_data") as mock: message = load_fixture("loqed/battery_update.json") @@ -42,6 +44,31 @@ async def test_webhook_rejects_invalid_message( mock.assert_not_called() +async def test_webhook_accepts_valid_message( + hass: HomeAssistant, + hass_client_no_auth, + integration: MockConfigEntry, + lock: loqed.Lock, +): + """Test webhook called with valid message.""" + await async_setup_component(hass, "http", {"http": {}}) + client = await hass_client_no_auth() + processed_message = json.loads(load_fixture("loqed/battery_update.json")) + coordinator = hass.data[DOMAIN][integration.entry_id][CONF_COORDINATOR] + lock.receiveWebhook = AsyncMock(return_value=processed_message) + + with patch.object(coordinator, "async_set_updated_data") as mock: + message = load_fixture("loqed/battery_update.json") + timestamp = 1653304609 + await client.post( + f"/api/webhook/{integration.data[CONF_WEBHOOK_ID]}", + data=message, + headers={"timestamp": str(timestamp), "hash": "incorrect hash"}, + ) + + mock.assert_called_with(processed_message) + + async def test_setup_webhook_in_bridge( hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock ): @@ -53,10 +80,7 @@ async def test_setup_webhook_in_bridge( webhooks_fixture = json.loads(load_fixture("loqed/get_all_webhooks.json")) lock.getWebhooks = AsyncMock(side_effect=[[], webhooks_fixture]) - with patch( - "homeassistant.components.webhook.async_generate_url", - return_value=webhooks_fixture[0]["url"], - ), patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( + with patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_status ): await async_setup_component(hass, DOMAIN, config) @@ -75,3 +99,10 @@ async def test_unload_entry(hass, integration: MockConfigEntry, lock: loqed.Lock lock.deleteWebhook.assert_called_with(webhook_index) assert integration.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + + +async def test_unload_entry_fails(hass, integration: MockConfigEntry, lock: loqed.Lock): + """Test unsuccessful unload of entry.""" + lock.deleteWebhook = AsyncMock(side_effect=Exception) + + assert not await hass.config_entries.async_unload(integration.entry_id) From dd8afa6b9abb881b6a6bc7be8aff1784481388a1 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Wed, 1 Jun 2022 15:03:02 +0000 Subject: [PATCH 07/28] Disables sensors that are not really reliable/useful --- homeassistant/components/loqed/sensor.py | 2 ++ tests/components/loqed/test_sensor.py | 39 +----------------------- 2 files changed, 3 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/loqed/sensor.py b/homeassistant/components/loqed/sensor.py index ada12d8f3120d..8334376b98e6b 100644 --- a/homeassistant/components/loqed/sensor.py +++ b/homeassistant/components/loqed/sensor.py @@ -30,6 +30,7 @@ key="wifi_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_registry_enabled_default=False, icon="mdi:signal", ), SensorEntityDescription( @@ -37,6 +38,7 @@ key="ble_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_registry_enabled_default=False, icon="mdi:signal", ), ] diff --git a/tests/components/loqed/test_sensor.py b/tests/components/loqed/test_sensor.py index 112e4bd6f19df..fa80d3d79ea12 100644 --- a/tests/components/loqed/test_sensor.py +++ b/tests/components/loqed/test_sensor.py @@ -6,12 +6,7 @@ SensorDeviceClass, SensorStateClass, ) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS, -) +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -33,38 +28,6 @@ async def test_battery_sensor( assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT -async def test_wifi_stength_sensor( - hass: HomeAssistant, - integration: MockConfigEntry, -) -> None: - """Test the wifi signal strength sensor.""" - entity_id = "sensor.loqed_wifi_signal_strength" - - state = hass.states.get(entity_id) - - assert state - assert state.state == "73" - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT - - -async def test_ble_strength_sensor( - hass: HomeAssistant, - integration: MockConfigEntry, -) -> None: - """Test the bluetooth signal strength sensor.""" - entity_id = "sensor.loqed_bluetooth_signal_strength" - - state = hass.states.get(entity_id) - - assert state - assert state.state == "20" - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SIGNAL_STRENGTH - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == SIGNAL_STRENGTH_DECIBELS - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT - - async def test_battery_sensor_update( hass: HomeAssistant, integration: MockConfigEntry ) -> None: From 2ff25b2dcac41e72e0af5bf84cbcb6734de770ee Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Wed, 8 Jun 2022 12:22:00 +0000 Subject: [PATCH 08/28] Fixes tests --- homeassistant/components/loqed/__init__.py | 3 +-- homeassistant/components/loqed/config_flow.py | 5 ++++- requirements_all.txt | 3 --- tests/components/loqed/conftest.py | 3 +++ tests/components/loqed/fixtures/get_all_webhooks.json | 2 +- tests/components/loqed/test_init.py | 5 ++++- 6 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index cfc4c794493be..ca8690a498070 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -106,8 +106,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) try: diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index b3851121782fa..392ffa9ef76f8 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -11,6 +11,7 @@ from homeassistant import config_entries from homeassistant.components import webhook +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult @@ -67,7 +68,9 @@ def __init__(self) -> None: """Initialize the ConfigFlow for the LOQED integration.""" self._host: str | None = None - async def async_step_zeroconf(self, discovery_info) -> FlowResult: + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> FlowResult: """Handle zeroconf discovery.""" self._host = discovery_info.hostname.rstrip(".") diff --git a/requirements_all.txt b/requirements_all.txt index baf46d9697096..9bafca7e5f3b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1151,9 +1151,6 @@ london-tube-status==0.5 # homeassistant.components.loqed loqedAPI==2.1.3 -# homeassistant.components.recorder -lru-dict==1.1.7 - # homeassistant.components.luftdaten luftdaten==0.7.4 diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index 57fc4fea9fa91..a0f3c61fd58f1 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -62,6 +62,9 @@ async def integration_fixture( with patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_status + ), patch( + "homeassistant.components.webhook.async_generate_url", + return_value="http://hook_id", ): await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/loqed/fixtures/get_all_webhooks.json b/tests/components/loqed/fixtures/get_all_webhooks.json index 991aededb30f3..cf53fcf56a924 100644 --- a/tests/components/loqed/fixtures/get_all_webhooks.json +++ b/tests/components/loqed/fixtures/get_all_webhooks.json @@ -1,7 +1,7 @@ [ { "id": 1, - "url": "http://172.17.0.2:8123/api/webhook/Webhook_id", + "url": "http://hook_id", "trigger_state_changed_open": 1, "trigger_state_changed_latch": 1, "trigger_state_changed_night_lock": 1, diff --git a/tests/components/loqed/test_init.py b/tests/components/loqed/test_init.py index 5221e78bf634c..f365d04fbed0f 100644 --- a/tests/components/loqed/test_init.py +++ b/tests/components/loqed/test_init.py @@ -82,11 +82,14 @@ async def test_setup_webhook_in_bridge( with patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_status + ), patch( + "homeassistant.components.webhook.async_generate_url", + return_value="http://hook_id", ): await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() - lock.registerWebhook.assert_called_with(webhooks_fixture[0]["url"]) + lock.registerWebhook.assert_called_with("http://hook_id") async def test_unload_entry(hass, integration: MockConfigEntry, lock: loqed.Lock): From d1ac007a4746b5c4f589277d2142af451823e96b Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Mon, 8 Aug 2022 19:51:39 +0000 Subject: [PATCH 09/28] Reworks data coordinator, corrects strings, fixes tests --- homeassistant/components/loqed/__init__.py | 96 ++--------------- homeassistant/components/loqed/config_flow.py | 6 +- homeassistant/components/loqed/const.py | 3 - homeassistant/components/loqed/coordinator.py | 101 ++++++++++++++++++ homeassistant/components/loqed/lock.py | 26 ++--- homeassistant/components/loqed/sensor.py | 26 ++--- homeassistant/components/loqed/strings.json | 4 +- tests/components/loqed/conftest.py | 5 +- tests/components/loqed/test_init.py | 15 +-- tests/components/loqed/test_lock.py | 18 +--- tests/components/loqed/test_sensor.py | 10 +- 11 files changed, 152 insertions(+), 158 deletions(-) create mode 100644 homeassistant/components/loqed/coordinator.py diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index ca8690a498070..21c0a45194966 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -3,18 +3,15 @@ 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.const import Platform +from homeassistant.core import HomeAssistant 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 +from .const import DOMAIN +from .coordinator import LoqedDataCoordinator PLATFORMS: list[str] = [Platform.LOCK, Platform.SENSOR] @@ -22,55 +19,6 @@ _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)) - 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 - ) - 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) @@ -84,49 +32,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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() + coordinator = LoqedDataCoordinator(hass, api, lock, entry) + await coordinator.ensure_webhooks() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - CONF_WEBHOOK_ID: webhook_id, - CONF_LOCK: lock, - CONF_COORDINATOR: coordinator, - CONF_WEBHOOK_INDEX: webhook_index, - } + await coordinator.async_config_entry_first_refresh() - hass.config_entries.async_setup_platforms(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 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] + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][entry.entry_id] if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) try: - await lock.deleteWebhook(data[CONF_WEBHOOK_INDEX]) + await coordinator.remove_webhooks() except Exception: # pylint: disable=broad-except _LOGGER.exception("Failed to delete webhook") return False return unload_ok - - -class LoqedDataCoordinator(DataUpdateCoordinator): - """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]: - """Fetch data from API endpoint.""" - async with async_timeout.timeout(10): - return await self._api.async_get_lock_details() diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 392ffa9ef76f8..a0cfd07e8a0dd 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -45,7 +45,7 @@ async def validate_input( try: session = async_get_clientsession(hass) - apiclient = loqed.APIClient(session, "http://" + newdata["ip"]) + apiclient = loqed.APIClient(session, f"http://{newdata['ip']}") api = loqed.LoqedAPI(apiclient) lock = await api.async_get_lock( newdata["api_key"], newdata["bkey"], newdata["key_id"], newdata["host"] @@ -62,11 +62,11 @@ async def validate_input( class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for loqed.""" - VERSION = 2 + VERSION = 1 + _host: str | None = None def __init__(self) -> None: """Initialize the ConfigFlow for the LOQED integration.""" - self._host: str | None = None async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo diff --git a/homeassistant/components/loqed/const.py b/homeassistant/components/loqed/const.py index f78728a58a961..6b1c0311a2d04 100644 --- a/homeassistant/components/loqed/const.py +++ b/homeassistant/components/loqed/const.py @@ -2,6 +2,3 @@ DOMAIN = "loqed" -CONF_WEBHOOK_INDEX = "webhook_index" -CONF_COORDINATOR = "coordinator" -CONF_LOCK = "lock" diff --git a/homeassistant/components/loqed/coordinator.py b/homeassistant/components/loqed/coordinator.py new file mode 100644 index 0000000000000..7e5de1fc5e421 --- /dev/null +++ b/homeassistant/components/loqed/coordinator.py @@ -0,0 +1,101 @@ +"""Provides the coordinator for a LOQED lock.""" +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 +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LoqedDataCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Data update coordinator for the loqed platform.""" + + def __init__( + self, + hass: HomeAssistant, + api: loqed.LoqedAPI, + lock: loqed.Lock, + entry: ConfigEntry, + ) -> None: + """Initialize the Loqed Data Update coordinator.""" + super().__init__(hass, _LOGGER, name="Loqed sensors") + self._hass = hass + self._api = api + self._entry = entry + self.lock = lock + + async def _async_update_data(self) -> dict[str, str]: + """Fetch data from API endpoint.""" + async with async_timeout.timeout(10): + return await self._api.async_get_lock_details() + + @callback + async def _handle_webhook( + self, hass: HomeAssistant, webhook_id: str, request: Request + ) -> None: + """Handle incoming Loqed messages.""" + _LOGGER.debug("Callback received: %s", request.headers) + received_ts = request.headers["TIMESTAMP"] + received_hash = request.headers["HASH"] + body = await request.text() + + _LOGGER.debug("Callback body: %s", body) + + event_data = await self.lock.receiveWebhook(body, received_hash, received_ts) + if "error" in event_data: + _LOGGER.warning("Incorrect callback received:: %s", event_data) + return + + self.async_set_updated_data(event_data) + + async def ensure_webhooks(self) -> None: + """Register webhook on LOQED bridge.""" + webhook_id = self._entry.data[CONF_WEBHOOK_ID] + + webhook.async_register( + self.hass, DOMAIN, "Loqed", webhook_id, self._handle_webhook + ) + webhook_url = webhook.async_generate_url(self.hass, webhook_id) + _LOGGER.info("Webhook URL: %s", webhook_url) + + webhooks = await self.lock.getWebhooks() + + webhook_index = next( + (x["id"] for x in webhooks if x["url"] == webhook_url), None + ) + + if not webhook_index: + await self.lock.registerWebhook(webhook_url) + webhooks = await self.lock.getWebhooks() + webhook_index = next(x["id"] for x in webhooks if x["url"] == webhook_url) + + _LOGGER.info("Webhook got index %s", webhook_index) + + async def remove_webhooks(self) -> None: + """Remove webhook from LOQED bridge.""" + webhook_id = self._entry.data[CONF_WEBHOOK_ID] + webhook_url = webhook.async_generate_url(self.hass, webhook_id) + + webhook.async_unregister( + self.hass, + webhook_id, + ) + _LOGGER.info("Webhook URL: %s", webhook_url) + + webhooks = await self.lock.getWebhooks() + + webhook_index = next( + (x["id"] for x in webhooks if x["url"] == webhook_url), None + ) + + if webhook_index: + await self.lock.deleteWebhook(webhook_index) diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index 01073dd4975a7..55b43d38c09a7 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -3,8 +3,6 @@ from typing import Any -from loqedAPI import loqed - from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -21,7 +19,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import LoqedDataCoordinator -from .const import CONF_COORDINATOR, CONF_LOCK, DOMAIN +from .const import DOMAIN LOCK_STATES = { "latch": STATE_UNLOCKED, @@ -47,51 +45,49 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Loqed lock platform.""" - entry_state = hass.data[DOMAIN][entry.entry_id] - lock = entry_state[CONF_LOCK] - coordinator = entry_state[CONF_COORDINATOR] + coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([LoqedLock(lock, coordinator)]) + async_add_entities([LoqedLock(coordinator)]) class LoqedLock(CoordinatorEntity[LoqedDataCoordinator], LockEntity): """Representation of a loqed lock.""" - def __init__(self, lock: loqed.Lock, coordinator: LoqedDataCoordinator) -> None: + def __init__(self, coordinator: LoqedDataCoordinator) -> None: """Initialize the lock.""" super().__init__(coordinator) - self._lock = lock + self._lock = coordinator.lock self._attr_unique_id = self._lock.id self._attr_name = self._lock.name self._attr_supported_features = LockEntityFeature.OPEN self._state = STATE_UNKNOWN self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, lock.id)}, + identifiers={(DOMAIN, self._lock.id)}, name="Loqed instance", ) @property - def changed_by(self): + def changed_by(self) -> str: """Return true if lock is locking.""" return "KeyID " + str(self._lock.last_key_id) @property - def is_locking(self): + def is_locking(self) -> bool | None: """Return true if lock is locking.""" return self._state == STATE_LOCKING @property - def is_unlocking(self): + def is_unlocking(self) -> bool | None: """Return true if lock is unlocking.""" return self._state == STATE_UNLOCKING @property - def is_jammed(self): + def is_jammed(self) -> bool | None: """Return true if lock is jammed.""" return self._state == STATE_JAMMED @property - def is_locked(self): + def is_locked(self) -> bool | None: """Return true if lock is locked.""" return self._state == STATE_LOCKED diff --git a/homeassistant/components/loqed/sensor.py b/homeassistant/components/loqed/sensor.py index 8334376b98e6b..19772ef1d1f02 100644 --- a/homeassistant/components/loqed/sensor.py +++ b/homeassistant/components/loqed/sensor.py @@ -15,26 +15,25 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import LoqedDataCoordinator -from .const import CONF_COORDINATOR, CONF_LOCK, DOMAIN +from .const import DOMAIN SENSORS: list[SensorEntityDescription] = [ SensorEntityDescription( - name="Loqed battery status", + name="Battery", key="battery_percentage", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, icon="mdi:battery", ), SensorEntityDescription( - name="Loqed wifi signal strength", + name="Wi-Fi signal", key="wifi_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, entity_registry_enabled_default=False, - icon="mdi:signal", ), SensorEntityDescription( - name="Loqed bluetooth signal strength", + name="Bluetooth signal", key="ble_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, @@ -48,13 +47,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Loqed sensor.""" - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][entry.entry_id][ - CONF_COORDINATOR - ] - mac_address = hass.data[DOMAIN][entry.entry_id][CONF_LOCK].id + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][entry.entry_id] - entities = [LoqedSensor(mac_address, sensor, coordinator) for sensor in SENSORS] - async_add_entities(entities) + async_add_entities(LoqedSensor(sensor, coordinator) for sensor in SENSORS) class LoqedSensor(CoordinatorEntity[LoqedDataCoordinator], SensorEntity): @@ -65,22 +60,17 @@ class LoqedSensor(CoordinatorEntity[LoqedDataCoordinator], SensorEntity): def __init__( self, - mac_address: str, sensor_description: SensorEntityDescription, coordinator: LoqedDataCoordinator, ) -> None: """Initialize the loqed sensor.""" super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, mac_address)}, + identifiers={(DOMAIN, coordinator.lock.id)}, name="Loqed instance", ) self.entity_description = sensor_description - self._attr_unique_id = f"{sensor_description.key}-{mac_address}" - self._attr_native_unit_of_measurement = ( - sensor_description.native_unit_of_measurement - ) - + self._attr_unique_id = f"{sensor_description.key}-{coordinator.lock.id}" self._attr_native_value = coordinator.data[sensor_description.key] @callback diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json index 755f6e13fb1c4..71a1d32dd852a 100644 --- a/homeassistant/components/loqed/strings.json +++ b/homeassistant/components/loqed/strings.json @@ -1,9 +1,9 @@ { "config": { - "flow_title": "LOQED Touch Smartlock setup ({host})", + "flow_title": "LOQED Touch Smartlock setup", "step": { "user": { - "description": "Visit {config_url} and:
* Create an API-key: [add new api-key]
* Copy the [Integration information] value by clicking on the copy button in the detail-page of the just created API-key.", + "description": "Visit {config_url} and: \n* Create an API-key by clicking 'add new api-key' \n* Copy the value of the 'Integration information' field by clicking on the copy button in the detail-page of the just created API-key.", "data": { "config": "Loqed Configuration copied from the API setup page" } diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index a0f3c61fd58f1..69f27e454e580 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -9,7 +9,6 @@ import pytest from homeassistant.components.loqed import DOMAIN -from homeassistant.components.loqed.const import CONF_COORDINATOR from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -24,7 +23,7 @@ def config_entry_fixture() -> MockConfigEntry: config = load_fixture("loqed/integration_config.json") json_config = json.loads(config) return MockConfigEntry( - version=2, + version=1, domain=DOMAIN, data={ "config": config, @@ -55,7 +54,7 @@ async def integration_fixture( hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock ) -> AsyncGenerator[MockConfigEntry, None]: """Set up the loqed integration with a config entry.""" - config: dict[str, Any] = {DOMAIN: {CONF_COORDINATOR: ""}} + config: dict[str, Any] = {DOMAIN: {"config": ""}} config_entry.add_to_hass(hass) lock_status = json.loads(load_fixture("loqed/status_ok.json")) diff --git a/tests/components/loqed/test_init.py b/tests/components/loqed/test_init.py index f365d04fbed0f..5595cdc145010 100644 --- a/tests/components/loqed/test_init.py +++ b/tests/components/loqed/test_init.py @@ -6,11 +6,7 @@ from loqedAPI import loqed -from homeassistant.components.loqed.const import ( - CONF_COORDINATOR, - CONF_WEBHOOK_INDEX, - DOMAIN, -) +from homeassistant.components.loqed.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant @@ -29,7 +25,7 @@ async def test_webhook_rejects_invalid_message( await async_setup_component(hass, "http", {"http": {}}) client = await hass_client_no_auth() - coordinator = hass.data[DOMAIN][integration.entry_id][CONF_COORDINATOR] + coordinator = hass.data[DOMAIN][integration.entry_id] lock.receiveWebhook = AsyncMock(return_value={"error": "invalid hash"}) with patch.object(coordinator, "async_set_updated_data") as mock: @@ -54,7 +50,7 @@ async def test_webhook_accepts_valid_message( await async_setup_component(hass, "http", {"http": {}}) client = await hass_client_no_auth() processed_message = json.loads(load_fixture("loqed/battery_update.json")) - coordinator = hass.data[DOMAIN][integration.entry_id][CONF_COORDINATOR] + coordinator = hass.data[DOMAIN][integration.entry_id] lock.receiveWebhook = AsyncMock(return_value=processed_message) with patch.object(coordinator, "async_set_updated_data") as mock: @@ -73,7 +69,7 @@ async def test_setup_webhook_in_bridge( hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock ): """Test webhook setup in loqed bridge.""" - config: dict[str, Any] = {DOMAIN: {CONF_COORDINATOR: ""}} + config: dict[str, Any] = {DOMAIN: {}} config_entry.add_to_hass(hass) lock_status = json.loads(load_fixture("loqed/status_ok.json")) @@ -94,12 +90,11 @@ async def test_setup_webhook_in_bridge( async def test_unload_entry(hass, integration: MockConfigEntry, lock: loqed.Lock): """Test successful unload of entry.""" - webhook_index = hass.data[DOMAIN][integration.entry_id][CONF_WEBHOOK_INDEX] assert await hass.config_entries.async_unload(integration.entry_id) await hass.async_block_till_done() - lock.deleteWebhook.assert_called_with(webhook_index) + lock.deleteWebhook.assert_called_with(1) assert integration.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) diff --git a/tests/components/loqed/test_lock.py b/tests/components/loqed/test_lock.py index 9cac8a29b9130..ce5390f342bd9 100644 --- a/tests/components/loqed/test_lock.py +++ b/tests/components/loqed/test_lock.py @@ -4,7 +4,7 @@ from loqedAPI import loqed from homeassistant.components.loqed import LoqedDataCoordinator -from homeassistant.components.loqed.const import CONF_COORDINATOR, DOMAIN +from homeassistant.components.loqed.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, @@ -37,9 +37,7 @@ async def test_lock_responds_to_updates( hass: HomeAssistant, integration: MockConfigEntry ) -> None: """Test the lock responding to updates.""" - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id][ - CONF_COORDINATOR - ] + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] coordinator.async_set_updated_data( { "go_to_state": "DAY_LOCK", @@ -60,9 +58,7 @@ async def test_lock_responds_to_status_updates( """Tests the lock responding to updates.""" message = json.loads(load_fixture("loqed/lock_going_to_daylock.json")) - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id][ - CONF_COORDINATOR - ] + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] coordinator.async_set_updated_data(message) entity_id = "lock.loqed_smart_lock" @@ -79,9 +75,7 @@ async def test_lock_responds_to_webhook_calls( """Tests the lock responding to updates.""" message = json.loads(load_fixture("loqed/nightlock_reached.json")) - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id][ - CONF_COORDINATOR - ] + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] coordinator.async_set_updated_data(message) entity_id = "lock.loqed_smart_lock" @@ -96,9 +90,7 @@ async def test_lock_responds_to_bolt_state_updates( hass: HomeAssistant, integration: MockConfigEntry ) -> None: """Tests the lock responding to updates.""" - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id][ - CONF_COORDINATOR - ] + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] coordinator.async_set_updated_data( { "bolt_state": "night_lock", diff --git a/tests/components/loqed/test_sensor.py b/tests/components/loqed/test_sensor.py index fa80d3d79ea12..f1389a66e6af7 100644 --- a/tests/components/loqed/test_sensor.py +++ b/tests/components/loqed/test_sensor.py @@ -1,6 +1,6 @@ """Tests the sensor platform of the Loqed integration.""" from homeassistant.components.loqed import LoqedDataCoordinator -from homeassistant.components.loqed.const import CONF_COORDINATOR, DOMAIN +from homeassistant.components.loqed.const import DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -17,7 +17,7 @@ async def test_battery_sensor( integration: MockConfigEntry, ) -> None: """Test the battery sensor.""" - entity_id = "sensor.loqed_battery_status" + entity_id = "sensor.battery" state = hass.states.get(entity_id) @@ -33,11 +33,9 @@ async def test_battery_sensor_update( ) -> None: """Tests the sensor responding to a coordinator update.""" - entity_id = "sensor.loqed_battery_status" + entity_id = "sensor.battery" - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id][ - CONF_COORDINATOR - ] + coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] coordinator.async_set_updated_data({"battery_percentage": 99}) state = hass.states.get(entity_id) From c8702992514edfff47fbd74544c21fc771262d54 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Mon, 8 Aug 2022 19:56:38 +0000 Subject: [PATCH 10/28] Removes sensor component for first PR --- homeassistant/components/loqed/__init__.py | 2 +- homeassistant/components/loqed/sensor.py | 83 ---------------------- tests/components/loqed/test_sensor.py | 42 ----------- 3 files changed, 1 insertion(+), 126 deletions(-) delete mode 100644 homeassistant/components/loqed/sensor.py delete mode 100644 tests/components/loqed/test_sensor.py diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index 21c0a45194966..64834b0070b9a 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -13,7 +13,7 @@ from .const import DOMAIN from .coordinator import LoqedDataCoordinator -PLATFORMS: list[str] = [Platform.LOCK, Platform.SENSOR] +PLATFORMS: list[str] = [Platform.LOCK] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/loqed/sensor.py b/homeassistant/components/loqed/sensor.py deleted file mode 100644 index 19772ef1d1f02..0000000000000 --- a/homeassistant/components/loqed/sensor.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Loqed sensor entities.""" -from __future__ import annotations - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo, EntityCategory -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from . import LoqedDataCoordinator -from .const import DOMAIN - -SENSORS: list[SensorEntityDescription] = [ - SensorEntityDescription( - name="Battery", - key="battery_percentage", - device_class=SensorDeviceClass.BATTERY, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:battery", - ), - SensorEntityDescription( - name="Wi-Fi signal", - key="wifi_strength", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - entity_registry_enabled_default=False, - ), - SensorEntityDescription( - name="Bluetooth signal", - key="ble_strength", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - entity_registry_enabled_default=False, - icon="mdi:signal", - ), -] - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the Loqed sensor.""" - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities(LoqedSensor(sensor, coordinator) for sensor in SENSORS) - - -class LoqedSensor(CoordinatorEntity[LoqedDataCoordinator], SensorEntity): - """Class representing a LoqedSensor.""" - - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_entity_category = EntityCategory.DIAGNOSTIC - - def __init__( - self, - sensor_description: SensorEntityDescription, - coordinator: LoqedDataCoordinator, - ) -> None: - """Initialize the loqed sensor.""" - super().__init__(coordinator) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.lock.id)}, - name="Loqed instance", - ) - self.entity_description = sensor_description - self._attr_unique_id = f"{sensor_description.key}-{coordinator.lock.id}" - self._attr_native_value = coordinator.data[sensor_description.key] - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - key = self.entity_description.key - - if key in self.coordinator.data: - self._attr_native_value = self.coordinator.data[key] - self.async_write_ha_state() diff --git a/tests/components/loqed/test_sensor.py b/tests/components/loqed/test_sensor.py deleted file mode 100644 index f1389a66e6af7..0000000000000 --- a/tests/components/loqed/test_sensor.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Tests the sensor platform of the Loqed integration.""" -from homeassistant.components.loqed import LoqedDataCoordinator -from homeassistant.components.loqed.const import DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_battery_sensor( - hass: HomeAssistant, - integration: MockConfigEntry, -) -> None: - """Test the battery sensor.""" - entity_id = "sensor.battery" - - state = hass.states.get(entity_id) - - assert state - assert state.state == "78" - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT - - -async def test_battery_sensor_update( - hass: HomeAssistant, integration: MockConfigEntry -) -> None: - """Tests the sensor responding to a coordinator update.""" - - entity_id = "sensor.battery" - - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] - coordinator.async_set_updated_data({"battery_percentage": 99}) - - state = hass.states.get(entity_id) - assert state.state == "99" From 28c83e9f1d0dc7817d207a35c579f491b2f314f4 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Mon, 8 Aug 2022 20:04:17 +0000 Subject: [PATCH 11/28] Initializes lock with sane defaults --- homeassistant/components/loqed/lock.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index 55b43d38c09a7..9965d9c24b0da 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -53,13 +53,15 @@ async def async_setup_entry( class LoqedLock(CoordinatorEntity[LoqedDataCoordinator], LockEntity): """Representation of a loqed lock.""" + _attr_supported_features = LockEntityFeature.OPEN + _state: str | None = None + def __init__(self, coordinator: LoqedDataCoordinator) -> None: """Initialize the lock.""" super().__init__(coordinator) self._lock = coordinator.lock self._attr_unique_id = self._lock.id self._attr_name = self._lock.name - self._attr_supported_features = LockEntityFeature.OPEN self._state = STATE_UNKNOWN self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._lock.id)}, From 877d047996db4aa88ce767f81f75a519ac2b7ea4 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Tue, 9 Aug 2022 05:21:16 +0000 Subject: [PATCH 12/28] Updates stale comment --- homeassistant/components/loqed/lock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index 9965d9c24b0da..c29d8b56983df 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -70,7 +70,7 @@ def __init__(self, coordinator: LoqedDataCoordinator) -> None: @property def changed_by(self) -> str: - """Return true if lock is locking.""" + """Return internal ID of last used key.""" return "KeyID " + str(self._lock.last_key_id) @property From f306935f36e40692aba9b009e75710044cbb239f Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Mon, 15 Aug 2022 19:33:41 +0000 Subject: [PATCH 13/28] Allows updates of hostname, types coordinator message, removes context --- homeassistant/components/loqed/config_flow.py | 3 +- homeassistant/components/loqed/coordinator.py | 57 ++++++++++++++++- homeassistant/components/loqed/lock.py | 63 +++---------------- tests/components/loqed/test_config_flow.py | 9 +-- 4 files changed, 63 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index a0cfd07e8a0dd..7bcef1f980aa6 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -81,7 +81,7 @@ async def async_step_zeroconf( # Check if already exists await self.async_set_unique_id(lock_data["bridge_mac_wifi"]) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured({CONF_HOST: self._host}) return await self.async_step_user() async def async_step_user( @@ -93,7 +93,6 @@ async def async_step_user( vol.Required("config"): str, } ) - self.context["title_placeholders"] = {CONF_HOST: self._host} if user_input is None: return self.async_show_form( step_id="user", diff --git a/homeassistant/components/loqed/coordinator.py b/homeassistant/components/loqed/coordinator.py index 7e5de1fc5e421..ec7d5467b49a9 100644 --- a/homeassistant/components/loqed/coordinator.py +++ b/homeassistant/components/loqed/coordinator.py @@ -1,5 +1,6 @@ """Provides the coordinator for a LOQED lock.""" import logging +from typing import TypedDict from aiohttp.web import Request import async_timeout @@ -16,7 +17,57 @@ _LOGGER = logging.getLogger(__name__) -class LoqedDataCoordinator(DataUpdateCoordinator[dict[str, str]]): +class BatteryMessage(TypedDict): + """Properties in a battery update message.""" + + mac_wifi: str + mac_ble: str + battery_type: str + battery_percentage: int + + +class StateReachedMessage(TypedDict): + """Properties in a battery update message.""" + + requested_state: str + requested_state_numeric: int + event_type: str + key_local_id: int + mac_wifi: str + mac_ble: str + + +class TransitionMessage(TypedDict): + """Properties in a battery update message.""" + + go_to_state: str + go_to_state_numeric: int + event_type: str + key_local_id: int + mac_wifi: str + mac_ble: str + + +class StatusMessage(TypedDict): + """Properties returned by the status endpoint of the bridhge.""" + + battery_percentage: int + battery_type: str + battery_type_numeric: int + battery_voltage: float + bolt_state: str + bolt_state_numeric: int + bridge_mac_wifi: str + bridge_mac_ble: str + lock_online: int + webhooks_number: int + ip_address: str + up_timestamp: int + wifi_strength: int + ble_strength: int + + +class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): """Data update coordinator for the loqed platform.""" def __init__( @@ -33,7 +84,7 @@ def __init__( self._entry = entry self.lock = lock - async def _async_update_data(self) -> dict[str, str]: + async def _async_update_data(self) -> StatusMessage: """Fetch data from API endpoint.""" async with async_timeout.timeout(10): return await self._api.async_get_lock_details() @@ -55,7 +106,7 @@ async def _handle_webhook( _LOGGER.warning("Incorrect callback received:: %s", event_data) return - self.async_set_updated_data(event_data) + self.async_update_listeners() async def ensure_webhooks(self) -> None: """Register webhook on LOQED bridge.""" diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index c29d8b56983df..2f2eb1c826fed 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -5,15 +5,8 @@ from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_UNKNOWN, - STATE_UNLOCKED, - STATE_UNLOCKING, -) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -21,23 +14,6 @@ from . import LoqedDataCoordinator from .const import DOMAIN -LOCK_STATES = { - "latch": STATE_UNLOCKED, - "night_lock": STATE_LOCKED, - "open": STATE_UNLOCKED, - "day_lock": STATE_UNLOCKED, - "unknown": STATE_UNKNOWN, -} - -LOCK_GO_TO_STATES = { - "latch": STATE_UNLOCKING, - "night_lock": STATE_LOCKING, - "open": STATE_UNLOCKING, - "day_lock": STATE_UNLOCKING, - "unknown": STATE_UNKNOWN, -} - - WEBHOOK_API_ENDPOINT = "/api/loqed/webhook" @@ -54,7 +30,6 @@ class LoqedLock(CoordinatorEntity[LoqedDataCoordinator], LockEntity): """Representation of a loqed lock.""" _attr_supported_features = LockEntityFeature.OPEN - _state: str | None = None def __init__(self, coordinator: LoqedDataCoordinator) -> None: """Initialize the lock.""" @@ -62,10 +37,10 @@ def __init__(self, coordinator: LoqedDataCoordinator) -> None: self._lock = coordinator.lock self._attr_unique_id = self._lock.id self._attr_name = self._lock.name - self._state = STATE_UNKNOWN self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._lock.id)}, name="Loqed instance", + connections={(CONNECTION_NETWORK_MAC, self._lock.id)}, ) @property @@ -76,55 +51,31 @@ def changed_by(self) -> str: @property def is_locking(self) -> bool | None: """Return true if lock is locking.""" - return self._state == STATE_LOCKING + return self._lock.bolt_state == "locking" @property def is_unlocking(self) -> bool | None: """Return true if lock is unlocking.""" - return self._state == STATE_UNLOCKING + return self._lock.bolt_state == "unlocking" @property def is_jammed(self) -> bool | None: """Return true if lock is jammed.""" - return self._state == STATE_JAMMED + return self._lock.bolt_state == "motor_stall" @property def is_locked(self) -> bool | None: """Return true if lock is locked.""" - return self._state == STATE_LOCKED + return self._lock.bolt_state in ["night_lock_remote", "night_lock"] async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - self._state = STATE_LOCKING - self.async_write_ha_state() - await self._lock.lock() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - self._state = STATE_UNLOCKING - self.async_write_ha_state() - await self._lock.unlock() async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - self._state = STATE_UNLOCKING - self.async_write_ha_state() - await self._lock.open() - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - data = self.coordinator.data - - if "requested_state" in data and data["requested_state"].lower() in LOCK_STATES: - self._state = LOCK_STATES[data["requested_state"].lower()] - self.async_schedule_update_ha_state() - elif "go_to_state" in data and data["go_to_state"].lower() in LOCK_GO_TO_STATES: - self._state = LOCK_GO_TO_STATES[data["go_to_state"].lower()] - self.async_schedule_update_ha_state() - elif "bolt_state" in data and data["bolt_state"] in LOCK_STATES: - self._state = LOCK_STATES[data["bolt_state"]] - self.async_schedule_update_ha_state() diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index c4ab276817a4d..e464a3274b00d 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -8,7 +8,7 @@ from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.components.loqed.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID +from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM @@ -134,13 +134,6 @@ async def test_zeroconf_wrong_config(hass: HomeAssistant) -> None: assert result["type"] == RESULT_TYPE_FORM assert result["errors"] is None - context = next( - flow["context"] - for flow in hass.config_entries.flow.async_progress() - if flow["flow_id"] == result["flow_id"] - ) - assert context["title_placeholders"][CONF_HOST] == "LOQED-ffeeddccbbaa.local" - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { From 8f3be3d4b67f85b9a0833303b2139f4428842252 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Tue, 27 Sep 2022 19:49:41 +0000 Subject: [PATCH 14/28] Handles coordinator updates that get triggered manually --- homeassistant/components/loqed/lock.py | 13 +++- tests/components/loqed/conftest.py | 1 + tests/components/loqed/test_init.py | 4 +- tests/components/loqed/test_lock.py | 83 ++------------------------ 4 files changed, 19 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index 2f2eb1c826fed..1453cde0354eb 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -1,11 +1,12 @@ """LOQED lock integration for Home Assistant.""" from __future__ import annotations +import logging from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -16,6 +17,8 @@ WEBHOOK_API_ENDPOINT = "/api/loqed/webhook" +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -79,3 +82,11 @@ async def async_unlock(self, **kwargs: Any) -> None: async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" await self._lock.open() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + _LOGGER.debug(self.coordinator.data) + if "bolt_state" in self.coordinator.data: + self._lock.updateState(self.coordinator.data["bolt_state"]).close() + self.async_write_ha_state() diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index 69f27e454e580..709551d5b9480 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -46,6 +46,7 @@ def lock_fixture() -> loqed.Lock: mock_lock = Mock(spec=loqed.Lock, id="Foo", last_key_id=2) mock_lock.name = "LOQED smart lock" mock_lock.getWebhooks = AsyncMock(return_value=webhooks_fixture) + mock_lock.bolt_state = "locked" return mock_lock diff --git a/tests/components/loqed/test_init.py b/tests/components/loqed/test_init.py index 5595cdc145010..89b67ee325855 100644 --- a/tests/components/loqed/test_init.py +++ b/tests/components/loqed/test_init.py @@ -53,7 +53,7 @@ async def test_webhook_accepts_valid_message( coordinator = hass.data[DOMAIN][integration.entry_id] lock.receiveWebhook = AsyncMock(return_value=processed_message) - with patch.object(coordinator, "async_set_updated_data") as mock: + with patch.object(coordinator, "async_update_listeners") as mock: message = load_fixture("loqed/battery_update.json") timestamp = 1653304609 await client.post( @@ -62,7 +62,7 @@ async def test_webhook_accepts_valid_message( headers={"timestamp": str(timestamp), "hash": "incorrect hash"}, ) - mock.assert_called_with(processed_message) + mock.assert_called() async def test_setup_webhook_in_bridge( diff --git a/tests/components/loqed/test_lock.py b/tests/components/loqed/test_lock.py index ce5390f342bd9..bfe2f2a8c7a2f 100644 --- a/tests/components/loqed/test_lock.py +++ b/tests/components/loqed/test_lock.py @@ -1,6 +1,4 @@ """Tests the lock platform of the Loqed integration.""" -import json - from loqedAPI import loqed from homeassistant.components.loqed import LoqedDataCoordinator @@ -11,13 +9,11 @@ SERVICE_OPEN, SERVICE_UNLOCK, STATE_LOCKED, - STATE_LOCKING, STATE_UNLOCKED, - STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry async def test_lock_entity( @@ -33,69 +29,13 @@ async def test_lock_entity( assert state.state == STATE_UNLOCKED -async def test_lock_responds_to_updates( - hass: HomeAssistant, integration: MockConfigEntry -) -> None: - """Test the lock responding to updates.""" - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] - coordinator.async_set_updated_data( - { - "go_to_state": "DAY_LOCK", - } - ) - - entity_id = "lock.loqed_smart_lock" - - state = hass.states.get(entity_id) - - assert state - assert state.state == STATE_UNLOCKING - - -async def test_lock_responds_to_status_updates( - hass: HomeAssistant, integration: MockConfigEntry -) -> None: - """Tests the lock responding to updates.""" - message = json.loads(load_fixture("loqed/lock_going_to_daylock.json")) - - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] - coordinator.async_set_updated_data(message) - - entity_id = "lock.loqed_smart_lock" - print(hass.state) - state = hass.states.get(entity_id) - - assert state - assert state.state == STATE_UNLOCKING - - -async def test_lock_responds_to_webhook_calls( - hass: HomeAssistant, integration: MockConfigEntry -) -> None: - """Tests the lock responding to updates.""" - message = json.loads(load_fixture("loqed/nightlock_reached.json")) - - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] - coordinator.async_set_updated_data(message) - - entity_id = "lock.loqed_smart_lock" - - state = hass.states.get(entity_id) - - assert state - assert state.state == STATE_LOCKED - - async def test_lock_responds_to_bolt_state_updates( - hass: HomeAssistant, integration: MockConfigEntry + hass: HomeAssistant, integration: MockConfigEntry, lock: loqed.Lock ) -> None: """Tests the lock responding to updates.""" coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] - coordinator.async_set_updated_data( - { - "bolt_state": "night_lock", - } - ) + lock.bolt_state = "night_lock" + coordinator.async_update_listeners() entity_id = "lock.loqed_smart_lock" @@ -118,11 +58,6 @@ async def test_lock_transition_to_unlocked( await hass.async_block_till_done() lock.unlock.assert_called() - state = hass.states.get(entity_id) - - assert state - assert state.state == STATE_UNLOCKING - async def test_lock_transition_to_locked( hass: HomeAssistant, integration: MockConfigEntry, lock: loqed.Lock @@ -137,11 +72,6 @@ async def test_lock_transition_to_locked( await hass.async_block_till_done() lock.lock.assert_called() - state = hass.states.get(entity_id) - - assert state - assert state.state == STATE_LOCKING - async def test_lock_transition_to_open( hass: HomeAssistant, integration: MockConfigEntry, lock: loqed.Lock @@ -155,8 +85,3 @@ async def test_lock_transition_to_open( ) await hass.async_block_till_done() lock.open.assert_called() - - state = hass.states.get(entity_id) - - assert state - assert state.state == STATE_UNLOCKING From 3d1a1ed6dc6e39a1da107a93e468a333f0eaa789 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Wed, 28 Sep 2022 19:32:30 +0000 Subject: [PATCH 15/28] Removes unused keys --- homeassistant/components/loqed/manifest.json | 2 -- homeassistant/generated/integrations.json | 5 +++++ homeassistant/generated/zeroconf.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json index 3a190d96202fc..a8da43a277ef4 100644 --- a/homeassistant/components/loqed/manifest.json +++ b/homeassistant/components/loqed/manifest.json @@ -4,8 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/loqed", "requirements": ["loqedAPI==2.1.3"], - "ssdp": [], - "homekit": {}, "dependencies": ["webhook"], "codeowners": ["@cpolhout"], "iot_class": "local_push", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 044bb8fec68e7..de9b1f340b731 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3085,6 +3085,11 @@ "config_flow": true, "iot_class": "local_push" }, + "loqed": { + "config_flow": true, + "iot_class": "local_push", + "name": "LOQED Touch Smart Lock" + }, "luftdaten": { "name": "Sensor.Community", "integration_type": "device", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 79ebfd18620d9..6b5676c4a25c1 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -450,7 +450,7 @@ }, { "domain": "loqed", - "name": "loqed*" + "name": "loqed*", }, { "domain": "nam", From 1463956d0a68d5314de2879f0e5b5c82ad07fee4 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Wed, 28 Sep 2022 19:39:59 +0000 Subject: [PATCH 16/28] Renames default name --- homeassistant/components/loqed/lock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index 1453cde0354eb..87cc8a7f39fce 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -42,7 +42,7 @@ def __init__(self, coordinator: LoqedDataCoordinator) -> None: self._attr_name = self._lock.name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._lock.id)}, - name="Loqed instance", + name="Loqed Lock", connections={(CONNECTION_NETWORK_MAC, self._lock.id)}, ) From ad110ed69ff9eea5737295e4abed68c98507fd60 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Tue, 22 Nov 2022 13:40:18 +0000 Subject: [PATCH 17/28] Moves to OAuth based flow --- homeassistant/components/loqed/__init__.py | 10 +- .../loqed/application_credentials.py | 14 + homeassistant/components/loqed/config_flow.py | 125 ++------- homeassistant/components/loqed/const.py | 2 + homeassistant/components/loqed/manifest.json | 2 +- .../generated/application_credentials.py | 1 + homeassistant/generated/integrations.json | 5 +- tests/components/loqed/conftest.py | 11 +- tests/components/loqed/test_config_flow.py | 247 ++++++++++-------- 9 files changed, 188 insertions(+), 229 deletions(-) create mode 100644 homeassistant/components/loqed/application_credentials.py diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index 64834b0070b9a..fd73caac34122 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -22,15 +22,15 @@ 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"] + host = entry.data["bridge_ip"] apiclient = loqed.APIClient(websession, f"http://{host}") api = loqed.LoqedAPI(apiclient) lock = await api.async_get_lock( - entry.data["api_key"], - entry.data["bkey"], - entry.data["key_id"], - entry.data["host"], + entry.data["lock_key_key"], + entry.data["bridge_key"], + entry.data["lock_key_local_id"], + entry.data["bridge_ip"], ) coordinator = LoqedDataCoordinator(hass, api, lock, entry) await coordinator.ensure_webhooks() diff --git a/homeassistant/components/loqed/application_credentials.py b/homeassistant/components/loqed/application_credentials.py new file mode 100644 index 0000000000000..781264c5b3a30 --- /dev/null +++ b/homeassistant/components/loqed/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform for the Loqed integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 7bcef1f980aa6..bc914bf5757e1 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -1,136 +1,63 @@ """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.components.zeroconf import ZeroconfServiceInfo 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 import config_entry_oauth2_flow 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( - f"Got config for {newdata['host']} while configuring {zeroconf_host} " - ) - - # 1. Checking loqed-connection - try: - session = async_get_clientsession(hass) - - apiclient = loqed.APIClient(session, f"http://{newdata['ip']}") - 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 - return newdata - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for loqed.""" +class ConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN): + """Handle a config flow for Loqed.""" VERSION = 1 - _host: str | None = None + DOMAIN = DOMAIN - def __init__(self) -> None: - """Initialize the ConfigFlow for the LOQED integration.""" + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" - self._host = discovery_info.hostname.rstrip(".") + host = discovery_info.hostname.rstrip(".") session = async_get_clientsession(self.hass) - apiclient = loqed.APIClient(session, f"http://{self._host}") + apiclient = loqed.APIClient(session, f"http://{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({CONF_HOST: self._host}) + self._abort_if_unique_id_configured({CONF_HOST: host}) 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, - } - ) - 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()}), - ) + async def async_oauth_create_entry(self, data: dict) -> FlowResult: + """Create an entry for the flow. - return self.async_show_form( - step_id="user", data_schema=user_data_schema, errors=errors + Ok to override if you want to fetch extra info or even add another step. + """ + session = async_get_clientsession(self.hass) + res = await session.request( + "GET", + "https://app.loqed.com/API/integration_oauth3/retrieve_lock_data.php", + headers={"Authorization": f"Bearer {data['token']['access_token']}"}, ) + config = ( + data + | {CONF_WEBHOOK_ID: webhook.async_generate_id()} + | await res.json(content_type="text/html") + ) -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" + return self.async_create_entry(title=self.flow_impl.name, data=config) diff --git a/homeassistant/components/loqed/const.py b/homeassistant/components/loqed/const.py index 6b1c0311a2d04..0374e72d5f097 100644 --- a/homeassistant/components/loqed/const.py +++ b/homeassistant/components/loqed/const.py @@ -2,3 +2,5 @@ DOMAIN = "loqed" +OAUTH2_AUTHORIZE = "https://app.loqed.com/API/integration_oauth3/login.php" +OAUTH2_TOKEN = "https://app.loqed.com/API/integration_oauth3/token.php" diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json index a8da43a277ef4..b372290a3509f 100644 --- a/homeassistant/components/loqed/manifest.json +++ b/homeassistant/components/loqed/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/loqed", "requirements": ["loqedAPI==2.1.3"], - "dependencies": ["webhook"], + "dependencies": ["webhook", "application_credentials"], "codeowners": ["@cpolhout"], "iot_class": "local_push", "zeroconf": [{ "type": "_http._tcp.local.", "name": "loqed*" }] diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index d1b330b5dbe72..92bcf4351bd07 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -11,6 +11,7 @@ "google_sheets", "home_connect", "lametric", + "loqed", "lyric", "neato", "nest", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index de9b1f340b731..789cd4c43de57 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3086,9 +3086,10 @@ "iot_class": "local_push" }, "loqed": { + "name": "LOQED Touch Smart Lock", + "integration_type": "hub", "config_flow": true, - "iot_class": "local_push", - "name": "LOQED Touch Smart Lock" + "iot_class": "local_push" }, "luftdaten": { "name": "Sensor.Community", diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index 709551d5b9480..9981239275cff 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -26,13 +26,12 @@ def config_entry_fixture() -> MockConfigEntry: version=1, domain=DOMAIN, data={ - "config": config, "id": "Foo", - "ip": json_config["bridge_ip"], - "host": json_config["bridge_mdns_hostname"], - "bkey": json_config["bridge_key"], - "key_id": int(json_config["lock_key_local_id"]), - "api_key": json_config["lock_key_key"], + "bridge_ip": json_config["bridge_ip"], + "bridge_mdns_hostname": json_config["bridge_mdns_hostname"], + "bridge_key": json_config["bridge_key"], + "lock_key_local_id": int(json_config["lock_key_local_id"]), + "lock_key_key": json_config["lock_key_key"], CONF_WEBHOOK_ID: "Webhook_id", }, ) diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index e464a3274b00d..eb24655022633 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -1,145 +1,160 @@ """Test the Loqed config flow.""" +from http import HTTPStatus import json -from unittest.mock import Mock, patch +from unittest.mock import patch -import aiohttp -from loqedAPI import loqed +import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf -from homeassistant.components.loqed.const import DOMAIN -from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.loqed.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component from tests.common import load_fixture +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + +DISCOVERED_LOCK = zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + addresses=["127.0.0.1"], + hostname="LOQED-aabbccddeeff", + port=None, + type="_http._tcp.local.", + name="aabbccddeeff._http._tcp.local.", + properties={"CtlN": "Loqed"}, +) + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +async def test_oauth_flow( + hass, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +): + """Check Manual OAuth flow.""" -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) - assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] is None - - loqed_integration_config = load_fixture("loqed/integration_config.json") + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) - mock_lock = Mock(spec=loqed.Lock, id="Foo") - webhook_id = "Webhook_ID" + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + aioclient_mock.get( + "https://app.loqed.com/API/integration_oauth3/retrieve_lock_data.php", + text=load_fixture("loqed/integration_config.json"), + ) with patch( - "loqedAPI.loqed.LoqedAPI.async_get_lock", - return_value=mock_lock, - ), patch( - "homeassistant.components.loqed.async_setup_entry", - return_value=True, - ), patch( - "homeassistant.components.webhook.async_generate_id", return_value=webhook_id - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "config": loqed_integration_config, - }, - ) + "homeassistant.components.loqed.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - json_config = json.loads(loqed_integration_config) - assert result2["type"] == RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "LOQED Touch Smart Lock" - assert result2["data"] == { - "id": "Foo", - "ip": json_config["bridge_ip"], - "host": json_config["bridge_mdns_hostname"], - "bkey": json_config["bridge_key"], - "key_id": int(json_config["lock_key_local_id"]), - "api_key": json_config["lock_key_key"], - "config": loqed_integration_config, - CONF_WEBHOOK_ID: webhook_id, - } - mock_lock.getWebhooks.assert_awaited() - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_setup( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Test zeroconf setup.""" + aioclient_mock.get( + "http://loqed-aabbccddeeff/status", + json=json.loads(load_fixture("loqed/status_ok.json")), ) - loqed_integration_config = load_fixture("loqed/integration_config.json") - - with patch( - "loqedAPI.loqed.LoqedAPI.async_get_lock", - side_effect=aiohttp.ClientError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "config": loqed_integration_config, - }, - ) - - assert result2["type"] == RESULT_TYPE_FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unexpected_exception(hass: HomeAssistant) -> None: - """Test we handle cannot unexpected exceptions.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=DISCOVERED_LOCK ) - loqed_integration_config = load_fixture("loqed/integration_config.json") - - with patch( - "loqedAPI.loqed.LoqedAPI.async_get_lock", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "config": loqed_integration_config, - }, - ) - - assert result2["type"] == RESULT_TYPE_FORM - assert result2["errors"] == {"base": "unknown"} - + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) -async def test_zeroconf_wrong_config(hass: HomeAssistant) -> None: - """Test zeroconf setup errors when provided wrong config.""" - lock_result = json.loads(load_fixture("loqed/status_ok.json")) - loqed_integration_config = load_fixture("loqed/integration_config.json") + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) - with patch( - "loqedAPI.loqed.LoqedAPI.async_get_lock_details", - return_value=lock_result, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], - hostname="LOQED-ffeeddccbbaa.local", - name="mock_name", - port=9123, - properties={}, - type="mock_type", - ), - ) - - assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] is None - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "config": loqed_integration_config, + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, }, ) + aioclient_mock.get( + "https://app.loqed.com/API/integration_oauth3/retrieve_lock_data.php", + text=load_fixture("loqed/integration_config.json"), + ) + + with patch( + "homeassistant.components.loqed.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() - assert result2["type"] == RESULT_TYPE_FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 From f3cee5df94a80e51cab5dc7165b483bca0ddb927 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Thu, 24 Nov 2022 07:32:54 +0000 Subject: [PATCH 18/28] Uses improved LoqedAPI library --- homeassistant/components/loqed/__init__.py | 6 +----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index fd73caac34122..42219bc33072d 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -49,10 +49,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - try: - await coordinator.remove_webhooks() - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Failed to delete webhook") - return False + await coordinator.remove_webhooks() return unload_ok diff --git a/requirements_all.txt b/requirements_all.txt index 9bafca7e5f3b3..ea8f0987585cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1149,7 +1149,7 @@ logi-circle==0.2.3 london-tube-status==0.5 # homeassistant.components.loqed -loqedAPI==2.1.3 +loqedAPI==2.1.4 # homeassistant.components.luftdaten luftdaten==0.7.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 713edd4f0eb43..f091fea18a0b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -878,7 +878,7 @@ life360==5.5.0 logi-circle==0.2.3 # homeassistant.components.loqed -loqedAPI==2.1.3 +loqedAPI==2.1.4 # homeassistant.components.luftdaten luftdaten==0.7.4 From 37077d2a17361949290c55e0078f859cdf71bf8d Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Thu, 24 Nov 2022 08:25:22 +0000 Subject: [PATCH 19/28] Updates manifest to match new lib version --- homeassistant/components/loqed/__init__.py | 2 +- homeassistant/components/loqed/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index 42219bc33072d..c9c486839305d 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -29,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: lock = await api.async_get_lock( entry.data["lock_key_key"], entry.data["bridge_key"], - entry.data["lock_key_local_id"], + int(entry.data["lock_key_local_id"]), entry.data["bridge_ip"], ) coordinator = LoqedDataCoordinator(hass, api, lock, entry) diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json index b372290a3509f..b2c3450d39530 100644 --- a/homeassistant/components/loqed/manifest.json +++ b/homeassistant/components/loqed/manifest.json @@ -3,7 +3,7 @@ "name": "LOQED Touch Smart Lock", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/loqed", - "requirements": ["loqedAPI==2.1.3"], + "requirements": ["loqedAPI==2.1.5"], "dependencies": ["webhook", "application_credentials"], "codeowners": ["@cpolhout"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index ea8f0987585cc..cbb2ed131c906 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1149,7 +1149,7 @@ logi-circle==0.2.3 london-tube-status==0.5 # homeassistant.components.loqed -loqedAPI==2.1.4 +loqedAPI==2.1.5 # homeassistant.components.luftdaten luftdaten==0.7.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f091fea18a0b7..6e330246ce86b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -878,7 +878,7 @@ life360==5.5.0 logi-circle==0.2.3 # homeassistant.components.loqed -loqedAPI==2.1.4 +loqedAPI==2.1.5 # homeassistant.components.luftdaten luftdaten==0.7.4 From 14348caeaceaa5873556c86b4ed00f73c6dd50c8 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Mon, 9 Jan 2023 20:11:24 +0000 Subject: [PATCH 20/28] Uses hostname and sets unique id for manual setups --- homeassistant/components/loqed/config_flow.py | 14 +++++++++----- tests/components/loqed/test_config_flow.py | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index bc914bf5757e1..497cd305487b0 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import re from loqedAPI import loqed @@ -30,7 +31,7 @@ async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" - host = discovery_info.hostname.rstrip(".") + host = discovery_info.host session = async_get_clientsession(self.hass) apiclient = loqed.APIClient(session, f"http://{host}") @@ -53,11 +54,14 @@ async def async_oauth_create_entry(self, data: dict) -> FlowResult: "https://app.loqed.com/API/integration_oauth3/retrieve_lock_data.php", headers={"Authorization": f"Bearer {data['token']['access_token']}"}, ) + lock_data = await res.json(content_type="text/html") - config = ( - data - | {CONF_WEBHOOK_ID: webhook.async_generate_id()} - | await res.json(content_type="text/html") + await self.async_set_unique_id( + re.sub( + r"LOQED-([a-f0-9]+)\.local", r"\1", lock_data["bridge_mdns_hostname"] + ) ) + config = data | {CONF_WEBHOOK_ID: webhook.async_generate_id()} | lock_data + return self.async_create_entry(title=self.flow_impl.name, data=config) diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index eb24655022633..3b79586d786cf 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -108,7 +108,7 @@ async def test_zeroconf_setup( ) -> None: """Test zeroconf setup.""" aioclient_mock.get( - "http://loqed-aabbccddeeff/status", + "http://127.0.0.1/status", json=json.loads(load_fixture("loqed/status_ok.json")), ) From e6a9eb775344ca78d11049ac83bca9ce16e8f08f Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Sun, 15 Jan 2023 15:39:13 +0000 Subject: [PATCH 21/28] Uses identifier instead of ip --- homeassistant/components/loqed/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index c9c486839305d..1248c75612fb5 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import re from loqedAPI import loqed @@ -30,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data["lock_key_key"], entry.data["bridge_key"], int(entry.data["lock_key_local_id"]), - entry.data["bridge_ip"], + re.sub(r"LOQED-([a-f0-9]+)\.local", r"\1", entry.data["bridge_mdns_hostname"]), ) coordinator = LoqedDataCoordinator(hass, api, lock, entry) await coordinator.ensure_webhooks() From e6a42b577eaf07fc53d8210dba2e5926ecd82bf2 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Mon, 16 Jan 2023 18:55:51 +0000 Subject: [PATCH 22/28] Adds correct branding --- homeassistant/components/loqed/entity.py | 29 ++++++++++++++++++++++++ homeassistant/components/loqed/lock.py | 11 ++------- 2 files changed, 31 insertions(+), 9 deletions(-) create mode 100755 homeassistant/components/loqed/entity.py diff --git a/homeassistant/components/loqed/entity.py b/homeassistant/components/loqed/entity.py new file mode 100755 index 0000000000000..1b1731815b46f --- /dev/null +++ b/homeassistant/components/loqed/entity.py @@ -0,0 +1,29 @@ +"""Base entity for the LOQED integration.""" +from __future__ import annotations + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LoqedDataCoordinator + + +class LoqedEntity(CoordinatorEntity[LoqedDataCoordinator]): + """Defines a LOQED entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: LoqedDataCoordinator) -> None: + """Initialize the LOQED entity.""" + super().__init__(coordinator=coordinator) + + lock_id = coordinator.lock.id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, lock_id)}, + manufacturer="LOQED", + name="LOQED Lock", + model="Touch Smart Lock", + connections={(CONNECTION_NETWORK_MAC, lock_id)}, + ) diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index 87cc8a7f39fce..a2ac82eaf73dc 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -7,13 +7,11 @@ from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import LoqedDataCoordinator from .const import DOMAIN +from .entity import LoqedEntity WEBHOOK_API_ENDPOINT = "/api/loqed/webhook" @@ -29,7 +27,7 @@ async def async_setup_entry( async_add_entities([LoqedLock(coordinator)]) -class LoqedLock(CoordinatorEntity[LoqedDataCoordinator], LockEntity): +class LoqedLock(LoqedEntity, LockEntity): """Representation of a loqed lock.""" _attr_supported_features = LockEntityFeature.OPEN @@ -40,11 +38,6 @@ def __init__(self, coordinator: LoqedDataCoordinator) -> None: self._lock = coordinator.lock self._attr_unique_id = self._lock.id self._attr_name = self._lock.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._lock.id)}, - name="Loqed Lock", - connections={(CONNECTION_NETWORK_MAC, self._lock.id)}, - ) @property def changed_by(self) -> str: From 82a51056528e51838036b642a21a9e82cc7c1b80 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Mon, 12 Jun 2023 12:10:47 +0000 Subject: [PATCH 23/28] Moves back to token based flow --- CODEOWNERS | 4 +- .../loqed/application_credentials.py | 14 -- homeassistant/components/loqed/config_flow.py | 129 +++++++++++++++--- homeassistant/components/loqed/lock.py | 6 +- homeassistant/components/loqed/manifest.json | 13 +- homeassistant/components/loqed/strings.json | 4 +- .../components/loqed/translations/en.json | 8 +- .../generated/application_credentials.py | 1 - 8 files changed, 129 insertions(+), 50 deletions(-) delete mode 100644 homeassistant/components/loqed/application_credentials.py mode change 100644 => 100755 homeassistant/components/loqed/lock.py diff --git a/CODEOWNERS b/CODEOWNERS index ad6f5c71fd211..4d7777f66c371 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -701,8 +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/loqed/ @mikewoudenberg +/tests/components/loqed/ @mikewoudenberg /homeassistant/components/lovelace/ @home-assistant/frontend /tests/components/lovelace/ @home-assistant/frontend /homeassistant/components/luci/ @mzdrale diff --git a/homeassistant/components/loqed/application_credentials.py b/homeassistant/components/loqed/application_credentials.py deleted file mode 100644 index 781264c5b3a30..0000000000000 --- a/homeassistant/components/loqed/application_credentials.py +++ /dev/null @@ -1,14 +0,0 @@ -"""application_credentials platform for the Loqed integration.""" - -from homeassistant.components.application_credentials import AuthorizationServer -from homeassistant.core import HomeAssistant - -from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN - - -async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: - """Return authorization server.""" - return AuthorizationServer( - authorize_url=OAUTH2_AUTHORIZE, - token_url=OAUTH2_TOKEN, - ) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 497cd305487b0..40020dcfb1b1c 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -2,36 +2,90 @@ from __future__ import annotations import logging -import re +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.components.zeroconf import ZeroconfServiceInfo -from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID +from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) -class ConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN): + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Loqed.""" VERSION = 1 DOMAIN = DOMAIN + _host: str | None = None @property def logger(self) -> logging.Logger: """Return logger.""" return logging.getLogger(__name__) + async def validate_input( + self, hass: HomeAssistant, data: dict[str, Any] + ) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + + newdata = data + + # 1. Checking loqed-connection + try: + session = async_get_clientsession(hass) + res = await session.request( + "GET", + "https://integrations.production.loqed.com/api/locks/", + headers={"Authorization": f"Bearer {data[CONF_API_TOKEN]}"}, + ) + lock_data = await res.json() + + selected_lock = next( + lock for lock in lock_data["data"] if lock["bridge_ip"] == self._host + ) + + apiclient = loqed.APIClient(session, f"http://{selected_lock['bridge_ip']}") + api = loqed.LoqedAPI(apiclient) + lock = await api.async_get_lock( + selected_lock["backend_key"], + selected_lock["bridge_key"], + selected_lock["local_id"], + selected_lock["bridge_ip"], + ) + newdata["id"] = lock.id + # checking getWebooks to check the bridgeKey + await lock.getWebhooks() + return { + "lock_key_key": selected_lock["backend_key"], + "bridge_key": selected_lock["bridge_key"], + "lock_key_local_id": selected_lock["local_id"], + "bridge_mdns_hostname": selected_lock["bridge_hostname"], + "bridge_ip": selected_lock["bridge_ip"], + "name": selected_lock["name"], + "id": selected_lock["id"], + } + + except aiohttp.ClientError: + _LOGGER.error("HTTP Connection error to loqed lock") + raise CannotConnect from aiohttp.ClientError + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" host = discovery_info.host + self._host = host session = async_get_clientsession(self.hass) apiclient = loqed.APIClient(session, f"http://{host}") @@ -41,27 +95,62 @@ async def async_step_zeroconf( # Check if already exists await self.async_set_unique_id(lock_data["bridge_mac_wifi"]) self._abort_if_unique_id_configured({CONF_HOST: host}) - return await self.async_step_user() - async def async_oauth_create_entry(self, data: dict) -> FlowResult: - """Create an entry for the flow. + return await self.async_step_user() - Ok to override if you want to fetch extra info or even add another step. - """ - session = async_get_clientsession(self.hass) - res = await session.request( - "GET", - "https://app.loqed.com/API/integration_oauth3/retrieve_lock_data.php", - headers={"Authorization": f"Bearer {data['token']['access_token']}"}, + 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(CONF_API_TOKEN): str, + } ) - lock_data = await res.json(content_type="text/html") + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=user_data_schema, + description_placeholders={ + "config_url": "https://integrations.production.loqed.com/personal-access-tokens", + }, + ) - await self.async_set_unique_id( - re.sub( - r"LOQED-([a-f0-9]+)\.local", r"\1", lock_data["bridge_mdns_hostname"] + errors = {} + + try: + info = await self.validate_input(self.hass, user_input) + 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()} | info + ), ) + + return self.async_show_form( + step_id="user", + data_schema=user_data_schema, + errors=errors, + description_placeholders={ + "config_url": "https://integrations.production.loqed.com/personal-access-tokens", + }, ) - config = data | {CONF_WEBHOOK_ID: webhook.async_generate_id()} | lock_data - return self.async_create_entry(title=self.flow_impl.name, data=config) +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py old mode 100644 new mode 100755 index a2ac82eaf73dc..5a7540ba89e1f --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -24,7 +24,7 @@ async def async_setup_entry( """Set up the Loqed lock platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([LoqedLock(coordinator)]) + async_add_entities([LoqedLock(coordinator, entry.data["name"])]) class LoqedLock(LoqedEntity, LockEntity): @@ -32,12 +32,12 @@ class LoqedLock(LoqedEntity, LockEntity): _attr_supported_features = LockEntityFeature.OPEN - def __init__(self, coordinator: LoqedDataCoordinator) -> None: + def __init__(self, coordinator: LoqedDataCoordinator, name: str) -> None: """Initialize the lock.""" super().__init__(coordinator) self._lock = coordinator.lock self._attr_unique_id = self._lock.id - self._attr_name = self._lock.name + self._attr_name = name @property def changed_by(self) -> str: diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json index b2c3450d39530..39ebcfd928c17 100644 --- a/homeassistant/components/loqed/manifest.json +++ b/homeassistant/components/loqed/manifest.json @@ -1,11 +1,16 @@ { "domain": "loqed", "name": "LOQED Touch Smart Lock", + "codeowners": ["@mikewoudenberg"], "config_flow": true, + "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/loqed", - "requirements": ["loqedAPI==2.1.5"], - "dependencies": ["webhook", "application_credentials"], - "codeowners": ["@cpolhout"], "iot_class": "local_push", - "zeroconf": [{ "type": "_http._tcp.local.", "name": "loqed*" }] + "requirements": ["loqedAPI==2.1.5"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "loqed*" + } + ] } diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json index 71a1d32dd852a..0a3525e87b5b8 100644 --- a/homeassistant/components/loqed/strings.json +++ b/homeassistant/components/loqed/strings.json @@ -3,9 +3,9 @@ "flow_title": "LOQED Touch Smartlock setup", "step": { "user": { - "description": "Visit {config_url} and: \n* Create an API-key by clicking 'add new api-key' \n* Copy the value of the 'Integration information' field by clicking on the copy button in the detail-page of the just created API-key.", + "description": "Login at {config_url} and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token.", "data": { - "config": "Loqed Configuration copied from the API setup page" + "api_key": "[%key:common::config_flow::data::api_key%]" } } }, diff --git a/homeassistant/components/loqed/translations/en.json b/homeassistant/components/loqed/translations/en.json index 8af97a49bb658..ae0b7fead7e68 100644 --- a/homeassistant/components/loqed/translations/en.json +++ b/homeassistant/components/loqed/translations/en.json @@ -1,20 +1,20 @@ { "config": { - "flow_title": "LOQED Touch Smartlock setup ({host})", "abort": { "already_configured": "Device is already configured" }, "error": { - "cannot_connect": "Failed to connect, did you copy the complete string with the copy button?", + "cannot_connect": "Failed to connect LOQED. Did you copy the correct access token?", "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" }, + "flow_title": "LOQED Touch Smartlock setup", "step": { "user": { "data": { - "config": "Lock-details: [Integration information] in the api-key details" + "api_key": "API Key" }, - "description": "Visit {config_url} and:
* Create an API-key: [add new api-key]
* Copy the [Integration information] value by clicking on the copy button in the detail-page of the just created API-key." + "description": "Login at {config_url} and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token." } } } diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 92bcf4351bd07..d1b330b5dbe72 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -11,7 +11,6 @@ "google_sheets", "home_connect", "lametric", - "loqed", "lyric", "neato", "nest", From 9daa3fe56adfd9a9a2770dd577a3e6a36d7e4f1e Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Wed, 14 Jun 2023 13:32:20 +0000 Subject: [PATCH 24/28] Completes tests --- homeassistant/components/loqed/config_flow.py | 48 +-- homeassistant/components/loqed/strings.json | 1 + .../components/loqed/translations/en.json | 5 +- tests/components/loqed/conftest.py | 6 +- .../loqed/fixtures/get_all_locks.json | 26 ++ tests/components/loqed/test_config_flow.py | 295 +++++++++++------- tests/components/loqed/test_lock.py | 10 +- 7 files changed, 247 insertions(+), 144 deletions(-) create mode 100644 tests/components/loqed/fixtures/get_all_locks.json diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 40020dcfb1b1c..2296f7a735b58 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import re from typing import Any import aiohttp @@ -11,7 +12,7 @@ from homeassistant import config_entries from homeassistant.components import webhook from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_WEBHOOK_ID +from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -29,18 +30,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): DOMAIN = DOMAIN _host: str | None = None - @property - def logger(self) -> logging.Logger: - """Return logger.""" - return logging.getLogger(__name__) - async def validate_input( self, hass: HomeAssistant, data: dict[str, Any] ) -> dict[str, Any]: """Validate the user input allows us to connect.""" - newdata = data - # 1. Checking loqed-connection try: session = async_get_clientsession(hass) @@ -50,9 +44,15 @@ async def validate_input( headers={"Authorization": f"Bearer {data[CONF_API_TOKEN]}"}, ) lock_data = await res.json() + except aiohttp.ClientError: + _LOGGER.error("HTTP Connection error to loqed API") + raise CannotConnect from aiohttp.ClientError + try: selected_lock = next( - lock for lock in lock_data["data"] if lock["bridge_ip"] == self._host + lock + for lock in lock_data["data"] + if lock["bridge_ip"] == self._host or lock["name"] == data.get("name") ) apiclient = loqed.APIClient(session, f"http://{selected_lock['bridge_ip']}") @@ -63,7 +63,7 @@ async def validate_input( selected_lock["local_id"], selected_lock["bridge_ip"], ) - newdata["id"] = lock.id + # checking getWebooks to check the bridgeKey await lock.getWebhooks() return { @@ -75,7 +75,8 @@ async def validate_input( "name": selected_lock["name"], "id": selected_lock["id"], } - + except StopIteration: + raise InvalidAuth from StopIteration except aiohttp.ClientError: _LOGGER.error("HTTP Connection error to loqed lock") raise CannotConnect from aiohttp.ClientError @@ -102,11 +103,21 @@ 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(CONF_API_TOKEN): str, - } + user_data_schema = ( + vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } + ) + if self._host + else vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_API_TOKEN): str, + } + ) ) + if user_input is None: return self.async_show_form( step_id="user", @@ -124,11 +135,10 @@ async def async_step_user( 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"]) + await self.async_set_unique_id( + re.sub(r"LOQED-([a-f0-9]+)\.local", r"\1", info["bridge_mdns_hostname"]) + ) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json index 0a3525e87b5b8..5448f01b7f90d 100644 --- a/homeassistant/components/loqed/strings.json +++ b/homeassistant/components/loqed/strings.json @@ -5,6 +5,7 @@ "user": { "description": "Login at {config_url} and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token.", "data": { + "name": "Name of your lock in the LOQED app.", "api_key": "[%key:common::config_flow::data::api_key%]" } } diff --git a/homeassistant/components/loqed/translations/en.json b/homeassistant/components/loqed/translations/en.json index ae0b7fead7e68..a961f10cb1b60 100644 --- a/homeassistant/components/loqed/translations/en.json +++ b/homeassistant/components/loqed/translations/en.json @@ -4,7 +4,7 @@ "already_configured": "Device is already configured" }, "error": { - "cannot_connect": "Failed to connect LOQED. Did you copy the correct access token?", + "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" }, @@ -12,7 +12,8 @@ "step": { "user": { "data": { - "api_key": "API Key" + "api_key": "API Key", + "name": "Name of your lock in the LOQED app." }, "description": "Login at {config_url} and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token." } diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index 9981239275cff..da7009a5744b3 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.loqed import DOMAIN -from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -33,6 +33,8 @@ def config_entry_fixture() -> MockConfigEntry: "lock_key_local_id": int(json_config["lock_key_local_id"]), "lock_key_key": json_config["lock_key_key"], CONF_WEBHOOK_ID: "Webhook_id", + CONF_API_TOKEN: "Token", + CONF_NAME: "Home", }, ) @@ -54,7 +56,7 @@ async def integration_fixture( hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock ) -> AsyncGenerator[MockConfigEntry, None]: """Set up the loqed integration with a config entry.""" - config: dict[str, Any] = {DOMAIN: {"config": ""}} + config: dict[str, Any] = {DOMAIN: {CONF_API_TOKEN: ""}} config_entry.add_to_hass(hass) lock_status = json.loads(load_fixture("loqed/status_ok.json")) diff --git a/tests/components/loqed/fixtures/get_all_locks.json b/tests/components/loqed/fixtures/get_all_locks.json new file mode 100644 index 0000000000000..bd8489c5c8703 --- /dev/null +++ b/tests/components/loqed/fixtures/get_all_locks.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "id": "Foo", + "name": "MyLock", + "battery_percentage": 64, + "battery_type": "nickel_metal_hydride", + "bolt_state": "day_lock", + "party_mode": false, + "guest_access_mode": false, + "twist_assist": false, + "touch_to_connect": true, + "lock_direction": "clockwise", + "mortise_lock_type": "cylinder_operated_no_handle_on_the_outside", + "supported_lock_states": ["open", "day_lock", "night_lock"], + "online": true, + "bridge_ip": "192.168.12.34", + "bridge_hostname": "LOQED-aabbccddeeff.local", + "local_id": 1, + "key_secret": "SGFsbG8gd2VyZWxk", + "backend_key": "aGVsbG8gd29ybGQ=", + "bridge_webhook_count": 1, + "bridge_key": "Ym9uam91ciBtb25kZQ==" + } + ] +} diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index 3b79586d786cf..e7251d0665bc3 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -1,160 +1,223 @@ """Test the Loqed config flow.""" -from http import HTTPStatus import json -from unittest.mock import patch +from unittest.mock import Mock, patch -import pytest +import aiohttp +from loqedAPI import loqed -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) -from homeassistant.components.loqed.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.components.loqed.const import DOMAIN +from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.setup import async_setup_component +from homeassistant.data_entry_flow import FlowResultType from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" - -DISCOVERED_LOCK = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", +zeroconf_data = zeroconf.ZeroconfServiceInfo( + host="192.168.12.34", addresses=["127.0.0.1"], - hostname="LOQED-aabbccddeeff", - port=None, - type="_http._tcp.local.", - name="aabbccddeeff._http._tcp.local.", - properties={"CtlN": "Loqed"}, + hostname="LOQED-ffeeddccbbaa.local", + name="mock_name", + port=9123, + properties={}, + type="mock_type", ) -@pytest.fixture -async def setup_credentials(hass: HomeAssistant) -> None: - """Fixture to setup credentials.""" - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - +async def test_create_entry_zeoconf( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we get can create a lock via zeroconf.""" + lock_result = json.loads(load_fixture("loqed/status_ok.json")) -async def test_oauth_flow( - hass, - hass_client_no_auth, - aioclient_mock, - current_request_with_host, - setup_credentials, -): - """Check Manual OAuth flow.""" + with patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock_details", + return_value=lock_result, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + mock_lock = Mock(spec=loqed.Lock, id="Foo") + webhook_id = "Webhook_ID" + all_locks_response = json.loads(load_fixture("loqed/get_all_locks.json")) + aioclient_mock.get( + "https://integrations.production.loqed.com/api/locks/", json=all_locks_response + ) + with patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock", + return_value=mock_lock, + ), patch( + "homeassistant.components.loqed.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.webhook.async_generate_id", return_value=webhook_id + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_TOKEN: "eyadiuyfasiuasf", + }, + ) + await hass.async_block_till_done() + found_lock = all_locks_response["data"][0] + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "LOQED Touch Smart Lock" + assert result2["data"] == { + "id": "Foo", + "lock_key_key": found_lock["backend_key"], + "bridge_key": found_lock["bridge_key"], + "lock_key_local_id": found_lock["local_id"], + "bridge_mdns_hostname": found_lock["bridge_hostname"], + "bridge_ip": found_lock["bridge_ip"], + "name": found_lock["name"], + CONF_WEBHOOK_ID: webhook_id, + CONF_API_TOKEN: "eyadiuyfasiuasf", + } + mock_lock.getWebhooks.assert_awaited() + + +async def test_create_entry_user( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we can create a lock via manual entry.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, + DOMAIN, + context={"source": config_entries.SOURCE_USER}, ) - assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP - assert result["url"] == ( - f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, - ) + lock_result = json.loads(load_fixture("loqed/status_ok.json")) + mock_lock = Mock(spec=loqed.Lock, id="Foo") + webhook_id = "Webhook_ID" + all_locks_response = json.loads(load_fixture("loqed/get_all_locks.json")) + found_lock = all_locks_response["data"][0] aioclient_mock.get( - "https://app.loqed.com/API/integration_oauth3/retrieve_lock_data.php", - text=load_fixture("loqed/integration_config.json"), + "https://integrations.production.loqed.com/api/locks/", json=all_locks_response ) with patch( - "homeassistant.components.loqed.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + "loqedAPI.loqed.LoqedAPI.async_get_lock", + return_value=mock_lock, + ), patch( + "homeassistant.components.loqed.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.webhook.async_generate_id", return_value=webhook_id + ), patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_result + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"}, + ) await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "LOQED Touch Smart Lock" + assert result2["data"] == { + "id": "Foo", + "lock_key_key": found_lock["backend_key"], + "bridge_key": found_lock["bridge_key"], + "lock_key_local_id": found_lock["local_id"], + "bridge_mdns_hostname": found_lock["bridge_hostname"], + "bridge_ip": found_lock["bridge_ip"], + "name": found_lock["name"], + CONF_WEBHOOK_ID: webhook_id, + CONF_API_TOKEN: "eyadiuyfasiuasf", + } + mock_lock.getWebhooks.assert_awaited() + + +async def test_cannot_connect( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None -async def test_zeroconf_setup( - hass: HomeAssistant, - hass_client_no_auth, - aioclient_mock, - current_request_with_host, - setup_credentials, -) -> None: - """Test zeroconf setup.""" aioclient_mock.get( - "http://127.0.0.1/status", - json=json.loads(load_fixture("loqed/status_ok.json")), + "https://integrations.production.loqed.com/api/locks/", exc=aiohttp.ClientError + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"}, ) + await hass.async_block_till_done() + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_invalid_auth_when_lock_not_found( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we handle a situation where the user enters an invalid lock name.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=DISCOVERED_LOCK + DOMAIN, + context={"source": config_entries.SOURCE_USER}, ) - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + all_locks_response = json.loads(load_fixture("loqed/get_all_locks.json")) + aioclient_mock.get( + "https://integrations.production.loqed.com/api/locks/", json=all_locks_response ) - assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP - assert result["url"] == ( - f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock2"}, ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, + +async def test_cannot_connect_when_lock_not_reachable( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we handle a situation where the user enters an invalid lock name.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + all_locks_response = json.loads(load_fixture("loqed/get_all_locks.json")) aioclient_mock.get( - "https://app.loqed.com/API/integration_oauth3/retrieve_lock_data.php", - text=load_fixture("loqed/integration_config.json"), + "https://integrations.production.loqed.com/api/locks/", json=all_locks_response ) with patch( - "homeassistant.components.loqed.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + "loqedAPI.loqed.LoqedAPI.async_get_lock", side_effect=aiohttp.ClientError + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"}, + ) await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/loqed/test_lock.py b/tests/components/loqed/test_lock.py index bfe2f2a8c7a2f..422b7ab683051 100644 --- a/tests/components/loqed/test_lock.py +++ b/tests/components/loqed/test_lock.py @@ -21,7 +21,7 @@ async def test_lock_entity( integration: MockConfigEntry, ) -> None: """Test the lock entity.""" - entity_id = "lock.loqed_smart_lock" + entity_id = "lock.loqed_lock_home" state = hass.states.get(entity_id) @@ -37,7 +37,7 @@ async def test_lock_responds_to_bolt_state_updates( lock.bolt_state = "night_lock" coordinator.async_update_listeners() - entity_id = "lock.loqed_smart_lock" + entity_id = "lock.loqed_lock_home" state = hass.states.get(entity_id) @@ -50,7 +50,7 @@ async def test_lock_transition_to_unlocked( ) -> None: """Tests the lock transitions to unlocked state.""" - entity_id = "lock.loqed_smart_lock" + entity_id = "lock.loqed_lock_home" await hass.services.async_call( "lock", SERVICE_UNLOCK, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -64,7 +64,7 @@ async def test_lock_transition_to_locked( ) -> None: """Tests the lock transitions to locked state.""" - entity_id = "lock.loqed_smart_lock" + entity_id = "lock.loqed_lock_home" await hass.services.async_call( "lock", SERVICE_LOCK, {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -78,7 +78,7 @@ async def test_lock_transition_to_open( ) -> None: """Tests the lock transitions to open state.""" - entity_id = "lock.loqed_smart_lock" + entity_id = "lock.loqed_lock_home" await hass.services.async_call( "lock", SERVICE_OPEN, {ATTR_ENTITY_ID: entity_id}, blocking=True From c2a3bd39b369b1e5b8d081e473fa6bdc9c847aae Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Wed, 14 Jun 2023 14:40:05 +0000 Subject: [PATCH 25/28] Removes incorrect executable flags --- homeassistant/components/loqed/entity.py | 0 homeassistant/components/loqed/lock.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 homeassistant/components/loqed/entity.py mode change 100755 => 100644 homeassistant/components/loqed/lock.py diff --git a/homeassistant/components/loqed/entity.py b/homeassistant/components/loqed/entity.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py old mode 100755 new mode 100644 From 2f1a09510e80f62f3b3e0dda287160ae5e3fdd54 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Wed, 14 Jun 2023 18:18:09 +0000 Subject: [PATCH 26/28] Removes incorrect executable flag --- homeassistant/components/loqed/config_flow.py | 11 ++-- homeassistant/components/loqed/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/loqed/test_config_flow.py | 54 ++++++++----------- 5 files changed, 32 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 2296f7a735b58..29379d91b269a 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -38,12 +38,13 @@ async def validate_input( # 1. Checking loqed-connection try: session = async_get_clientsession(hass) - res = await session.request( - "GET", - "https://integrations.production.loqed.com/api/locks/", - headers={"Authorization": f"Bearer {data[CONF_API_TOKEN]}"}, + cloud_api_client = loqed.APIClient( + session, + "https://integrations.production.loqed.com/", + data[CONF_API_TOKEN], ) - lock_data = await res.json() + cloud_client = loqed.LoqedCloudAPI(cloud_api_client) + lock_data = await cloud_client.async_get_locks() except aiohttp.ClientError: _LOGGER.error("HTTP Connection error to loqed API") raise CannotConnect from aiohttp.ClientError diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json index 39ebcfd928c17..c78e4b6a2fa5e 100644 --- a/homeassistant/components/loqed/manifest.json +++ b/homeassistant/components/loqed/manifest.json @@ -6,7 +6,7 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/loqed", "iot_class": "local_push", - "requirements": ["loqedAPI==2.1.5"], + "requirements": ["loqedAPI==2.1.6"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index cbb2ed131c906..2861e4fff1abb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1149,7 +1149,7 @@ logi-circle==0.2.3 london-tube-status==0.5 # homeassistant.components.loqed -loqedAPI==2.1.5 +loqedAPI==2.1.6 # homeassistant.components.luftdaten luftdaten==0.7.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e330246ce86b..5001aa6b177f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -878,7 +878,7 @@ life360==5.5.0 logi-circle==0.2.3 # homeassistant.components.loqed -loqedAPI==2.1.5 +loqedAPI==2.1.6 # homeassistant.components.luftdaten luftdaten==0.7.4 diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index e7251d0665bc3..78385360117ba 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -26,9 +26,7 @@ ) -async def test_create_entry_zeoconf( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_create_entry_zeoconf(hass: HomeAssistant) -> None: """Test we get can create a lock via zeroconf.""" lock_result = json.loads(load_fixture("loqed/status_ok.json")) @@ -48,11 +46,10 @@ async def test_create_entry_zeoconf( mock_lock = Mock(spec=loqed.Lock, id="Foo") webhook_id = "Webhook_ID" all_locks_response = json.loads(load_fixture("loqed/get_all_locks.json")) - aioclient_mock.get( - "https://integrations.production.loqed.com/api/locks/", json=all_locks_response - ) with patch( + "loqedAPI.loqed.LoqedCloudAPI.async_get_locks", return_value=all_locks_response + ), patch( "loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=mock_lock, ), patch( @@ -103,11 +100,10 @@ async def test_create_entry_user( webhook_id = "Webhook_ID" all_locks_response = json.loads(load_fixture("loqed/get_all_locks.json")) found_lock = all_locks_response["data"][0] - aioclient_mock.get( - "https://integrations.production.loqed.com/api/locks/", json=all_locks_response - ) with patch( + "loqedAPI.loqed.LoqedCloudAPI.async_get_locks", return_value=all_locks_response + ), patch( "loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=mock_lock, ), patch( @@ -152,15 +148,14 @@ async def test_cannot_connect( assert result["type"] == FlowResultType.FORM assert result["errors"] is None - aioclient_mock.get( - "https://integrations.production.loqed.com/api/locks/", exc=aiohttp.ClientError - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"}, - ) - await hass.async_block_till_done() + with patch( + "loqedAPI.loqed.LoqedCloudAPI.async_get_locks", side_effect=aiohttp.ClientError + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"}, + ) + await hass.async_block_till_done() assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} @@ -179,15 +174,15 @@ async def test_invalid_auth_when_lock_not_found( assert result["errors"] is None all_locks_response = json.loads(load_fixture("loqed/get_all_locks.json")) - aioclient_mock.get( - "https://integrations.production.loqed.com/api/locks/", json=all_locks_response - ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock2"}, - ) - await hass.async_block_till_done() + with patch( + "loqedAPI.loqed.LoqedCloudAPI.async_get_locks", return_value=all_locks_response + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock2"}, + ) + await hass.async_block_till_done() assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} @@ -206,13 +201,10 @@ async def test_cannot_connect_when_lock_not_reachable( assert result["errors"] is None all_locks_response = json.loads(load_fixture("loqed/get_all_locks.json")) - aioclient_mock.get( - "https://integrations.production.loqed.com/api/locks/", json=all_locks_response - ) with patch( - "loqedAPI.loqed.LoqedAPI.async_get_lock", side_effect=aiohttp.ClientError - ): + "loqedAPI.loqed.LoqedCloudAPI.async_get_locks", return_value=all_locks_response + ), patch("loqedAPI.loqed.LoqedAPI.async_get_lock", side_effect=aiohttp.ClientError): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"}, From 69673479065515992630ed30c47d6e50362bf795 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Fri, 16 Jun 2023 12:05:03 +0000 Subject: [PATCH 27/28] Uses latest cloud_loqed api --- homeassistant/components/loqed/config_flow.py | 9 ++++----- homeassistant/components/loqed/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/loqed/test_config_flow.py | 15 ++++++++++----- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 29379d91b269a..c757d2f0080ae 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import aiohttp -from loqedAPI import loqed +from loqedAPI import cloud_loqed, loqed import voluptuous as vol from homeassistant import config_entries @@ -38,12 +38,11 @@ async def validate_input( # 1. Checking loqed-connection try: session = async_get_clientsession(hass) - cloud_api_client = loqed.APIClient( + cloud_api_client = cloud_loqed.CloudAPIClient( session, - "https://integrations.production.loqed.com/", data[CONF_API_TOKEN], ) - cloud_client = loqed.LoqedCloudAPI(cloud_api_client) + cloud_client = cloud_loqed.LoqedCloudAPI(cloud_api_client) lock_data = await cloud_client.async_get_locks() except aiohttp.ClientError: _LOGGER.error("HTTP Connection error to loqed API") @@ -68,7 +67,7 @@ async def validate_input( # checking getWebooks to check the bridgeKey await lock.getWebhooks() return { - "lock_key_key": selected_lock["backend_key"], + "lock_key_key": selected_lock["key_secret"], "bridge_key": selected_lock["bridge_key"], "lock_key_local_id": selected_lock["local_id"], "bridge_mdns_hostname": selected_lock["bridge_hostname"], diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json index c78e4b6a2fa5e..1000d8f804d64 100644 --- a/homeassistant/components/loqed/manifest.json +++ b/homeassistant/components/loqed/manifest.json @@ -6,7 +6,7 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/loqed", "iot_class": "local_push", - "requirements": ["loqedAPI==2.1.6"], + "requirements": ["loqedAPI==2.1.7"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 2861e4fff1abb..be8e2bfa6ceaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1149,7 +1149,7 @@ logi-circle==0.2.3 london-tube-status==0.5 # homeassistant.components.loqed -loqedAPI==2.1.6 +loqedAPI==2.1.7 # homeassistant.components.luftdaten luftdaten==0.7.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5001aa6b177f5..3133f99397bbc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -878,7 +878,7 @@ life360==5.5.0 logi-circle==0.2.3 # homeassistant.components.loqed -loqedAPI==2.1.6 +loqedAPI==2.1.7 # homeassistant.components.luftdaten luftdaten==0.7.4 diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index 78385360117ba..d131bdd421f0b 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -48,7 +48,8 @@ async def test_create_entry_zeoconf(hass: HomeAssistant) -> None: all_locks_response = json.loads(load_fixture("loqed/get_all_locks.json")) with patch( - "loqedAPI.loqed.LoqedCloudAPI.async_get_locks", return_value=all_locks_response + "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", + return_value=all_locks_response, ), patch( "loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=mock_lock, @@ -102,7 +103,8 @@ async def test_create_entry_user( found_lock = all_locks_response["data"][0] with patch( - "loqedAPI.loqed.LoqedCloudAPI.async_get_locks", return_value=all_locks_response + "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", + return_value=all_locks_response, ), patch( "loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=mock_lock, @@ -149,7 +151,8 @@ async def test_cannot_connect( assert result["errors"] is None with patch( - "loqedAPI.loqed.LoqedCloudAPI.async_get_locks", side_effect=aiohttp.ClientError + "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", + side_effect=aiohttp.ClientError, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -176,7 +179,8 @@ async def test_invalid_auth_when_lock_not_found( all_locks_response = json.loads(load_fixture("loqed/get_all_locks.json")) with patch( - "loqedAPI.loqed.LoqedCloudAPI.async_get_locks", return_value=all_locks_response + "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", + return_value=all_locks_response, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -203,7 +207,8 @@ async def test_cannot_connect_when_lock_not_reachable( all_locks_response = json.loads(load_fixture("loqed/get_all_locks.json")) with patch( - "loqedAPI.loqed.LoqedCloudAPI.async_get_locks", return_value=all_locks_response + "loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks", + return_value=all_locks_response, ), patch("loqedAPI.loqed.LoqedAPI.async_get_lock", side_effect=aiohttp.ClientError): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], From faa8c115829984d5f6061de347701b70254f9abd Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Mon, 19 Jun 2023 14:54:16 +0000 Subject: [PATCH 28/28] Fixes tests to match picking correct key for manipulating lock --- tests/components/loqed/test_config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index d131bdd421f0b..c9c577e719999 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -26,7 +26,7 @@ ) -async def test_create_entry_zeoconf(hass: HomeAssistant) -> None: +async def test_create_entry_zeroconf(hass: HomeAssistant) -> None: """Test we get can create a lock via zeroconf.""" lock_result = json.loads(load_fixture("loqed/status_ok.json")) @@ -72,7 +72,7 @@ async def test_create_entry_zeoconf(hass: HomeAssistant) -> None: assert result2["title"] == "LOQED Touch Smart Lock" assert result2["data"] == { "id": "Foo", - "lock_key_key": found_lock["backend_key"], + "lock_key_key": found_lock["key_secret"], "bridge_key": found_lock["bridge_key"], "lock_key_local_id": found_lock["local_id"], "bridge_mdns_hostname": found_lock["bridge_hostname"], @@ -126,7 +126,7 @@ async def test_create_entry_user( assert result2["title"] == "LOQED Touch Smart Lock" assert result2["data"] == { "id": "Foo", - "lock_key_key": found_lock["backend_key"], + "lock_key_key": found_lock["key_secret"], "bridge_key": found_lock["bridge_key"], "lock_key_local_id": found_lock["local_id"], "bridge_mdns_hostname": found_lock["bridge_hostname"],