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