diff --git a/.coveragerc b/.coveragerc index 0c48b0ebb25..26f05a7816a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -935,6 +935,7 @@ omit = homeassistant/components/sense/sensor.py homeassistant/components/sensehat/light.py homeassistant/components/sensehat/sensor.py + homeassistant/components/sensibo/__init__.py homeassistant/components/sensibo/climate.py homeassistant/components/serial/sensor.py homeassistant/components/serial_pm/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index b38a168d850..75149c273e6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -793,7 +793,8 @@ homeassistant/components/select/* @home-assistant/core tests/components/select/* @home-assistant/core homeassistant/components/sense/* @kbickar tests/components/sense/* @kbickar -homeassistant/components/sensibo/* @andrey-git +homeassistant/components/sensibo/* @andrey-git @gjohansson-ST +tests/components/sensibo/* @andrey-git @gjohansson-ST homeassistant/components/sentry/* @dcramer @frenck tests/components/sentry/* @dcramer @frenck homeassistant/components/serial/* @fabaff diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index 41959bdc9b2..c384c826859 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -1 +1,62 @@ """The sensibo component.""" +from __future__ import annotations + +import asyncio +import logging + +import aiohttp +import async_timeout +import pysensibo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import _INITIAL_FETCH_FIELDS, DOMAIN, PLATFORMS, TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Sensibo from a config entry.""" + client = pysensibo.SensiboClient( + entry.data[CONF_API_KEY], session=async_get_clientsession(hass), timeout=TIMEOUT + ) + devicelist = [] + try: + async with async_timeout.timeout(TIMEOUT): + for dev in await client.async_get_devices(_INITIAL_FETCH_FIELDS): + devicelist.append(dev) + except ( + aiohttp.client_exceptions.ClientConnectorError, + asyncio.TimeoutError, + pysensibo.SensiboError, + ) as err: + raise ConfigEntryNotReady( + f"Failed to get devices from Sensibo servers: {err}" + ) from err + + if not devicelist: + return False + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + "devices": devicelist, + "client": client, + } + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + _LOGGER.debug("Loaded entry for %s", entry.title) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Sensibo config entry.""" + if await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + _LOGGER.debug("Unloaded entry for %s", entry.title) + return True + return False diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 7104d9ebff7..60ea483865f 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -8,7 +8,10 @@ import async_timeout import pysensibo import voluptuous as vol -from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity +from homeassistant.components.climate import ( + PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + ClimateEntity, +) from homeassistant.components.climate.const import ( HVAC_MODE_COOL, HVAC_MODE_DRY, @@ -20,6 +23,7 @@ from homeassistant.components.climate.const import ( SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_STATE, @@ -30,21 +34,22 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, ) -from homeassistant.exceptions import PlatformNotReady +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + ConfigType, + DiscoveryInfoType, +) from homeassistant.util.temperature import convert as convert_temperature -from .const import DOMAIN as SENSIBO_DOMAIN +from .const import _FETCH_FIELDS, ALL, DOMAIN, TIMEOUT _LOGGER = logging.getLogger(__name__) -ALL = ["all"] -TIMEOUT = 8 - SERVICE_ASSUME_STATE = "assume_state" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_ID, default=ALL): vol.All(cv.ensure_list, [cv.string]), @@ -55,18 +60,6 @@ ASSUME_STATE_SCHEMA = vol.Schema( {vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_STATE): cv.string} ) -_FETCH_FIELDS = ",".join( - [ - "room{name}", - "measurements", - "remoteCapabilities", - "acState", - "connectionStatus{isAlive}", - "temperatureUnit", - ] -) -_INITIAL_FETCH_FIELDS = f"id,{_FETCH_FIELDS}" - FIELD_TO_FLAG = { "fanLevel": SUPPORT_FAN_MODE, "swing": SUPPORT_SWING_MODE, @@ -84,29 +77,38 @@ SENSIBO_TO_HA = { HA_TO_SENSIBO = {value: key for key, value in SENSIBO_TO_HA.items()} -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +): """Set up Sensibo devices.""" - client = pysensibo.SensiboClient( - config[CONF_API_KEY], session=async_get_clientsession(hass), timeout=TIMEOUT + _LOGGER.warning( + "Loading Sensibo via platform setup is deprecated; Please remove it from your configuration" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) ) - devices = [] - try: - async with async_timeout.timeout(TIMEOUT): - for dev in await client.async_get_devices(_INITIAL_FETCH_FIELDS): - if config[CONF_ID] == ALL or dev["id"] in config[CONF_ID]: - devices.append( - SensiboClimate(client, dev, hass.config.units.temperature_unit) - ) - except ( - aiohttp.client_exceptions.ClientConnectorError, - asyncio.TimeoutError, - pysensibo.SensiboError, - ) as err: - _LOGGER.error("Failed to get devices from Sensibo servers") - raise PlatformNotReady from err - if not devices: - return + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Sensibo climate entry.""" + + data = hass.data[DOMAIN][entry.entry_id] + client = data["client"] + devicelist = data["devices"] + + devices = [ + SensiboClimate(client, dev, hass.config.units.temperature_unit) + for dev in devicelist + ] async_add_entities(devices) @@ -128,7 +130,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= await asyncio.wait(update_tasks) hass.services.async_register( - SENSIBO_DOMAIN, + DOMAIN, SERVICE_ASSUME_STATE, async_assume_state, schema=ASSUME_STATE_SCHEMA, diff --git a/homeassistant/components/sensibo/config_flow.py b/homeassistant/components/sensibo/config_flow.py new file mode 100644 index 00000000000..0d9e7880f38 --- /dev/null +++ b/homeassistant/components/sensibo/config_flow.py @@ -0,0 +1,91 @@ +"""Adds config flow for Sensibo integration.""" +from __future__ import annotations + +import asyncio +import logging + +import aiohttp +import async_timeout +from pysensibo import SensiboClient, SensiboError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import _INITIAL_FETCH_FIELDS, DEFAULT_NAME, DOMAIN, TIMEOUT + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +async def async_validate_api(hass: HomeAssistant, api_key: str) -> bool: + """Get data from API.""" + client = SensiboClient( + api_key, + session=async_get_clientsession(hass), + timeout=TIMEOUT, + ) + + try: + async with async_timeout.timeout(TIMEOUT): + if await client.async_get_devices(_INITIAL_FETCH_FIELDS): + return True + except ( + aiohttp.ClientConnectionError, + asyncio.TimeoutError, + SensiboError, + ) as err: + _LOGGER.error("Failed to get devices from Sensibo servers %s", err) + return False + + +class SensiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sensibo integration.""" + + VERSION = 1 + + async def async_step_import(self, config: dict): + """Import a configuration from config.yaml.""" + + self.context.update( + {"title_placeholders": {"Sensibo": f"YAML import {DOMAIN}"}} + ) + if CONF_NAME not in config: + config[CONF_NAME] = DEFAULT_NAME + return await self.async_step_user(user_input=config) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + + errors: dict[str, str] = {} + + if user_input is not None: + + api_key = user_input[CONF_API_KEY] + name = user_input[CONF_NAME] + + await self.async_set_unique_id(api_key) + self._abort_if_unique_id_configured() + + validate = await async_validate_api(self.hass, api_key) + if validate: + return self.async_create_entry( + title=name, + data={CONF_NAME: name, CONF_API_KEY: api_key}, + ) + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/sensibo/const.py b/homeassistant/components/sensibo/const.py index 383eca59f47..45d53df2d80 100644 --- a/homeassistant/components/sensibo/const.py +++ b/homeassistant/components/sensibo/const.py @@ -1,3 +1,18 @@ """Constants for Sensibo.""" DOMAIN = "sensibo" +PLATFORMS = ["climate"] +ALL = ["all"] +DEFAULT_NAME = "Sensibo@Home" +TIMEOUT = 8 +_FETCH_FIELDS = ",".join( + [ + "room{name}", + "measurements", + "remoteCapabilities", + "acState", + "connectionStatus{isAlive}", + "temperatureUnit", + ] +) +_INITIAL_FETCH_FIELDS = f"id,{_FETCH_FIELDS}" diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index 3cea31c5d5e..bf0142628b4 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -3,6 +3,10 @@ "name": "Sensibo", "documentation": "https://www.home-assistant.io/integrations/sensibo", "requirements": ["pysensibo==1.0.3"], - "codeowners": ["@andrey-git"], - "iot_class": "cloud_polling" + "config_flow": true, + "codeowners": ["@andrey-git", "@gjohansson-ST"], + "iot_class": "cloud_polling", + "homekit": { + "models": ["Sensibo"] + } } diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json new file mode 100644 index 00000000000..22751964999 --- /dev/null +++ b/homeassistant/components/sensibo/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error":{ + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "name": "[%key:common::config_flow::data::name%]" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/en.json b/homeassistant/components/sensibo/translations/en.json new file mode 100644 index 00000000000..4d07dadc086 --- /dev/null +++ b/homeassistant/components/sensibo/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error":{ + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "name": "Name" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f1474f415d0..473caaa44c8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -263,6 +263,7 @@ FLOWS = [ "samsungtv", "screenlogic", "sense", + "sensibo", "sentry", "sharkiq", "shelly", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index d5b8839bd77..bc4a83f3261 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -369,6 +369,7 @@ HOMEKIT = { "Presence": "netatmo", "Rachio": "rachio", "SPK5": "rainmachine", + "Sensibo": "sensibo", "Smart Bridge": "lutron_caseta", "Socket": "wemo", "TRADFRI": "tradfri", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 88f43c270b1..4aa16511c71 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1102,6 +1102,9 @@ pyrituals==0.0.6 # homeassistant.components.ruckus_unleashed pyruckus==0.12 +# homeassistant.components.sensibo +pysensibo==1.0.3 + # homeassistant.components.serial # homeassistant.components.zha pyserial-asyncio==0.5 diff --git a/tests/components/sensibo/__init__.py b/tests/components/sensibo/__init__.py new file mode 100644 index 00000000000..8dd2ed661bc --- /dev/null +++ b/tests/components/sensibo/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sensibo integration.""" diff --git a/tests/components/sensibo/test_config_flow.py b/tests/components/sensibo/test_config_flow.py new file mode 100644 index 00000000000..b277ed80e96 --- /dev/null +++ b/tests/components/sensibo/test_config_flow.py @@ -0,0 +1,155 @@ +"""Test the Sensibo config flow.""" +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +import aiohttp +from pysensibo import SensiboError +import pytest + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + +DOMAIN = "sensibo" + + +def devices(): + """Return list of test devices.""" + return (yield from [{"id": "xyzxyz"}, {"id": "abcabc"}]) + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", + return_value=devices(), + ), patch( + "homeassistant.components.sensibo.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Sensibo@Home", + CONF_API_KEY: "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + "name": "Sensibo@Home", + "api_key": "1234567890", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + + with patch( + "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", + return_value=devices(), + ), patch( + "homeassistant.components.sensibo.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Sensibo@Home" + assert result2["data"] == { + "name": "Sensibo@Home", + "api_key": "1234567890", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_already_exist(hass: HomeAssistant) -> None: + """Test import of yaml already exist.""" + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: "Sensibo@Home", + CONF_API_KEY: "1234567890", + }, + unique_id="1234567890", + ).add_to_hass(hass) + + with patch( + "homeassistant.components.sensibo.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", + return_value=devices(), + ): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_API_KEY: "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "error_message", + [ + (aiohttp.ClientConnectionError), + (asyncio.TimeoutError), + (SensiboError), + ], +) +async def test_flow_fails(hass: HomeAssistant, error_message) -> None: + """Test config flow errors.""" + + result4 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result4["type"] == RESULT_TYPE_FORM + assert result4["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.sensibo.config_flow.SensiboClient.async_get_devices", + side_effect=error_message, + ): + result4 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input={ + CONF_NAME: "Sensibo@Home", + CONF_API_KEY: "1234567890", + }, + ) + + assert result4["errors"] == {"base": "cannot_connect"}