From 670393cb82df3aa9fe819d7c81538aa48ce66d37 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 12 Jan 2020 16:36:40 +0200 Subject: [PATCH 01/35] Add MELCloud integration * Provides a climate and sensor platforms. Multiple platforms on one go is not the best option, but it does not make sense to remove them and commit them later either. * Email and access token are stored to the ConfigEntry. The token can be updated by adding the integration again with the same email address. The config flow is aborted and the update is performed on the background. --- .coveragerc | 2 + CODEOWNERS | 1 + .../components/melcloud/.translations/en.json | 23 ++ homeassistant/components/melcloud/__init__.py | 161 ++++++++++++++ homeassistant/components/melcloud/climate.py | 199 ++++++++++++++++++ .../components/melcloud/config_flow.py | 101 +++++++++ homeassistant/components/melcloud/const.py | 31 +++ .../components/melcloud/manifest.json | 14 ++ homeassistant/components/melcloud/sensor.py | 111 ++++++++++ .../components/melcloud/strings.json | 23 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/melcloud/__init__.py | 1 + tests/components/melcloud/test_config_flow.py | 171 +++++++++++++++ 15 files changed, 845 insertions(+) create mode 100644 homeassistant/components/melcloud/.translations/en.json create mode 100644 homeassistant/components/melcloud/__init__.py create mode 100644 homeassistant/components/melcloud/climate.py create mode 100644 homeassistant/components/melcloud/config_flow.py create mode 100644 homeassistant/components/melcloud/const.py create mode 100644 homeassistant/components/melcloud/manifest.json create mode 100644 homeassistant/components/melcloud/sensor.py create mode 100644 homeassistant/components/melcloud/strings.json create mode 100644 tests/components/melcloud/__init__.py create mode 100644 tests/components/melcloud/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 369aaa3b4e0049..13c0683e9ea9aa 100644 --- a/.coveragerc +++ b/.coveragerc @@ -410,6 +410,8 @@ omit = homeassistant/components/mcp23017/* homeassistant/components/media_extractor/* homeassistant/components/mediaroom/media_player.py + homeassistant/components/melcloud/climate.py + homeassistant/components/melcloud/sensor.py homeassistant/components/message_bird/notify.py homeassistant/components/met/weather.py homeassistant/components/meteo_france/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index c3f018ef83a38a..46c3d416b5dcd0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -204,6 +204,7 @@ homeassistant/components/mastodon/* @fabaff homeassistant/components/matrix/* @tinloaf homeassistant/components/mcp23017/* @jardiamj homeassistant/components/mediaroom/* @dgomes +homeassistant/components/melcloud/* @vilppuvuorinen homeassistant/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen homeassistant/components/meteo_france/* @victorcerutti @oncleben31 @Quentame diff --git a/homeassistant/components/melcloud/.translations/en.json b/homeassistant/components/melcloud/.translations/en.json new file mode 100644 index 00000000000000..c77495a7d72413 --- /dev/null +++ b/homeassistant/components/melcloud/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "MELCloud", + "step": { + "user": { + "title": "Connect to MELCloud", + "description": "Connect using your MELCloud account.", + "data": { + "email": "Email used to login to MELCloud.", + "password": "MELCloud password." + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "MELCloud integration already configured for {email}. Existing access token has been refreshed." + } + } +} diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py new file mode 100644 index 00000000000000..1bb297b777f9b0 --- /dev/null +++ b/homeassistant/components/melcloud/__init__.py @@ -0,0 +1,161 @@ +"""The MELCloud Climate integration.""" +import asyncio +from datetime import timedelta +import logging +from typing import Dict, List, Optional + +from aiohttp import ClientConnectionError +from async_timeout import timeout +from pymelcloud import Client, Device +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_EMAIL, + CONF_TOKEN, +) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import Throttle + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +PLATFORMS = ["climate", "sensor"] + +CONF_LANGUAGE = "language" +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_TOKEN): str})}, extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigEntry): + """Establish connection with MELCloud.""" + if DOMAIN not in config: + return True + + email = config[DOMAIN].get(CONF_EMAIL) + token = config[DOMAIN].get(CONF_TOKEN) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_EMAIL: email, CONF_TOKEN: token}, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Establish connection with MELClooud.""" + conf = entry.data + mel_api = await mel_api_setup(hass, conf[CONF_TOKEN]) + if not mel_api: + return False + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_api}) + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await asyncio.wait( + [ + hass.config_entries.async_forward_entry_unload(config_entry, platform) + for platform in PLATFORMS + ] + ) + hass.data[DOMAIN].pop(config_entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return True + + +class MelCloudDevice: + """MELCloud Device instance.""" + + def __init__(self, device: Device): + """Construct a device wrapper.""" + self.device = device + self.name = device.name + self._available = True + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self, **kwargs): + """Pull the latest data from MELCloud.""" + try: + await self.device.update() + self._available = True + except ClientConnectionError: + _LOGGER.warning("Connection failed for %s", self.name) + self._available = False + + async def async_set(self, properties: Dict[str, any]): + """Write state changes to the MELCloud API.""" + try: + await self.device.set(properties) + self._available = True + except ClientConnectionError: + _LOGGER.warning("Connection failed for %s", self.name) + self._available = False + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def device_id(self): + """Return device ID.""" + return self.device.device_id + + @property + def building_id(self): + """Return building ID of the device.""" + return self.device.building_id + + @property + def device_info(self): + """Return a device description for device registry.""" + _device_info = { + "identifiers": {(DOMAIN, f"{self.device.mac}-{self.device.serial}")}, + "manufacturer": "Mitsubishi Electric", + "name": self.name, + } + unit_infos = self.device.units + if unit_infos is not None: + _device_info["model"] = ", ".join( + list(set(map(lambda x: x["model"], unit_infos))) + ) + return _device_info + + +async def mel_api_setup(hass, token) -> Optional[List[MelCloudDevice]]: + """Create a MELCloud instance only once.""" + session = hass.helpers.aiohttp_client.async_get_clientsession() + try: + with timeout(10): + client = Client( + token, + session, + conf_update_interval=timedelta(minutes=5), + device_set_debounce=timedelta(milliseconds=500), + ) + devices = await client.get_devices() + except asyncio.TimeoutError: + _LOGGER.debug("Connection timed out") + raise ConfigEntryNotReady + except ClientConnectionError: + _LOGGER.debug("ClientConnectionError") + raise ConfigEntryNotReady + except Exception: # pylint: disable=broad-except + _LOGGER.error("Unexpected error when initializing client") + return None + + return [MelCloudDevice(device) for device in devices] diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py new file mode 100644 index 00000000000000..5f87d94c579a5a --- /dev/null +++ b/homeassistant/components/melcloud/climate.py @@ -0,0 +1,199 @@ +"""Platform for climate integration.""" +from datetime import timedelta +import logging +from typing import List, Optional + +from pymelcloud import Device + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ( + PRECISION_TENTHS, + PRECISION_WHOLE, + TEMP_CELSIUS, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.temperature import convert as convert_temperature + +from .const import ( + DOMAIN, + HVAC_MODE_LOOKUP, + HVAC_MODE_REVERSE_LOOKUP, + TEMP_UNIT_LOOKUP, +) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +): + """Set up MelCloud device climate based on config_entry.""" + mel_devices = hass.data[DOMAIN].get(entry.entry_id) + async_add_entities( + [MelCloudClimate(mel_device) for mel_device in mel_devices], True + ) + + +class MelCloudClimate(ClimateDevice): + """MELCloud device.""" + + def __init__(self, device: Device, name=None): + """Initialize the climate.""" + self._api = device + if name is None: + name = device.name + + self._name = "{} {}".format(name, "HVAC") + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self._api.device.serial}-{self._api.device.mac}-climate" + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return True + + async def async_update(self): + """Update state from MELCloud.""" + await self._api.async_update() + + @property + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info + + @property + def state(self) -> str: + """Return the current state.""" + return self.hvac_mode + + @property + def precision(self) -> float: + """Return the precision of the system.""" + if self.hass.config.units.temperature_unit == TEMP_CELSIUS: + return PRECISION_TENTHS + return PRECISION_WHOLE + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + return TEMP_UNIT_LOOKUP.get(self._api.device.temp_unit, TEMP_CELSIUS) + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + mode = self._api.device.operation_mode + if mode is None: + return HVAC_MODE_OFF + return HVAC_MODE_LOOKUP.get(mode) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + operation_mode = HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode, None) + if operation_mode is None: + raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") + await self._api.device.set({"operation_mode": operation_mode}) + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return list(map(HVAC_MODE_LOOKUP.get, self._api.device.operation_modes())) + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._api.device.temperature + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + return self._api.device.target_temperature + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + await self._api.device.set( + {"target_temperature": kwargs.get("temperature", self.target_temperature)} + ) + + @property + def target_temperature_step(self) -> Optional[float]: + """Return the supported step of target temperature.""" + return self._api.device.target_temperature_step + + @property + def fan_mode(self) -> Optional[str]: + """Return the fan setting.""" + speed = self._api.device.fan_speed + if speed is None: + return None + return speed.replace("-", " ") + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await self._api.device.set({"fan_speed": fan_mode.replace(" ", "-")}) + + @property + def fan_modes(self) -> Optional[List[str]]: + """Return the list of available fan modes.""" + speeds = self._api.device.fan_speeds() + if speeds is None: + return None + return list(map(lambda x: x.replace("-", " "), speeds)) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + if not self._api.device.power: + await self._api.device.set({"power": True}) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + if self._api.device.power: + await self._api.device.set({"power": False}) + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_FAN_MODE | SUPPORT_TARGET_TEMPERATURE + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + min_value = self._api.device.target_temperature_min + if min_value is not None: + return min_value + + return convert_temperature( + DEFAULT_MIN_TEMP, TEMP_CELSIUS, self.temperature_unit + ) + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + max_value = self._api.device.target_temperature_max + if max_value is not None: + return max_value + + return convert_temperature( + DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit + ) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py new file mode 100644 index 00000000000000..7e02e6f4c1e015 --- /dev/null +++ b/homeassistant/components/melcloud/config_flow.py @@ -0,0 +1,101 @@ +"""Config flow for the MELCloud platform.""" +import asyncio +import logging +from typing import Callable + +from aiohttp import ClientError, ClientResponseError +from async_timeout import timeout +import pymelcloud +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_EMAIL, + CONF_PASSWORD, + CONF_TOKEN, +) + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register("melcloud") +class FlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _create_entry(self, email: str, token: str): + """Register new entry.""" + for entry in self._async_current_entries(): + if entry.data.get(CONF_EMAIL, entry.title) == email: + entry.connection_class = self.CONNECTION_CLASS + self.hass.config_entries.async_update_entry( + entry, data={CONF_EMAIL: email, CONF_TOKEN: token} + ) + return self.async_abort( + reason="already_configured", + description_placeholders={"email": email}, + ) + + return self.async_create_entry( + title=email, data={CONF_EMAIL: email, CONF_TOKEN: token} + ) + + async def _init_client(self, email: str, password: str) -> pymelcloud.Client: + return await pymelcloud.login( + email, password, self.hass.helpers.aiohttp_client.async_get_clientsession(), + ) + + async def _init_client_with_token(self, token: str) -> pymelcloud.Client: + return pymelcloud.Client( + token, self.hass.helpers.aiohttp_client.async_get_clientsession(), + ) + + async def _create_client( + self, email: str, client_creator: Callable[[], pymelcloud.Client], + ): + """Create client.""" + try: + client = await client_creator() + with timeout(10): + await client.update_confs() + except asyncio.TimeoutError: + return self.async_abort(reason="cannot_connect") + except ClientResponseError as err: + if err.status == 401 or err.status == 403: + return self.async_abort(reason="invalid_auth") + else: + return self.async_abort(reason="cannot_connect") + except ClientError: + _LOGGER.exception("ClientError") + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error creating device") + return self.async_abort(reason="unknown") + + return await self._create_entry(email, client.token) + + async def async_step_user(self, user_input=None): + """User initiated config flow.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + ) + email = user_input[CONF_EMAIL] + return await self._create_client( + email, lambda: self._init_client(email, user_input[CONF_PASSWORD]) + ) + + async def async_step_import(self, user_input): + """Import a config entry.""" + email = user_input.get(CONF_EMAIL) + token = user_input.get(CONF_TOKEN) + if not token: + return await self.async_step_user() + return await self._create_client( + email, lambda: self._init_client_with_token(token) + ) diff --git a/homeassistant/components/melcloud/const.py b/homeassistant/components/melcloud/const.py new file mode 100644 index 00000000000000..1205602f883fef --- /dev/null +++ b/homeassistant/components/melcloud/const.py @@ -0,0 +1,31 @@ +"""Constants for the MELCloud Climate integration.""" +import pymelcloud + +from homeassistant.const import ( + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.components.climate.const import ( + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, +) + +DOMAIN = "melcloud" + +HVAC_MODE_LOOKUP = { + pymelcloud.OPERATION_MODE_HEAT: HVAC_MODE_HEAT, + pymelcloud.OPERATION_MODE_DRY: HVAC_MODE_DRY, + pymelcloud.OPERATION_MODE_COOL: HVAC_MODE_COOL, + pymelcloud.OPERATION_MODE_FAN_ONLY: HVAC_MODE_FAN_ONLY, + pymelcloud.OPERATION_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL, +} +HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in HVAC_MODE_LOOKUP.items()} + +TEMP_UNIT_LOOKUP = { + pymelcloud.UNIT_TEMP_CELSIUS: TEMP_CELSIUS, + pymelcloud.UNIT_TEMP_FAHRENHEIT: TEMP_FAHRENHEIT, +} +TEMP_UNIT_REVERSE_LOOKUP = {v: k for k, v in TEMP_UNIT_LOOKUP.items()} diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json new file mode 100644 index 00000000000000..837902eda58cd7 --- /dev/null +++ b/homeassistant/components/melcloud/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "melcloud", + "name": "MELCloud", + "config_flow": true, + "documentation": "", + "requirements": ["pymelcloud==0.7.0"], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@vilppuvuorinen" + ] +} diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py new file mode 100644 index 00000000000000..7eff8dd2f83c83 --- /dev/null +++ b/homeassistant/components/melcloud/sensor.py @@ -0,0 +1,111 @@ +"""Support for MelCloud device sensors.""" +import logging + +from pymelcloud import Device + +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, +) +from homeassistant.helpers.entity import Entity +from homeassistant.util.unit_system import UnitSystem + +from .const import ( + DOMAIN, + TEMP_UNIT_LOOKUP, +) + + +ATTR_MEASUREMENT = "measurement" +ATTR_ICON = "icon" +ATTR_UNIT_FN = "unit_fn" +ATTR_DEVICE_CLASS = "device_class" +ATTR_VALUE_FN = "value_fn" + +SENSORS = [ + { + ATTR_MEASUREMENT: "Inside Temperature", + ATTR_ICON: "mdi:thermometer", + ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get( + x._api.device.temp_unit, TEMP_CELSIUS + ), + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_VALUE_FN: lambda x: x._api.device.temperature, + }, + { + ATTR_MEASUREMENT: "Energy", + ATTR_ICON: "mdi:factory", + ATTR_UNIT_FN: lambda x: "kWh", + ATTR_DEVICE_CLASS: None, + ATTR_VALUE_FN: lambda x: x._api.device.total_energy_consumed, + }, +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up MELCloud device sensors based on config_entry.""" + mel_devices = hass.data[DOMAIN].get(entry.entry_id) + async_add_entities( + [ + MelCloudSensor(mel_device, definition, hass.config.units) + for definition in SENSORS + for mel_device in mel_devices + ], + True, + ) + + +class MelCloudSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, device: Device, definition, units: UnitSystem, name=None): + """Initialize the sensor.""" + self._api = device + if name is None: + self._name_slug = device.name + else: + self._name_slug = name + + self._def = definition + + @property + def unique_id(self): + """Return a unique ID.""" + normalized = self._def[ATTR_MEASUREMENT].lower().replace(" ", "_") + return f"{self._api.device.serial}-{self._api.device.mac}-{normalized}" + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._def[ATTR_ICON] + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name_slug} {self._def[ATTR_MEASUREMENT]}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._def[ATTR_VALUE_FN](self) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._def[ATTR_UNIT_FN](self) + + @property + def device_class(self): + """Return device class.""" + return self._def[ATTR_DEVICE_CLASS] + + async def async_update(self): + """Retrieve latest state.""" + await self._api.async_update() + + @property + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json new file mode 100644 index 00000000000000..c77495a7d72413 --- /dev/null +++ b/homeassistant/components/melcloud/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "MELCloud", + "step": { + "user": { + "title": "Connect to MELCloud", + "description": "Connect using your MELCloud account.", + "data": { + "email": "Email used to login to MELCloud.", + "password": "MELCloud password." + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "MELCloud integration already configured for {email}. Existing access token has been refreshed." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4872b08e9fc86b..c77a8de9388c4d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -54,6 +54,7 @@ "logi_circle", "luftdaten", "mailgun", + "melcloud", "met", "meteo_france", "mikrotik", diff --git a/requirements_all.txt b/requirements_all.txt index 3a46e3514e42ca..dd0f8d0af5bbc6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1354,6 +1354,9 @@ pymailgunner==1.4 # homeassistant.components.mediaroom pymediaroom==0.6.4 +# homeassistant.components.melcloud +pymelcloud==0.7.0 + # homeassistant.components.somfy pymfy==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4fa6515bab65a..46c1ea00ee9ff9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -484,6 +484,9 @@ pylitejet==0.1 # homeassistant.components.mailgun pymailgunner==1.4 +# homeassistant.components.melcloud +pymelcloud==0.7.0 + # homeassistant.components.somfy pymfy==0.7.1 diff --git a/tests/components/melcloud/__init__.py b/tests/components/melcloud/__init__.py new file mode 100644 index 00000000000000..f20383660d4bd6 --- /dev/null +++ b/tests/components/melcloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the MELCloud integration.""" diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py new file mode 100644 index 00000000000000..e7f092ae643cc4 --- /dev/null +++ b/tests/components/melcloud/test_config_flow.py @@ -0,0 +1,171 @@ +"""Test the MELCloud config flow.""" +from aiohttp import ClientError, ClientResponseError +import asyncio +from asynctest import patch as async_patch +from unittest.mock import PropertyMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.melcloud import config_flow +from homeassistant.components.melcloud.const import DOMAIN + +from tests.common import mock_coro + + +def init_config_flow(hass): + """Init flow.""" + flow = config_flow.FlowHandler() + flow.hass = hass + return flow + + +@pytest.fixture +def mock_login(): + """Mock Client in pymelcloud.""" + with async_patch("pymelcloud.Client") as mock, async_patch( + "pymelcloud.login" + ) as login_mock: + type(mock()).token = PropertyMock(return_value="test-token") + mock().update_confs.return_value = mock_coro() + mock().get_devices.return_value = mock_coro([]) + + login_mock.return_value = mock_coro(mock()) + yield login_mock + + +@pytest.fixture +def mock_request_info(): + """Mock RequestInfo to create ClientResposenErrors.""" + with async_patch("aiohttp.RequestInfo") as mock_ri: + mock_ri.return_value.real_url.return_value = "" + yield mock_ri + + +async def test_form(hass, mock_login): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with async_patch( + "homeassistant.components.melcloud.async_setup", return_value=mock_coro(True) + ) as mock_setup, async_patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=mock_coro(True), + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "test-email@test-domain.com", "password": "test-password"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-email@test-domain.com" + assert result2["data"] == { + "email": "test-email@test-domain.com", + "token": "test-token", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("error", [(ClientError()), (asyncio.TimeoutError())]) +async def test_form_errors(hass, mock_login, error): + """Test we handle cannot connect error.""" + mock_login.return_value = mock_coro(exception=error) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with async_patch( + "homeassistant.components.melcloud.async_setup", return_value=mock_coro(True) + ), async_patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=mock_coro(True), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "test-email@test-domain.com", "password": "test-password"}, + ) + + assert len(mock_login.mock_calls) == 1 + assert result2["type"] == "abort" + assert result2["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + "error,message", + [(401, "invalid_auth"), (403, "invalid_auth"), (500, "cannot_connect")], +) +async def test_form_response_errors( + hass, mock_login, mock_request_info, error, message +): + """Test we handle response errors.""" + mock_login.return_value = mock_coro( + exception=ClientResponseError(mock_request_info(), (), status=error), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with async_patch( + "homeassistant.components.melcloud.async_setup", return_value=mock_coro(True) + ), async_patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=mock_coro(True), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "test-email@test-domain.com", "password": "test-password"}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == message + + +async def test_token_refresh(hass, mock_login): + """Re-configuration with existing email should refresh token.""" + await hass.config_entries.async_add( + config_entries.ConfigEntry( + 1, + DOMAIN, + "", + {"email": "test-email@test-domain.com", "token": "test-original-token"}, + config_entries.SOURCE_USER, + config_entries.CONN_CLASS_CLOUD_POLL, + {}, + ) + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with async_patch( + "homeassistant.components.melcloud.async_setup", return_value=mock_coro(True) + ) as mock_setup, async_patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=mock_coro(True), + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "test-email@test-domain.com", "password": "test-password"}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + entry = entries[0] + assert entry.data["email"] == "test-email@test-domain.com" + assert entry.data["token"] == "test-token" From bf1968aeba501c30abcdddf299de7500815daff5 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 12 Jan 2020 21:31:20 +0200 Subject: [PATCH 02/35] Run isort --- homeassistant/components/melcloud/__init__.py | 5 +---- homeassistant/components/melcloud/climate.py | 15 +++------------ homeassistant/components/melcloud/config_flow.py | 6 +----- homeassistant/components/melcloud/const.py | 9 +++------ homeassistant/components/melcloud/sensor.py | 11 ++--------- tests/components/melcloud/test_config_flow.py | 4 ++-- 6 files changed, 12 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 1bb297b777f9b0..3e1342f7404dc2 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -10,10 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_EMAIL, - CONF_TOKEN, -) +from homeassistant.const import CONF_EMAIL, CONF_TOKEN from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import Throttle diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 5f87d94c579a5a..75dd5f75657662 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -7,27 +7,18 @@ from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( - DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, HVAC_MODE_OFF, SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ( - PRECISION_TENTHS, - PRECISION_WHOLE, - TEMP_CELSIUS, -) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, TEMP_CELSIUS from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.temperature import convert as convert_temperature -from .const import ( - DOMAIN, - HVAC_MODE_LOOKUP, - HVAC_MODE_REVERSE_LOOKUP, - TEMP_UNIT_LOOKUP, -) +from .const import DOMAIN, HVAC_MODE_LOOKUP, HVAC_MODE_REVERSE_LOOKUP, TEMP_UNIT_LOOKUP ENTITY_ID_FORMAT = DOMAIN + ".{}" SCAN_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 7e02e6f4c1e015..383005f6520b9d 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -9,11 +9,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_EMAIL, - CONF_PASSWORD, - CONF_TOKEN, -) +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/melcloud/const.py b/homeassistant/components/melcloud/const.py index 1205602f883fef..768432c6e36f30 100644 --- a/homeassistant/components/melcloud/const.py +++ b/homeassistant/components/melcloud/const.py @@ -1,17 +1,14 @@ """Constants for the MELCloud Climate integration.""" import pymelcloud -from homeassistant.const import ( - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) from homeassistant.components.climate.const import ( HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, ) +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT DOMAIN = "melcloud" diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 7eff8dd2f83c83..87e78cf346763c 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -3,18 +3,11 @@ from pymelcloud import Device -from homeassistant.const import ( - DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS, -) +from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from homeassistant.helpers.entity import Entity from homeassistant.util.unit_system import UnitSystem -from .const import ( - DOMAIN, - TEMP_UNIT_LOOKUP, -) - +from .const import DOMAIN, TEMP_UNIT_LOOKUP ATTR_MEASUREMENT = "measurement" ATTR_ICON = "icon" diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index e7f092ae643cc4..a1860bb1ef003d 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -1,9 +1,9 @@ """Test the MELCloud config flow.""" -from aiohttp import ClientError, ClientResponseError import asyncio -from asynctest import patch as async_patch from unittest.mock import PropertyMock +from aiohttp import ClientError, ClientResponseError +from asynctest import patch as async_patch import pytest from homeassistant import config_entries From 0ebcfa11c72565c33375a3170b0579a9b81b54be Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 12 Jan 2020 22:07:21 +0200 Subject: [PATCH 03/35] Fix pylint errors --- homeassistant/components/melcloud/config_flow.py | 3 +-- homeassistant/components/melcloud/sensor.py | 10 +++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 383005f6520b9d..666aa26da1d508 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -61,8 +61,7 @@ async def _create_client( except ClientResponseError as err: if err.status == 401 or err.status == 403: return self.async_abort(reason="invalid_auth") - else: - return self.async_abort(reason="cannot_connect") + return self.async_abort(reason="cannot_connect") except ClientError: _LOGGER.exception("ClientError") return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 87e78cf346763c..0296d2e25ab968 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -20,17 +20,17 @@ ATTR_MEASUREMENT: "Inside Temperature", ATTR_ICON: "mdi:thermometer", ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get( - x._api.device.temp_unit, TEMP_CELSIUS + x.device.temp_unit, TEMP_CELSIUS ), ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda x: x._api.device.temperature, + ATTR_VALUE_FN: lambda x: x.device.temperature, }, { ATTR_MEASUREMENT: "Energy", ATTR_ICON: "mdi:factory", ATTR_UNIT_FN: lambda x: "kWh", ATTR_DEVICE_CLASS: None, - ATTR_VALUE_FN: lambda x: x._api.device.total_energy_consumed, + ATTR_VALUE_FN: lambda x: x.device.total_energy_consumed, }, ] @@ -82,12 +82,12 @@ def name(self): @property def state(self): """Return the state of the sensor.""" - return self._def[ATTR_VALUE_FN](self) + return self._def[ATTR_VALUE_FN](self._api) @property def unit_of_measurement(self): """Return the unit of measurement.""" - return self._def[ATTR_UNIT_FN](self) + return self._def[ATTR_UNIT_FN](self._api) @property def device_class(self): From 9f3b2b07799c78b2a06f740d10c510137c41d8c7 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 12 Jan 2020 22:15:57 +0200 Subject: [PATCH 04/35] Run black --- homeassistant/components/melcloud/sensor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 0296d2e25ab968..5bc5b0643c067a 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -19,9 +19,7 @@ { ATTR_MEASUREMENT: "Inside Temperature", ATTR_ICON: "mdi:thermometer", - ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get( - x.device.temp_unit, TEMP_CELSIUS - ), + ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ATTR_VALUE_FN: lambda x: x.device.temperature, }, From 859e44a21088bb7e2cfc7ddf20c916321e7d639b Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Mon, 13 Jan 2020 00:00:13 +0200 Subject: [PATCH 05/35] Increase coverage --- homeassistant/components/melcloud/__init__.py | 1 + tests/components/melcloud/test_config_flow.py | 66 +++++++++++++++++-- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 3e1342f7404dc2..cb11b8a9048f24 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -136,6 +136,7 @@ def device_info(self): async def mel_api_setup(hass, token) -> Optional[List[MelCloudDevice]]: """Create a MELCloud instance only once.""" session = hass.helpers.aiohttp_client.async_get_clientsession() + print("setup") try: with timeout(10): client = Client( diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index a1860bb1ef003d..d4d560352ff179 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -21,14 +21,25 @@ def init_config_flow(hass): @pytest.fixture -def mock_login(): +def mock_client(): """Mock Client in pymelcloud.""" + with async_patch("pymelcloud.Client") as mock: + type(mock.return_value).token = PropertyMock(return_value="test-token") + mock.return_value.update_confs.return_value = mock_coro() + mock.return_value.get_devices.return_value = mock_coro([]) + + yield mock + + +@pytest.fixture +def mock_login(): + """Mock login in pymelcloud.""" with async_patch("pymelcloud.Client") as mock, async_patch( "pymelcloud.login" ) as login_mock: - type(mock()).token = PropertyMock(return_value="test-token") - mock().update_confs.return_value = mock_coro() - mock().get_devices.return_value = mock_coro([]) + type(mock.return_value).token = PropertyMock(return_value="test-token") + mock.return_value.update_confs.return_value = mock_coro() + mock.return_value.get_devices.return_value = mock_coro([]) login_mock.return_value = mock_coro(mock()) yield login_mock @@ -72,8 +83,15 @@ async def test_form(hass, mock_login): assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize("error", [(ClientError()), (asyncio.TimeoutError())]) -async def test_form_errors(hass, mock_login, error): +@pytest.mark.parametrize( + "error,reason", + [ + (ClientError(), "cannot_connect"), + (asyncio.TimeoutError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_form_errors(hass, mock_login, error, reason): """Test we handle cannot connect error.""" mock_login.return_value = mock_coro(exception=error) @@ -94,7 +112,7 @@ async def test_form_errors(hass, mock_login, error): assert len(mock_login.mock_calls) == 1 assert result2["type"] == "abort" - assert result2["reason"] == "cannot_connect" + assert result2["reason"] == reason @pytest.mark.parametrize( @@ -128,6 +146,40 @@ async def test_form_response_errors( assert result2["reason"] == message +async def test_failed_import_form(hass, mock_login): + """Test we get the form if imported without token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={}, + ) + assert result["type"] == "form" + assert result["errors"] is None + + +async def test_import_with_token(hass, mock_login, mock_client): + """Test successful import.""" + with async_patch( + "homeassistant.components.melcloud.async_setup", return_value=mock_coro(True) + ) as mock_setup, async_patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=mock_coro(True), + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"email": "test-email@test-domain.com", "token": "test-token"}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "test-email@test-domain.com" + assert result["data"] == { + "email": "test-email@test-domain.com", + "token": "test-token", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_token_refresh(hass, mock_login): """Re-configuration with existing email should refresh token.""" await hass.config_entries.async_add( From a183ff2c824bbaa670f46d4158dba3f5b32c4a3e Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Mon, 13 Jan 2020 08:21:27 +0200 Subject: [PATCH 06/35] Update pymelcloud dependency --- homeassistant/components/melcloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 837902eda58cd7..9caabb0a96dec6 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -3,7 +3,7 @@ "name": "MELCloud", "config_flow": true, "documentation": "", - "requirements": ["pymelcloud==0.7.0"], + "requirements": ["pymelcloud==0.7.1"], "ssdp": [], "zeroconf": [], "homekit": {}, diff --git a/requirements_all.txt b/requirements_all.txt index dd0f8d0af5bbc6..07b0ea480cfeb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1355,7 +1355,7 @@ pymailgunner==1.4 pymediaroom==0.6.4 # homeassistant.components.melcloud -pymelcloud==0.7.0 +pymelcloud==0.7.1 # homeassistant.components.somfy pymfy==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 46c1ea00ee9ff9..ef450885ed1631 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -485,7 +485,7 @@ pylitejet==0.1 pymailgunner==1.4 # homeassistant.components.melcloud -pymelcloud==0.7.0 +pymelcloud==0.7.1 # homeassistant.components.somfy pymfy==0.7.1 From f1a6446a9d36bf26a2a4da348c294c51289d77e5 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sat, 25 Jan 2020 11:20:43 +0200 Subject: [PATCH 07/35] Add HVAC_MODE_OFF emulation --- homeassistant/components/melcloud/climate.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 75dd5f75657662..e00fd85f56dc72 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -95,21 +95,31 @@ def temperature_unit(self) -> str: def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode.""" mode = self._api.device.operation_mode - if mode is None: + if not self._api.device.power or mode is None: return HVAC_MODE_OFF return HVAC_MODE_LOOKUP.get(mode) async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self._api.device.set({"power": False}) + return + operation_mode = HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode, None) if operation_mode is None: raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") - await self._api.device.set({"operation_mode": operation_mode}) + + props = {"operation_mode": operation_mode} + if self.hvac_mode == HVAC_MODE_OFF: + props["power"] = True + await self._api.device.set(props) @property def hvac_modes(self) -> List[str]: """Return the list of available hvac operation modes.""" - return list(map(HVAC_MODE_LOOKUP.get, self._api.device.operation_modes())) + return [HVAC_MODE_OFF] + list( + map(HVAC_MODE_LOOKUP.get, self._api.device.operation_modes()) + ) @property def current_temperature(self) -> Optional[float]: From 60d80adee6e4e4c2421bff24ba9c8d2b4f5824fa Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sat, 25 Jan 2020 11:24:15 +0200 Subject: [PATCH 08/35] Remove print --- homeassistant/components/melcloud/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index cb11b8a9048f24..3e1342f7404dc2 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -136,7 +136,6 @@ def device_info(self): async def mel_api_setup(hass, token) -> Optional[List[MelCloudDevice]]: """Create a MELCloud instance only once.""" session = hass.helpers.aiohttp_client.async_get_clientsession() - print("setup") try: with timeout(10): client = Client( From b7fe6d3755c5668869e54abc60a15ea9324a230e Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Wed, 29 Jan 2020 22:28:53 +0200 Subject: [PATCH 09/35] Update pymelcloud to enable device type filtering --- homeassistant/components/melcloud/__init__.py | 7 ++- homeassistant/components/melcloud/climate.py | 21 ++++---- .../components/melcloud/config_flow.py | 48 ++++++++++--------- homeassistant/components/melcloud/const.py | 17 +++---- .../components/melcloud/manifest.json | 2 +- homeassistant/components/melcloud/sensor.py | 9 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/melcloud/test_config_flow.py | 48 ++++++------------- 9 files changed, 74 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 3e1342f7404dc2..4068998456be43 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -6,7 +6,7 @@ from aiohttp import ClientConnectionError from async_timeout import timeout -from pymelcloud import Client, Device +from pymelcloud import Device, get_devices import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -138,13 +138,12 @@ async def mel_api_setup(hass, token) -> Optional[List[MelCloudDevice]]: session = hass.helpers.aiohttp_client.async_get_clientsession() try: with timeout(10): - client = Client( + devices = await get_devices( token, session, conf_update_interval=timedelta(minutes=5), - device_set_debounce=timedelta(milliseconds=500), + device_set_debounce=timedelta(seconds=1), ) - devices = await client.get_devices() except asyncio.TimeoutError: _LOGGER.debug("Connection timed out") raise ConfigEntryNotReady diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index e00fd85f56dc72..aa8b3dbb72ede4 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -3,7 +3,7 @@ import logging from typing import List, Optional -from pymelcloud import Device +from pymelcloud import AtaDevice from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( @@ -32,14 +32,19 @@ async def async_setup_entry( """Set up MelCloud device climate based on config_entry.""" mel_devices = hass.data[DOMAIN].get(entry.entry_id) async_add_entities( - [MelCloudClimate(mel_device) for mel_device in mel_devices], True + [ + AtaDeviceClimate(mel_device) + for mel_device in mel_devices + if isinstance(mel_device.device, AtaDevice) + ], + True, ) -class MelCloudClimate(ClimateDevice): - """MELCloud device.""" +class AtaDeviceClimate(ClimateDevice): + """Air-to-Air climate device.""" - def __init__(self, device: Device, name=None): + def __init__(self, device: AtaDevice, name=None): """Initialize the climate.""" self._api = device if name is None: @@ -118,13 +123,13 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: def hvac_modes(self) -> List[str]: """Return the list of available hvac operation modes.""" return [HVAC_MODE_OFF] + list( - map(HVAC_MODE_LOOKUP.get, self._api.device.operation_modes()) + map(HVAC_MODE_LOOKUP.get, self._api.device.operation_modes) ) @property def current_temperature(self) -> Optional[float]: """Return the current temperature.""" - return self._api.device.temperature + return self._api.device.room_temperature @property def target_temperature(self) -> Optional[float]: @@ -157,7 +162,7 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: @property def fan_modes(self) -> Optional[List[str]]: """Return the list of available fan modes.""" - speeds = self._api.device.fan_speeds() + speeds = self._api.device.fan_speeds if speeds is None: return None return list(map(lambda x: x.replace("-", " "), speeds)) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 666aa26da1d508..ff4f038ec9e6c0 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -1,7 +1,7 @@ """Config flow for the MELCloud platform.""" import asyncio import logging -from typing import Callable +from typing import Optional from aiohttp import ClientError, ClientResponseError from async_timeout import timeout @@ -35,27 +35,35 @@ async def _create_entry(self, email: str, token: str): ) return self.async_create_entry( - title=email, data={CONF_EMAIL: email, CONF_TOKEN: token} - ) - - async def _init_client(self, email: str, password: str) -> pymelcloud.Client: - return await pymelcloud.login( - email, password, self.hass.helpers.aiohttp_client.async_get_clientsession(), - ) - - async def _init_client_with_token(self, token: str) -> pymelcloud.Client: - return pymelcloud.Client( - token, self.hass.helpers.aiohttp_client.async_get_clientsession(), + title=email, data={CONF_EMAIL: email, CONF_TOKEN: token}, ) async def _create_client( - self, email: str, client_creator: Callable[[], pymelcloud.Client], + self, + email: str, + *, + password: Optional[str] = None, + token: Optional[str] = None, ): """Create client.""" + if password is None and token is None: + raise ValueError( + "Invalid internal state. Called without either password or token", + ) + try: - client = await client_creator() with timeout(10): - await client.update_confs() + acquired_token = token + if acquired_token is None: + acquired_token = await pymelcloud.login( + email, + password, + self.hass.helpers.aiohttp_client.async_get_clientsession(), + ) + await pymelcloud.get_devices( + acquired_token, + self.hass.helpers.aiohttp_client.async_get_clientsession(), + ) except asyncio.TimeoutError: return self.async_abort(reason="cannot_connect") except ClientResponseError as err: @@ -69,7 +77,7 @@ async def _create_client( _LOGGER.exception("Unexpected error creating device") return self.async_abort(reason="unknown") - return await self._create_entry(email, client.token) + return await self._create_entry(email, acquired_token) async def async_step_user(self, user_input=None): """User initiated config flow.""" @@ -81,9 +89,7 @@ async def async_step_user(self, user_input=None): ), ) email = user_input[CONF_EMAIL] - return await self._create_client( - email, lambda: self._init_client(email, user_input[CONF_PASSWORD]) - ) + return await self._create_client(email, password=user_input[CONF_PASSWORD],) async def async_step_import(self, user_input): """Import a config entry.""" @@ -91,6 +97,4 @@ async def async_step_import(self, user_input): token = user_input.get(CONF_TOKEN) if not token: return await self.async_step_user() - return await self._create_client( - email, lambda: self._init_client_with_token(token) - ) + return await self._create_client(email, token=token,) diff --git a/homeassistant/components/melcloud/const.py b/homeassistant/components/melcloud/const.py index 768432c6e36f30..e262be2c3fb3b1 100644 --- a/homeassistant/components/melcloud/const.py +++ b/homeassistant/components/melcloud/const.py @@ -1,5 +1,6 @@ """Constants for the MELCloud Climate integration.""" -import pymelcloud +import pymelcloud.ata_device as ata_device +from pymelcloud.const import UNIT_TEMP_CELSIUS, UNIT_TEMP_FAHRENHEIT from homeassistant.components.climate.const import ( HVAC_MODE_COOL, @@ -13,16 +14,16 @@ DOMAIN = "melcloud" HVAC_MODE_LOOKUP = { - pymelcloud.OPERATION_MODE_HEAT: HVAC_MODE_HEAT, - pymelcloud.OPERATION_MODE_DRY: HVAC_MODE_DRY, - pymelcloud.OPERATION_MODE_COOL: HVAC_MODE_COOL, - pymelcloud.OPERATION_MODE_FAN_ONLY: HVAC_MODE_FAN_ONLY, - pymelcloud.OPERATION_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL, + ata_device.OPERATION_MODE_HEAT: HVAC_MODE_HEAT, + ata_device.OPERATION_MODE_DRY: HVAC_MODE_DRY, + ata_device.OPERATION_MODE_COOL: HVAC_MODE_COOL, + ata_device.OPERATION_MODE_FAN_ONLY: HVAC_MODE_FAN_ONLY, + ata_device.OPERATION_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL, } HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in HVAC_MODE_LOOKUP.items()} TEMP_UNIT_LOOKUP = { - pymelcloud.UNIT_TEMP_CELSIUS: TEMP_CELSIUS, - pymelcloud.UNIT_TEMP_FAHRENHEIT: TEMP_FAHRENHEIT, + UNIT_TEMP_CELSIUS: TEMP_CELSIUS, + UNIT_TEMP_FAHRENHEIT: TEMP_FAHRENHEIT, } TEMP_UNIT_REVERSE_LOOKUP = {v: k for k, v in TEMP_UNIT_LOOKUP.items()} diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 9caabb0a96dec6..4bdff3bd72a1b6 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -3,7 +3,7 @@ "name": "MELCloud", "config_flow": true, "documentation": "", - "requirements": ["pymelcloud==0.7.1"], + "requirements": ["pymelcloud==1.0.1"], "ssdp": [], "zeroconf": [], "homekit": {}, diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 5bc5b0643c067a..fbf044be48be6d 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -1,7 +1,7 @@ """Support for MelCloud device sensors.""" import logging -from pymelcloud import Device +from pymelcloud import AtaDevice from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from homeassistant.helpers.entity import Entity @@ -17,11 +17,11 @@ SENSORS = [ { - ATTR_MEASUREMENT: "Inside Temperature", + ATTR_MEASUREMENT: "Room Temperature", ATTR_ICON: "mdi:thermometer", ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda x: x.device.temperature, + ATTR_VALUE_FN: lambda x: x.device.room_temperature, }, { ATTR_MEASUREMENT: "Energy", @@ -43,6 +43,7 @@ async def async_setup_entry(hass, entry, async_add_entities): MelCloudSensor(mel_device, definition, hass.config.units) for definition in SENSORS for mel_device in mel_devices + if isinstance(mel_device.device, AtaDevice) ], True, ) @@ -51,7 +52,7 @@ async def async_setup_entry(hass, entry, async_add_entities): class MelCloudSensor(Entity): """Representation of a Sensor.""" - def __init__(self, device: Device, definition, units: UnitSystem, name=None): + def __init__(self, device: AtaDevice, definition, units: UnitSystem, name=None): """Initialize the sensor.""" self._api = device if name is None: diff --git a/requirements_all.txt b/requirements_all.txt index 07b0ea480cfeb3..a5062853e4ba18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1355,7 +1355,7 @@ pymailgunner==1.4 pymediaroom==0.6.4 # homeassistant.components.melcloud -pymelcloud==0.7.1 +pymelcloud==1.0.1 # homeassistant.components.somfy pymfy==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef450885ed1631..f8776500cf8c11 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -485,7 +485,7 @@ pylitejet==0.1 pymailgunner==1.4 # homeassistant.components.melcloud -pymelcloud==0.7.1 +pymelcloud==1.0.1 # homeassistant.components.somfy pymfy==0.7.1 diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index d4d560352ff179..1948cf6342d02b 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -1,48 +1,30 @@ """Test the MELCloud config flow.""" import asyncio -from unittest.mock import PropertyMock from aiohttp import ClientError, ClientResponseError from asynctest import patch as async_patch import pytest from homeassistant import config_entries -from homeassistant.components.melcloud import config_flow from homeassistant.components.melcloud.const import DOMAIN from tests.common import mock_coro -def init_config_flow(hass): - """Init flow.""" - flow = config_flow.FlowHandler() - flow.hass = hass - return flow - - @pytest.fixture -def mock_client(): - """Mock Client in pymelcloud.""" - with async_patch("pymelcloud.Client") as mock: - type(mock.return_value).token = PropertyMock(return_value="test-token") - mock.return_value.update_confs.return_value = mock_coro() - mock.return_value.get_devices.return_value = mock_coro([]) - +def mock_login(): + """Mock pymelcloud login.""" + with async_patch("pymelcloud.login") as mock: + mock.return_value = mock_coro("test-token") yield mock @pytest.fixture -def mock_login(): - """Mock login in pymelcloud.""" - with async_patch("pymelcloud.Client") as mock, async_patch( - "pymelcloud.login" - ) as login_mock: - type(mock.return_value).token = PropertyMock(return_value="test-token") - mock.return_value.update_confs.return_value = mock_coro() - mock.return_value.get_devices.return_value = mock_coro([]) - - login_mock.return_value = mock_coro(mock()) - yield login_mock +def mock_get_devices(): + """Mock pymelcloud get_devices.""" + with async_patch("pymelcloud.get_devices") as mock: + mock.return_value = mock_coro([]) + yield mock @pytest.fixture @@ -53,7 +35,7 @@ def mock_request_info(): yield mock_ri -async def test_form(hass, mock_login): +async def test_form(hass, mock_login, mock_get_devices): """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -91,7 +73,7 @@ async def test_form(hass, mock_login): (Exception(), "unknown"), ], ) -async def test_form_errors(hass, mock_login, error, reason): +async def test_form_errors(hass, mock_login, mock_get_devices, error, reason): """Test we handle cannot connect error.""" mock_login.return_value = mock_coro(exception=error) @@ -120,7 +102,7 @@ async def test_form_errors(hass, mock_login, error, reason): [(401, "invalid_auth"), (403, "invalid_auth"), (500, "cannot_connect")], ) async def test_form_response_errors( - hass, mock_login, mock_request_info, error, message + hass, mock_login, mock_get_devices, mock_request_info, error, message ): """Test we handle response errors.""" mock_login.return_value = mock_coro( @@ -146,7 +128,7 @@ async def test_form_response_errors( assert result2["reason"] == message -async def test_failed_import_form(hass, mock_login): +async def test_failed_import_form(hass, mock_login, mock_get_devices): """Test we get the form if imported without token.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={}, @@ -155,7 +137,7 @@ async def test_failed_import_form(hass, mock_login): assert result["errors"] is None -async def test_import_with_token(hass, mock_login, mock_client): +async def test_import_with_token(hass, mock_login, mock_get_devices): """Test successful import.""" with async_patch( "homeassistant.components.melcloud.async_setup", return_value=mock_coro(True) @@ -180,7 +162,7 @@ async def test_import_with_token(hass, mock_login, mock_client): assert len(mock_setup_entry.mock_calls) == 1 -async def test_token_refresh(hass, mock_login): +async def test_token_refresh(hass, mock_login, mock_get_devices): """Re-configuration with existing email should refresh token.""" await hass.config_entries.async_add( config_entries.ConfigEntry( From 59125f4e36a21beddbab736ae95298aadbce4876 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Wed, 29 Jan 2020 22:54:13 +0200 Subject: [PATCH 10/35] Collapse except blocks and chain ClientNotReadys --- homeassistant/components/melcloud/__init__.py | 11 ++--------- homeassistant/components/melcloud/config_flow.py | 8 +------- tests/components/melcloud/test_config_flow.py | 6 +----- 3 files changed, 4 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 4068998456be43..16b04d728e9bf9 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -144,14 +144,7 @@ async def mel_api_setup(hass, token) -> Optional[List[MelCloudDevice]]: conf_update_interval=timedelta(minutes=5), device_set_debounce=timedelta(seconds=1), ) - except asyncio.TimeoutError: - _LOGGER.debug("Connection timed out") - raise ConfigEntryNotReady - except ClientConnectionError: - _LOGGER.debug("ClientConnectionError") - raise ConfigEntryNotReady - except Exception: # pylint: disable=broad-except - _LOGGER.error("Unexpected error when initializing client") - return None + except (asyncio.TimeoutError, ClientConnectionError) as ex: + raise ConfigEntryNotReady() from ex return [MelCloudDevice(device) for device in devices] diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index ff4f038ec9e6c0..504d50b8bba098 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -64,18 +64,12 @@ async def _create_client( acquired_token, self.hass.helpers.aiohttp_client.async_get_clientsession(), ) - except asyncio.TimeoutError: - return self.async_abort(reason="cannot_connect") except ClientResponseError as err: if err.status == 401 or err.status == 403: return self.async_abort(reason="invalid_auth") return self.async_abort(reason="cannot_connect") - except ClientError: - _LOGGER.exception("ClientError") + except (asyncio.TimeoutError, ClientError): return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected error creating device") - return self.async_abort(reason="unknown") return await self._create_entry(email, acquired_token) diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index 1948cf6342d02b..9145298ee397aa 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -67,11 +67,7 @@ async def test_form(hass, mock_login, mock_get_devices): @pytest.mark.parametrize( "error,reason", - [ - (ClientError(), "cannot_connect"), - (asyncio.TimeoutError(), "cannot_connect"), - (Exception(), "unknown"), - ], + [(ClientError(), "cannot_connect"), (asyncio.TimeoutError(), "cannot_connect")], ) async def test_form_errors(hass, mock_login, mock_get_devices, error, reason): """Test we handle cannot connect error.""" From 181d9b1164365e958d105c3a8b3980f1f9da1d0a Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Wed, 29 Jan 2020 23:00:38 +0200 Subject: [PATCH 11/35] Add preliminary documentation URL --- homeassistant/components/melcloud/manifest.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 4bdff3bd72a1b6..45c73ddc8e44ed 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -2,13 +2,11 @@ "domain": "melcloud", "name": "MELCloud", "config_flow": true, - "documentation": "", + "documentation": "https://www.home-assistant.io/integrations/melcloud", "requirements": ["pymelcloud==1.0.1"], "ssdp": [], "zeroconf": [], "homekit": {}, "dependencies": [], - "codeowners": [ - "@vilppuvuorinen" - ] + "codeowners": ["@vilppuvuorinen"] } From aae799949a030d888db0eb8566a24fa947b8e944 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Thu, 30 Jan 2020 20:22:40 +0200 Subject: [PATCH 12/35] Use list comp for creating model info Filters out empty model names form units. --- homeassistant/components/melcloud/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 16b04d728e9bf9..3219393bef650d 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -128,7 +128,7 @@ def device_info(self): unit_infos = self.device.units if unit_infos is not None: _device_info["model"] = ", ".join( - list(set(map(lambda x: x["model"], unit_infos))) + [x["model"] for x in unit_infos if len(x["model"]) > 0] ) return _device_info From 35359968332c86c8731b696fc7fedd1c8a5ccf4f Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Thu, 30 Jan 2020 20:36:32 +0200 Subject: [PATCH 13/35] f-string galore Dropped 'HVAC' from AtaDevice name template. --- homeassistant/components/melcloud/climate.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index aa8b3dbb72ede4..ee33be086f1059 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -20,7 +20,7 @@ from .const import DOMAIN, HVAC_MODE_LOOKUP, HVAC_MODE_REVERSE_LOOKUP, TEMP_UNIT_LOOKUP -ENTITY_ID_FORMAT = DOMAIN + ".{}" +ENTITY_ID_FORMAT = f"{DOMAIN}.{{}}" SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -49,8 +49,7 @@ def __init__(self, device: AtaDevice, name=None): self._api = device if name is None: name = device.name - - self._name = "{} {}".format(name, "HVAC") + self._name = name @property def unique_id(self) -> Optional[str]: From 5df0ce227cf05963b70aa7ad2600e1d6fbd5e0ba Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Thu, 30 Jan 2020 21:44:21 +0200 Subject: [PATCH 14/35] Delegate fan mode mapping to pymelcloud --- homeassistant/components/melcloud/climate.py | 12 +++--------- homeassistant/components/melcloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index ee33be086f1059..f70a29ec6f2119 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -149,22 +149,16 @@ def target_temperature_step(self) -> Optional[float]: @property def fan_mode(self) -> Optional[str]: """Return the fan setting.""" - speed = self._api.device.fan_speed - if speed is None: - return None - return speed.replace("-", " ") + return self._api.device.fan_speed async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._api.device.set({"fan_speed": fan_mode.replace(" ", "-")}) + await self._api.device.set({"fan_speed": fan_mode}) @property def fan_modes(self) -> Optional[List[str]]: """Return the list of available fan modes.""" - speeds = self._api.device.fan_speeds - if speeds is None: - return None - return list(map(lambda x: x.replace("-", " "), speeds)) + return self._api.device.fan_speeds async def async_turn_on(self) -> None: """Turn the entity on.""" diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 45c73ddc8e44ed..63248e2ee74a3c 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -3,7 +3,7 @@ "name": "MELCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", - "requirements": ["pymelcloud==1.0.1"], + "requirements": ["pymelcloud==1.2.0"], "ssdp": [], "zeroconf": [], "homekit": {}, diff --git a/requirements_all.txt b/requirements_all.txt index a5062853e4ba18..ab9de3d4539cb6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1355,7 +1355,7 @@ pymailgunner==1.4 pymediaroom==0.6.4 # homeassistant.components.melcloud -pymelcloud==1.0.1 +pymelcloud==1.2.0 # homeassistant.components.somfy pymfy==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f8776500cf8c11..f63d304717e8a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -485,7 +485,7 @@ pylitejet==0.1 pymailgunner==1.4 # homeassistant.components.melcloud -pymelcloud==1.0.1 +pymelcloud==1.2.0 # homeassistant.components.somfy pymfy==0.7.1 From 7b1f8fed0785cb6d6c59fbfbc362ba8b6691f2f3 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Thu, 30 Jan 2020 22:30:19 +0200 Subject: [PATCH 15/35] Fix type annotation --- homeassistant/components/melcloud/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index f70a29ec6f2119..a88e9642d50bc6 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -13,6 +13,7 @@ SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.components.melcloud import MelCloudDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, TEMP_CELSIUS from homeassistant.helpers.typing import HomeAssistantType @@ -44,7 +45,7 @@ async def async_setup_entry( class AtaDeviceClimate(ClimateDevice): """Air-to-Air climate device.""" - def __init__(self, device: AtaDevice, name=None): + def __init__(self, device: MelCloudDevice, name=None): """Initialize the climate.""" self._api = device if name is None: From 005b0cc040a42dcba6d0d70aef31668b4ababaff Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Thu, 30 Jan 2020 22:37:59 +0200 Subject: [PATCH 16/35] Access AtaDevice through self._device --- homeassistant/components/melcloud/climate.py | 41 ++++++++++---------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index a88e9642d50bc6..43eff832790814 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -48,6 +48,7 @@ class AtaDeviceClimate(ClimateDevice): def __init__(self, device: MelCloudDevice, name=None): """Initialize the climate.""" self._api = device + self._device = self._api.device if name is None: name = device.name self._name = name @@ -55,7 +56,7 @@ def __init__(self, device: MelCloudDevice, name=None): @property def unique_id(self) -> Optional[str]: """Return a unique ID.""" - return f"{self._api.device.serial}-{self._api.device.mac}-climate" + return f"{self._device.serial}-{self._device.mac}-climate" @property def name(self): @@ -94,20 +95,20 @@ def precision(self) -> float: @property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" - return TEMP_UNIT_LOOKUP.get(self._api.device.temp_unit, TEMP_CELSIUS) + return TEMP_UNIT_LOOKUP.get(self._device.temp_unit, TEMP_CELSIUS) @property def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode.""" - mode = self._api.device.operation_mode - if not self._api.device.power or mode is None: + mode = self._device.operation_mode + if not self._device.power or mode is None: return HVAC_MODE_OFF return HVAC_MODE_LOOKUP.get(mode) async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" if hvac_mode == HVAC_MODE_OFF: - await self._api.device.set({"power": False}) + await self._device.set({"power": False}) return operation_mode = HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode, None) @@ -117,59 +118,59 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: props = {"operation_mode": operation_mode} if self.hvac_mode == HVAC_MODE_OFF: props["power"] = True - await self._api.device.set(props) + await self._device.set(props) @property def hvac_modes(self) -> List[str]: """Return the list of available hvac operation modes.""" return [HVAC_MODE_OFF] + list( - map(HVAC_MODE_LOOKUP.get, self._api.device.operation_modes) + map(HVAC_MODE_LOOKUP.get, self._device.operation_modes) ) @property def current_temperature(self) -> Optional[float]: """Return the current temperature.""" - return self._api.device.room_temperature + return self._device.room_temperature @property def target_temperature(self) -> Optional[float]: """Return the temperature we try to reach.""" - return self._api.device.target_temperature + return self._device.target_temperature async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" - await self._api.device.set( + await self._device.set( {"target_temperature": kwargs.get("temperature", self.target_temperature)} ) @property def target_temperature_step(self) -> Optional[float]: """Return the supported step of target temperature.""" - return self._api.device.target_temperature_step + return self._device.target_temperature_step @property def fan_mode(self) -> Optional[str]: """Return the fan setting.""" - return self._api.device.fan_speed + return self._device.fan_speed async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._api.device.set({"fan_speed": fan_mode}) + await self._device.set({"fan_speed": fan_mode}) @property def fan_modes(self) -> Optional[List[str]]: """Return the list of available fan modes.""" - return self._api.device.fan_speeds + return self._device.fan_speeds async def async_turn_on(self) -> None: """Turn the entity on.""" - if not self._api.device.power: - await self._api.device.set({"power": True}) + if not self._device.power: + await self._device.set({"power": True}) async def async_turn_off(self) -> None: """Turn the entity off.""" - if self._api.device.power: - await self._api.device.set({"power": False}) + if self._device.power: + await self._device.set({"power": False}) @property def supported_features(self) -> int: @@ -179,7 +180,7 @@ def supported_features(self) -> int: @property def min_temp(self) -> float: """Return the minimum temperature.""" - min_value = self._api.device.target_temperature_min + min_value = self._device.target_temperature_min if min_value is not None: return min_value @@ -190,7 +191,7 @@ def min_temp(self) -> float: @property def max_temp(self) -> float: """Return the maximum temperature.""" - max_value = self._api.device.target_temperature_max + max_value = self._device.target_temperature_max if max_value is not None: return max_value From cd89dab8c304e16943d573af6a937b3f85fa495c Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Fri, 31 Jan 2020 07:40:58 +0200 Subject: [PATCH 17/35] Prefer list comprehension --- homeassistant/components/melcloud/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 43eff832790814..1123493a54a346 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -123,9 +123,9 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: @property def hvac_modes(self) -> List[str]: """Return the list of available hvac operation modes.""" - return [HVAC_MODE_OFF] + list( - map(HVAC_MODE_LOOKUP.get, self._device.operation_modes) - ) + return [HVAC_MODE_OFF] + [ + HVAC_MODE_LOOKUP.get(mode) for mode in self._device.operation_modes + ] @property def current_temperature(self) -> Optional[float]: From 42f00b5cfe499f25866c7e7134cd60101f84563e Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sat, 8 Feb 2020 12:10:44 +0200 Subject: [PATCH 18/35] Update pymelcloud to leverage device type grouping The updated backend lib returns devices in a dict grouped by the device type. The devices do not necessarily need to be in a single list and this way isinstance is not required to extract devices by type. --- homeassistant/components/melcloud/__init__.py | 5 ++++- homeassistant/components/melcloud/climate.py | 8 ++------ homeassistant/components/melcloud/manifest.json | 2 +- homeassistant/components/melcloud/sensor.py | 5 ++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/melcloud/test_config_flow.py | 5 ++++- 7 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 3219393bef650d..8e8f0efcc794f3 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -147,4 +147,7 @@ async def mel_api_setup(hass, token) -> Optional[List[MelCloudDevice]]: except (asyncio.TimeoutError, ClientConnectionError) as ex: raise ConfigEntryNotReady() from ex - return [MelCloudDevice(device) for device in devices] + wrapped_devices = {} + for k, value in devices.items(): + wrapped_devices[k] = [MelCloudDevice(device) for device in value] + return wrapped_devices diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 1123493a54a346..498fa40a2027b7 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -3,7 +3,7 @@ import logging from typing import List, Optional -from pymelcloud import AtaDevice +from pymelcloud import DEVICE_TYPE_ATA from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( @@ -33,11 +33,7 @@ async def async_setup_entry( """Set up MelCloud device climate based on config_entry.""" mel_devices = hass.data[DOMAIN].get(entry.entry_id) async_add_entities( - [ - AtaDeviceClimate(mel_device) - for mel_device in mel_devices - if isinstance(mel_device.device, AtaDevice) - ], + [AtaDeviceClimate(mel_device) for mel_device in mel_devices[DEVICE_TYPE_ATA]], True, ) diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 63248e2ee74a3c..3c7182fe5b007d 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -3,7 +3,7 @@ "name": "MELCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", - "requirements": ["pymelcloud==1.2.0"], + "requirements": ["pymelcloud==2.0.0"], "ssdp": [], "zeroconf": [], "homekit": {}, diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index fbf044be48be6d..84826b0ea318cd 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -1,7 +1,7 @@ """Support for MelCloud device sensors.""" import logging -from pymelcloud import AtaDevice +from pymelcloud import DEVICE_TYPE_ATA, AtaDevice from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from homeassistant.helpers.entity import Entity @@ -42,8 +42,7 @@ async def async_setup_entry(hass, entry, async_add_entities): [ MelCloudSensor(mel_device, definition, hass.config.units) for definition in SENSORS - for mel_device in mel_devices - if isinstance(mel_device.device, AtaDevice) + for mel_device in mel_devices[DEVICE_TYPE_ATA] ], True, ) diff --git a/requirements_all.txt b/requirements_all.txt index ab9de3d4539cb6..ad4e601936d264 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1355,7 +1355,7 @@ pymailgunner==1.4 pymediaroom==0.6.4 # homeassistant.components.melcloud -pymelcloud==1.2.0 +pymelcloud==2.0.0 # homeassistant.components.somfy pymfy==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f63d304717e8a4..4420c9ae103676 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -485,7 +485,7 @@ pylitejet==0.1 pymailgunner==1.4 # homeassistant.components.melcloud -pymelcloud==1.2.0 +pymelcloud==2.0.0 # homeassistant.components.somfy pymfy==0.7.1 diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index 9145298ee397aa..c9e4546fa67102 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -3,6 +3,7 @@ from aiohttp import ClientError, ClientResponseError from asynctest import patch as async_patch +import pymelcloud import pytest from homeassistant import config_entries @@ -23,7 +24,9 @@ def mock_login(): def mock_get_devices(): """Mock pymelcloud get_devices.""" with async_patch("pymelcloud.get_devices") as mock: - mock.return_value = mock_coro([]) + mock.return_value = mock_coro( + {pymelcloud.DEVICE_TYPE_ATA: [], pymelcloud.DEVICE_TYPE_ATW: []} + ) yield mock From 7117a864de90bc2d42d03ac3544c5fb1aa991883 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sat, 8 Feb 2020 20:52:10 +0200 Subject: [PATCH 19/35] Remove DOMAIN presence check This does not seem to make much sense after all. --- homeassistant/components/melcloud/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 8e8f0efcc794f3..7192fba9b112e5 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -31,9 +31,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigEntry): """Establish connection with MELCloud.""" - if DOMAIN not in config: - return True - email = config[DOMAIN].get(CONF_EMAIL) token = config[DOMAIN].get(CONF_TOKEN) hass.async_create_task( From edb99946752292c0280d5725f4e5194ff538b9ef Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sat, 8 Feb 2020 21:11:47 +0200 Subject: [PATCH 20/35] Fix async_setup_entry Entry setup used half-baked naming from few experimentations back. The naming conventiens were unified to match the platforms. A redundant noneness check was also removed after evaluating the possible return values from the backend lib. --- homeassistant/components/melcloud/__init__.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 7192fba9b112e5..17b24ec297a037 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -2,7 +2,7 @@ import asyncio from datetime import timedelta import logging -from typing import Dict, List, Optional +from typing import Dict, List from aiohttp import ClientConnectionError from async_timeout import timeout @@ -46,10 +46,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigEntry): async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Establish connection with MELClooud.""" conf = entry.data - mel_api = await mel_api_setup(hass, conf[CONF_TOKEN]) - if not mel_api: - return False - hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_api}) + mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) for platform in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, platform) @@ -130,8 +128,8 @@ def device_info(self): return _device_info -async def mel_api_setup(hass, token) -> Optional[List[MelCloudDevice]]: - """Create a MELCloud instance only once.""" +async def mel_devices_setup(hass, token) -> List[MelCloudDevice]: + """Query connected devices from MELCloud.""" session = hass.helpers.aiohttp_client.async_get_clientsession() try: with timeout(10): From d82ec6596c52943f6b0955d85135a5097e0bf2c9 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sat, 8 Feb 2020 23:28:17 +0200 Subject: [PATCH 21/35] Simplify empty model name check --- homeassistant/components/melcloud/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 17b24ec297a037..4b4f207672ce0d 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -123,7 +123,7 @@ def device_info(self): unit_infos = self.device.units if unit_infos is not None: _device_info["model"] = ", ".join( - [x["model"] for x in unit_infos if len(x["model"]) > 0] + [x["model"] for x in unit_infos if x["model"]] ) return _device_info From c70be1fd6c459c95443f9362f00a0f1f2d7ecc89 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 10:42:56 +0200 Subject: [PATCH 22/35] Improve config validation * Use config_validation strings. * Add CONF_EMAIL to config schema. The value is not strictly required when configuring through configuration.yaml, but having it there makes things more consistent. * Use dict[key] to access required properties. * Add DOMAIN in config check back to async_setup. This is required if an integration is configured throught config_flow. --- homeassistant/components/melcloud/__init__.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 4b4f207672ce0d..6e810b31358279 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_TOKEN from homeassistant.exceptions import ConfigEntryNotReady +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import Throttle @@ -25,14 +26,22 @@ CONF_LANGUAGE = "language" CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Required(CONF_TOKEN): str})}, extra=vol.ALLOW_EXTRA, + { + DOMAIN: vol.Schema( + {vol.Required(CONF_EMAIL): cv.string, vol.Required(CONF_TOKEN): cv.string} + ) + }, + extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistantType, config: ConfigEntry): """Establish connection with MELCloud.""" - email = config[DOMAIN].get(CONF_EMAIL) - token = config[DOMAIN].get(CONF_TOKEN) + if DOMAIN not in config: + return True + + email = config[DOMAIN][CONF_EMAIL] + token = config[DOMAIN][CONF_TOKEN] hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, From 76ca68c23bc3c278f96374dcf71d1fdd0b82cb81 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 10:48:09 +0200 Subject: [PATCH 23/35] Remove unused manifest properties --- homeassistant/components/melcloud/manifest.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 3c7182fe5b007d..43331def303ec6 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -4,9 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", "requirements": ["pymelcloud==2.0.0"], - "ssdp": [], - "zeroconf": [], - "homekit": {}, "dependencies": [], "codeowners": ["@vilppuvuorinen"] } From a80b1eedc53bc336260c689fd7dd6bea28ac78ad Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 10:49:23 +0200 Subject: [PATCH 24/35] Remove redundant ClimateDevice property override --- homeassistant/components/melcloud/climate.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 498fa40a2027b7..c0ecb5d753e15a 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -59,14 +59,6 @@ def name(self): """Return the display name of this light.""" return self._name - @property - def should_poll(self) -> bool: - """Return True if entity has to be polled for state. - - False if entity pushes its state to HA. - """ - return True - async def async_update(self): """Update state from MELCloud.""" await self._api.async_update() From a65b78010b7053d3bb2bd5162d989bb26dcd8c8a Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 19:28:33 +0200 Subject: [PATCH 25/35] Add __init__.py to coverage exclusion --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index 13c0683e9ea9aa..bd9bf19632109f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -410,6 +410,7 @@ omit = homeassistant/components/mcp23017/* homeassistant/components/media_extractor/* homeassistant/components/mediaroom/media_player.py + homeassistant/components/melcloud/__init__.py homeassistant/components/melcloud/climate.py homeassistant/components/melcloud/sensor.py homeassistant/components/message_bird/notify.py From fdcfcff70cbe4fd8c579725c2e034126a4b3ab36 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 20:02:25 +0200 Subject: [PATCH 26/35] Use CONF_USERNAME instead of CONF_EMAIL --- .../components/melcloud/.translations/en.json | 2 +- homeassistant/components/melcloud/__init__.py | 11 ++++--- .../components/melcloud/config_flow.py | 32 +++++++++---------- .../components/melcloud/strings.json | 2 +- tests/components/melcloud/test_config_flow.py | 29 ++++++----------- 5 files changed, 34 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/melcloud/.translations/en.json b/homeassistant/components/melcloud/.translations/en.json index c77495a7d72413..eb3505054054cd 100644 --- a/homeassistant/components/melcloud/.translations/en.json +++ b/homeassistant/components/melcloud/.translations/en.json @@ -6,7 +6,7 @@ "title": "Connect to MELCloud", "description": "Connect using your MELCloud account.", "data": { - "email": "Email used to login to MELCloud.", + "username": "Email used to login to MELCloud.", "password": "MELCloud password." } } diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 6e810b31358279..56744d7b61b505 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_TOKEN +from homeassistant.const import CONF_TOKEN, CONF_USERNAME from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType @@ -28,7 +28,10 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( - {vol.Required(CONF_EMAIL): cv.string, vol.Required(CONF_TOKEN): cv.string} + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_TOKEN): cv.string, + } ) }, extra=vol.ALLOW_EXTRA, @@ -40,13 +43,13 @@ async def async_setup(hass: HomeAssistantType, config: ConfigEntry): if DOMAIN not in config: return True - email = config[DOMAIN][CONF_EMAIL] + username = config[DOMAIN][CONF_USERNAME] token = config[DOMAIN][CONF_TOKEN] hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, - data={CONF_EMAIL: email, CONF_TOKEN: token}, + data={CONF_USERNAME: username, CONF_TOKEN: token}, ) ) return True diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 504d50b8bba098..5dd2c78401b93e 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME _LOGGER = logging.getLogger(__name__) @@ -21,26 +21,26 @@ class FlowHandler(config_entries.ConfigFlow): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - async def _create_entry(self, email: str, token: str): + async def _create_entry(self, username: str, token: str): """Register new entry.""" for entry in self._async_current_entries(): - if entry.data.get(CONF_EMAIL, entry.title) == email: + if entry.data[CONF_USERNAME] == username: entry.connection_class = self.CONNECTION_CLASS self.hass.config_entries.async_update_entry( - entry, data={CONF_EMAIL: email, CONF_TOKEN: token} + entry, data={CONF_USERNAME: username, CONF_TOKEN: token} ) return self.async_abort( reason="already_configured", - description_placeholders={"email": email}, + description_placeholders={"email": username}, ) return self.async_create_entry( - title=email, data={CONF_EMAIL: email, CONF_TOKEN: token}, + title=username, data={CONF_USERNAME: username, CONF_TOKEN: token}, ) async def _create_client( self, - email: str, + username: str, *, password: Optional[str] = None, token: Optional[str] = None, @@ -56,7 +56,7 @@ async def _create_client( acquired_token = token if acquired_token is None: acquired_token = await pymelcloud.login( - email, + username, password, self.hass.helpers.aiohttp_client.async_get_clientsession(), ) @@ -71,7 +71,7 @@ async def _create_client( except (asyncio.TimeoutError, ClientError): return self.async_abort(reason="cannot_connect") - return await self._create_entry(email, acquired_token) + return await self._create_entry(username, acquired_token) async def async_step_user(self, user_input=None): """User initiated config flow.""" @@ -79,16 +79,14 @@ async def async_step_user(self, user_input=None): return self.async_show_form( step_id="user", data_schema=vol.Schema( - {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} ), ) - email = user_input[CONF_EMAIL] - return await self._create_client(email, password=user_input[CONF_PASSWORD],) + username = user_input[CONF_USERNAME] + return await self._create_client(username, password=user_input[CONF_PASSWORD],) async def async_step_import(self, user_input): """Import a config entry.""" - email = user_input.get(CONF_EMAIL) - token = user_input.get(CONF_TOKEN) - if not token: - return await self.async_step_user() - return await self._create_client(email, token=token,) + return await self._create_client( + user_input[CONF_USERNAME], token=user_input[CONF_TOKEN] + ) diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index c77495a7d72413..eb3505054054cd 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -6,7 +6,7 @@ "title": "Connect to MELCloud", "description": "Connect using your MELCloud account.", "data": { - "email": "Email used to login to MELCloud.", + "username": "Email used to login to MELCloud.", "password": "MELCloud password." } } diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index c9e4546fa67102..c0343e29447743 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -54,13 +54,13 @@ async def test_form(hass, mock_login, mock_get_devices): ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"email": "test-email@test-domain.com", "password": "test-password"}, + {"username": "test-email@test-domain.com", "password": "test-password"}, ) assert result2["type"] == "create_entry" assert result2["title"] == "test-email@test-domain.com" assert result2["data"] == { - "email": "test-email@test-domain.com", + "username": "test-email@test-domain.com", "token": "test-token", } await hass.async_block_till_done() @@ -88,7 +88,7 @@ async def test_form_errors(hass, mock_login, mock_get_devices, error, reason): ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"email": "test-email@test-domain.com", "password": "test-password"}, + {"username": "test-email@test-domain.com", "password": "test-password"}, ) assert len(mock_login.mock_calls) == 1 @@ -120,22 +120,13 @@ async def test_form_response_errors( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"email": "test-email@test-domain.com", "password": "test-password"}, + {"username": "test-email@test-domain.com", "password": "test-password"}, ) assert result2["type"] == "abort" assert result2["reason"] == message -async def test_failed_import_form(hass, mock_login, mock_get_devices): - """Test we get the form if imported without token.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={}, - ) - assert result["type"] == "form" - assert result["errors"] is None - - async def test_import_with_token(hass, mock_login, mock_get_devices): """Test successful import.""" with async_patch( @@ -147,13 +138,13 @@ async def test_import_with_token(hass, mock_login, mock_get_devices): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={"email": "test-email@test-domain.com", "token": "test-token"}, + data={"username": "test-email@test-domain.com", "token": "test-token"}, ) assert result["type"] == "create_entry" assert result["title"] == "test-email@test-domain.com" assert result["data"] == { - "email": "test-email@test-domain.com", + "username": "test-email@test-domain.com", "token": "test-token", } await hass.async_block_till_done() @@ -162,13 +153,13 @@ async def test_import_with_token(hass, mock_login, mock_get_devices): async def test_token_refresh(hass, mock_login, mock_get_devices): - """Re-configuration with existing email should refresh token.""" + """Re-configuration with existing username should refresh token.""" await hass.config_entries.async_add( config_entries.ConfigEntry( 1, DOMAIN, "", - {"email": "test-email@test-domain.com", "token": "test-original-token"}, + {"username": "test-email@test-domain.com", "token": "test-original-token"}, config_entries.SOURCE_USER, config_entries.CONN_CLASS_CLOUD_POLL, {}, @@ -187,7 +178,7 @@ async def test_token_refresh(hass, mock_login, mock_get_devices): ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"email": "test-email@test-domain.com", "password": "test-password"}, + {"username": "test-email@test-domain.com", "password": "test-password"}, ) assert result2["type"] == "abort" @@ -200,5 +191,5 @@ async def test_token_refresh(hass, mock_login, mock_get_devices): assert len(entries) == 1 entry = entries[0] - assert entry.data["email"] == "test-email@test-domain.com" + assert entry.data["username"] == "test-email@test-domain.com" assert entry.data["token"] == "test-token" From 819cbf9b97aa8316c7f66e5b10b283bb642e1bc2 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 20:04:49 +0200 Subject: [PATCH 27/35] Use asyncio.gather instead of asyncio.wait --- homeassistant/components/melcloud/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 56744d7b61b505..bb4ad3dec7cf70 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -69,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): async def async_unload_entry(hass, config_entry): """Unload a config entry.""" - await asyncio.wait( + await asyncio.gather( [ hass.config_entries.async_forward_entry_unload(config_entry, platform) for platform in PLATFORMS From 16ae770b2f0d9a535517d8557a5e429edeabe6c4 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 20:25:52 +0200 Subject: [PATCH 28/35] Misc fixes * any -> Any * Better names for dict iterations * Proper dict access with mandatory/known keys * Remove unused 'name' argument * Remove unnecessary platform info from unique_ids * Remove redundant methods from climate platform * Remove redundant default value from dict get * Update ConfigFlow sub-classing * Define sensors in a dict instead of a list --- homeassistant/components/melcloud/__init__.py | 10 +++---- homeassistant/components/melcloud/climate.py | 27 ++++-------------- .../components/melcloud/config_flow.py | 4 +-- homeassistant/components/melcloud/sensor.py | 28 ++++++++----------- 4 files changed, 24 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index bb4ad3dec7cf70..799265dbd14b75 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -2,7 +2,7 @@ import asyncio from datetime import timedelta import logging -from typing import Dict, List +from typing import Any, Dict, List from aiohttp import ClientConnectionError from async_timeout import timeout @@ -100,7 +100,7 @@ async def async_update(self, **kwargs): _LOGGER.warning("Connection failed for %s", self.name) self._available = False - async def async_set(self, properties: Dict[str, any]): + async def async_set(self, properties: Dict[str, Any]): """Write state changes to the MELCloud API.""" try: await self.device.set(properties) @@ -145,7 +145,7 @@ async def mel_devices_setup(hass, token) -> List[MelCloudDevice]: session = hass.helpers.aiohttp_client.async_get_clientsession() try: with timeout(10): - devices = await get_devices( + all_devices = await get_devices( token, session, conf_update_interval=timedelta(minutes=5), @@ -155,6 +155,6 @@ async def mel_devices_setup(hass, token) -> List[MelCloudDevice]: raise ConfigEntryNotReady() from ex wrapped_devices = {} - for k, value in devices.items(): - wrapped_devices[k] = [MelCloudDevice(device) for device in value] + for device_type, devices in all_devices.items(): + wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices] return wrapped_devices diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index c0ecb5d753e15a..f49b2833b86614 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -15,13 +15,12 @@ ) from homeassistant.components.melcloud import MelCloudDevice from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.temperature import convert as convert_temperature from .const import DOMAIN, HVAC_MODE_LOOKUP, HVAC_MODE_REVERSE_LOOKUP, TEMP_UNIT_LOOKUP -ENTITY_ID_FORMAT = f"{DOMAIN}.{{}}" SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -31,7 +30,7 @@ async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ): """Set up MelCloud device climate based on config_entry.""" - mel_devices = hass.data[DOMAIN].get(entry.entry_id) + mel_devices = hass.data[DOMAIN][entry.entry_id] async_add_entities( [AtaDeviceClimate(mel_device) for mel_device in mel_devices[DEVICE_TYPE_ATA]], True, @@ -41,18 +40,16 @@ async def async_setup_entry( class AtaDeviceClimate(ClimateDevice): """Air-to-Air climate device.""" - def __init__(self, device: MelCloudDevice, name=None): + def __init__(self, device: MelCloudDevice): """Initialize the climate.""" self._api = device self._device = self._api.device - if name is None: - name = device.name - self._name = name + self._name = device.name @property def unique_id(self) -> Optional[str]: """Return a unique ID.""" - return f"{self._device.serial}-{self._device.mac}-climate" + return f"{self._device.serial}-{self._device.mac}" @property def name(self): @@ -68,18 +65,6 @@ def device_info(self): """Return a device description for device registry.""" return self._api.device_info - @property - def state(self) -> str: - """Return the current state.""" - return self.hvac_mode - - @property - def precision(self) -> float: - """Return the precision of the system.""" - if self.hass.config.units.temperature_unit == TEMP_CELSIUS: - return PRECISION_TENTHS - return PRECISION_WHOLE - @property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" @@ -99,7 +84,7 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: await self._device.set({"power": False}) return - operation_mode = HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode, None) + operation_mode = HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) if operation_mode is None: raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 5dd2c78401b93e..ad947a75b6bb53 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -9,13 +9,13 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.melcloud.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME _LOGGER = logging.getLogger(__name__) -@config_entries.HANDLERS.register("melcloud") -class FlowHandler(config_entries.ConfigFlow): +class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 84826b0ea318cd..d9a466eab5c7a0 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -9,28 +9,25 @@ from .const import DOMAIN, TEMP_UNIT_LOOKUP -ATTR_MEASUREMENT = "measurement" ATTR_ICON = "icon" ATTR_UNIT_FN = "unit_fn" ATTR_DEVICE_CLASS = "device_class" ATTR_VALUE_FN = "value_fn" -SENSORS = [ - { - ATTR_MEASUREMENT: "Room Temperature", +SENSORS = { + "Room Temperature": { ATTR_ICON: "mdi:thermometer", ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ATTR_VALUE_FN: lambda x: x.device.room_temperature, }, - { - ATTR_MEASUREMENT: "Energy", + "Energy": { ATTR_ICON: "mdi:factory", ATTR_UNIT_FN: lambda x: "kWh", ATTR_DEVICE_CLASS: None, ATTR_VALUE_FN: lambda x: x.device.total_energy_consumed, }, -] +} _LOGGER = logging.getLogger(__name__) @@ -40,8 +37,8 @@ async def async_setup_entry(hass, entry, async_add_entities): mel_devices = hass.data[DOMAIN].get(entry.entry_id) async_add_entities( [ - MelCloudSensor(mel_device, definition, hass.config.units) - for definition in SENSORS + MelCloudSensor(mel_device, measurement, definition, hass.config.units) + for measurement, definition in SENSORS.items() for mel_device in mel_devices[DEVICE_TYPE_ATA] ], True, @@ -51,20 +48,17 @@ async def async_setup_entry(hass, entry, async_add_entities): class MelCloudSensor(Entity): """Representation of a Sensor.""" - def __init__(self, device: AtaDevice, definition, units: UnitSystem, name=None): + def __init__(self, device: AtaDevice, measurement, definition, units: UnitSystem): """Initialize the sensor.""" self._api = device - if name is None: - self._name_slug = device.name - else: - self._name_slug = name - + self._name_slug = device.name + self._measurement = measurement self._def = definition @property def unique_id(self): """Return a unique ID.""" - normalized = self._def[ATTR_MEASUREMENT].lower().replace(" ", "_") + normalized = self._measurement.lower().replace(" ", "_") return f"{self._api.device.serial}-{self._api.device.mac}-{normalized}" @property @@ -75,7 +69,7 @@ def icon(self): @property def name(self): """Return the name of the sensor.""" - return f"{self._name_slug} {self._def[ATTR_MEASUREMENT]}" + return f"{self._name_slug} {self._measurement}" @property def state(self): From a00f9c60844cb001d29d487e9d95d48668184c74 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 20:37:48 +0200 Subject: [PATCH 29/35] Use _abort_if_unique_id_configured to update token --- .../components/melcloud/.translations/en.json | 2 +- homeassistant/components/melcloud/config_flow.py | 13 ++----------- homeassistant/components/melcloud/strings.json | 2 +- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/melcloud/.translations/en.json b/homeassistant/components/melcloud/.translations/en.json index eb3505054054cd..477ca7eb5e2844 100644 --- a/homeassistant/components/melcloud/.translations/en.json +++ b/homeassistant/components/melcloud/.translations/en.json @@ -17,7 +17,7 @@ "unknown": "Unexpected error" }, "abort": { - "already_configured": "MELCloud integration already configured for {email}. Existing access token has been refreshed." + "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." } } } diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index ad947a75b6bb53..6fde7aba949742 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -23,17 +23,8 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def _create_entry(self, username: str, token: str): """Register new entry.""" - for entry in self._async_current_entries(): - if entry.data[CONF_USERNAME] == username: - entry.connection_class = self.CONNECTION_CLASS - self.hass.config_entries.async_update_entry( - entry, data={CONF_USERNAME: username, CONF_TOKEN: token} - ) - return self.async_abort( - reason="already_configured", - description_placeholders={"email": username}, - ) - + await self.async_set_unique_id(username) + self._abort_if_unique_id_configured({CONF_TOKEN: token}) return self.async_create_entry( title=username, data={CONF_USERNAME: username, CONF_TOKEN: token}, ) diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index eb3505054054cd..477ca7eb5e2844 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -17,7 +17,7 @@ "unknown": "Unexpected error" }, "abort": { - "already_configured": "MELCloud integration already configured for {email}. Existing access token has been refreshed." + "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." } } } From 1acac8bbc753e046483a79d8ad69c7419d09066c Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 21:10:37 +0200 Subject: [PATCH 30/35] Fix them tests --- .../components/melcloud/config_flow.py | 2 +- tests/components/melcloud/test_config_flow.py | 125 +++++++----------- 2 files changed, 52 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 6fde7aba949742..aa43ee391d57ba 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -74,7 +74,7 @@ async def async_step_user(self, user_input=None): ), ) username = user_input[CONF_USERNAME] - return await self._create_client(username, password=user_input[CONF_PASSWORD],) + return await self._create_client(username, password=user_input[CONF_PASSWORD]) async def async_step_import(self, user_input): """Import a config entry.""" diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index c0343e29447743..fab880c6bd93ac 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -2,38 +2,39 @@ import asyncio from aiohttp import ClientError, ClientResponseError -from asynctest import patch as async_patch +from asynctest import patch import pymelcloud import pytest from homeassistant import config_entries from homeassistant.components.melcloud.const import DOMAIN -from tests.common import mock_coro +from tests.common import MockConfigEntry @pytest.fixture def mock_login(): """Mock pymelcloud login.""" - with async_patch("pymelcloud.login") as mock: - mock.return_value = mock_coro("test-token") + with patch("pymelcloud.login") as mock: + mock.return_value = "test-token" yield mock @pytest.fixture def mock_get_devices(): """Mock pymelcloud get_devices.""" - with async_patch("pymelcloud.get_devices") as mock: - mock.return_value = mock_coro( - {pymelcloud.DEVICE_TYPE_ATA: [], pymelcloud.DEVICE_TYPE_ATW: []} - ) + with patch("pymelcloud.get_devices") as mock: + mock.return_value = { + pymelcloud.DEVICE_TYPE_ATA: [], + pymelcloud.DEVICE_TYPE_ATW: [], + } yield mock @pytest.fixture def mock_request_info(): - """Mock RequestInfo to create ClientResposenErrors.""" - with async_patch("aiohttp.RequestInfo") as mock_ri: + """Mock RequestInfo to create ClientResponseErrors.""" + with patch("aiohttp.RequestInfo") as mock_ri: mock_ri.return_value.real_url.return_value = "" yield mock_ri @@ -46,11 +47,10 @@ async def test_form(hass, mock_login, mock_get_devices): assert result["type"] == "form" assert result["errors"] is None - with async_patch( - "homeassistant.components.melcloud.async_setup", return_value=mock_coro(True) - ) as mock_setup, async_patch( - "homeassistant.components.melcloud.async_setup_entry", - return_value=mock_coro(True), + with patch( + "homeassistant.components.melcloud.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.melcloud.async_setup_entry", return_value=True, ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -74,26 +74,17 @@ async def test_form(hass, mock_login, mock_get_devices): ) async def test_form_errors(hass, mock_login, mock_get_devices, error, reason): """Test we handle cannot connect error.""" - mock_login.return_value = mock_coro(exception=error) + mock_login.side_effect = error result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"username": "test-email@test-domain.com", "password": "test-password"}, ) - with async_patch( - "homeassistant.components.melcloud.async_setup", return_value=mock_coro(True) - ), async_patch( - "homeassistant.components.melcloud.async_setup_entry", - return_value=mock_coro(True), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-email@test-domain.com", "password": "test-password"}, - ) - assert len(mock_login.mock_calls) == 1 - assert result2["type"] == "abort" - assert result2["reason"] == reason + assert result["type"] == "abort" + assert result["reason"] == reason @pytest.mark.parametrize( @@ -104,36 +95,24 @@ async def test_form_response_errors( hass, mock_login, mock_get_devices, mock_request_info, error, message ): """Test we handle response errors.""" - mock_login.return_value = mock_coro( - exception=ClientResponseError(mock_request_info(), (), status=error), - ) + mock_login.side_effect = ClientResponseError(mock_request_info(), (), status=error) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"username": "test-email@test-domain.com", "password": "test-password"}, ) - with async_patch( - "homeassistant.components.melcloud.async_setup", return_value=mock_coro(True) - ), async_patch( - "homeassistant.components.melcloud.async_setup_entry", - return_value=mock_coro(True), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-email@test-domain.com", "password": "test-password"}, - ) - - assert result2["type"] == "abort" - assert result2["reason"] == message + assert result["type"] == "abort" + assert result["reason"] == message async def test_import_with_token(hass, mock_login, mock_get_devices): """Test successful import.""" - with async_patch( - "homeassistant.components.melcloud.async_setup", return_value=mock_coro(True) - ) as mock_setup, async_patch( - "homeassistant.components.melcloud.async_setup_entry", - return_value=mock_coro(True), + with patch( + "homeassistant.components.melcloud.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.melcloud.async_setup_entry", return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( DOMAIN, @@ -155,34 +134,32 @@ async def test_import_with_token(hass, mock_login, mock_get_devices): async def test_token_refresh(hass, mock_login, mock_get_devices): """Re-configuration with existing username should refresh token.""" await hass.config_entries.async_add( - config_entries.ConfigEntry( - 1, - DOMAIN, - "", - {"username": "test-email@test-domain.com", "token": "test-original-token"}, - config_entries.SOURCE_USER, - config_entries.CONN_CLASS_CLOUD_POLL, - {}, + MockConfigEntry( + domain=DOMAIN, + data={ + "username": "test-email@test-domain.com", + "token": "test-original-token", + }, + unique_id="test-email@test-domain.com", ) ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with async_patch( - "homeassistant.components.melcloud.async_setup", return_value=mock_coro(True) - ) as mock_setup, async_patch( - "homeassistant.components.melcloud.async_setup_entry", - return_value=mock_coro(True), + with patch( + "homeassistant.components.melcloud.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.melcloud.async_setup_entry", return_value=True, ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"username": "test-email@test-domain.com", "password": "test-password"}, + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + "username": "test-email@test-domain.com", + "password": "test-password", + }, ) - assert result2["type"] == "abort" - assert result2["reason"] == "already_configured" + assert result["type"] == "abort" + assert result["reason"] == "already_configured" await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 From 9612ac43f3e0d113ed11d57ddbf8c12bc5f71802 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 21:47:10 +0200 Subject: [PATCH 31/35] Remove current state guards --- homeassistant/components/melcloud/climate.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index f49b2833b86614..9ac8fe86a9402e 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -137,13 +137,11 @@ def fan_modes(self) -> Optional[List[str]]: async def async_turn_on(self) -> None: """Turn the entity on.""" - if not self._device.power: - await self._device.set({"power": True}) + await self._device.set({"power": True}) async def async_turn_off(self) -> None: """Turn the entity off.""" - if self._device.power: - await self._device.set({"power": False}) + await self._device.set({"power": False}) @property def supported_features(self) -> int: From ea9fa9c3c9cbebc4e6f18e4a52b2466c919b4f95 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 21:50:38 +0200 Subject: [PATCH 32/35] Fix that gather call --- homeassistant/components/melcloud/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 799265dbd14b75..ef932f36aa4bbd 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -70,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): async def async_unload_entry(hass, config_entry): """Unload a config entry.""" await asyncio.gather( - [ + *[ hass.config_entries.async_forward_entry_unload(config_entry, platform) for platform in PLATFORMS ] From 1fcfe91d370e050717528716c7b177fa315354d3 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 21:55:25 +0200 Subject: [PATCH 33/35] Implement sensor definitions without str manipulation --- homeassistant/components/melcloud/sensor.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index d9a466eab5c7a0..428c83a4ee3038 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -9,19 +9,22 @@ from .const import DOMAIN, TEMP_UNIT_LOOKUP +ATTR_MEASUREMENT_NAME = "measurement_name" ATTR_ICON = "icon" ATTR_UNIT_FN = "unit_fn" ATTR_DEVICE_CLASS = "device_class" ATTR_VALUE_FN = "value_fn" SENSORS = { - "Room Temperature": { + "room_temperature": { + ATTR_MEASUREMENT_NAME: "Room Temperature", ATTR_ICON: "mdi:thermometer", ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ATTR_VALUE_FN: lambda x: x.device.room_temperature, }, - "Energy": { + "energy": { + ATTR_MEASUREMENT_NAME: "Energy", ATTR_ICON: "mdi:factory", ATTR_UNIT_FN: lambda x: "kWh", ATTR_DEVICE_CLASS: None, @@ -58,8 +61,7 @@ def __init__(self, device: AtaDevice, measurement, definition, units: UnitSystem @property def unique_id(self): """Return a unique ID.""" - normalized = self._measurement.lower().replace(" ", "_") - return f"{self._api.device.serial}-{self._api.device.mac}-{normalized}" + return f"{self._api.device.serial}-{self._api.device.mac}-{self._measurement}" @property def icon(self): @@ -69,7 +71,7 @@ def icon(self): @property def name(self): """Return the name of the sensor.""" - return f"{self._name_slug} {self._measurement}" + return f"{self._name_slug} {self._def[ATTR_MEASUREMENT_NAME]}" @property def state(self): From d9a56c23d42c0c3268ed05b94e339e16e15d59d8 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Sun, 9 Feb 2020 22:00:25 +0200 Subject: [PATCH 34/35] Use relative intra-package imports --- homeassistant/components/melcloud/climate.py | 2 +- .../components/melcloud/config_flow.py | 3 ++- tests/components/melcloud/test_config_flow.py | 17 ++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 9ac8fe86a9402e..95cb1489f45998 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -13,12 +13,12 @@ SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.components.melcloud import MelCloudDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.temperature import convert as convert_temperature +from . import MelCloudDevice from .const import DOMAIN, HVAC_MODE_LOOKUP, HVAC_MODE_REVERSE_LOOKUP, TEMP_UNIT_LOOKUP SCAN_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index aa43ee391d57ba..ab1037b65558e5 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -9,9 +9,10 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.melcloud.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index fab880c6bd93ac..90c766f0831acc 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -133,16 +133,15 @@ async def test_import_with_token(hass, mock_login, mock_get_devices): async def test_token_refresh(hass, mock_login, mock_get_devices): """Re-configuration with existing username should refresh token.""" - await hass.config_entries.async_add( - MockConfigEntry( - domain=DOMAIN, - data={ - "username": "test-email@test-domain.com", - "token": "test-original-token", - }, - unique_id="test-email@test-domain.com", - ) + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "test-email@test-domain.com", + "token": "test-original-token", + }, + unique_id="test-email@test-domain.com", ) + mock_entry.add_to_hass(hass) with patch( "homeassistant.components.melcloud.async_setup", return_value=True From f95dd320027d05e6439ed6279da94c0aea6dbd93 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Mon, 10 Feb 2020 14:21:45 +0200 Subject: [PATCH 35/35] Update homeassistant/components/melcloud/config_flow.py Co-Authored-By: Martin Hjelmare --- homeassistant/components/melcloud/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index ab1037b65558e5..6bda8cc3c28242 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -11,7 +11,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME -from .const import DOMAIN +from .const import DOMAIN # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__)