From ca2bc9906d72e1bf1ab058df3c2e4492f5ecdcb8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Aug 2020 12:43:31 +0200 Subject: [PATCH] Add Shelly integration (#39178) --- .coveragerc | 2 + CODEOWNERS | 1 + homeassistant/components/shelly/__init__.py | 187 ++++++++++++++++++ .../components/shelly/config_flow.py | 129 ++++++++++++ homeassistant/components/shelly/const.py | 3 + homeassistant/components/shelly/manifest.json | 9 + homeassistant/components/shelly/strings.json | 24 +++ homeassistant/components/shelly/switch.py | 71 +++++++ .../components/shelly/translations/en.json | 24 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 3 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/shelly/__init__.py | 1 + tests/components/shelly/test_config_flow.py | 162 +++++++++++++++ tests/test_config_entries.py | 1 + 16 files changed, 624 insertions(+) create mode 100644 homeassistant/components/shelly/__init__.py create mode 100644 homeassistant/components/shelly/config_flow.py create mode 100644 homeassistant/components/shelly/const.py create mode 100644 homeassistant/components/shelly/manifest.json create mode 100644 homeassistant/components/shelly/strings.json create mode 100644 homeassistant/components/shelly/switch.py create mode 100644 homeassistant/components/shelly/translations/en.json create mode 100644 tests/components/shelly/__init__.py create mode 100644 tests/components/shelly/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index bccf6bbf2ea..8c67762250e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -754,6 +754,8 @@ omit = homeassistant/components/seventeentrack/sensor.py homeassistant/components/shiftr/* homeassistant/components/shodan/sensor.py + homeassistant/components/shelly/__init__.py + homeassistant/components/shelly/switch.py homeassistant/components/sht31/sensor.py homeassistant/components/sigfox/sensor.py homeassistant/components/simplepush/notify.py diff --git a/CODEOWNERS b/CODEOWNERS index de2fae6e532..d91591a4526 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -367,6 +367,7 @@ homeassistant/components/serial/* @fabaff homeassistant/components/seven_segments/* @fabaff homeassistant/components/seventeentrack/* @bachya homeassistant/components/shell_command/* @home-assistant/core +homeassistant/components/shelly/* @balloob homeassistant/components/shiftr/* @fabaff homeassistant/components/shodan/* @fabaff homeassistant/components/sighthound/* @robmarkcole diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py new file mode 100644 index 00000000000..4829aeb49f2 --- /dev/null +++ b/homeassistant/components/shelly/__init__.py @@ -0,0 +1,187 @@ +"""The Shelly integration.""" +import asyncio +from datetime import timedelta +import logging + +from aiocoap import error as aiocoap_error +import aioshelly +import async_timeout + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import ( + aiohttp_client, + device_registry, + entity, + update_coordinator, +) + +from .const import DOMAIN + +PLATFORMS = ["switch"] +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Shelly component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Shelly from a config entry.""" + try: + async with async_timeout.timeout(5): + device = await aioshelly.Device.create( + entry.data["host"], aiohttp_client.async_get_clientsession(hass) + ) + except (asyncio.TimeoutError, OSError): + raise ConfigEntryNotReady + + wrapper = hass.data[DOMAIN][entry.entry_id] = ShellyDeviceWrapper( + hass, entry, device + ) + await wrapper.async_setup() + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): + """Wrapper for a Shelly device with Home Assistant specific functions.""" + + def __init__(self, hass, entry, device: aioshelly.Device): + """Initialize the Shelly device wrapper.""" + super().__init__( + hass, + _LOGGER, + name=device.settings["name"] or entry.title, + update_interval=timedelta(seconds=5), + ) + self.hass = hass + self.entry = entry + self.device = device + self._unsub_stop = None + + async def _async_update_data(self): + """Fetch data.""" + # Race condition on shutdown. Stop all the fetches. + if self._unsub_stop is None: + return None + + try: + async with async_timeout.timeout(5): + return await self.device.update() + except aiocoap_error.Error: + raise update_coordinator.UpdateFailed("Error fetching data") + + @property + def model(self): + """Model of the device.""" + return self.device.settings["device"]["type"] + + @property + def mac(self): + """Mac address of the device.""" + return self.device.settings["device"]["mac"] + + async def async_setup(self): + """Set up the wrapper.""" + self._unsub_stop = self.hass.bus.async_listen( + EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop + ) + dev_reg = await device_registry.async_get_registry(self.hass) + model_type = self.device.settings["device"]["type"] + dev_reg.async_get_or_create( + config_entry_id=self.entry.entry_id, + name=self.name, + connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)}, + # This is duplicate but otherwise via_device can't work + identifiers={(DOMAIN, self.mac)}, + manufacturer="Shelly", + model=aioshelly.MODEL_NAMES.get(model_type, model_type), + sw_version=self.device.settings["fw"], + ) + + async def shutdown(self): + """Shutdown the device wrapper.""" + if self._unsub_stop: + self._unsub_stop() + self._unsub_stop = None + await self.device.shutdown() + + async def _handle_ha_stop(self, _): + """Handle Home Assistant stopping.""" + self._unsub_stop = None + await self.shutdown() + + +class ShellyBlockEntity(entity.Entity): + """Helper class to represent a block.""" + + def __init__(self, wrapper: ShellyDeviceWrapper, block): + """Initialize Shelly entity.""" + self.wrapper = wrapper + self.block = block + + @property + def name(self): + """Name of entity.""" + return f"{self.wrapper.name} - {self.block.description}" + + @property + def should_poll(self): + """If device should be polled.""" + return False + + @property + def device_info(self): + """Device info.""" + return { + "connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)} + } + + @property + def available(self): + """Available.""" + return self.wrapper.last_update_success + + @property + def unique_id(self): + """Return unique ID of entity.""" + return f"{self.wrapper.mac}-{self.block.index}" + + async def async_added_to_hass(self): + """When entity is added to HASS.""" + self.async_on_remove(self.wrapper.async_add_listener(self._update_callback)) + + async def async_update(self): + """Update entity with latest info.""" + await self.wrapper.async_request_refresh() + + @callback + def _update_callback(self): + """Handle device update.""" + self.async_write_ha_state() + + +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: + await hass.data[DOMAIN].pop(entry.entry_id).shutdown() + + return unload_ok diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py new file mode 100644 index 00000000000..c58bcf8c89a --- /dev/null +++ b/homeassistant/components/shelly/config_flow.py @@ -0,0 +1,129 @@ +"""Config flow for Shelly integration.""" +import asyncio +import logging + +import aiohttp +import aioshelly +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({"host": str}) + +HTTP_CONNECT_ERRORS = (asyncio.TimeoutError, aiohttp.ClientError) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + async with async_timeout.timeout(5): + device = await aioshelly.Device.create( + data["host"], aiohttp_client.async_get_clientsession(hass) + ) + + await device.shutdown() + + # Return info that you want to store in the config entry. + return {"title": device.settings["name"], "mac": device.settings["device"]["mac"]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Shelly.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + host = None + info = None + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await self._async_get_info(user_input["host"]) + except HTTP_CONNECT_ERRORS: + errors["base"] = "cannot_connect" + + else: + if info["auth"]: + return self.async_abort(reason="auth_not_supported") + + try: + device_info = await validate_input(self.hass, user_input) + except asyncio.TimeoutError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device_info["mac"]) + return self.async_create_entry( + title=device_info["title"] or user_input["host"], + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_zeroconf(self, zeroconf_info): + """Handle zeroconf discovery.""" + if not zeroconf_info.get("name", "").startswith("shelly"): + return self.async_abort(reason="not_shelly") + + try: + self.info = info = await self._async_get_info(zeroconf_info["host"]) + except HTTP_CONNECT_ERRORS: + return self.async_abort(reason="cannot_connect") + + if info["auth"]: + return self.async_abort(reason="auth_not_supported") + + await self.async_set_unique_id(info["mac"]) + self._abort_if_unique_id_configured({"host": zeroconf_info["host"]}) + self.host = zeroconf_info["host"] + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["title_placeholders"] = {"name": zeroconf_info["host"]} + return await self.async_step_confirm_discovery() + + async def async_step_confirm_discovery(self, user_input=None): + """Handle discovery confirm.""" + errors = {} + if user_input is not None: + try: + device_info = await validate_input(self.hass, {"host": self.host}) + except asyncio.TimeoutError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=device_info["title"] or self.host, data={"host": self.host} + ) + + return self.async_show_form( + step_id="confirm_discovery", + description_placeholders={ + "model": aioshelly.MODEL_NAMES.get( + self.info["type"], self.info["type"] + ), + "host": self.host, + }, + errors=errors, + ) + + async def _async_get_info(self, host): + """Get info from shelly device.""" + async with async_timeout.timeout(5): + return await aioshelly.get_info( + aiohttp_client.async_get_clientsession(self.hass), host, + ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py new file mode 100644 index 00000000000..5c9a55915fe --- /dev/null +++ b/homeassistant/components/shelly/const.py @@ -0,0 +1,3 @@ +"""Constants for the Shelly integration.""" + +DOMAIN = "shelly" diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json new file mode 100644 index 00000000000..149b8ef18cd --- /dev/null +++ b/homeassistant/components/shelly/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "shelly", + "name": "Shelly", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/shelly2", + "requirements": ["aioshelly==0.1.2"], + "zeroconf": ["_http._tcp.local."], + "codeowners": ["@balloob"] +} diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json new file mode 100644 index 00000000000..9c5f5707914 --- /dev/null +++ b/homeassistant/components/shelly/strings.json @@ -0,0 +1,24 @@ +{ + "title": "Shelly", + "config": { + "flow_title": "Shelly: {name}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "confirm_discovery": { + "description": "Do you want to set up the {model} at {host}?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "auth_not_supported": "Shelly devices requiring authentication are not currently supported." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py new file mode 100644 index 00000000000..4a6c2a21b0b --- /dev/null +++ b/homeassistant/components/shelly/switch.py @@ -0,0 +1,71 @@ +"""Switch for Shelly.""" +from homeassistant.components.shelly import ShellyBlockEntity +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import callback + +from .const import DOMAIN + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up switches for device.""" + wrapper = hass.data[DOMAIN][config_entry.entry_id] + relay_blocks = [block for block in wrapper.device.blocks if block.type == "relay"] + + if not relay_blocks: + return + + if wrapper.model == "SHSW-25" and wrapper.device.settings["mode"] != "relay": + return + + multiple_blocks = len(relay_blocks) > 1 + async_add_entities( + RelaySwitch(wrapper, block, multiple_blocks=multiple_blocks) + for block in relay_blocks + ) + + +class RelaySwitch(ShellyBlockEntity, SwitchEntity): + """Switch that controls a relay block on Shelly devices.""" + + def __init__(self, *args, multiple_blocks) -> None: + """Initialize relay switch.""" + super().__init__(*args) + self.multiple_blocks = multiple_blocks + self.control_result = None + + @property + def is_on(self) -> bool: + """If switch is on.""" + if self.control_result: + return self.control_result["ison"] + + return self.block.output + + @property + def device_info(self): + """Device info.""" + if not self.multiple_blocks: + return super().device_info + + # If a device has multiple relays, we want to expose as separate device + return { + "name": self.name, + "identifiers": {(DOMAIN, self.wrapper.mac, self.block.index)}, + "via_device": (DOMAIN, self.wrapper.mac), + } + + async def async_turn_on(self, **kwargs): + """Turn on relay.""" + self.control_result = await self.block.turn_on() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn off relay.""" + self.control_result = await self.block.turn_off() + self.async_write_ha_state() + + @callback + def _update_callback(self): + """When device updates, clear control result that overrides state.""" + self.control_result = None + super()._update_callback() diff --git a/homeassistant/components/shelly/translations/en.json b/homeassistant/components/shelly/translations/en.json new file mode 100644 index 00000000000..89660bbda1a --- /dev/null +++ b/homeassistant/components/shelly/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "auth_not_supported": "Authenticated Shelly devices are not currently supported.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "Shelly: {name}", + "step": { + "confirm_discovery": { + "description": "Do you want to set up the {model} at {host}?" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + } + }, + "title": "Shelly" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e1ab7647446..b5db34ec485 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -151,6 +151,7 @@ FLOWS = [ "samsungtv", "sense", "sentry", + "shelly", "shopping_list", "simplisafe", "smappee", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 7779fbb155e..ba12b4ec4de 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -37,6 +37,9 @@ ZEROCONF = { "_hap._tcp.local.": [ "homekit_controller" ], + "_http._tcp.local.": [ + "shelly" + ], "_ipp._tcp.local.": [ "ipp" ], diff --git a/requirements_all.txt b/requirements_all.txt index d0fd51ca3f5..153cb8117ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -221,6 +221,9 @@ aiopvpc==2.0.2 # homeassistant.components.webostv aiopylgtv==0.3.3 +# homeassistant.components.shelly +aioshelly==0.1.2 + # homeassistant.components.switcher_kis aioswitcher==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 362ed03aec6..d3f45827bc4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -131,6 +131,9 @@ aiopvpc==2.0.2 # homeassistant.components.webostv aiopylgtv==0.3.3 +# homeassistant.components.shelly +aioshelly==0.1.2 + # homeassistant.components.switcher_kis aioswitcher==1.2.0 diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py new file mode 100644 index 00000000000..3c502c81deb --- /dev/null +++ b/tests/components/shelly/__init__.py @@ -0,0 +1 @@ +"""Tests for the Shelly integration.""" diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py new file mode 100644 index 00000000000..c80397d9150 --- /dev/null +++ b/tests/components/shelly/test_config_flow.py @@ -0,0 +1,162 @@ +"""Test the Shelly config flow.""" +import asyncio + +from homeassistant import config_entries, setup +from homeassistant.components.shelly.const import DOMAIN + +from tests.async_mock import AsyncMock, Mock, 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( + "aioshelly.get_info", + return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + ), patch( + "aioshelly.Device.create", + return_value=Mock( + shutdown=AsyncMock(), + settings={"name": "Test name", "device": {"mac": "test-mac"}}, + ), + ), patch( + "homeassistant.components.shelly.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.shelly.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"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Test name" + assert result2["data"] == { + "host": "1.1.1.1", + } + 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_auth(hass): + """Test we can't manually configure if auth is required.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "aioshelly.get_info", + return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "1.1.1.1"}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "auth_not_supported" + + +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( + "aioshelly.get_info", side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "1.1.1.1"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_zeroconf(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "aioshelly.get_info", + return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data={"host": "1.1.1.1", "name": "shelly1pm-12345"}, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "aioshelly.Device.create", + return_value=Mock( + shutdown=AsyncMock(), + settings={"name": "Test name", "device": {"mac": "test-mac"}}, + ), + ), patch( + "homeassistant.components.shelly.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.shelly.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {},) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Test name" + assert result2["data"] == { + "host": "1.1.1.1", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_already_configured(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0"} + ) + entry.add_to_hass(hass) + + with patch( + "aioshelly.get_info", + return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data={"host": "1.1.1.1", "name": "shelly1pm-12345"}, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + # Test config entry got updated with latest IP + assert entry.data["host"] == "1.1.1.1" + + +async def test_zeroconf_require_auth(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "aioshelly.get_info", + return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data={"host": "1.1.1.1", "name": "shelly1pm-12345"}, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert result["type"] == "abort" + assert result["reason"] == "auth_not_supported" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 644987de43c..988e67718ef 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1127,6 +1127,7 @@ async def test_unique_id_update_existing_entry_without_reload(hass, manager): domain="comp", data={"additional": "data", "host": "0.0.0.0"}, unique_id="mock-unique-id", + state=config_entries.ENTRY_STATE_LOADED, ) entry.add_to_hass(hass)