From ee257234689c682fa674528cc0c23c0bebda320b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 8 Mar 2021 21:56:24 +0000 Subject: [PATCH] Add option to reverse switch behaviour in KMTronic (#47532) --- homeassistant/components/kmtronic/__init__.py | 13 +++- .../components/kmtronic/config_flow.py | 41 ++++++++++- homeassistant/components/kmtronic/const.py | 5 +- .../components/kmtronic/strings.json | 9 +++ homeassistant/components/kmtronic/switch.py | 32 ++++----- .../components/kmtronic/translations/en.json | 12 +++- tests/components/kmtronic/test_config_flow.py | 70 +++++++++++-------- tests/components/kmtronic/test_init.py | 62 ++++++++++++++++ tests/components/kmtronic/test_switch.py | 67 +++++++++++++----- 9 files changed, 240 insertions(+), 71 deletions(-) create mode 100644 tests/components/kmtronic/test_init.py diff --git a/homeassistant/components/kmtronic/__init__.py b/homeassistant/components/kmtronic/__init__.py index 0ac4ea8cb59..2903eb926e5 100644 --- a/homeassistant/components/kmtronic/__init__.py +++ b/homeassistant/components/kmtronic/__init__.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DATA_COORDINATOR, DATA_HOST, DATA_HUB, DOMAIN, MANUFACTURER +from .const import DATA_COORDINATOR, DATA_HUB, DOMAIN, MANUFACTURER, UPDATE_LISTENER CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) @@ -67,7 +67,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN][entry.entry_id] = { DATA_HUB: hub, - DATA_HOST: entry.data[DATA_HOST], DATA_COORDINATOR: coordinator, } @@ -76,9 +75,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.config_entries.async_forward_entry_setup(entry, platform) ) + update_listener = entry.add_update_listener(async_update_options) + hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] = update_listener + return True +async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = all( @@ -90,6 +97,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ) ) if unload_ok: + update_listener = hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] + update_listener() 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 index 841c541dd4e..736e6a46c11 100644 --- a/homeassistant/components/kmtronic/config_flow.py +++ b/homeassistant/components/kmtronic/config_flow.py @@ -8,13 +8,21 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback from homeassistant.helpers import aiohttp_client +from .const import CONF_REVERSE from .const import DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema({CONF_HOST: str, CONF_USERNAME: str, CONF_PASSWORD: str}) +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) async def validate_input(hass: core.HomeAssistant, data): @@ -44,6 +52,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return KMTronicOptionsFlow(config_entry) + async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} @@ -71,3 +85,28 @@ class CannotConnect(exceptions.HomeAssistantError): class InvalidAuth(exceptions.HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class KMTronicOptionsFlow(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_REVERSE, + default=self.config_entry.options.get(CONF_REVERSE), + ): bool, + } + ), + ) diff --git a/homeassistant/components/kmtronic/const.py b/homeassistant/components/kmtronic/const.py index 8ca37a0b797..8b34d423724 100644 --- a/homeassistant/components/kmtronic/const.py +++ b/homeassistant/components/kmtronic/const.py @@ -2,10 +2,13 @@ DOMAIN = "kmtronic" +CONF_REVERSE = "reverse" + DATA_HUB = "hub" -DATA_HOST = "host" DATA_COORDINATOR = "coordinator" MANUFACTURER = "KMtronic" ATTR_MANUFACTURER = "manufacturer" ATTR_IDENTIFIERS = "identifiers" + +UPDATE_LISTENER = "update_listener" diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json index 7becb830d91..2aaa0d2f8dd 100644 --- a/homeassistant/components/kmtronic/strings.json +++ b/homeassistant/components/kmtronic/strings.json @@ -17,5 +17,14 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "options": { + "step": { + "init": { + "data": { + "reverse": "Reverse switch logic (use NC)" + } + } + } } } diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py index 5970ec20cb8..d37cd54ce1a 100644 --- a/homeassistant/components/kmtronic/switch.py +++ b/homeassistant/components/kmtronic/switch.py @@ -3,19 +3,19 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_COORDINATOR, DATA_HOST, DATA_HUB, DOMAIN +from .const import CONF_REVERSE, DATA_COORDINATOR, 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] + reverse = entry.options.get(CONF_REVERSE, False) await hub.async_get_relays() async_add_entities( [ - KMtronicSwitch(coordinator, host, relay, entry.unique_id) + KMtronicSwitch(coordinator, relay, reverse, entry.entry_id) for relay in hub.relays ] ) @@ -24,17 +24,12 @@ async def async_setup_entry(hass, entry, async_add_entities): class KMtronicSwitch(CoordinatorEntity, SwitchEntity): """KMtronic Switch Entity.""" - def __init__(self, coordinator, host, relay, config_entry_id): + def __init__(self, coordinator, relay, reverse, 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 + self._reverse = reverse @property def name(self) -> str: @@ -46,22 +41,25 @@ class KMtronicSwitch(CoordinatorEntity, SwitchEntity): """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.""" + if self._reverse: + return not self._relay.is_on return self._relay.is_on async def async_turn_on(self, **kwargs) -> None: """Turn the switch on.""" - await self._relay.turn_on() + if self._reverse: + await self._relay.turn_off() + else: + 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() + if self._reverse: + await self._relay.turn_on() + else: + await self._relay.turn_off() self.async_write_ha_state() diff --git a/homeassistant/components/kmtronic/translations/en.json b/homeassistant/components/kmtronic/translations/en.json index f15fe84c3ed..0a1bde9fb19 100644 --- a/homeassistant/components/kmtronic/translations/en.json +++ b/homeassistant/components/kmtronic/translations/en.json @@ -13,7 +13,17 @@ "data": { "host": "Host", "password": "Password", - "username": "Username" + "username": "Username", + "reverse": "Reverse switch logic (use NC)" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "reverse": "Reverse switch logic (use NC)" } } } diff --git a/tests/components/kmtronic/test_config_flow.py b/tests/components/kmtronic/test_config_flow.py index ebbbf626451..b5ebdc79c8b 100644 --- a/tests/components/kmtronic/test_config_flow.py +++ b/tests/components/kmtronic/test_config_flow.py @@ -3,9 +3,9 @@ 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 homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.kmtronic.const import CONF_REVERSE, DOMAIN +from homeassistant.config_entries import ENTRY_STATE_LOADED from tests.common import MockConfigEntry @@ -49,6 +49,43 @@ async def test_form(hass): assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_options(hass, aioclient_mock): + """Test that the options form.""" + 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() + + assert config_entry.state == ENTRY_STATE_LOADED + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_REVERSE: True} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_REVERSE: True} + + await hass.async_block_till_done() + + assert config_entry.state == "loaded" + + async def test_form_invalid_auth(hass): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -116,30 +153,3 @@ async def test_form_unknown_error(hass): 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_init.py b/tests/components/kmtronic/test_init.py new file mode 100644 index 00000000000..1b9cf7cb407 --- /dev/null +++ b/tests/components/kmtronic/test_init.py @@ -0,0 +1,62 @@ +"""The tests for the KMtronic component.""" +import asyncio + +from homeassistant.components.kmtronic.const import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) + +from tests.common import MockConfigEntry + + +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() + + 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 + + +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 diff --git a/tests/components/kmtronic/test_switch.py b/tests/components/kmtronic/test_switch.py index 5eec3537176..df8ecda2c2e 100644 --- a/tests/components/kmtronic/test_switch.py +++ b/tests/components/kmtronic/test_switch.py @@ -3,7 +3,6 @@ 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 @@ -88,24 +87,6 @@ async def test_update(hass, aioclient_mock): 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() @@ -148,3 +129,51 @@ async def test_failed_update(hass, aioclient_mock): await hass.async_block_till_done() state = hass.states.get("switch.relay1") assert state.state == STATE_UNAVAILABLE + + +async def test_relay_on_off_reversed(hass, aioclient_mock): + """Tests the relay turns on correctly when configured as reverse.""" + + 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"}, + options={"reverse": True}, + ).add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Mocks the response for turning a relay1 off + aioclient_mock.get( + "http://1.1.1.1/FF0101", + text="", + ) + + state = hass.states.get("switch.relay1") + assert state.state == "on" + + 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" + + # 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_on", {"entity_id": "switch.relay1"}, blocking=True + ) + + await hass.async_block_till_done() + state = hass.states.get("switch.relay1") + assert state.state == "on"