diff --git a/.coveragerc b/.coveragerc index d42d7cbb3b3..f28e9aaeda6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -599,6 +599,8 @@ omit = homeassistant/components/solaredge/sensor.py homeassistant/components/solaredge_local/sensor.py homeassistant/components/solax/sensor.py + homeassistant/components/soma/cover.py + homeassistant/components/soma/__init__.py homeassistant/components/somfy/* homeassistant/components/somfy_mylink/* homeassistant/components/sonarr/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 4a6dfdbf6e6..db0ff3226c3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -254,6 +254,7 @@ homeassistant/components/smarty/* @z0mbieprocess homeassistant/components/smtp/* @fabaff homeassistant/components/solaredge_local/* @drobtravels @scheric homeassistant/components/solax/* @squishykid +homeassistant/components/soma/* @ratsept homeassistant/components/somfy/* @tetienne homeassistant/components/songpal/* @rytilahti homeassistant/components/spaceapi/* @fabaff diff --git a/homeassistant/components/soma/.translations/en.json b/homeassistant/components/soma/.translations/en.json new file mode 100644 index 00000000000..738d0fd6422 --- /dev/null +++ b/homeassistant/components/soma/.translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Soma Connect.", + "missing_configuration": "The Soma component is not configured. Please follow the documentation.", + "connection_error": "Connection to the specified device failed." + }, + "create_entry": { + "default": "Successfully authenticated with Soma." + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "username": "Username" + }, + "title": "Set up Soma Connect" + } + }, + "title": "Soma" + } +} diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py new file mode 100644 index 00000000000..5bf51e743e9 --- /dev/null +++ b/homeassistant/components/soma/__init__.py @@ -0,0 +1,111 @@ +"""Support for Soma Smartshades.""" +import logging + +import voluptuous as vol +from api.soma_api import SomaApi + +import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType + +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import DOMAIN, HOST, PORT, API + + +DEVICES = "devices" + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.string} + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SOMA_COMPONENTS = ["cover"] + + +async def async_setup(hass, config): + """Set up the Soma component.""" + if DOMAIN not in config: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + data=config[DOMAIN], + context={"source": config_entries.SOURCE_IMPORT}, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up Soma from a config entry.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][API] = SomaApi(entry.data[HOST], entry.data[PORT]) + devices = await hass.async_add_executor_job(hass.data[DOMAIN][API].list_devices) + hass.data[DOMAIN][DEVICES] = devices["shades"] + + for component in SOMA_COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + return True + + +class SomaEntity(Entity): + """Representation of a generic Soma device.""" + + def __init__(self, device, api): + """Initialize the Soma device.""" + self.device = device + self.api = api + self.current_position = 50 + + @property + def unique_id(self): + """Return the unique id base on the id returned by pysoma API.""" + return self.device["mac"] + + @property + def name(self): + """Return the name of the device.""" + return self.device["name"] + + @property + def device_info(self): + """Return device specific attributes. + + Implemented by platform classes. + """ + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Wazombi Labs", + } + + async def async_update(self): + """Update the device with the latest data.""" + response = await self.hass.async_add_executor_job( + self.api.get_shade_state, self.device["mac"] + ) + if response["result"] != "success": + _LOGGER.error( + "Unable to reach device %s (%s)", self.device["name"], response["msg"] + ) + return + self.current_position = 100 - response["position"] diff --git a/homeassistant/components/soma/config_flow.py b/homeassistant/components/soma/config_flow.py new file mode 100644 index 00000000000..e2f89273520 --- /dev/null +++ b/homeassistant/components/soma/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow for Soma.""" +import logging + +import voluptuous as vol +from api.soma_api import SomaApi +from requests import RequestException + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 3000 + + +class SomaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Instantiate config flow.""" + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + if user_input is None: + data = { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + + return self.async_show_form(step_id="user", data_schema=vol.Schema(data)) + + return await self.async_step_creation(user_input) + + async def async_step_creation(self, user_input=None): + """Finish config flow.""" + api = SomaApi(user_input["host"], user_input["port"]) + try: + await self.hass.async_add_executor_job(api.list_devices) + _LOGGER.info("Successfully set up Soma Connect") + return self.async_create_entry( + title="Soma Connect", + data={"host": user_input["host"], "port": user_input["port"]}, + ) + except RequestException: + _LOGGER.error("Connection to SOMA Connect failed") + return self.async_abort(reason="connection_error") + + async def async_step_import(self, user_input=None): + """Handle flow start from existing config section.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="already_setup") + return await self.async_step_creation(user_input) diff --git a/homeassistant/components/soma/const.py b/homeassistant/components/soma/const.py new file mode 100644 index 00000000000..815a0176e7e --- /dev/null +++ b/homeassistant/components/soma/const.py @@ -0,0 +1,6 @@ +"""Define constants for the Soma component.""" + +DOMAIN = "soma" +HOST = "host" +PORT = "port" +API = "api" diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py new file mode 100644 index 00000000000..1577b7f2911 --- /dev/null +++ b/homeassistant/components/soma/cover.py @@ -0,0 +1,79 @@ +"""Support for Soma Covers.""" + +import logging + +from homeassistant.components.cover import CoverDevice, ATTR_POSITION +from homeassistant.components.soma import DOMAIN, SomaEntity, DEVICES, API + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Soma cover platform.""" + + devices = hass.data[DOMAIN][DEVICES] + + async_add_entities( + [SomaCover(cover, hass.data[DOMAIN][API]) for cover in devices], True + ) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up platform. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +class SomaCover(SomaEntity, CoverDevice): + """Representation of a Soma cover device.""" + + def close_cover(self, **kwargs): + """Close the cover.""" + response = self.api.set_shade_position(self.device["mac"], 100) + if response["result"] != "success": + _LOGGER.error( + "Unable to reach device %s (%s)", self.device["name"], response["msg"] + ) + + def open_cover(self, **kwargs): + """Open the cover.""" + response = self.api.set_shade_position(self.device["mac"], 0) + if response["result"] != "success": + _LOGGER.error( + "Unable to reach device %s (%s)", self.device["name"], response["msg"] + ) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + # Set cover position to some value where up/down are both enabled + self.current_position = 50 + response = self.api.stop_shade(self.device["mac"]) + if response["result"] != "success": + _LOGGER.error( + "Unable to reach device %s (%s)", self.device["name"], response["msg"] + ) + + def set_cover_position(self, **kwargs): + """Move the cover shutter to a specific position.""" + self.current_position = kwargs[ATTR_POSITION] + response = self.api.set_shade_position( + self.device["mac"], 100 - kwargs[ATTR_POSITION] + ) + if response["result"] != "success": + _LOGGER.error( + "Unable to reach device %s (%s)", self.device["name"], response["msg"] + ) + + @property + def current_cover_position(self): + """Return the current position of cover shutter.""" + return self.current_position + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self.current_position == 0 diff --git a/homeassistant/components/soma/manifest.json b/homeassistant/components/soma/manifest.json new file mode 100644 index 00000000000..35a77c063b8 --- /dev/null +++ b/homeassistant/components/soma/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "soma", + "name": "Soma Open API", + "config_flow": true, + "documentation": "", + "dependencies": [], + "codeowners": [ + "@ratsept" + ], + "requirements": [ + "pysoma==0.0.10" + ] +} diff --git a/homeassistant/components/soma/strings.json b/homeassistant/components/soma/strings.json new file mode 100644 index 00000000000..eac817ce119 --- /dev/null +++ b/homeassistant/components/soma/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Soma account.", + "authorize_url_timeout": "Timeout generating authorize url.", + "missing_configuration": "The Soma component is not configured. Please follow the documentation." + }, + "create_entry": { + "default": "Successfully authenticated with Soma." + }, + "title": "Soma" + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ab7b339e582..21f57934e95 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -55,6 +55,7 @@ FLOWS = [ "smartthings", "smhi", "solaredge", + "soma", "somfy", "sonos", "tellduslive", diff --git a/requirements_all.txt b/requirements_all.txt index c25ca6a54fe..5482af01cc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1443,6 +1443,9 @@ pysmarty==0.8 # homeassistant.components.snmp pysnmp==4.4.11 +# homeassistant.components.soma +pysoma==0.0.10 + # homeassistant.components.sonos pysonos==0.0.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2701513a6de..801c09f322d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -349,6 +349,9 @@ pysmartapp==0.3.2 # homeassistant.components.smartthings pysmartthings==0.6.9 +# homeassistant.components.soma +pysoma==0.0.10 + # homeassistant.components.sonos pysonos==0.0.23 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 1e484e0dfc4..9991a6bc1f0 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -143,6 +143,7 @@ TEST_REQUIREMENTS = ( "pysma", "pysmartapp", "pysmartthings", + "pysoma", "pysonos", "pyspcwebgw", "python_awair", diff --git a/tests/components/soma/__init__.py b/tests/components/soma/__init__.py new file mode 100644 index 00000000000..8d84668e5ea --- /dev/null +++ b/tests/components/soma/__init__.py @@ -0,0 +1 @@ +"""Tests for the Soma component.""" diff --git a/tests/components/soma/test_config_flow.py b/tests/components/soma/test_config_flow.py new file mode 100644 index 00000000000..764a18d1b8b --- /dev/null +++ b/tests/components/soma/test_config_flow.py @@ -0,0 +1,60 @@ +"""Tests for the Soma config flow.""" +from unittest.mock import patch + +from api.soma_api import SomaApi +from requests import RequestException + +from homeassistant import data_entry_flow +from homeassistant.components.soma import config_flow, DOMAIN +from tests.common import MockConfigEntry + + +MOCK_HOST = "123.45.67.89" +MOCK_PORT = 3000 + + +async def test_form(hass): + """Test user form showing.""" + flow = config_flow.SomaFlowHandler() + flow.hass = hass + result = await flow.async_step_user() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_import_abort(hass): + """Test configuration from YAML aborting with existing entity.""" + flow = config_flow.SomaFlowHandler() + flow.hass = hass + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + result = await flow.async_step_import() + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_setup" + + +async def test_import_create(hass): + """Test configuration from YAML.""" + flow = config_flow.SomaFlowHandler() + flow.hass = hass + with patch.object(SomaApi, "list_devices", return_value={}): + result = await flow.async_step_import({"host": MOCK_HOST, "port": MOCK_PORT}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_exception(hass): + """Test if RequestException fires when no connection can be made.""" + flow = config_flow.SomaFlowHandler() + flow.hass = hass + with patch.object(SomaApi, "list_devices", side_effect=RequestException()): + result = await flow.async_step_import({"host": MOCK_HOST, "port": MOCK_PORT}) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "connection_error" + + +async def test_full_flow(hass): + """Check classic use case.""" + hass.data[DOMAIN] = {} + flow = config_flow.SomaFlowHandler() + flow.hass = hass + with patch.object(SomaApi, "list_devices", return_value={}): + result = await flow.async_step_user({"host": MOCK_HOST, "port": MOCK_PORT}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY