Add MELCloud integration (#30712)

* 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.

* Run isort

* Fix pylint errors

* Run black

* Increase coverage

* Update pymelcloud dependency

* Add HVAC_MODE_OFF emulation

* Remove print

* Update pymelcloud to enable device type filtering

* Collapse except blocks and chain ClientNotReadys

* Add preliminary documentation URL

* Use list comp for creating model info

Filters out empty model names form units.

* f-string galore

Dropped 'HVAC' from AtaDevice name template.

* Delegate fan mode mapping to pymelcloud

* Fix type annotation

* Access AtaDevice through self._device

* Prefer list comprehension

* 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.

* Remove DOMAIN presence check

This does not seem to make much sense after all.

* 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.

* Simplify empty model name check

* 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.

* Remove unused manifest properties

* Remove redundant ClimateDevice property override

* Add __init__.py to coverage exclusion

* Use CONF_USERNAME instead of CONF_EMAIL

* Use asyncio.gather instead of asyncio.wait

* 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

* Use _abort_if_unique_id_configured to update token

* Fix them tests

* Remove current state guards

* Fix that gather call

* Implement sensor definitions without str manipulation

* Use relative intra-package imports

* Update homeassistant/components/melcloud/config_flow.py

Co-Authored-By: Martin Hjelmare <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Vilppu Vuorinen 2020-02-10 23:09:12 +02:00 committed by GitHub
parent 7e0560c7dc
commit b78d156f0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 780 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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."
}
}
}

View File

@ -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

View File

@ -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
)

View File

@ -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]
)

View File

@ -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()}

View File

@ -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"]
}

View File

@ -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

View File

@ -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."
}
}
}

View File

@ -54,6 +54,7 @@ FLOWS = [
"logi_circle",
"luftdaten",
"mailgun",
"melcloud",
"met",
"meteo_france",
"mikrotik",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the MELCloud integration."""

View File

@ -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"