Add MELCloud token refresh upon firmware upgrade (#104391)

* Adding initial setup

* Update homeassistant/components/melcloud/__init__.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Adding ConfigEntryNotReady exception

* Update homeassistant/components/melcloud/__init__.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update homeassistant/components/melcloud/config_flow.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Update homeassistant/components/melcloud/__init__.py

Co-authored-by: G Johansson <goran.johansson@shiftit.se>

* Placing exception handling in setup_entry

* Expanding test cases

---------

Co-authored-by: G Johansson <goran.johansson@shiftit.se>
This commit is contained in:
Erwin Douna 2023-12-02 23:07:06 +01:00 committed by GitHub
parent b48ad268b5
commit 7a9c3819e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 256 additions and 15 deletions

View File

@ -6,7 +6,7 @@ from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from aiohttp import ClientConnectionError from aiohttp import ClientConnectionError, ClientResponseError
from pymelcloud import Device, get_devices from pymelcloud import Device, get_devices
from pymelcloud.atw_device import Zone from pymelcloud.atw_device import Zone
import voluptuous as vol import voluptuous as vol
@ -14,7 +14,7 @@ import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@ -66,7 +66,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Establish connection with MELClooud.""" """Establish connection with MELClooud."""
conf = entry.data conf = entry.data
try:
mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN])
except ClientResponseError as ex:
if isinstance(ex, ClientResponseError) and ex.code == 401:
raise ConfigEntryAuthFailed from ex
raise ConfigEntryNotReady from ex
except (asyncio.TimeoutError, ClientConnectionError) as ex:
raise ConfigEntryNotReady from ex
hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices})
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
@ -162,7 +170,6 @@ async def mel_devices_setup(
) -> dict[str, list[MelCloudDevice]]: ) -> dict[str, list[MelCloudDevice]]:
"""Query connected devices from MELCloud.""" """Query connected devices from MELCloud."""
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
try:
async with asyncio.timeout(10): async with asyncio.timeout(10):
all_devices = await get_devices( all_devices = await get_devices(
token, token,
@ -170,9 +177,6 @@ async def mel_devices_setup(
conf_update_interval=timedelta(minutes=5), conf_update_interval=timedelta(minutes=5),
device_set_debounce=timedelta(seconds=1), device_set_debounce=timedelta(seconds=1),
) )
except (asyncio.TimeoutError, ClientConnectionError) as ex:
raise ConfigEntryNotReady() from ex
wrapped_devices: dict[str, list[MelCloudDevice]] = {} wrapped_devices: dict[str, list[MelCloudDevice]] = {}
for device_type, devices in all_devices.items(): for device_type, devices in all_devices.items():
wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices] wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices]

View File

@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Mapping
from http import HTTPStatus from http import HTTPStatus
import logging
from typing import Any
from aiohttp import ClientError, ClientResponseError from aiohttp import ClientError, ClientResponseError
import pymelcloud import pymelcloud
@ -11,12 +14,14 @@ import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import AbortFlow, FlowResultType from homeassistant.data_entry_flow import AbortFlow, FlowResult, FlowResultType
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_create_import_issue( async def async_create_import_issue(
hass: HomeAssistant, source: str, issue: str, success: bool = False hass: HomeAssistant, source: str, issue: str, success: bool = False
@ -56,6 +61,8 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
entry: config_entries.ConfigEntry | None = None
async def _create_entry(self, username: str, token: str): async def _create_entry(self, username: str, token: str):
"""Register new entry.""" """Register new entry."""
await self.async_set_unique_id(username) await self.async_set_unique_id(username)
@ -126,3 +133,67 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if result["type"] == FlowResultType.CREATE_ENTRY: if result["type"] == FlowResultType.CREATE_ENTRY:
await async_create_import_issue(self.hass, self.context["source"], "", True) await async_create_import_issue(self.hass, self.context["source"], "", True)
return result return result
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Handle initiation of re-authentication with MELCloud."""
self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle re-authentication with MELCloud."""
errors: dict[str, str] = {}
if user_input is not None and self.entry:
aquired_token, errors = await self.async_reauthenticate_client(user_input)
if not errors:
self.hass.config_entries.async_update_entry(
self.entry,
data={CONF_TOKEN: aquired_token},
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
),
errors=errors,
)
async def async_reauthenticate_client(
self, user_input: dict[str, Any]
) -> tuple[str | None, dict[str, str]]:
"""Reauthenticate with MELCloud."""
errors: dict[str, str] = {}
acquired_token = None
try:
async with asyncio.timeout(10):
acquired_token = await pymelcloud.login(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
async_get_clientsession(self.hass),
)
except (ClientResponseError, AttributeError) as err:
if isinstance(err, ClientResponseError) and err.status in (
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
):
errors["base"] = "invalid_auth"
elif isinstance(err, AttributeError) and err.name == "get":
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
except (
asyncio.TimeoutError,
ClientError,
):
errors["base"] = "cannot_connect"
return acquired_token, errors

View File

@ -8,6 +8,14 @@
"username": "[%key:common::config_flow::data::email%]", "username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
} }
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Melcloud integration needs to re-authenticate your connection details",
"data": {
"username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
} }
}, },
"error": { "error": {
@ -16,6 +24,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed."
} }
}, },

View File

@ -9,7 +9,9 @@ import pytest
from homeassistant import config_entries, data_entry_flow from homeassistant import config_entries, data_entry_flow
from homeassistant.components.melcloud.const import DOMAIN from homeassistant.components.melcloud.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
import homeassistant.helpers.issue_registry as ir import homeassistant.helpers.issue_registry as ir
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -287,3 +289,158 @@ async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices)
entry = entries[0] entry = entries[0]
assert entry.data["username"] == "test-email@test-domain.com" assert entry.data["username"] == "test-email@test-domain.com"
assert entry.data["token"] == "test-token" assert entry.data["token"] == "test-token"
async def test_token_reauthentication(
hass: HomeAssistant,
mock_login,
mock_get_devices,
) -> None:
"""Re-configuration with existing username should refresh token, if made invalid."""
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)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"unique_id": mock_entry.unique_id,
"entry_id": mock_entry.entry_id,
},
data=mock_entry.data,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
with patch(
"homeassistant.components.melcloud.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": "test-email@test-domain.com", "password": "test-password"},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("error", "reason"),
[
(asyncio.TimeoutError(), "cannot_connect"),
(AttributeError(name="get"), "invalid_auth"),
],
)
async def test_form_errors_reauthentication(
hass: HomeAssistant, mock_login, error, reason
) -> None:
"""Test we handle cannot connect error."""
mock_login.side_effect = error
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)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"unique_id": mock_entry.unique_id,
"entry_id": mock_entry.entry_id,
},
data=mock_entry.data,
)
with patch(
"homeassistant.components.melcloud.async_setup_entry",
return_value=True,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": "test-email@test-domain.com", "password": "test-password"},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
assert result["errors"]["base"] == reason
mock_login.side_effect = None
with patch(
"homeassistant.components.melcloud.async_setup_entry",
return_value=True,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": "test-email@test-domain.com", "password": "test-password"},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
@pytest.mark.parametrize(
("error", "reason"),
[
(HTTPStatus.UNAUTHORIZED, "invalid_auth"),
(HTTPStatus.FORBIDDEN, "invalid_auth"),
(HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"),
],
)
async def test_client_errors_reauthentication(
hass: HomeAssistant, mock_login, mock_request_info, error, reason
) -> None:
"""Test we handle cannot connect error."""
mock_login.side_effect = ClientResponseError(mock_request_info(), (), status=error)
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)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"unique_id": mock_entry.unique_id,
"entry_id": mock_entry.entry_id,
},
data=mock_entry.data,
)
with patch(
"homeassistant.components.melcloud.async_setup_entry",
return_value=True,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": "test-email@test-domain.com", "password": "test-password"},
)
await hass.async_block_till_done()
assert result["errors"]["base"] == reason
assert result["type"] == FlowResultType.FORM
mock_login.side_effect = None
with patch(
"homeassistant.components.melcloud.async_setup_entry",
return_value=True,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": "test-email@test-domain.com", "password": "test-password"},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reauth_successful"