From 2770811dda4594aadf650ca06d00c560c667a74e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker <joostlek@outlook.com> Date: Fri, 21 Jun 2024 17:22:03 +0200 Subject: [PATCH] Add Knocki integration (#119140) Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/knocki/__init__.py | 52 +++++++++ .../components/knocki/config_flow.py | 62 ++++++++++ homeassistant/components/knocki/const.py | 7 ++ homeassistant/components/knocki/event.py | 64 ++++++++++ homeassistant/components/knocki/manifest.json | 11 ++ homeassistant/components/knocki/strings.json | 29 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/knocki/__init__.py | 12 ++ tests/components/knocki/conftest.py | 57 +++++++++ .../components/knocki/fixtures/triggers.json | 16 +++ .../knocki/snapshots/test_event.ambr | 55 +++++++++ tests/components/knocki/test_config_flow.py | 109 ++++++++++++++++++ tests/components/knocki/test_event.py | 75 ++++++++++++ tests/components/knocki/test_init.py | 43 +++++++ 20 files changed, 618 insertions(+) create mode 100644 homeassistant/components/knocki/__init__.py create mode 100644 homeassistant/components/knocki/config_flow.py create mode 100644 homeassistant/components/knocki/const.py create mode 100644 homeassistant/components/knocki/event.py create mode 100644 homeassistant/components/knocki/manifest.json create mode 100644 homeassistant/components/knocki/strings.json create mode 100644 tests/components/knocki/__init__.py create mode 100644 tests/components/knocki/conftest.py create mode 100644 tests/components/knocki/fixtures/triggers.json create mode 100644 tests/components/knocki/snapshots/test_event.ambr create mode 100644 tests/components/knocki/test_config_flow.py create mode 100644 tests/components/knocki/test_event.py create mode 100644 tests/components/knocki/test_init.py diff --git a/.strict-typing b/.strict-typing index 313dda48649..2a6edfedd32 100644 --- a/.strict-typing +++ b/.strict-typing @@ -261,6 +261,7 @@ homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* homeassistant.components.jvc_projector.* homeassistant.components.kaleidescape.* +homeassistant.components.knocki.* homeassistant.components.knx.* homeassistant.components.kraken.* homeassistant.components.lacrosse.* diff --git a/CODEOWNERS b/CODEOWNERS index aa33cdfe38f..6999f9e08a0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -737,6 +737,8 @@ build.json @home-assistant/supervisor /tests/components/kitchen_sink/ @home-assistant/core /homeassistant/components/kmtronic/ @dgomes /tests/components/kmtronic/ @dgomes +/homeassistant/components/knocki/ @joostlek @jgatto1 +/tests/components/knocki/ @joostlek @jgatto1 /homeassistant/components/knx/ @Julius2342 @farmio @marvin-w /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund diff --git a/homeassistant/components/knocki/__init__.py b/homeassistant/components/knocki/__init__.py new file mode 100644 index 00000000000..ef024d6f4d6 --- /dev/null +++ b/homeassistant/components/knocki/__init__.py @@ -0,0 +1,52 @@ +"""The Knocki integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from knocki import KnockiClient, KnockiConnectionError, Trigger + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +PLATFORMS: list[Platform] = [Platform.EVENT] + +type KnockiConfigEntry = ConfigEntry[KnockiData] + + +@dataclass +class KnockiData: + """Knocki data.""" + + client: KnockiClient + triggers: list[Trigger] + + +async def async_setup_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bool: + """Set up Knocki from a config entry.""" + client = KnockiClient( + session=async_get_clientsession(hass), token=entry.data[CONF_TOKEN] + ) + + try: + triggers = await client.get_triggers() + except KnockiConnectionError as exc: + raise ConfigEntryNotReady from exc + + entry.runtime_data = KnockiData(client=client, triggers=triggers) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_create_background_task( + hass, client.start_websocket(), "knocki-websocket" + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/knocki/config_flow.py b/homeassistant/components/knocki/config_flow.py new file mode 100644 index 00000000000..724c65f83df --- /dev/null +++ b/homeassistant/components/knocki/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Knocki integration.""" + +from __future__ import annotations + +from typing import Any + +from knocki import KnockiClient, KnockiConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class KnockiConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Knocki.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + client = KnockiClient(session=async_get_clientsession(self.hass)) + try: + token_response = await client.login( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + await self.async_set_unique_id(token_response.user_id) + self._abort_if_unique_id_configured() + client.token = token_response.token + await client.link() + except HomeAssistantError: + # Catch the unique_id abort and reraise it to keep the code clean + raise + except KnockiConnectionError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Error logging into the Knocki API") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={ + CONF_TOKEN: token_response.token, + }, + ) + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=DATA_SCHEMA, + ) diff --git a/homeassistant/components/knocki/const.py b/homeassistant/components/knocki/const.py new file mode 100644 index 00000000000..a54852e9292 --- /dev/null +++ b/homeassistant/components/knocki/const.py @@ -0,0 +1,7 @@ +"""Constants for the Knocki integration.""" + +import logging + +DOMAIN = "knocki" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/knocki/event.py b/homeassistant/components/knocki/event.py new file mode 100644 index 00000000000..8cd5de21958 --- /dev/null +++ b/homeassistant/components/knocki/event.py @@ -0,0 +1,64 @@ +"""Event entity for Knocki integration.""" + +from knocki import Event, EventType, KnockiClient, Trigger + +from homeassistant.components.event import EventEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import KnockiConfigEntry +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KnockiConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Knocki from a config entry.""" + entry_data = entry.runtime_data + + async_add_entities( + KnockiTrigger(trigger, entry_data.client) for trigger in entry_data.triggers + ) + + +EVENT_TRIGGERED = "triggered" + + +class KnockiTrigger(EventEntity): + """Representation of a Knocki trigger.""" + + _attr_event_types = [EVENT_TRIGGERED] + _attr_has_entity_name = True + _attr_translation_key = "knocki" + + def __init__(self, trigger: Trigger, client: KnockiClient) -> None: + """Initialize the entity.""" + self._trigger = trigger + self._client = client + self._attr_name = trigger.details.name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, trigger.device_id)}, + manufacturer="Knocki", + serial_number=trigger.device_id, + name=trigger.device_id, + ) + self._attr_unique_id = f"{trigger.device_id}_{trigger.details.trigger_id}" + + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + self.async_on_remove( + self._client.register_listener(EventType.TRIGGERED, self._handle_event) + ) + + def _handle_event(self, event: Event) -> None: + """Handle incoming event.""" + if ( + event.payload.details.trigger_id == self._trigger.details.trigger_id + and event.payload.device_id == self._trigger.device_id + ): + self._trigger_event(EVENT_TRIGGERED) + self.schedule_update_ha_state() diff --git a/homeassistant/components/knocki/manifest.json b/homeassistant/components/knocki/manifest.json new file mode 100644 index 00000000000..bf4dcea4b67 --- /dev/null +++ b/homeassistant/components/knocki/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "knocki", + "name": "Knocki", + "codeowners": ["@joostlek", "@jgatto1"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/knocki", + "integration_type": "device", + "iot_class": "cloud_push", + "loggers": ["knocki"], + "requirements": ["knocki==0.1.5"] +} diff --git a/homeassistant/components/knocki/strings.json b/homeassistant/components/knocki/strings.json new file mode 100644 index 00000000000..b7a7daad1fc --- /dev/null +++ b/homeassistant/components/knocki/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "event": { + "knocki": { + "state_attributes": { + "event_type": { + "state": { + "triggered": "Triggered" + } + } + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7cd0e270703..f33e37c1a7b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -281,6 +281,7 @@ FLOWS = { "kegtron", "keymitt_ble", "kmtronic", + "knocki", "knx", "kodi", "konnected", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0fe63cc02ff..fbb2e8ed8aa 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3078,6 +3078,12 @@ "config_flow": true, "iot_class": "local_push" }, + "knocki": { + "name": "Knocki", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_push" + }, "knx": { "name": "KNX", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 4e4d9cc624b..740eb4f2b5b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2373,6 +2373,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.knocki.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.knx.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index d9dd5bbe61b..a87d781d649 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1208,6 +1208,9 @@ kegtron-ble==0.4.0 # homeassistant.components.kiwi kiwiki-client==0.1.1 +# homeassistant.components.knocki +knocki==0.1.5 + # homeassistant.components.knx knx-frontend==2024.1.20.105944 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33ff276c8ad..787062155c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -986,6 +986,9 @@ justnimbus==0.7.3 # homeassistant.components.kegtron kegtron-ble==0.4.0 +# homeassistant.components.knocki +knocki==0.1.5 + # homeassistant.components.knx knx-frontend==2024.1.20.105944 diff --git a/tests/components/knocki/__init__.py b/tests/components/knocki/__init__.py new file mode 100644 index 00000000000..4ebf6b0dd01 --- /dev/null +++ b/tests/components/knocki/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Knocki integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/knocki/conftest.py b/tests/components/knocki/conftest.py new file mode 100644 index 00000000000..e1bc2e29cde --- /dev/null +++ b/tests/components/knocki/conftest.py @@ -0,0 +1,57 @@ +"""Common fixtures for the Knocki tests.""" + +from unittest.mock import AsyncMock, patch + +from knocki import TokenResponse, Trigger +import pytest +from typing_extensions import Generator + +from homeassistant.components.knocki.const import DOMAIN +from homeassistant.const import CONF_TOKEN + +from tests.common import MockConfigEntry, load_json_array_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.knocki.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_knocki_client() -> Generator[AsyncMock]: + """Mock a Knocki client.""" + with ( + patch( + "homeassistant.components.knocki.KnockiClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.knocki.config_flow.KnockiClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.login.return_value = TokenResponse(token="test-token", user_id="test-id") + client.get_triggers.return_value = [ + Trigger.from_dict(trigger) + for trigger in load_json_array_fixture("triggers.json", DOMAIN) + ] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Knocki", + unique_id="test-id", + data={ + CONF_TOKEN: "test-token", + }, + ) diff --git a/tests/components/knocki/fixtures/triggers.json b/tests/components/knocki/fixtures/triggers.json new file mode 100644 index 00000000000..13dc3906b35 --- /dev/null +++ b/tests/components/knocki/fixtures/triggers.json @@ -0,0 +1,16 @@ +[ + { + "device": "KNC1-W-00000214", + "gesture": "d060b870-15ba-42c9-a932-2d2951087152", + "details": { + "description": "Eeee", + "name": "Aaaa", + "id": 31 + }, + "type": "homeassistant", + "user": "7a4d5bf9-01b1-413b-bb4d-77728e931dcc", + "updatedAt": 1716378013721, + "createdAt": 1716378013721, + "id": "1a050b25-7fed-4e0e-b5af-792b8b4650de" + } +] diff --git a/tests/components/knocki/snapshots/test_event.ambr b/tests/components/knocki/snapshots/test_event.ambr new file mode 100644 index 00000000000..fba1c90b45d --- /dev/null +++ b/tests/components/knocki/snapshots/test_event.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_entities[event.knc1_w_00000214_aaaa-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'triggered', + ]), + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.knc1_w_00000214_aaaa', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aaaa', + 'platform': 'knocki', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'knocki', + 'unique_id': 'KNC1-W-00000214_31', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[event.knc1_w_00000214_aaaa-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'triggered', + ]), + 'friendly_name': 'KNC1-W-00000214 Aaaa', + }), + 'context': <ANY>, + 'entity_id': 'event.knc1_w_00000214_aaaa', + 'last_changed': <ANY>, + 'last_reported': <ANY>, + 'last_updated': <ANY>, + 'state': 'unknown', + }) +# --- diff --git a/tests/components/knocki/test_config_flow.py b/tests/components/knocki/test_config_flow.py new file mode 100644 index 00000000000..baf43c3ad30 --- /dev/null +++ b/tests/components/knocki/test_config_flow.py @@ -0,0 +1,109 @@ +"""Tests for the Knocki event platform.""" + +from unittest.mock import AsyncMock + +from knocki import KnockiConnectionError +import pytest + +from homeassistant.components.knocki.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_TOKEN: "test-token", + } + assert result["result"].unique_id == "test-id" + assert len(mock_knocki_client.link.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplcate_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_knocki_client: AsyncMock, +) -> None: + """Test abort when setting up duplicate entry.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize(("field"), ["login", "link"]) +@pytest.mark.parametrize( + ("exception", "error"), + [(KnockiConnectionError, "cannot_connect"), (Exception, "unknown")], +) +async def test_exceptions( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_setup_entry: AsyncMock, + field: str, + exception: Exception, + error: str, +) -> None: + """Test exceptions.""" + getattr(mock_knocki_client, field).side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + getattr(mock_knocki_client, field).side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/knocki/test_event.py b/tests/components/knocki/test_event.py new file mode 100644 index 00000000000..a53e2811854 --- /dev/null +++ b/tests/components/knocki/test_event.py @@ -0,0 +1,75 @@ +"""Tests for the Knocki event platform.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock + +from knocki import Event, EventType, Trigger, TriggerDetails +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2022-01-01T12:00:00Z") +async def test_subscription( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subscription.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.knc1_w_00000214_aaaa").state == STATE_UNKNOWN + + event_function: Callable[[Event], None] = ( + mock_knocki_client.register_listener.call_args[0][1] + ) + + async def _call_event_function( + device_id: str = "KNC1-W-00000214", trigger_id: int = 31 + ) -> None: + event_function( + Event( + EventType.TRIGGERED, + Trigger( + device_id=device_id, details=TriggerDetails(trigger_id, "aaaa") + ), + ) + ) + await hass.async_block_till_done() + + await _call_event_function(device_id="KNC1-W-00000215") + assert hass.states.get("event.knc1_w_00000214_aaaa").state == STATE_UNKNOWN + + await _call_event_function(trigger_id=32) + assert hass.states.get("event.knc1_w_00000214_aaaa").state == STATE_UNKNOWN + + await _call_event_function() + assert ( + hass.states.get("event.knc1_w_00000214_aaaa").state + == "2022-01-01T12:00:00.000+00:00" + ) + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_knocki_client.register_listener.return_value.called diff --git a/tests/components/knocki/test_init.py b/tests/components/knocki/test_init.py new file mode 100644 index 00000000000..7db0e1047b5 --- /dev/null +++ b/tests/components/knocki/test_init.py @@ -0,0 +1,43 @@ +"""Test the Home Knocki init module.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from knocki import KnockiConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_initialization_failure( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test initialization failure.""" + mock_knocki_client.get_triggers.side_effect = KnockiConnectionError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -- GitLab