Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Knocki integration #119140

Merged
merged 19 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions homeassistant/components/knocki/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
62 changes: 62 additions & 0 deletions homeassistant/components/knocki/config_flow.py
Original file line number Diff line number Diff line change
@@ -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
joostlek marked this conversation as resolved.
Show resolved Hide resolved
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,
)
7 changes: 7 additions & 0 deletions homeassistant/components/knocki/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Constants for the Knocki integration."""

import logging

DOMAIN = "knocki"

LOGGER = logging.getLogger(__package__)
64 changes: 64 additions & 0 deletions homeassistant/components/knocki/event.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 11 additions & 0 deletions homeassistant/components/knocki/manifest.json
Original file line number Diff line number Diff line change
@@ -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"]
}
29 changes: 29 additions & 0 deletions homeassistant/components/knocki/strings.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@
"kegtron",
"keymitt_ble",
"kmtronic",
"knocki",
"knx",
"kodi",
"konnected",
Expand Down
6 changes: 6 additions & 0 deletions homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -3073,6 +3073,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",
Expand Down
10 changes: 10 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,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

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,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

Expand Down
12 changes: 12 additions & 0 deletions tests/components/knocki/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
57 changes: 57 additions & 0 deletions tests/components/knocki/conftest.py
Original file line number Diff line number Diff line change
@@ -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",
},
)
16 changes: 16 additions & 0 deletions tests/components/knocki/fixtures/triggers.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
Loading