From 7e693afcf317a34fa23ddefc36c33a07ca4b0140 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 28 May 2020 17:52:25 +0200 Subject: [PATCH] Update plugwise to async and config_flow (#33691) * Update plugwise async, config_flow and multi entity * Update battery percentage * Fix yamllint on services * Fix yamllint on services * Fix formatting for pyupgrade * Update homeassistant/components/plugwise/__init__.py Co-Authored-By: Robert Svensson * Add try/except on setup * Bump module version, battery version and valve position * Removing sensor, switch, water_heater for later (child) PRs * Catchup and version bump * Remove title from strings.json * Readd already reviewd await try/except * Readd already reviewed config_flow * Fix pylint * Fix per 0.109 translations * Remove unused import from merge * Update plugwise async, config_flow and multi entity * Update battery percentage * Fix yamllint on services * Fix yamllint on services * Bump module version * Bump module version, battery version and valve position * Removing sensor, switch, water_heater for later (child) PRs * Catchup and version bump * Remove title from strings.json * Fix pylint * Fix per 0.109 translations * Translations and config_flow, module version bump with required changes * Translations and config_flow, module version bump with required changes * Fix requirements * Fix requirements * Fix pylint * Fix pylint * Update homeassistant/components/plugwise/__init__.py Improvement Co-authored-by: J. Nick Koston * Update homeassistant/components/plugwise/__init__.py Improvement Co-authored-by: J. Nick Koston * Update homeassistant/components/plugwise/__init__.py Improvement Co-authored-by: J. Nick Koston * Include configentrynotready on import * Update __init__.py * DataUpdateCoordinator and comment non-PR-platforms * Fix reqs * Rename devices variable in favor of entities * Rework updates with DataUpdateCoordinator * Peer review * Peer review second part * Cleanup comments and redundant code * Added required config_flow test * Peer review third part * Update service was replaced by DataUpdateCoordinator * Corrected testing, version bump for InvalidAuth, move uniq_id * Remove according to review * Await connect (py38) * Remove unneccesary code * Show only when multiple * Improve config_flow, rename consts * Update homeassistant/components/plugwise/climate.py Co-authored-by: J. Nick Koston * Update homeassistant/components/plugwise/climate.py Co-authored-by: J. Nick Koston * Process review comments Co-authored-by: Robert Svensson Co-authored-by: J. Nick Koston --- .coveragerc | 3 +- CODEOWNERS | 2 +- homeassistant/components/plugwise/__init__.py | 162 +++++++- homeassistant/components/plugwise/climate.py | 371 +++++++++--------- .../components/plugwise/config_flow.py | 81 ++++ homeassistant/components/plugwise/const.py | 42 ++ .../components/plugwise/manifest.json | 8 +- .../components/plugwise/strings.json | 22 ++ .../components/plugwise/translations/en.json | 22 ++ .../components/plugwise/translations/nl.json | 22 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 6 +- requirements_test_all.txt | 3 + tests/components/plugwise/__init__.py | 1 + tests/components/plugwise/test_config_flow.py | 83 ++++ 15 files changed, 638 insertions(+), 191 deletions(-) create mode 100644 homeassistant/components/plugwise/config_flow.py create mode 100644 homeassistant/components/plugwise/const.py create mode 100644 homeassistant/components/plugwise/strings.json create mode 100644 homeassistant/components/plugwise/translations/en.json create mode 100644 homeassistant/components/plugwise/translations/nl.json create mode 100644 tests/components/plugwise/__init__.py create mode 100644 tests/components/plugwise/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 9f29ab80bb8..bd09465f643 100644 --- a/.coveragerc +++ b/.coveragerc @@ -602,7 +602,8 @@ omit = homeassistant/components/plaato/* homeassistant/components/plex/media_player.py homeassistant/components/plex/sensor.py - homeassistant/components/plugwise/* + homeassistant/components/plugwise/__init__.py + homeassistant/components/plugwise/climate.py homeassistant/components/plum_lightpad/* homeassistant/components/pocketcasts/sensor.py homeassistant/components/point/* diff --git a/CODEOWNERS b/CODEOWNERS index 569d104d0bb..26d9c1f58b9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -310,7 +310,7 @@ homeassistant/components/pilight/* @trekky12 homeassistant/components/plaato/* @JohNan homeassistant/components/plant/* @ChristianKuehnel homeassistant/components/plex/* @jjlawren -homeassistant/components/plugwise/* @laetificat @CoMPaTech @bouwew +homeassistant/components/plugwise/* @CoMPaTech @bouwew homeassistant/components/plum_lightpad/* @ColinHarrington homeassistant/components/point/* @fredrike homeassistant/components/powerwall/* @bdraco @jrester diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index 489e7d3f496..c6509091e1c 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -1 +1,161 @@ -"""Plugwise Climate (current only Anna) component for Home Assistant.""" +"""Plugwise platform for Home Assistant Core.""" + +import asyncio +from datetime import timedelta +import logging + +from Plugwise_Smile.Smile import Smile +import async_timeout +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +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.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + +ALL_PLATFORMS = ["climate"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Plugwise platform.""" + return True + + +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["host"], password=entry.data["password"], 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: + _LOGGER.error("Error while communicating to device") + raise ConfigEntryNotReady + + except asyncio.TimeoutError: + _LOGGER.error("Timeout while connecting to Smile") + raise ConfigEntryNotReady + + if api.smile_type == "power": + update_interval = timedelta(seconds=10) + else: + update_interval = timedelta(seconds=60) + + 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: + raise UpdateFailed("Smile update failed") + + 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() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + "api": api, + "coordinator": coordinator, + } + + 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], + ) + + for component in ALL_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +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 + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class SmileGateway(Entity): + """Represent Smile Gateway.""" + + def __init__(self, api, coordinator): + """Initialise the sensor.""" + self._api = api + self._coordinator = coordinator + self._unique_id = None + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def should_poll(self): + """Return False, updates are controlled via coordinator.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self._coordinator.last_update_success + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.async_on_remove(self._coordinator.async_add_listener(self._process_data)) + + def _process_data(self): + """Interpret and process API data.""" + raise NotImplementedError + + async def async_update(self): + """Update the entity.""" + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 8e2e525217a..209fdcdd242 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -1,11 +1,11 @@ """Plugwise Climate component for Home Assistant.""" import logging +from typing import Dict -import haanna -import voluptuous as vol +from Plugwise_Smile.Smile import Smile -from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity +from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( CURRENT_HVAC_COOL, CURRENT_HVAC_HEAT, @@ -13,129 +13,140 @@ from homeassistant.components.climate.const import ( HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - TEMP_CELSIUS, +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from . import SmileGateway +from .const import ( + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + DOMAIN, + SCHEDULE_OFF, + SCHEDULE_ON, + THERMOSTAT_ICON, ) -from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv + +HVAC_MODES_HEAT_ONLY = [HVAC_MODE_HEAT, HVAC_MODE_AUTO] +HVAC_MODES_HEAT_COOL = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO] SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE _LOGGER = logging.getLogger(__name__) -# Configuration directives -CONF_MIN_TEMP = "min_temp" -CONF_MAX_TEMP = "max_temp" -CONF_LEGACY = "legacy_anna" -# Default directives -DEFAULT_NAME = "Plugwise Thermostat" -DEFAULT_USERNAME = "smile" -DEFAULT_TIMEOUT = 10 -DEFAULT_PORT = 80 -DEFAULT_ICON = "mdi:thermometer" -DEFAULT_MIN_TEMP = 4 -DEFAULT_MAX_TEMP = 30 +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Smile Thermostats from a config entry.""" + api = hass.data[DOMAIN][config_entry.entry_id]["api"] + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] -# HVAC modes -HVAC_MODES_1 = [HVAC_MODE_HEAT, HVAC_MODE_AUTO] -HVAC_MODES_2 = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO] - -# Read platform configuration -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_LEGACY, default=False): cv.boolean, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): cv.positive_int, - vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): cv.positive_int, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Add the Plugwise (Anna) Thermostat.""" - api = haanna.Haanna( - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_HOST], - config[CONF_PORT], - config[CONF_LEGACY], - ) - try: - api.ping_anna_thermostat() - except OSError: - _LOGGER.debug("Ping failed, retrying later", exc_info=True) - raise PlatformNotReady - devices = [ - ThermostatDevice( - api, config[CONF_NAME], config[CONF_MIN_TEMP], config[CONF_MAX_TEMP] - ) + entities = [] + thermostat_classes = [ + "thermostat", + "zone_thermostat", + "thermostatic_radiator_valve", ] - add_entities(devices, True) + all_entities = api.get_all_devices() + + for dev_id, device in all_entities.items(): + + if device["class"] not in thermostat_classes: + continue + + thermostat = PwThermostat( + api, + coordinator, + device["name"], + dev_id, + device["location"], + device["class"], + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + ) + + entities.append(thermostat) + + async_add_entities(entities, True) -class ThermostatDevice(ClimateEntity): - """Representation of the Plugwise thermostat.""" +class PwThermostat(SmileGateway, ClimateEntity): + """Representation of an Plugwise thermostat.""" - def __init__(self, api, name, min_temp, max_temp): + def __init__( + self, api, coordinator, name, dev_id, loc_id, model, min_temp, max_temp + ): """Set up the Plugwise API.""" + super().__init__(api, coordinator) + self._api = api + self._name = name + self._dev_id = dev_id + self._loc_id = loc_id + self._model = model self._min_temp = min_temp self._max_temp = max_temp - self._name = name - self._direct_objects = None - self._domain_objects = None - self._outdoor_temperature = None + self._selected_schema = None self._last_active_schema = None self._preset_mode = None self._presets = None self._presets_list = None - self._boiler_status = None - self._heating_status = None - self._cooling_status = None - self._dhw_status = None + self._boiler_state = None + self._heating_state = None + self._cooling_state = None + self._dhw_state = None + self._hvac_mode = None self._schema_names = None self._schema_status = None - self._current_temperature = None - self._thermostat_temperature = None - self._boiler_temperature = None + self._temperature = None + self._setpoint = None self._water_pressure = None - self._schedule_temperature = None + self._schedule_temp = None self._hvac_mode = None + self._single_thermostat = self._api.single_master_thermostat() + self._unique_id = f"{dev_id}-climate" @property def hvac_action(self): - """Return the current hvac action.""" - if self._heating_status or self._boiler_status or self._dhw_status: - return CURRENT_HVAC_HEAT - if self._cooling_status: - return CURRENT_HVAC_COOL - return CURRENT_HVAC_IDLE + """Return the current action.""" + if self._single_thermostat: + if self._heating_state or self._boiler_state: + return CURRENT_HVAC_HEAT + if self._cooling_state: + return CURRENT_HVAC_COOL + return CURRENT_HVAC_IDLE + if self._heating_state is not None or self._boiler_state is not None: + if self._setpoint > self._temperature: + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE @property def name(self): """Return the name of the thermostat, 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._name, + "manufacturer": "Plugwise", + "model": self._model.replace("_", " ").title(), + } + + if self._dev_id != self._api.gateway_id: + device_information["via_device"] = (DOMAIN, self._api.gateway_id) + del device_information["via_device"] + + return device_information + @property def icon(self): """Return the icon to use in the frontend.""" - return DEFAULT_ICON + return THERMOSTAT_ICON @property def supported_features(self): @@ -146,82 +157,47 @@ class ThermostatDevice(ClimateEntity): def device_state_attributes(self): """Return the device specific state attributes.""" attributes = {} - if self._outdoor_temperature: - attributes["outdoor_temperature"] = self._outdoor_temperature if self._schema_names: - attributes["available_schemas"] = self._schema_names + if len(self._schema_names) > 1: + attributes["available_schemas"] = self._schema_names if self._selected_schema: attributes["selected_schema"] = self._selected_schema - if self._boiler_temperature: - attributes["boiler_temperature"] = self._boiler_temperature - if self._water_pressure: - attributes["water_pressure"] = self._water_pressure return attributes @property def preset_modes(self): - """Return the available preset modes list. - - And make the presets with their temperatures available. - """ + """Return the available preset modes list.""" return self._presets_list @property def hvac_modes(self): """Return the available hvac modes list.""" - if self._heating_status is not None or self._boiler_status is not None: - if self._cooling_status is not None: - return HVAC_MODES_2 - return HVAC_MODES_1 - return None + if self._heating_state is not None or self._boiler_state is not None: + if self._cooling_state is not None: + return HVAC_MODES_HEAT_COOL + return HVAC_MODES_HEAT_ONLY @property def hvac_mode(self): """Return current active hvac state.""" - if self._schema_status: - return HVAC_MODE_AUTO - if self._heating_status or self._boiler_status or self._dhw_status: - if self._cooling_status: - return HVAC_MODE_HEAT_COOL - return HVAC_MODE_HEAT - return HVAC_MODE_OFF + return self._hvac_mode @property def target_temperature(self): - """Return the target_temperature. - - From the XML the thermostat-value is used because it updates 'immediately' - compared to the target_temperature-value. This way the information on the card - is "immediately" updated after changing the preset, temperature, etc. - """ - return self._thermostat_temperature + """Return the target_temperature.""" + return self._setpoint @property def preset_mode(self): - """Return the active selected schedule-name. - - Or, return the active preset, or return Temporary in case of a manual change - in the set-temperature with a weekschedule active. - Or return Manual in case of a manual change and no weekschedule active. - """ + """Return the active preset.""" if self._presets: - presets = self._presets - preset_temperature = presets.get(self._preset_mode, "none") - if self.hvac_mode == HVAC_MODE_AUTO: - if self._thermostat_temperature == self._schedule_temperature: - return f"{self._selected_schema}" - if self._thermostat_temperature == preset_temperature: - return self._preset_mode - return "Temporary" - if self._thermostat_temperature != preset_temperature: - return "Manual" return self._preset_mode return None @property def current_temperature(self): """Return the current room temperature.""" - return self._current_temperature + return self._temperature @property def min_temp(self): @@ -238,62 +214,93 @@ class ThermostatDevice(ClimateEntity): """Return the unit of measured temperature.""" return TEMP_CELSIUS - def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" - _LOGGER.debug("Adjusting temperature") temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is not None and self._min_temp < temperature < self._max_temp: - _LOGGER.debug("Changing temporary temperature") - self._api.set_temperature(self._domain_objects, temperature) + if (temperature is not None) and ( + self._min_temp < temperature < self._max_temp + ): + try: + await self._api.set_temperature(self._loc_id, temperature) + self._setpoint = temperature + self.async_write_ha_state() + except Smile.PlugwiseError: + _LOGGER.error("Error while communicating to device") else: _LOGGER.error("Invalid temperature requested") - def set_hvac_mode(self, hvac_mode): + async def async_set_hvac_mode(self, hvac_mode): """Set the hvac mode.""" - _LOGGER.debug("Adjusting hvac_mode (i.e. schedule/schema)") - schema_mode = "false" + state = SCHEDULE_OFF if hvac_mode == HVAC_MODE_AUTO: - schema_mode = "true" - self._api.set_schema_state( - self._domain_objects, self._last_active_schema, schema_mode - ) + state = SCHEDULE_ON + try: + await self._api.set_temperature(self._loc_id, self._schedule_temp) + self._setpoint = self._schedule_temp + except Smile.PlugwiseError: + _LOGGER.error("Error while communicating to device") + try: + await self._api.set_schedule_state( + self._loc_id, self._last_active_schema, state + ) + self._hvac_mode = hvac_mode + self.async_write_ha_state() + except Smile.PlugwiseError: + _LOGGER.error("Error while communicating to device") - def set_preset_mode(self, preset_mode): + async def async_set_preset_mode(self, preset_mode): """Set the preset mode.""" - _LOGGER.debug("Changing preset mode") - self._api.set_preset(self._domain_objects, preset_mode) + try: + await self._api.set_preset(self._loc_id, preset_mode) + self._preset_mode = preset_mode + self._setpoint = self._presets.get(self._preset_mode, "none")[0] + self.async_write_ha_state() + except Smile.PlugwiseError: + _LOGGER.error("Error while communicating to device") - def update(self): - """Update the data from the thermostat.""" - _LOGGER.debug("Update called") - self._direct_objects = self._api.get_direct_objects() - self._domain_objects = self._api.get_domain_objects() - self._outdoor_temperature = self._api.get_outdoor_temperature( - self._domain_objects - ) - self._selected_schema = self._api.get_active_schema_name(self._domain_objects) - self._last_active_schema = self._api.get_last_active_schema_name( - self._domain_objects - ) - self._preset_mode = self._api.get_current_preset(self._domain_objects) - self._presets = self._api.get_presets(self._domain_objects) - self._presets_list = list(self._api.get_presets(self._domain_objects)) - self._boiler_status = self._api.get_boiler_status(self._direct_objects) - self._heating_status = self._api.get_heating_status(self._direct_objects) - self._cooling_status = self._api.get_cooling_status(self._direct_objects) - self._dhw_status = self._api.get_domestic_hot_water_status(self._direct_objects) - self._schema_names = self._api.get_schema_names(self._domain_objects) - self._schema_status = self._api.get_schema_state(self._domain_objects) - self._current_temperature = self._api.get_current_temperature( - self._domain_objects - ) - self._thermostat_temperature = self._api.get_thermostat_temperature( - self._domain_objects - ) - self._schedule_temperature = self._api.get_schedule_temperature( - self._domain_objects - ) - self._boiler_temperature = self._api.get_boiler_temperature( - self._domain_objects - ) - self._water_pressure = self._api.get_water_pressure(self._domain_objects) + def _process_data(self): + """Update the data for this climate device.""" + climate_data = self._api.get_device_data(self._dev_id) + heater_central_data = self._api.get_device_data(self._api.heater_id) + + if "setpoint" in climate_data: + self._setpoint = climate_data["setpoint"] + if "temperature" in climate_data: + self._temperature = climate_data["temperature"] + if "schedule_temperature" in climate_data: + self._schedule_temp = climate_data["schedule_temperature"] + if "available_schedules" in climate_data: + self._schema_names = climate_data["available_schedules"] + if "selected_schedule" in climate_data: + self._selected_schema = climate_data["selected_schedule"] + if self._selected_schema is not None: + self._schema_status = True + else: + self._schema_status = False + if "last_used" in climate_data: + self._last_active_schema = climate_data["last_used"] + if "presets" in climate_data: + self._presets = climate_data["presets"] + if self._presets: + self._presets_list = list(self._presets) + if "active_preset" in climate_data: + self._preset_mode = climate_data["active_preset"] + + if "boiler_state" in heater_central_data: + if heater_central_data["boiler_state"] is not None: + self._boiler_state = heater_central_data["boiler_state"] + if "heating_state" in heater_central_data: + if heater_central_data["heating_state"] is not None: + self._heating_state = heater_central_data["heating_state"] + if "cooling_state" in heater_central_data: + if heater_central_data["cooling_state"] is not None: + self._cooling_state = heater_central_data["cooling_state"] + + if self._schema_status: + self._hvac_mode = HVAC_MODE_AUTO + elif self._heating_state is not None or self._boiler_state is not None: + self._hvac_mode = HVAC_MODE_HEAT + if self._cooling_state is not None: + self._hvac_mode = HVAC_MODE_HEAT_COOL + + self.async_write_ha_state() diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py new file mode 100644 index 00000000000..7182665a506 --- /dev/null +++ b/homeassistant/components/plugwise/config_flow.py @@ -0,0 +1,81 @@ +"""Config flow for Plugwise integration.""" +import logging + +from Plugwise_Smile.Smile import Smile +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_HOST): str, vol.Required(CONF_PASSWORD): str} +) + + +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. + """ + websession = async_get_clientsession(hass, verify_ssl=False) + api = Smile( + host=data["host"], password=data["password"], timeout=30, websession=websession + ) + + try: + await api.connect() + except Smile.InvalidAuthentication: + raise InvalidAuth + except Smile.ConnectionFailedError: + raise CannotConnect + + return api + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Plugwise Smile.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + api = None + if user_input is not None: + + try: + api = await validate_input(self.hass, user_input) + + return self.async_create_entry(title=api.smile_name, data=user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + await self.async_set_unique_id(api.gateway_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=api.smile_name, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py new file mode 100644 index 00000000000..2f804ef09a3 --- /dev/null +++ b/homeassistant/components/plugwise/const.py @@ -0,0 +1,42 @@ +"""Constant for Plugwise component.""" +DOMAIN = "plugwise" + +# Default directives +DEFAULT_NAME = "Smile" +DEFAULT_USERNAME = "smile" +DEFAULT_TIMEOUT = 10 +DEFAULT_PORT = 80 +DEFAULT_MIN_TEMP = 4 +DEFAULT_MAX_TEMP = 30 +DEFAULT_SCAN_INTERVAL = {"thermostat": 60, "power": 10} + +DEVICE_CLASS_GAS = "gas" + +# Configuration directives +CONF_MIN_TEMP = "min_temp" +CONF_MAX_TEMP = "max_temp" +CONF_THERMOSTAT = "thermostat" +CONF_POWER = "power" +CONF_HEATER = "heater" +CONF_SOLAR = "solar" +CONF_GAS = "gas" + +ATTR_ILLUMINANCE = "illuminance" +CURRENT_HVAC_DHW = "hot_water" +DEVICE_STATE = "device_state" + +SCHEDULE_ON = "true" +SCHEDULE_OFF = "false" + +# Icons +SWITCH_ICON = "mdi:electric-switch" +THERMOSTAT_ICON = "mdi:thermometer" +WATER_ICON = "mdi:water-pump" +FLAME_ICON = "mdi:fire" +COOL_ICON = "mdi:snowflake" +IDLE_ICON = "mdi:circle-off-outline" +GAS_ICON = "mdi:fire" +POWER_ICON = "mdi:flash" +POWER_FAILURE_ICON = "mdi:flash-off" +SWELL_SAG_ICON = "mdi:pulse" +VALVE_ICON = "mdi:valve" diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 55e43c2a29f..2a0d5a1e0fc 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -1,7 +1,9 @@ { "domain": "plugwise", - "name": "Plugwise Anna", + "name": "Plugwise", "documentation": "https://www.home-assistant.io/integrations/plugwise", - "codeowners": ["@laetificat", "@CoMPaTech", "@bouwew"], - "requirements": ["haanna==0.15.0"] + "requirements": ["Plugwise_Smile==0.2.10"], + "dependencies": [], + "codeowners": ["@CoMPaTech", "@bouwew"], + "config_flow": true } diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json new file mode 100644 index 00000000000..00499a26ac2 --- /dev/null +++ b/homeassistant/components/plugwise/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to the Smile", + "description": "Details", + "data": { + "host": "Smile IP address", + "password": "Smile ID" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication, check the 8 characters of your Smile ID", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "This Smile is already configured" + } + } +} diff --git a/homeassistant/components/plugwise/translations/en.json b/homeassistant/components/plugwise/translations/en.json new file mode 100644 index 00000000000..b7aa7ec957d --- /dev/null +++ b/homeassistant/components/plugwise/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to the Smile", + "description": "Details", + "data": { + "host": "Smile IP address", + "password": "Smile ID" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication, check the 8 characters of your Smile ID", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "This Smile is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/nl.json b/homeassistant/components/plugwise/translations/nl.json new file mode 100644 index 00000000000..7665136a58e --- /dev/null +++ b/homeassistant/components/plugwise/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Verbinden met de Smile", + "description": "Gegevens", + "data": { + "host": "IP adres van de Smile", + "password": "Smile ID" + } + } + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "invalid_auth": "Authenticatie mislukt, voer de 8 karakters van je Smile goed in", + "unknown": "Overwachte fout" + }, + "abort": { + "already_configured": "Deze Smile is al geconfigureerd" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1c5ae1a4d7b..b0309482205 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -114,6 +114,7 @@ FLOWS = [ "pi_hole", "plaato", "plex", + "plugwise", "point", "powerwall", "ps4", diff --git a/requirements_all.txt b/requirements_all.txt index faeb59a2c53..707f3dae386 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -43,6 +43,9 @@ Mastodon.py==1.5.1 # homeassistant.components.orangepi_gpio OPi.GPIO==0.4.0 +# homeassistant.components.plugwise +Plugwise_Smile==0.2.10 + # homeassistant.components.essent PyEssent==0.13 @@ -697,9 +700,6 @@ ha-ffmpeg==2.0 # homeassistant.components.philips_js ha-philipsjs==0.0.8 -# homeassistant.components.plugwise -haanna==0.15.0 - # homeassistant.components.habitica habitipy==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 474b527d717..5afb1307533 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -6,6 +6,9 @@ # homeassistant.components.homekit HAP-python==2.8.4 +# homeassistant.components.plugwise +Plugwise_Smile==0.2.10 + # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/tests/components/plugwise/__init__.py b/tests/components/plugwise/__init__.py new file mode 100644 index 00000000000..1904b581ab1 --- /dev/null +++ b/tests/components/plugwise/__init__.py @@ -0,0 +1 @@ +"""Tests for the Plugwise integration.""" diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py new file mode 100644 index 00000000000..b70b658bd65 --- /dev/null +++ b/tests/components/plugwise/test_config_flow.py @@ -0,0 +1,83 @@ +"""Test the Plugwise config flow.""" +from Plugwise_Smile.Smile import Smile +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.plugwise.const import DOMAIN + +from tests.async_mock import patch + + +@pytest.fixture(name="mock_smile") +def mock_smile(): + """Create a Mock Smile for testing exceptions.""" + with patch("homeassistant.components.plugwise.config_flow.Smile",) as smile_mock: + smile_mock.InvalidAuthentication = Smile.InvalidAuthentication + smile_mock.ConnectionFailedError = Smile.ConnectionFailedError + smile_mock.return_value.connect.return_value = True + yield smile_mock.return_value + + +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.plugwise.config_flow.Smile.connect", + return_value=True, + ), patch( + "homeassistant.components.plugwise.async_setup", return_value=True, + ) as mock_setup, patch( + "homeassistant.components.plugwise.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", "password": "test-password"}, + ) + + assert result2["type"] == "create_entry" + assert result2["data"] == { + "host": "1.1.1.1", + "password": "test-password", + } + 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_auth(hass, mock_smile): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_smile.connect.side_effect = Smile.InvalidAuthentication + mock_smile.gateway_id = "0a636a4fc1704ab4a24e4f7e37fb187a" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "1.1.1.1", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass, mock_smile): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_smile.connect.side_effect = Smile.ConnectionFailedError + mock_smile.gateway_id = "0a636a4fc1704ab4a24e4f7e37fb187a" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": "1.1.1.1", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"}