Add Knocki integration (#119140)

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>
This commit is contained in:
Joost Lekkerkerker
2024-06-21 17:22:03 +02:00
committed by GitHub
parent 710e245819
commit 2770811dda
20 changed files with 618 additions and 0 deletions

View File

@ -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.*

View File

@ -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

View File

@ -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)

View File

@ -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,
)

View File

@ -0,0 +1,7 @@
"""Constants for the Knocki integration."""
import logging
DOMAIN = "knocki"
LOGGER = logging.getLogger(__package__)

View File

@ -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()

View File

@ -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"]
}

View File

@ -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"
}
}
}
}
}
}
}

View File

@ -281,6 +281,7 @@ FLOWS = {
"kegtron",
"keymitt_ble",
"kmtronic",
"knocki",
"knx",
"kodi",
"konnected",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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",
},
)

View File

@ -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"
}
]

View File

@ -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',
})
# ---

View File

@ -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

View File

@ -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

View File

@ -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