From 5c29adea3de3d74abc4166f45dc4cec904152135 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 22 Feb 2021 06:12:50 +0000 Subject: [PATCH] Add KMTronic Integration (#41682) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + homeassistant/components/kmtronic/__init__.py | 104 ++++++++++++ .../components/kmtronic/config_flow.py | 74 +++++++++ homeassistant/components/kmtronic/const.py | 16 ++ .../components/kmtronic/manifest.json | 8 + .../components/kmtronic/strings.json | 21 +++ homeassistant/components/kmtronic/switch.py | 67 ++++++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/kmtronic/__init__.py | 1 + tests/components/kmtronic/test_config_flow.py | 145 +++++++++++++++++ tests/components/kmtronic/test_switch.py | 150 ++++++++++++++++++ 13 files changed, 594 insertions(+) create mode 100644 homeassistant/components/kmtronic/__init__.py create mode 100644 homeassistant/components/kmtronic/config_flow.py create mode 100644 homeassistant/components/kmtronic/const.py create mode 100644 homeassistant/components/kmtronic/manifest.json create mode 100644 homeassistant/components/kmtronic/strings.json create mode 100644 homeassistant/components/kmtronic/switch.py create mode 100644 tests/components/kmtronic/__init__.py create mode 100644 tests/components/kmtronic/test_config_flow.py create mode 100644 tests/components/kmtronic/test_switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 788f3636143..a9d4ce63209 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -243,6 +243,7 @@ homeassistant/components/keba/* @dannerph homeassistant/components/keenetic_ndms2/* @foxel homeassistant/components/kef/* @basnijholt homeassistant/components/keyboard_remote/* @bendavid +homeassistant/components/kmtronic/* @dgomes homeassistant/components/knx/* @Julius2342 @farmio @marvin-w homeassistant/components/kodi/* @OnFreund @cgtobi homeassistant/components/konnected/* @heythisisnate @kit-klein diff --git a/homeassistant/components/kmtronic/__init__.py b/homeassistant/components/kmtronic/__init__.py new file mode 100644 index 00000000000..b55ab9e1c9c --- /dev/null +++ b/homeassistant/components/kmtronic/__init__.py @@ -0,0 +1,104 @@ +"""The kmtronic integration.""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +from pykmtronic.auth import Auth +from pykmtronic.hub import KMTronicHubAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_HOSTNAME, + CONF_PASSWORD, + CONF_USERNAME, + DATA_COORDINATOR, + DATA_HOST, + DATA_HUB, + DOMAIN, + MANUFACTURER, +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + +PLATFORMS = ["switch"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the kmtronic component.""" + hass.data[DOMAIN] = {} + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up kmtronic from a config entry.""" + + session = aiohttp_client.async_get_clientsession(hass) + auth = Auth( + session, + f"http://{entry.data[CONF_HOSTNAME]}", + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + hub = KMTronicHubAPI(auth) + + async def async_update_data(): + try: + async with async_timeout.timeout(10): + await hub.async_update_relays() + except aiohttp.client_exceptions.ClientResponseError as err: + raise UpdateFailed(f"Wrong credentials: {err}") from err + except ( + asyncio.TimeoutError, + aiohttp.client_exceptions.ClientConnectorError, + ) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{MANUFACTURER} {hub.name}", + update_method=async_update_data, + update_interval=timedelta(seconds=30), + ) + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = { + DATA_HUB: hub, + DATA_HOST: entry.data[DATA_HOST], + DATA_COORDINATOR: coordinator, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/kmtronic/config_flow.py b/homeassistant/components/kmtronic/config_flow.py new file mode 100644 index 00000000000..376bb64c34c --- /dev/null +++ b/homeassistant/components/kmtronic/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for kmtronic integration.""" +import logging + +import aiohttp +from pykmtronic.auth import Auth +from pykmtronic.hub import KMTronicHubAPI +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.helpers import aiohttp_client + +from .const import CONF_HOSTNAME, CONF_PASSWORD, CONF_USERNAME +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({CONF_HOSTNAME: str, CONF_USERNAME: str, CONF_PASSWORD: str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + + session = aiohttp_client.async_get_clientsession(hass) + auth = Auth( + session, + f"http://{data[CONF_HOSTNAME]}", + data[CONF_USERNAME], + data[CONF_PASSWORD], + ) + hub = KMTronicHubAPI(auth) + + try: + await hub.async_get_status() + except aiohttp.client_exceptions.ClientResponseError as err: + raise InvalidAuth from err + except aiohttp.client_exceptions.ClientConnectorError as err: + raise CannotConnect from err + + return data + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for kmtronic.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + + return self.async_create_entry(title=info["host"], 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=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/kmtronic/const.py b/homeassistant/components/kmtronic/const.py new file mode 100644 index 00000000000..58553217799 --- /dev/null +++ b/homeassistant/components/kmtronic/const.py @@ -0,0 +1,16 @@ +"""Constants for the kmtronic integration.""" + +DOMAIN = "kmtronic" + +CONF_HOSTNAME = "host" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" + +DATA_HUB = "hub" +DATA_HOST = "host" +DATA_COORDINATOR = "coordinator" + +MANUFACTURER = "KMtronic" +ATTR_MANUFACTURER = "manufacturer" +ATTR_IDENTIFIERS = "identifiers" +ATTR_NAME = "name" diff --git a/homeassistant/components/kmtronic/manifest.json b/homeassistant/components/kmtronic/manifest.json new file mode 100644 index 00000000000..27e9f953eb7 --- /dev/null +++ b/homeassistant/components/kmtronic/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "kmtronic", + "name": "KMtronic", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/kmtronic", + "requirements": ["pykmtronic==0.0.3"], + "codeowners": ["@dgomes"] +} diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json new file mode 100644 index 00000000000..7becb830d91 --- /dev/null +++ b/homeassistant/components/kmtronic/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py new file mode 100644 index 00000000000..5970ec20cb8 --- /dev/null +++ b/homeassistant/components/kmtronic/switch.py @@ -0,0 +1,67 @@ +"""KMtronic Switch integration.""" + +from homeassistant.components.switch import SwitchEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DATA_COORDINATOR, DATA_HOST, DATA_HUB, DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + """Config entry example.""" + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + hub = hass.data[DOMAIN][entry.entry_id][DATA_HUB] + host = hass.data[DOMAIN][entry.entry_id][DATA_HOST] + await hub.async_get_relays() + + async_add_entities( + [ + KMtronicSwitch(coordinator, host, relay, entry.unique_id) + for relay in hub.relays + ] + ) + + +class KMtronicSwitch(CoordinatorEntity, SwitchEntity): + """KMtronic Switch Entity.""" + + def __init__(self, coordinator, host, relay, config_entry_id): + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self._host = host + self._relay = relay + self._config_entry_id = config_entry_id + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return self.coordinator.last_update_success + + @property + def name(self) -> str: + """Return the name of the entity.""" + return f"Relay{self._relay.id}" + + @property + def unique_id(self) -> str: + """Return the unique ID of the entity.""" + return f"{self._config_entry_id}_relay{self._relay.id}" + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return True + + @property + def is_on(self): + """Return entity state.""" + return self._relay.is_on + + async def async_turn_on(self, **kwargs) -> None: + """Turn the switch on.""" + await self._relay.turn_on() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the switch off.""" + await self._relay.turn_off() + self.async_write_ha_state() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7e17a839068..8e8949e5788 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -115,6 +115,7 @@ FLOWS = [ "izone", "juicenet", "keenetic_ndms2", + "kmtronic", "kodi", "konnected", "kulersky", diff --git a/requirements_all.txt b/requirements_all.txt index 0d8203279b5..05ed5e7aa92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1473,6 +1473,9 @@ pyitachip2ir==0.0.7 # homeassistant.components.kira pykira==0.1.1 +# homeassistant.components.kmtronic +pykmtronic==0.0.3 + # homeassistant.components.kodi pykodi==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99a0351ee37..46c91feeb16 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -775,6 +775,9 @@ pyisy==2.1.0 # homeassistant.components.kira pykira==0.1.1 +# homeassistant.components.kmtronic +pykmtronic==0.0.3 + # homeassistant.components.kodi pykodi==0.2.1 diff --git a/tests/components/kmtronic/__init__.py b/tests/components/kmtronic/__init__.py new file mode 100644 index 00000000000..2f089d6495f --- /dev/null +++ b/tests/components/kmtronic/__init__.py @@ -0,0 +1 @@ +"""Tests for the kmtronic integration.""" diff --git a/tests/components/kmtronic/test_config_flow.py b/tests/components/kmtronic/test_config_flow.py new file mode 100644 index 00000000000..ebbbf626451 --- /dev/null +++ b/tests/components/kmtronic/test_config_flow.py @@ -0,0 +1,145 @@ +"""Test the kmtronic config flow.""" +from unittest.mock import Mock, patch + +from aiohttp import ClientConnectorError, ClientResponseError + +from homeassistant import config_entries, setup +from homeassistant.components.kmtronic.const import DOMAIN +from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED + +from tests.common import MockConfigEntry + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status", + return_value=[Mock()], + ), patch( + "homeassistant.components.kmtronic.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.kmtronic.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status", + side_effect=ClientResponseError(None, None, status=401), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status", + side_effect=ClientConnectorError(None, Mock()), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass): + """Test we handle unknown errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status", + side_effect=Exception(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_unload_config_entry(hass, aioclient_mock): + """Test entry unloading.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={"host": "1.1.1.1", "username": "admin", "password": "admin"}, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="00", + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0] is config_entry + assert config_entry.state == ENTRY_STATE_LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ENTRY_STATE_NOT_LOADED diff --git a/tests/components/kmtronic/test_switch.py b/tests/components/kmtronic/test_switch.py new file mode 100644 index 00000000000..5eec3537176 --- /dev/null +++ b/tests/components/kmtronic/test_switch.py @@ -0,0 +1,150 @@ +"""The tests for the KMtronic switch platform.""" +import asyncio +from datetime import timedelta + +from homeassistant.components.kmtronic.const import DOMAIN +from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_relay_on_off(hass, aioclient_mock): + """Tests the relay turns on correctly.""" + + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="00", + ) + + MockConfigEntry( + domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"} + ).add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Mocks the response for turning a relay1 on + aioclient_mock.get( + "http://1.1.1.1/FF0101", + text="", + ) + + state = hass.states.get("switch.relay1") + assert state.state == "off" + + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.relay1"}, blocking=True + ) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == "on" + + # Mocks the response for turning a relay1 off + aioclient_mock.get( + "http://1.1.1.1/FF0100", + text="", + ) + + await hass.services.async_call( + "switch", "turn_off", {"entity_id": "switch.relay1"}, blocking=True + ) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == "off" + + +async def test_update(hass, aioclient_mock): + """Tests switch refreshes status periodically.""" + now = dt_util.utcnow() + future = now + timedelta(minutes=10) + + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="00", + ) + + MockConfigEntry( + domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"} + ).add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == "off" + + aioclient_mock.clear_requests() + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="11", + ) + async_fire_time_changed(hass, future) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == "on" + + +async def test_config_entry_not_ready(hass, aioclient_mock): + """Tests configuration entry not ready.""" + + aioclient_mock.get( + "http://1.1.1.1/status.xml", + exc=asyncio.TimeoutError(), + ) + + config_entry = MockConfigEntry( + domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"} + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_failed_update(hass, aioclient_mock): + """Tests coordinator update fails.""" + now = dt_util.utcnow() + future = now + timedelta(minutes=10) + + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="00", + ) + + MockConfigEntry( + domain=DOMAIN, data={"host": "1.1.1.1", "username": "foo", "password": "bar"} + ).add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == "off" + + aioclient_mock.clear_requests() + aioclient_mock.get( + "http://1.1.1.1/status.xml", + text="401 Unauthorized: Password required", + status=401, + ) + async_fire_time_changed(hass, future) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == STATE_UNAVAILABLE + + future += timedelta(minutes=10) + aioclient_mock.clear_requests() + aioclient_mock.get( + "http://1.1.1.1/status.xml", + exc=asyncio.TimeoutError(), + ) + async_fire_time_changed(hass, future) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == STATE_UNAVAILABLE