diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index f7986f91540..5a684a233eb 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -1,41 +1,21 @@ """Plugwise platform for Home Assistant Core.""" import asyncio -from datetime import timedelta import logging -from typing import Dict -from Plugwise_Smile.Smile import Smile -import async_timeout import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant -from .const import ( - COORDINATOR, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - UNDO_UPDATE_LISTENER, -) +from .const import ALL_PLATFORMS, DOMAIN, UNDO_UPDATE_LISTENER +from .gateway import async_setup_entry_gw CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) _LOGGER = logging.getLogger(__name__) -SENSOR_PLATFORMS = ["sensor"] -ALL_PLATFORMS = ["binary_sensor", "climate", "sensor", "switch"] - async def async_setup(hass: HomeAssistant, config: dict): """Set up the Plugwise platform.""" @@ -43,108 +23,11 @@ async def async_setup(hass: HomeAssistant, config: dict): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Plugwise Smiles from a config entry.""" - websession = async_get_clientsession(hass, verify_ssl=False) - - api = Smile( - host=entry.data[CONF_HOST], - password=entry.data[CONF_PASSWORD], - port=entry.data.get(CONF_PORT, DEFAULT_PORT), - timeout=30, - websession=websession, - ) - - try: - connected = await api.connect() - - if not connected: - _LOGGER.error("Unable to connect to Smile") - raise ConfigEntryNotReady - - except Smile.InvalidAuthentication: - _LOGGER.error("Invalid Smile ID") - return False - - except Smile.PlugwiseError as err: - _LOGGER.error("Error while communicating to device") - raise ConfigEntryNotReady from err - - except asyncio.TimeoutError as err: - _LOGGER.error("Timeout while connecting to Smile") - raise ConfigEntryNotReady from err - - update_interval = timedelta( - seconds=entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL[api.smile_type] - ) - ) - - async def async_update_data(): - """Update data via API endpoint.""" - try: - async with async_timeout.timeout(10): - await api.full_update_device() - return True - except Smile.XMLDataMissingError as err: - raise UpdateFailed("Smile update failed") from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="Smile", - update_method=async_update_data, - update_interval=update_interval, - ) - - await coordinator.async_refresh() - - if not coordinator.last_update_success: - raise ConfigEntryNotReady - - api.get_all_devices() - - if entry.unique_id is None: - if api.smile_version[0] != "1.8.0": - hass.config_entries.async_update_entry(entry, unique_id=api.smile_hostname) - - undo_listener = entry.add_update_listener(_update_listener) - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "api": api, - COORDINATOR: coordinator, - UNDO_UPDATE_LISTENER: undo_listener, - } - - device_registry = await dr.async_get_registry(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, api.gateway_id)}, - manufacturer="Plugwise", - name=entry.title, - model=f"Smile {api.smile_name}", - sw_version=api.smile_version[0], - ) - - single_master_thermostat = api.single_master_thermostat() - - platforms = ALL_PLATFORMS - if single_master_thermostat is None: - platforms = SENSOR_PLATFORMS - - for component in platforms: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, component) - ) - - return True - - -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): - """Handle options update.""" - coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] - coordinator.update_interval = timedelta( - seconds=entry.options.get(CONF_SCAN_INTERVAL) - ) + """Set up Plugwise components from a config entry.""" + if entry.data.get(CONF_HOST): + return await async_setup_entry_gw(hass, entry) + # PLACEHOLDER USB entry setup + return False async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): @@ -164,60 +47,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class SmileGateway(CoordinatorEntity): - """Represent Smile Gateway.""" - - def __init__(self, api, coordinator, name, dev_id): - """Initialise the gateway.""" - super().__init__(coordinator) - - self._api = api - self._name = name - self._dev_id = dev_id - - self._unique_id = None - self._model = None - - self._entity_name = self._name - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of the entity, if any.""" - return self._name - - @property - def device_info(self) -> Dict[str, any]: - """Return the device information.""" - - device_information = { - "identifiers": {(DOMAIN, self._dev_id)}, - "name": self._entity_name, - "manufacturer": "Plugwise", - } - - if self._model is not None: - device_information["model"] = self._model.replace("_", " ").title() - - if self._dev_id != self._api.gateway_id: - device_information["via_device"] = (DOMAIN, self._api.gateway_id) - - return device_information - - async def async_added_to_hass(self): - """Subscribe to updates.""" - self._async_process_data() - self.async_on_remove( - self.coordinator.async_add_listener(self._async_process_data) - ) - - @callback - def _async_process_data(self): - """Interpret and process API data.""" - raise NotImplementedError diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index c6e34ed46cb..baa004e688d 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -18,7 +18,6 @@ from homeassistant.components.climate.const import ( from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import callback -from . import SmileGateway from .const import ( COORDINATOR, DEFAULT_MAX_TEMP, @@ -27,6 +26,7 @@ from .const import ( SCHEDULE_OFF, SCHEDULE_ON, ) +from .gateway import SmileGateway HVAC_MODES_HEAT_ONLY = [HVAC_MODE_HEAT, HVAC_MODE_AUTO] HVAC_MODES_HEAT_COOL = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO] diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 14405062231..364f58007c6 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -14,35 +14,30 @@ from .const import ( # pylint:disable=unused-import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN, + ZEROCONF_MAP, ) _LOGGER = logging.getLogger(__name__) -ZEROCONF_MAP = { - "smile": "P1 DSMR", - "smile_thermo": "Climate (Anna)", - "smile_open_therm": "Climate (Adam)", -} - -def _base_schema(discovery_info): - """Generate base schema.""" - base_schema = {} +def _base_gw_schema(discovery_info): + """Generate base schema for gateways.""" + base_gw_schema = {} if not discovery_info: - base_schema[vol.Required(CONF_HOST)] = str - base_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int + base_gw_schema[vol.Required(CONF_HOST)] = str + base_gw_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int - base_schema[vol.Required(CONF_PASSWORD)] = str + base_gw_schema[vol.Required(CONF_PASSWORD)] = str - return vol.Schema(base_schema) + return vol.Schema(base_gw_schema) -async def validate_input(hass: core.HomeAssistant, data): +async def validate_gw_input(hass: core.HomeAssistant, data): """ - Validate the user input allows us to connect. + Validate the user input allows us to connect to the gateray. - Data has the keys from _base_schema() with values provided by the user. + Data has the keys from _base_gw_schema() with values provided by the user. """ websession = async_get_clientsession(hass, verify_ssl=False) @@ -64,6 +59,9 @@ async def validate_input(hass: core.HomeAssistant, data): return api +# PLACEHOLDER USB connection validation + + class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Plugwise Smile.""" @@ -95,8 +93,8 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } return await self.async_step_user() - async def async_step_user(self, user_input=None): - """Handle the initial step.""" + async def async_step_user_gateway(self, user_input=None): + """Handle the initial step for gateways.""" errors = {} if user_input is not None: @@ -110,7 +108,7 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") try: - api = await validate_input(self.hass, user_input) + api = await validate_gw_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" @@ -128,11 +126,19 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=api.smile_name, data=user_input) return self.async_show_form( - step_id="user", - data_schema=_base_schema(self.discovery_info), + step_id="user_gateway", + data_schema=_base_gw_schema(self.discovery_info), errors=errors or {}, ) + # PLACEHOLDER USB async_step_user_usb and async_step_user_usb_manual_paht + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + + # PLACEHOLDER USB vs Gateway Logic + return await self.async_step_user_gateway() + @staticmethod @callback def async_get_options_flow(config_entry): diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index 6feceff2d3c..056606307e5 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -1,6 +1,9 @@ """Constant for Plugwise component.""" DOMAIN = "plugwise" +SENSOR_PLATFORMS = ["sensor"] +ALL_PLATFORMS = ["binary_sensor", "climate", "sensor", "switch"] + # Sensor mapping SENSOR_MAP_MODEL = 0 SENSOR_MAP_UOM = 1 @@ -42,3 +45,9 @@ FLOW_ON_ICON = "mdi:water-pump" UNDO_UPDATE_LISTENER = "undo_update_listener" COORDINATOR = "coordinator" + +ZEROCONF_MAP = { + "smile": "P1", + "smile_thermo": "Anna", + "smile_open_therm": "Adam", +} diff --git a/homeassistant/components/plugwise/gateway.py b/homeassistant/components/plugwise/gateway.py new file mode 100644 index 00000000000..610fff18e4b --- /dev/null +++ b/homeassistant/components/plugwise/gateway.py @@ -0,0 +1,217 @@ +"""Plugwise platform for Home Assistant Core.""" + +import asyncio +from datetime import timedelta +import logging +from typing import Dict + +from Plugwise_Smile.Smile import Smile +import async_timeout +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + ALL_PLATFORMS, + COORDINATOR, + DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + SENSOR_PLATFORMS, + UNDO_UPDATE_LISTENER, +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry_gw(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Plugwise Smiles from a config entry.""" + websession = async_get_clientsession(hass, verify_ssl=False) + + api = Smile( + host=entry.data[CONF_HOST], + password=entry.data[CONF_PASSWORD], + port=entry.data.get(CONF_PORT, DEFAULT_PORT), + timeout=30, + websession=websession, + ) + + try: + connected = await api.connect() + + if not connected: + _LOGGER.error("Unable to connect to Smile") + raise ConfigEntryNotReady + + except Smile.InvalidAuthentication: + _LOGGER.error("Invalid Smile ID") + return False + + except Smile.PlugwiseError as err: + _LOGGER.error("Error while communicating to device") + raise ConfigEntryNotReady from err + + except asyncio.TimeoutError as err: + _LOGGER.error("Timeout while connecting to Smile") + raise ConfigEntryNotReady from err + + update_interval = timedelta( + seconds=entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL[api.smile_type] + ) + ) + + async def async_update_data(): + """Update data via API endpoint.""" + try: + async with async_timeout.timeout(10): + await api.full_update_device() + return True + except Smile.XMLDataMissingError as err: + raise UpdateFailed("Smile update failed") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="Smile", + update_method=async_update_data, + update_interval=update_interval, + ) + + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + api.get_all_devices() + + if entry.unique_id is None: + if api.smile_version[0] != "1.8.0": + hass.config_entries.async_update_entry(entry, unique_id=api.smile_hostname) + + undo_listener = entry.add_update_listener(_update_listener) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + "api": api, + COORDINATOR: coordinator, + UNDO_UPDATE_LISTENER: undo_listener, + } + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, api.gateway_id)}, + manufacturer="Plugwise", + name=entry.title, + model=f"Smile {api.smile_name}", + sw_version=api.smile_version[0], + ) + + single_master_thermostat = api.single_master_thermostat() + + platforms = ALL_PLATFORMS + if single_master_thermostat is None: + platforms = SENSOR_PLATFORMS + + for component in platforms: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + coordinator.update_interval = timedelta( + seconds=entry.options.get(CONF_SCAN_INTERVAL) + ) + + +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 ALL_PLATFORMS + ] + ) + ) + + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class SmileGateway(CoordinatorEntity): + """Represent Smile Gateway.""" + + def __init__(self, api, coordinator, name, dev_id): + """Initialise the gateway.""" + super().__init__(coordinator) + + self._api = api + self._name = name + self._dev_id = dev_id + + self._unique_id = None + self._model = None + + self._entity_name = self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of the entity, if any.""" + return self._name + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + + device_information = { + "identifiers": {(DOMAIN, self._dev_id)}, + "name": self._entity_name, + "manufacturer": "Plugwise", + } + + if self._model is not None: + device_information["model"] = self._model.replace("_", " ").title() + + if self._dev_id != self._api.gateway_id: + device_information["via_device"] = (DOMAIN, self._api.gateway_id) + + return device_information + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self._async_process_data() + self.async_on_remove( + self.coordinator.async_add_listener(self._async_process_data) + ) + + @callback + def _async_process_data(self): + """Interpret and process API data.""" + raise NotImplementedError diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 787f4630001..483153c6709 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -19,7 +19,6 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.entity import Entity -from . import SmileGateway from .const import ( COOL_ICON, COORDINATOR, @@ -32,6 +31,7 @@ from .const import ( SENSOR_MAP_UOM, UNIT_LUMEN, ) +from .gateway import SmileGateway _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 6a780282a13..4e6ea18017b 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -12,6 +12,13 @@ "config": { "step": { "user": { + "title": "Plugwise type", + "description": "Product:", + "data": { + "flow_type": "Connection type" + } + }, + "user_gateway": { "title": "Connect to the Smile", "description": "Please enter:", "data": { diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index a34eabe3d2e..d7b45f4999a 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -7,8 +7,8 @@ from Plugwise_Smile.Smile import Smile from homeassistant.components.switch import SwitchEntity from homeassistant.core import callback -from . import SmileGateway from .const import COORDINATOR, DOMAIN +from .gateway import SmileGateway _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index 11e077c8a24..1d2a6c9cf32 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -7,7 +7,7 @@ from Plugwise_Smile.Smile import Smile import jsonpickle import pytest -from tests.async_mock import AsyncMock, patch +from tests.async_mock import AsyncMock, Mock, patch from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -47,7 +47,7 @@ def mock_smile_error(aioclient_mock: AiohttpClientMocker) -> None: @pytest.fixture(name="mock_smile_notconnect") def mock_smile_notconnect(): """Mock the Plugwise Smile general connection failure for Home Assistant.""" - with patch("homeassistant.components.plugwise.Smile") as smile_mock: + with patch("homeassistant.components.plugwise.gateway.Smile") as smile_mock: smile_mock.InvalidAuthentication = Smile.InvalidAuthentication smile_mock.ConnectionFailedError = Smile.ConnectionFailedError smile_mock.PlugwiseError = Smile.PlugwiseError @@ -64,7 +64,7 @@ def _get_device_data(chosen_env, device_id): def mock_smile_adam(): """Create a Mock Adam environment for testing exceptions.""" chosen_env = "adam_multiple_devices_per_zone" - with patch("homeassistant.components.plugwise.Smile") as smile_mock: + with patch("homeassistant.components.plugwise.gateway.Smile") as smile_mock: smile_mock.InvalidAuthentication = Smile.InvalidAuthentication smile_mock.ConnectionFailedError = Smile.ConnectionFailedError smile_mock.XMLDataMissingError = Smile.XMLDataMissingError @@ -79,6 +79,9 @@ def mock_smile_adam(): smile_mock.return_value.full_update_device.side_effect = AsyncMock( return_value=True ) + smile_mock.return_value.single_master_thermostat.side_effect = Mock( + return_value=True + ) smile_mock.return_value.set_schedule_state.side_effect = AsyncMock( return_value=True ) @@ -104,7 +107,7 @@ def mock_smile_adam(): def mock_smile_anna(): """Create a Mock Anna environment for testing exceptions.""" chosen_env = "anna_heatpump" - with patch("homeassistant.components.plugwise.Smile") as smile_mock: + with patch("homeassistant.components.plugwise.gateway.Smile") as smile_mock: smile_mock.InvalidAuthentication = Smile.InvalidAuthentication smile_mock.ConnectionFailedError = Smile.ConnectionFailedError smile_mock.XMLDataMissingError = Smile.XMLDataMissingError @@ -119,6 +122,9 @@ def mock_smile_anna(): smile_mock.return_value.full_update_device.side_effect = AsyncMock( return_value=True ) + smile_mock.return_value.single_master_thermostat.side_effect = Mock( + return_value=True + ) smile_mock.return_value.set_schedule_state.side_effect = AsyncMock( return_value=True ) @@ -144,7 +150,7 @@ def mock_smile_anna(): def mock_smile_p1(): """Create a Mock P1 DSMR environment for testing exceptions.""" chosen_env = "p1v3_full_option" - with patch("homeassistant.components.plugwise.Smile") as smile_mock: + with patch("homeassistant.components.plugwise.gateway.Smile") as smile_mock: smile_mock.InvalidAuthentication = Smile.InvalidAuthentication smile_mock.ConnectionFailedError = Smile.ConnectionFailedError smile_mock.XMLDataMissingError = Smile.XMLDataMissingError @@ -160,6 +166,10 @@ def mock_smile_p1(): return_value=True ) + smile_mock.return_value.single_master_thermostat.side_effect = Mock( + return_value=None + ) + smile_mock.return_value.get_all_devices.return_value = _read_json( chosen_env, "get_all_devices" ) diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 713cd4930d7..d968f1825f0 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -4,11 +4,14 @@ import asyncio from Plugwise_Smile.Smile import Smile +from homeassistant.components.plugwise import DOMAIN +from homeassistant.components.plugwise.gateway import async_unload_entry from homeassistant.config_entries import ( ENTRY_STATE_SETUP_ERROR, ENTRY_STATE_SETUP_RETRY, ) +from tests.common import AsyncMock from tests.components.plugwise.common import async_init_integration @@ -43,3 +46,12 @@ async def test_smile_adam_xmlerror(hass, mock_smile_adam): mock_smile_adam.full_update_device.side_effect = Smile.XMLDataMissingError entry = await async_init_integration(hass, mock_smile_adam) assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_unload_entry(hass, mock_smile_adam): + """Test being able to unload an entry.""" + entry = await async_init_integration(hass, mock_smile_adam) + + mock_smile_adam.async_reset = AsyncMock(return_value=True) + assert await async_unload_entry(hass, entry) + assert not hass.data[DOMAIN]