diff --git a/.coveragerc b/.coveragerc index 369aaa3b4e0..bd9bf196321 100644 --- a/.coveragerc +++ b/.coveragerc @@ -410,6 +410,9 @@ 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 homeassistant/components/met/weather.py homeassistant/components/meteo_france/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index c3f018ef83a..46c3d416b5d 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 00000000000..477ca7eb5e2 --- /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": { + "username": "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 this email. Access token has been refreshed." + } + } +} diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py new file mode 100644 index 00000000000..ef932f36aa4 --- /dev/null +++ b/homeassistant/components/melcloud/__init__.py @@ -0,0 +1,160 @@ +"""The MELCloud Climate integration.""" +import asyncio +from datetime import timedelta +import logging +from typing import Any, Dict, List + +from aiohttp import ClientConnectionError +from async_timeout import timeout +from pymelcloud import Device, get_devices +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +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 +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_USERNAME): cv.string, + vol.Required(CONF_TOKEN): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigEntry): + """Establish connection with MELCloud.""" + if DOMAIN not in config: + return True + + 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_USERNAME: username, CONF_TOKEN: token}, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Establish connection with MELClooud.""" + conf = entry.data + 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) + ) + return True + + +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 + ] + ) + 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( + [x["model"] for x in unit_infos if x["model"]] + ) + return _device_info + + +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): + all_devices = await get_devices( + token, + session, + conf_update_interval=timedelta(minutes=5), + device_set_debounce=timedelta(seconds=1), + ) + except (asyncio.TimeoutError, ClientConnectionError) as ex: + raise ConfigEntryNotReady() from ex + + wrapped_devices = {} + 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 new file mode 100644 index 00000000000..95cb1489f45 --- /dev/null +++ b/homeassistant/components/melcloud/climate.py @@ -0,0 +1,171 @@ +"""Platform for climate integration.""" +from datetime import timedelta +import logging +from typing import List, Optional + +from pymelcloud import DEVICE_TYPE_ATA + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +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) + +_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][entry.entry_id] + async_add_entities( + [AtaDeviceClimate(mel_device) for mel_device in mel_devices[DEVICE_TYPE_ATA]], + True, + ) + + +class AtaDeviceClimate(ClimateDevice): + """Air-to-Air climate device.""" + + def __init__(self, device: MelCloudDevice): + """Initialize the climate.""" + self._api = device + self._device = self._api.device + self._name = device.name + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self._device.serial}-{self._device.mac}" + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + 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 temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + 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._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._device.set({"power": False}) + return + + operation_mode = HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) + if operation_mode is None: + raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") + + props = {"operation_mode": operation_mode} + if self.hvac_mode == HVAC_MODE_OFF: + props["power"] = True + await self._device.set(props) + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac 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]: + """Return the current temperature.""" + return self._device.room_temperature + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + return self._device.target_temperature + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + 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._device.target_temperature_step + + @property + def fan_mode(self) -> Optional[str]: + """Return the fan setting.""" + return self._device.fan_speed + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target 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._device.fan_speeds + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self._device.set({"power": True}) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self._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._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._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 00000000000..6bda8cc3c28 --- /dev/null +++ b/homeassistant/components/melcloud/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow for the MELCloud platform.""" +import asyncio +import logging +from typing import Optional + +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_PASSWORD, CONF_TOKEN, CONF_USERNAME + +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _create_entry(self, username: str, token: str): + """Register new entry.""" + 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}, + ) + + async def _create_client( + self, + username: 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: + with timeout(10): + acquired_token = token + if acquired_token is None: + acquired_token = await pymelcloud.login( + username, + password, + self.hass.helpers.aiohttp_client.async_get_clientsession(), + ) + await pymelcloud.get_devices( + acquired_token, + self.hass.helpers.aiohttp_client.async_get_clientsession(), + ) + 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 (asyncio.TimeoutError, ClientError): + return self.async_abort(reason="cannot_connect") + + return await self._create_entry(username, acquired_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_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + ) + 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.""" + return await self._create_client( + user_input[CONF_USERNAME], token=user_input[CONF_TOKEN] + ) diff --git a/homeassistant/components/melcloud/const.py b/homeassistant/components/melcloud/const.py new file mode 100644 index 00000000000..e262be2c3fb --- /dev/null +++ b/homeassistant/components/melcloud/const.py @@ -0,0 +1,29 @@ +"""Constants for the MELCloud Climate integration.""" +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, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, +) +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT + +DOMAIN = "melcloud" + +HVAC_MODE_LOOKUP = { + 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 = { + 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 new file mode 100644 index 00000000000..43331def303 --- /dev/null +++ b/homeassistant/components/melcloud/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "melcloud", + "name": "MELCloud", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/melcloud", + "requirements": ["pymelcloud==2.0.0"], + "dependencies": [], + "codeowners": ["@vilppuvuorinen"] +} diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py new file mode 100644 index 00000000000..428c83a4ee3 --- /dev/null +++ b/homeassistant/components/melcloud/sensor.py @@ -0,0 +1,98 @@ +"""Support for MelCloud device sensors.""" +import logging + +from pymelcloud import DEVICE_TYPE_ATA, AtaDevice + +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_NAME = "measurement_name" +ATTR_ICON = "icon" +ATTR_UNIT_FN = "unit_fn" +ATTR_DEVICE_CLASS = "device_class" +ATTR_VALUE_FN = "value_fn" + +SENSORS = { + "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": { + ATTR_MEASUREMENT_NAME: "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__) + + +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, measurement, definition, hass.config.units) + for measurement, definition in SENSORS.items() + for mel_device in mel_devices[DEVICE_TYPE_ATA] + ], + True, + ) + + +class MelCloudSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, device: AtaDevice, measurement, definition, units: UnitSystem): + """Initialize the sensor.""" + self._api = device + self._name_slug = device.name + self._measurement = measurement + self._def = definition + + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self._api.device.serial}-{self._api.device.mac}-{self._measurement}" + + @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_NAME]}" + + @property + def state(self): + """Return the state of the sensor.""" + 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._api) + + @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 00000000000..477ca7eb5e2 --- /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": { + "username": "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 this email. Access token has been refreshed." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4872b08e9fc..c77a8de9388 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -54,6 +54,7 @@ FLOWS = [ "logi_circle", "luftdaten", "mailgun", + "melcloud", "met", "meteo_france", "mikrotik", diff --git a/requirements_all.txt b/requirements_all.txt index 7079f858245..acfb089c203 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==2.0.0 + # homeassistant.components.somfy pymfy==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 85022c10dc2..af22deb6b6c 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==2.0.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 00000000000..f20383660d4 --- /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 00000000000..90c766f0831 --- /dev/null +++ b/tests/components/melcloud/test_config_flow.py @@ -0,0 +1,171 @@ +"""Test the MELCloud config flow.""" +import asyncio + +from aiohttp import ClientError, ClientResponseError +from asynctest import patch +import pymelcloud +import pytest + +from homeassistant import config_entries +from homeassistant.components.melcloud.const import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_login(): + """Mock pymelcloud login.""" + with patch("pymelcloud.login") as mock: + mock.return_value = "test-token" + yield mock + + +@pytest.fixture +def mock_get_devices(): + """Mock pymelcloud get_devices.""" + 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 ClientResponseErrors.""" + with patch("aiohttp.RequestInfo") as mock_ri: + mock_ri.return_value.real_url.return_value = "" + yield mock_ri + + +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} + ) + assert result["type"] == "form" + assert result["errors"] is None + + 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"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-email@test-domain.com" + assert result2["data"] == { + "username": "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,reason", + [(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.""" + mock_login.side_effect = error + + 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 len(mock_login.mock_calls) == 1 + assert result["type"] == "abort" + assert result["reason"] == reason + + +@pytest.mark.parametrize( + "error,message", + [(401, "invalid_auth"), (403, "invalid_auth"), (500, "cannot_connect")], +) +async def test_form_response_errors( + hass, mock_login, mock_get_devices, mock_request_info, error, message +): + """Test we handle response errors.""" + 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}, + data={"username": "test-email@test-domain.com", "password": "test-password"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == message + + +async def test_import_with_token(hass, mock_login, mock_get_devices): + """Test successful import.""" + 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, + context={"source": config_entries.SOURCE_IMPORT}, + 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"] == { + "username": "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, mock_get_devices): + """Re-configuration with existing username should refresh token.""" + 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 + ) 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, + context={"source": config_entries.SOURCE_USER}, + data={ + "username": "test-email@test-domain.com", + "password": "test-password", + }, + ) + + 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 + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + entry = entries[0] + assert entry.data["username"] == "test-email@test-domain.com" + assert entry.data["token"] == "test-token"