diff --git a/CODEOWNERS b/CODEOWNERS index e518f19c9f9..78c2c9616f2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -89,6 +89,7 @@ homeassistant/components/demo/* @home-assistant/core homeassistant/components/denonavr/* @scarface-4711 @starkillerOG homeassistant/components/derivative/* @afaucogney homeassistant/components/device_automation/* @home-assistant/core +homeassistant/components/devolo_home_control/* @2Fake @Shutgun homeassistant/components/digital_ocean/* @fabaff homeassistant/components/directv/* @ctalkington homeassistant/components/discogs/* @thibmaek diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py new file mode 100644 index 00000000000..cfe1549f3c4 --- /dev/null +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -0,0 +1,82 @@ +"""The devolo_home_control integration.""" +from functools import partial + +from devolo_home_control_api.homecontrol import HomeControl +from devolo_home_control_api.mydevolo import Mydevolo + +from homeassistant.components import switch as ha_switch +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.typing import HomeAssistantType + +from .const import CONF_HOMECONTROL, CONF_MYDEVOLO, DOMAIN, PLATFORMS + +SUPPORTED_PLATFORMS = [ha_switch.DOMAIN] + + +async def async_setup(hass, config): + """Get all devices and add them to hass.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up the devolo account from a config entry.""" + conf = entry.data + hass.data.setdefault(DOMAIN, {}) + try: + mydevolo = Mydevolo.get_instance() + except SyntaxError: + mydevolo = Mydevolo() + + mydevolo.user = conf[CONF_USERNAME] + mydevolo.password = conf[CONF_PASSWORD] + mydevolo.url = conf[CONF_MYDEVOLO] + mydevolo.mprm = conf[CONF_HOMECONTROL] + + credentials_valid = await hass.async_add_executor_job(mydevolo.credentials_valid) + + if not credentials_valid: + return False + + if await hass.async_add_executor_job(mydevolo.maintenance): + raise ConfigEntryNotReady + + gateway_ids = await hass.async_add_executor_job(mydevolo.get_gateway_ids) + gateway_id = gateway_ids[0] + mprm_url = mydevolo.mprm + + try: + hass.data[DOMAIN]["homecontrol"] = await hass.async_add_executor_job( + partial(HomeControl, gateway_id=gateway_id, url=mprm_url) + ) + except ConnectionError: + raise ConfigEntryNotReady + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + def shutdown(event): + hass.data[DOMAIN]["homecontrol"].websocket_disconnect( + f"websocket disconnect requested by {EVENT_HOMEASSISTANT_STOP}" + ) + + # Listen when EVENT_HOMEASSISTANT_STOP is fired + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + unload = await hass.config_entries.async_forward_entry_unload( + config_entry, "switch" + ) + + await hass.async_add_executor_job( + hass.data[DOMAIN]["homecontrol"].websocket_disconnect + ) + del hass.data[DOMAIN]["homecontrol"] + return unload diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py new file mode 100644 index 00000000000..d104bdde275 --- /dev/null +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow to configure the devolo home control integration.""" +import logging + +from devolo_home_control_api.mydevolo import Mydevolo +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback + +from .const import ( # pylint:disable=unused-import + CONF_HOMECONTROL, + CONF_MYDEVOLO, + DEFAULT_MPRM, + DEFAULT_MYDEVOLO, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a devolo HomeControl config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + def __init__(self): + """Initialize devolo Home Control flow.""" + self.data_schema = { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_MYDEVOLO, default=DEFAULT_MYDEVOLO): str, + vol.Required(CONF_HOMECONTROL, default=DEFAULT_MPRM): str, + } + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_form(user_input) + user = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + try: + mydevolo = Mydevolo.get_instance() + except SyntaxError: + mydevolo = Mydevolo() + mydevolo.user = user + mydevolo.password = password + mydevolo.url = user_input[CONF_MYDEVOLO] + mydevolo.mprm = user_input[CONF_HOMECONTROL] + credentials_valid = await self.hass.async_add_executor_job( + mydevolo.credentials_valid + ) + if not credentials_valid: + return self._show_form({"base": "invalid_credentials"}) + _LOGGER.debug("Credentials valid") + gateway_ids = await self.hass.async_add_executor_job(mydevolo.get_gateway_ids) + await self.async_set_unique_id(gateway_ids[0]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="devolo Home Control", + data={ + CONF_PASSWORD: password, + CONF_USERNAME: user, + CONF_MYDEVOLO: mydevolo.url, + CONF_HOMECONTROL: mydevolo.mprm, + }, + ) + + @callback + def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.data_schema), + errors=errors if errors else {}, + ) diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py new file mode 100644 index 00000000000..0d5bb9a3356 --- /dev/null +++ b/homeassistant/components/devolo_home_control/const.py @@ -0,0 +1,8 @@ +"""Constants for the devolo_home_control integration.""" + +DOMAIN = "devolo_home_control" +DEFAULT_MYDEVOLO = "https://www.mydevolo.com" +DEFAULT_MPRM = "https://homecontrol.mydevolo.com" +PLATFORMS = ["switch"] +CONF_MYDEVOLO = "mydevolo_url" +CONF_HOMECONTROL = "home_control_url" diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json new file mode 100644 index 00000000000..e3a4e2f8720 --- /dev/null +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "devolo_home_control", + "name": "devolo_home_control", + "documentation": "https://www.home-assistant.io/integrations/devolo_home_control", + "requirements": ["devolo-home-control-api==0.10.0"], + "config_flow": true, + "codeowners": [ + "@2Fake", + "@Shutgun"], + "quality_scale": "silver" +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json new file mode 100644 index 00000000000..71b31dc2ebf --- /dev/null +++ b/homeassistant/components/devolo_home_control/strings.json @@ -0,0 +1,23 @@ +{ + "title": "devolo Home Control", + "config": { + "abort": { + "already_configured": "This Home Control Central is already configured." + }, + "error": { + "invalid_credentials": "Incorrect user name and/or password." + }, + "step": { + "user": { + "data": { + "username": "Username", + "password": "Password", + "Mydevolo_URL": "mydevolo URL", + "Home_Control_URL": "Home Control URL" + }, + "description": "Set up your devolo Home Control.", + "title": "devolo Home Control" + } + } + } +} diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py new file mode 100644 index 00000000000..7a7cd7a583b --- /dev/null +++ b/homeassistant/components/devolo_home_control/switch.py @@ -0,0 +1,152 @@ +"""Platform for light integration.""" +import logging + +from homeassistant.components.switch import SwitchDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Get all devices and setup the switch devices via config entry.""" + devices = hass.data[DOMAIN]["homecontrol"].binary_switch_devices + + entities = [] + for device in devices: + for binary_switch in device.binary_switch_property: + entities.append( + DevoloSwitch( + homecontrol=hass.data[DOMAIN]["homecontrol"], + device_instance=device, + element_uid=binary_switch, + ) + ) + async_add_entities(entities) + + +class DevoloSwitch(SwitchDevice): + """Representation of an Awesome Light.""" + + def __init__(self, homecontrol, device_instance, element_uid): + """Initialize an devolo Switch.""" + self._device_instance = device_instance + + # Create the unique ID + self._unique_id = element_uid + + self._homecontrol = homecontrol + self._name = self._device_instance.itemName + self._available = self._device_instance.is_online() + + # Get the brand and model information + self._brand = self._device_instance.brand + self._model = self._device_instance.name + + self._binary_switch_property = self._device_instance.binary_switch_property.get( + self._unique_id + ) + self._is_on = self._binary_switch_property.state + + if hasattr(self._device_instance, "consumption_property"): + self._consumption = self._device_instance.consumption_property.get( + self._unique_id.replace("BinarySwitch", "Meter") + ).current + else: + self._consumption = None + + self.subscriber = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.subscriber = Subscriber(self._device_instance.itemName, callback=self.sync) + self._homecontrol.publisher.register( + self._device_instance.uid, self.subscriber, self.sync + ) + + @property + def unique_id(self): + """Return the unique ID of the switch.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._device_instance.uid)}, + "name": self.name, + "manufacturer": self._brand, + "model": self._model, + } + + @property + def device_id(self): + """Return the ID of this switch.""" + return self._unique_id + + @property + def name(self): + """Return the display name of this switch.""" + return self._name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def is_on(self): + """Return the state.""" + return self._is_on + + @property + def current_power_w(self): + """Return the current consumption.""" + return self._consumption + + @property + def available(self): + """Return the online state.""" + return self._available + + def turn_on(self, **kwargs): + """Switch on the device.""" + self._is_on = True + self._binary_switch_property.set_binary_switch(state=True) + + def turn_off(self, **kwargs): + """Switch off the device.""" + self._is_on = False + self._binary_switch_property.set_binary_switch(state=False) + + def sync(self, message=None): + """Update the binary switch state and consumption.""" + if message[0].startswith("devolo.BinarySwitch"): + self._is_on = self._device_instance.binary_switch_property[message[0]].state + elif message[0].startswith("devolo.Meter"): + self._consumption = self._device_instance.consumption_property[ + message[0] + ].current + elif message[0].startswith("hdm"): + self._available = self._device_instance.is_online() + else: + _LOGGER.debug("No valid message received: %s", message) + self.schedule_update_ha_state() + + +class Subscriber: + """Subscriber class for the publisher in mprm websocket class.""" + + def __init__(self, name, callback): + """Initiate the device.""" + self.name = name + self.callback = callback + + def update(self, message): + """Trigger hass to update the device.""" + _LOGGER.debug('%s got message "%s"', self.name, message) + self.callback(message) diff --git a/homeassistant/components/devolo_home_control/translations/en.json b/homeassistant/components/devolo_home_control/translations/en.json new file mode 100644 index 00000000000..a320a45699f --- /dev/null +++ b/homeassistant/components/devolo_home_control/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "This Home Control Central is already configured." + }, + "error": { + "invalid_credentials": "Incorrect user name and/or password." + }, + "step": { + "user": { + "data": { + "Home_Control_URL": "Home Control URL", + "Mydevolo_URL": "mydevolo URL", + "password": "Password", + "username": "Username" + }, + "description": "Set up your devolo Home Control.", + "title": "devolo Home Control" + } + } + }, + "title": "devolo Home Control" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9189af4cdd3..eca4a938f6e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -25,6 +25,7 @@ FLOWS = [ "coronavirus", "daikin", "deconz", + "devolo_home_control", "dialogflow", "directv", "doorbird", diff --git a/requirements_all.txt b/requirements_all.txt index 4670a3243b6..8b6317f5608 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -459,6 +459,9 @@ deluge-client==1.7.1 # homeassistant.components.denonavr denonavr==0.8.1 +# homeassistant.components.devolo_home_control +devolo-home-control-api==0.10.0 + # homeassistant.components.directv directv==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b83aa29a53..f4f9a5d63bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -193,6 +193,9 @@ defusedxml==0.6.0 # homeassistant.components.denonavr denonavr==0.8.1 +# homeassistant.components.devolo_home_control +devolo-home-control-api==0.10.0 + # homeassistant.components.directv directv==0.3.0 diff --git a/tests/components/devolo_home_control/__init__.py b/tests/components/devolo_home_control/__init__.py new file mode 100644 index 00000000000..5e1e323cad8 --- /dev/null +++ b/tests/components/devolo_home_control/__init__.py @@ -0,0 +1 @@ +"""Tests for the devolo_home_control integration.""" diff --git a/tests/components/devolo_home_control/test_config_flow.py b/tests/components/devolo_home_control/test_config_flow.py new file mode 100644 index 00000000000..aacf33b69c1 --- /dev/null +++ b/tests/components/devolo_home_control/test_config_flow.py @@ -0,0 +1,102 @@ +"""Test the devolo_home_control config flow.""" +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.devolo_home_control.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER + +from tests.async_mock import patch +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.devolo_home_control.async_setup", return_value=True, + ) as mock_setup, patch( + "homeassistant.components.devolo_home_control.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.devolo_home_control.config_flow.Mydevolo.credentials_valid", + return_value=True, + ), patch( + "homeassistant.components.devolo_home_control.config_flow.Mydevolo.get_gateway_ids", + return_value=["123456"], + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "home_control_url": "https://homecontrol.mydevolo.com", + "mydevolo_url": "https://www.mydevolo.com", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "devolo Home Control" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + "home_control_url": "https://homecontrol.mydevolo.com", + "mydevolo_url": "https://www.mydevolo.com", + } + + 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_credentials(hass): + """Test if we get the error message on invalid credentials.""" + 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.devolo_home_control.config_flow.Mydevolo.credentials_valid", + return_value=False, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "home_control_url": "https://homecontrol.mydevolo.com", + "mydevolo_url": "https://www.mydevolo.com", + }, + ) + + assert result["errors"] == {"base": "invalid_credentials"} + + +async def test_form_already_configured(hass): + """Test if we get the error message on already configured.""" + with patch( + "homeassistant.components.devolo_home_control.config_flow.Mydevolo.get_gateway_ids", + return_value=["1234567"], + ), patch( + "homeassistant.components.devolo_home_control.config_flow.Mydevolo.credentials_valid", + return_value=True, + ): + MockConfigEntry(domain=DOMAIN, unique_id="1234567", data={}).add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + "username": "test-username", + "password": "test-password", + "home_control_url": "https://homecontrol.mydevolo.com", + "mydevolo_url": "https://www.mydevolo.com", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured"