From ad43394a69b13c594c1608d3b6e24ec48efd2f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Thu, 27 Jan 2022 21:43:27 +0000 Subject: [PATCH 01/19] First version of laundrify integration --- CODEOWNERS | 2 + .../components/laundrify/__init__.py | 51 +++++ .../components/laundrify/binary_sensor.py | 131 +++++++++++++ .../components/laundrify/config_flow.py | 119 ++++++++++++ homeassistant/components/laundrify/const.py | 9 + .../components/laundrify/manifest.json | 17 ++ .../components/laundrify/strings.json | 36 ++++ .../components/laundrify/translations/en.json | 36 ++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/laundrify/__init__.py | 86 +++++++++ tests/components/laundrify/const.py | 10 + .../laundrify/fixtures/machines.json | 32 ++++ .../components/laundrify/test_config_flow.py | 175 ++++++++++++++++++ 15 files changed, 711 insertions(+) create mode 100644 homeassistant/components/laundrify/__init__.py create mode 100644 homeassistant/components/laundrify/binary_sensor.py create mode 100644 homeassistant/components/laundrify/config_flow.py create mode 100644 homeassistant/components/laundrify/const.py create mode 100644 homeassistant/components/laundrify/manifest.json create mode 100644 homeassistant/components/laundrify/strings.json create mode 100644 homeassistant/components/laundrify/translations/en.json create mode 100644 tests/components/laundrify/__init__.py create mode 100644 tests/components/laundrify/const.py create mode 100644 tests/components/laundrify/fixtures/machines.json create mode 100644 tests/components/laundrify/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 6a851cccae1768..3141ab754b7b22 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -544,6 +544,8 @@ build.json @home-assistant/supervisor /homeassistant/components/lametric/ @robbiet480 @frenck /homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol /tests/components/launch_library/ @ludeeus @DurgNomis-drol +homeassistant/components/laundrify/* @xLarry +tests/components/laundrify/* @xLarry /homeassistant/components/lcn/ @alengwenus /tests/components/lcn/ @alengwenus /homeassistant/components/lg_netcast/ @Drafteed diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py new file mode 100644 index 00000000000000..17d665ed797c9b --- /dev/null +++ b/homeassistant/components/laundrify/__init__.py @@ -0,0 +1,51 @@ +"""The laundrify integration.""" +from __future__ import annotations + +import logging + +from laundrify_aio import LaundrifyAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[str] = ["binary_sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up laundrify from a config entry.""" + # _LOGGER.info(entry.data) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {} + + # Registers update listener to update config entry when options are updated. + unsub_options_update_listener = entry.add_update_listener(options_update_listener) + # Store a reference to the unsubscribe function to cleanup if an entry is unloaded. + hass.data[DOMAIN][entry.entry_id]["update_listener"] = unsub_options_update_listener + + hass.data[DOMAIN][entry.entry_id]["api"] = LaundrifyAPI(entry.data["access_token"]) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def options_update_listener(hass: HomeAssistant, config_entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + # Remove options_update_listener. + hass.data[DOMAIN][entry.entry_id]["update_listener"]() + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py new file mode 100644 index 00000000000000..a93133907b66b2 --- /dev/null +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -0,0 +1,131 @@ +"""Platform for binary sensor integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import async_timeout +from laundrify_aio.errors import ApiConnectionError, ApiUnauthorized + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + CONF_POLL_INTERVAL, + DEFAULT_POLL_INTERVAL, + DOMAIN, + MANUFACTURER, + MODEL, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up sensors from a config entry created in the integrations UI.""" + # API object has been stored here by __init__.py + laundrify_api = hass.data[DOMAIN][config.entry_id]["api"] + + async def async_update_data(): + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + try: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with async_timeout.timeout(10): + return await laundrify_api.get_machines() + except ApiUnauthorized as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise ConfigEntryAuthFailed from err + except ApiConnectionError as err: + raise UpdateFailed from err + + poll_interval = config.options.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="binary_sensor", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=poll_interval), + ) + + # + # Fetch initial data so we have data when entities subscribe + # + # If the refresh fails, async_config_entry_first_refresh will + # raise ConfigEntryNotReady and setup will try again later + # + # If you do not want to retry setup on failure, use + # coordinator.async_refresh() instead + # + await coordinator.async_config_entry_first_refresh() + + async_add_entities( + LaundrifyPowerPlug(coordinator, idx) for idx, ent in enumerate(coordinator.data) + ) + + +class LaundrifyPowerPlug(CoordinatorEntity, BinarySensorEntity): + """An entity using CoordinatorEntity. + + The CoordinatorEntity class provides: + should_poll + async_update + async_added_to_hass + available + + """ + + _attr_device_class = BinarySensorDeviceClass.RUNNING + _attr_icon = "mdi:washing-machine" + + def __init__(self, coordinator, idx): + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self.idx = idx + self._attr_unique_id = coordinator.data[idx]["_id"] + self._attr_name = coordinator.data[idx]["name"] + + @property + def device_info(self): + """Configure the Device of this Entity.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + "manufacturer": MANUFACTURER, + "model": MODEL, + "sw_version": self.coordinator.data[self.idx]["firmwareVersion"], + } + + @property + def is_on(self): + """Return entity state.""" + try: + return self.coordinator.data[self.idx]["status"] == "ON" + except IndexError: + _LOGGER.warning("The backend didn't return any data for this device") + self._attr_available = False + return None diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py new file mode 100644 index 00000000000000..b885454613b327 --- /dev/null +++ b/homeassistant/components/laundrify/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for laundrify integration.""" +from __future__ import annotations + +import logging + +from laundrify_aio import LaundrifyAPI +from laundrify_aio.errors import ApiConnectionError, InvalidFormat, UnknownAuthCode +from voluptuous import All, Optional, Range, Required, Schema + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = Schema({Required(CONF_CODE): str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for laundrify.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initialized by the user.""" + return await self.async_step_init(user_input) + + async def async_step_init(self, user_input=None) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form(step_id="init", data_schema=CONFIG_SCHEMA) + + errors = {} + + try: + access_token = await LaundrifyAPI.exchange_auth_code(user_input[CONF_CODE]) + except InvalidFormat: + errors[CONF_CODE] = "invalid_format" + except UnknownAuthCode: + errors[CONF_CODE] = "invalid_auth" + except ApiConnectionError as err: + _LOGGER.warning(str(err)) + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + entry_data = {CONF_ACCESS_TOKEN: access_token} + + # The integration supports only a single config entry + existing_entry = await self.async_set_unique_id(DOMAIN) + if existing_entry: + _LOGGER.info( + "%s entry already exists, going to update and reload it", DOMAIN + ) + self.hass.config_entries.async_update_entry( + existing_entry, data=entry_data + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="single_instance_allowed") + + # Create a new entry if it doesn't exist + return self.async_create_entry( + title=DOMAIN, + data=entry_data, + ) + + return self.async_show_form( + step_id="init", data_schema=CONFIG_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, user_input=None): + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=Schema({}), + ) + return await self.async_step_init() + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options flow for laundrify.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=Schema( + { + Optional( + CONF_POLL_INTERVAL, + default=self.config_entry.options.get( + CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL + ), + ): All(int, Range(min=10)), + } + ), + ) diff --git a/homeassistant/components/laundrify/const.py b/homeassistant/components/laundrify/const.py new file mode 100644 index 00000000000000..cc9a030b6e2349 --- /dev/null +++ b/homeassistant/components/laundrify/const.py @@ -0,0 +1,9 @@ +"""Constants for the laundrify integration.""" + +DOMAIN = "laundrify" + +MANUFACTURER = "laundrify" +MODEL = "WLAN-Adapter (SU02)" + +CONF_POLL_INTERVAL = "poll_interval" +DEFAULT_POLL_INTERVAL = 60 diff --git a/homeassistant/components/laundrify/manifest.json b/homeassistant/components/laundrify/manifest.json new file mode 100644 index 00000000000000..690d5bad0aee36 --- /dev/null +++ b/homeassistant/components/laundrify/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "laundrify", + "name": "laundrify", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/laundrify", + "requirements": [ + "laundrify_aio==1.0.0" + ], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@xLarry" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/laundrify/strings.json b/homeassistant/components/laundrify/strings.json new file mode 100644 index 00000000000000..4eb3fb71951b78 --- /dev/null +++ b/homeassistant/components/laundrify/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "init": { + "description": "Please enter your personal AuthCode that is shown in the laundrify-App.", + "data": { + "code": "Auth Code (xxx-xxx)", + "poll_interval": "Poll Interval (in seconds)" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The laundrify integration needs to re-authenticate." + } + }, + "error": { + "invalid_format": "Invalid format. Please specify as xxx-xxx.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "options": { + "step": { + "init": { + "title": "Configure laundrify", + "data": { + "poll_interval": "Poll Interval (in seconds)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/laundrify/translations/en.json b/homeassistant/components/laundrify/translations/en.json new file mode 100644 index 00000000000000..4f70c21b70ae49 --- /dev/null +++ b/homeassistant/components/laundrify/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "invalid_format": "Invalid format. Please specify as xxx-xxx.", + "unknown": "Unexpected error" + }, + "step": { + "init": { + "data": { + "code": "Auth Code (xxx-xxx)", + "poll_interval": "Poll Interval (in seconds)" + }, + "description": "Please enter your personal AuthCode that is shown in the laundrify-App." + }, + "reauth_confirm": { + "description": "The laundrify integration needs to re-authenticate.", + "title": "Reauthenticate Integration" + } + } + }, + "options": { + "step": { + "init": { + "title": "Configure laundrify", + "data": { + "poll_interval": "Poll Interval (in seconds)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c51c4a9733a5f7..bb223749ddeae3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -183,6 +183,7 @@ "kraken", "kulersky", "launch_library", + "laundrify", "life360", "lifx", "litejet", diff --git a/requirements_all.txt b/requirements_all.txt index bdd79e06124047..bfdb67435dca16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -914,6 +914,9 @@ krakenex==2.1.0 # homeassistant.components.eufy lakeside==0.12 +# homeassistant.components.laundrify +laundrify_aio==1.0.0 + # homeassistant.components.foscam libpyfoscam==1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26493228f07cdd..be8f50cbee0454 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -636,6 +636,9 @@ kostal_plenticore==0.2.0 # homeassistant.components.kraken krakenex==2.1.0 +# homeassistant.components.laundrify +laundrify_aio==1.0.0 + # homeassistant.components.foscam libpyfoscam==1.0 diff --git a/tests/components/laundrify/__init__.py b/tests/components/laundrify/__init__.py new file mode 100644 index 00000000000000..21aae358ea59ab --- /dev/null +++ b/tests/components/laundrify/__init__.py @@ -0,0 +1,86 @@ +"""Tests for the laundrify integration.""" +import json +from unittest.mock import patch + +from laundrify_aio import errors + +from homeassistant.components.laundrify import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from .const import VALID_ACCESS_TOKEN + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + + +def _patch_laundrify_exchange_code(): + return patch( + "homeassistant.components.laundrify.config_flow.LaundrifyAPI.exchange_auth_code", + return_value=VALID_ACCESS_TOKEN, + ) + + +def _patch_laundrify_get_machines(): + return patch( + "homeassistant.components.laundrify.LaundrifyAPI.get_machines", + return_value=json.loads(load_fixture("laundrify/machines.json")), + ) + + +def create_entry( + hass: HomeAssistant, access_token: str = VALID_ACCESS_TOKEN +) -> MockConfigEntry: + """Create laundrify entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DOMAIN, + data={CONF_ACCESS_TOKEN: access_token}, + ) + entry.add_to_hass(hass) + return entry + + +async def init_integration( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + access_token: str = VALID_ACCESS_TOKEN, + error: bool = False, +) -> MockConfigEntry: + """Set up the laundrify integration in Home Assistant.""" + entry = create_entry(hass, access_token=access_token) + await mock_responses(hass, aioclient_mock, access_token=access_token, error=error) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + +async def mock_responses( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + access_token: str = VALID_ACCESS_TOKEN, + error: bool = False, +): + """Mock responses from Efergy.""" + base_url = "https://test.laundrify.de/api" + # api = LaundrifyAPI(auth_code, session=async_get_clientsession(hass)) + if error: + aioclient_mock.get( + f"{base_url}getInstant", + exc=errors.ApiConnectionError, + ) + return + aioclient_mock.get( + f"{base_url}/machines", + text=load_fixture("laundrify/machines.json"), + ) + if access_token == VALID_ACCESS_TOKEN: + aioclient_mock.get( + f"{base_url}/getCurrentValuesSummary", + text=load_fixture("laundrify/machines.json"), + ) + else: + aioclient_mock.get( + f"{base_url}/getCurrentValuesSummary", + text=load_fixture("laundrify/machines.json"), + ) diff --git a/tests/components/laundrify/const.py b/tests/components/laundrify/const.py new file mode 100644 index 00000000000000..c350d369f1f8b2 --- /dev/null +++ b/tests/components/laundrify/const.py @@ -0,0 +1,10 @@ +"""Constants for the laundrify tests.""" + +from homeassistant.const import CONF_CODE + +VALID_AUTH_CODE = "999-001" +VALID_ACCESS_TOKEN = "validAccessToken1234" + +VALID_USER_INPUT = { + CONF_CODE: VALID_AUTH_CODE, +} diff --git a/tests/components/laundrify/fixtures/machines.json b/tests/components/laundrify/fixtures/machines.json new file mode 100644 index 00000000000000..38d7bc91021d6d --- /dev/null +++ b/tests/components/laundrify/fixtures/machines.json @@ -0,0 +1,32 @@ +[ + { + "_id": "14", + "name": "Demo Waschmaschine", + "status": "OFF", + "lastChange": null, + "time_threshold": 95, + "power_threshold": 8, + "has_creaseprotect": true, + "share_id": "demo", + "ssid": null, + "internalIP": null, + "subnet": null, + "gateway": null, + "firmwareVersion": null, + "chipID": null, + "useInterpolation": false, + "poweron_threshold": 10000, + "createdAt": "2018-09-09T06:27:08.943Z", + "updatedAt": "2022-01-25T18:57:38.977Z", + "_hub": null, + "Listing": { + "_id": "659d4a73", + "origin": "migration", + "isSubscribed": false, + "createdAt": "2022-01-20T13:28:47.824Z", + "updatedAt": "2022-01-20T13:28:47.825Z", + "_machine": "14", + "_user": 9125 + } + } +] \ No newline at end of file diff --git a/tests/components/laundrify/test_config_flow.py b/tests/components/laundrify/test_config_flow.py new file mode 100644 index 00000000000000..6798f04918197d --- /dev/null +++ b/tests/components/laundrify/test_config_flow.py @@ -0,0 +1,175 @@ +"""Test the laundrify config flow.""" +from unittest.mock import patch + +from laundrify_aio import errors + +from homeassistant.components.laundrify.const import CONF_POLL_INTERVAL, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import ( + _patch_laundrify_exchange_code, + _patch_laundrify_get_machines, + create_entry, +) +from .const import VALID_ACCESS_TOKEN, VALID_AUTH_CODE, VALID_USER_INPUT + + +def _patch_setup_entry(): + return patch("homeassistant.components.laundrify.async_setup_entry") + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + with _patch_laundrify_exchange_code(), _patch_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DOMAIN + assert result["data"] == { + CONF_ACCESS_TOKEN: VALID_ACCESS_TOKEN, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_format(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + with _patch_laundrify_exchange_code() as laundrify_mock: + laundrify_mock.side_effect = errors.InvalidFormat + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_CODE: "invalidFormat"}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {CONF_CODE: "invalid_format"} + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + with _patch_laundrify_exchange_code() as laundrify_mock: + laundrify_mock.side_effect = errors.UnknownAuthCode + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=VALID_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {CONF_CODE: "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant): + """Test we handle cannot connect error.""" + with _patch_laundrify_exchange_code() as laundrify_mock: + laundrify_mock.side_effect = errors.ApiConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=VALID_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_unkown_exception(hass: HomeAssistant): + """Test we handle all other errors.""" + with _patch_laundrify_exchange_code() as laundrify_mock: + laundrify_mock.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=VALID_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_step_reauth(hass: HomeAssistant) -> None: + """Test the reauth form is shown.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + + +async def test_integration_already_exists(hass: HomeAssistant): + """Test we only allow a single config flow.""" + create_entry(hass) + with _patch_laundrify_exchange_code(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CODE: VALID_AUTH_CODE, + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_setup_entry_api_unauthorized(hass): + """Test that ConfigEntryAuthFailed is thrown when authentication fails.""" + with _patch_setup_entry(), _patch_laundrify_get_machines() as laundrify_mock: + laundrify_mock.side_effect = errors.ApiUnauthorized + config_entry = create_entry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) + + +async def test_options_flow(hass): + """Test options flow is shown.""" + with _patch_laundrify_exchange_code(), _patch_laundrify_get_machines(): + config_entry = create_entry(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_POLL_INTERVAL: 30} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_POLL_INTERVAL: 30} From f05473dbfaf0bcf3edc9d4d25ac7bae17a60ee57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Sun, 30 Jan 2022 13:26:24 +0000 Subject: [PATCH 02/19] Code cleanup --- homeassistant/components/laundrify/__init__.py | 1 - homeassistant/components/laundrify/binary_sensor.py | 3 ++- homeassistant/components/laundrify/const.py | 2 ++ tests/components/laundrify/__init__.py | 1 - tests/components/laundrify/test_config_flow.py | 2 +- 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index 17d665ed797c9b..faec630f207b09 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -17,7 +17,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up laundrify from a config entry.""" - # _LOGGER.info(entry.data) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = {} diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index a93133907b66b2..e5a0ea864c18a7 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -27,6 +27,7 @@ DOMAIN, MANUFACTURER, MODEL, + REQUEST_TIMEOUT, ) _LOGGER = logging.getLogger(__name__) @@ -48,7 +49,7 @@ async def async_update_data(): try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(10): + async with async_timeout.timeout(REQUEST_TIMEOUT): return await laundrify_api.get_machines() except ApiUnauthorized as err: # Raising ConfigEntryAuthFailed will cancel future updates diff --git a/homeassistant/components/laundrify/const.py b/homeassistant/components/laundrify/const.py index cc9a030b6e2349..1e0e0bbcf1927f 100644 --- a/homeassistant/components/laundrify/const.py +++ b/homeassistant/components/laundrify/const.py @@ -7,3 +7,5 @@ CONF_POLL_INTERVAL = "poll_interval" DEFAULT_POLL_INTERVAL = 60 + +REQUEST_TIMEOUT = 10 diff --git a/tests/components/laundrify/__init__.py b/tests/components/laundrify/__init__.py index 21aae358ea59ab..7923da09a940d1 100644 --- a/tests/components/laundrify/__init__.py +++ b/tests/components/laundrify/__init__.py @@ -63,7 +63,6 @@ async def mock_responses( ): """Mock responses from Efergy.""" base_url = "https://test.laundrify.de/api" - # api = LaundrifyAPI(auth_code, session=async_get_clientsession(hass)) if error: aioclient_mock.get( f"{base_url}getInstant", diff --git a/tests/components/laundrify/test_config_flow.py b/tests/components/laundrify/test_config_flow.py index 6798f04918197d..ee6884bacf9de3 100644 --- a/tests/components/laundrify/test_config_flow.py +++ b/tests/components/laundrify/test_config_flow.py @@ -49,7 +49,7 @@ async def test_form(hass: HomeAssistant) -> None: async def test_form_invalid_format(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" + """Test we handle invalid format.""" with _patch_laundrify_exchange_code() as laundrify_mock: laundrify_mock.side_effect = errors.InvalidFormat result = await hass.config_entries.flow.async_init( From 64b2f6d7ceed732778bd6ff29a19d102630c3247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Mon, 28 Feb 2022 15:21:00 +0000 Subject: [PATCH 03/19] Code cleanup after review #2 --- .../components/laundrify/__init__.py | 22 +++++------------- .../components/laundrify/binary_sensor.py | 23 ++----------------- .../components/laundrify/manifest.json | 4 ---- .../components/laundrify/strings.json | 2 +- 4 files changed, 9 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index faec630f207b09..2bb1f251244c0f 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -1,31 +1,24 @@ """The laundrify integration.""" from __future__ import annotations -import logging - from laundrify_aio import LaundrifyAPI from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - -PLATFORMS: list[str] = ["binary_sensor"] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up laundrify from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + "api": LaundrifyAPI(entry.data[CONF_ACCESS_TOKEN]) + } - # Registers update listener to update config entry when options are updated. - unsub_options_update_listener = entry.add_update_listener(options_update_listener) - # Store a reference to the unsubscribe function to cleanup if an entry is unloaded. - hass.data[DOMAIN][entry.entry_id]["update_listener"] = unsub_options_update_listener - - hass.data[DOMAIN][entry.entry_id]["api"] = LaundrifyAPI(entry.data["access_token"]) + entry.async_on_unload(entry.add_update_listener(options_update_listener)) hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -41,9 +34,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - # Remove options_update_listener. - hass.data[DOMAIN][entry.entry_id]["update_listener"]() - if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index e5a0ea864c18a7..6541edfb4d3c2f 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -37,7 +37,7 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up sensors from a config entry created in the integrations UI.""" - # API object has been stored here by __init__.py + laundrify_api = hass.data[DOMAIN][config.entry_id]["api"] async def async_update_data(): @@ -63,22 +63,11 @@ async def async_update_data(): coordinator = DataUpdateCoordinator( hass, _LOGGER, - # Name of the data. For logging purposes. name="binary_sensor", update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. update_interval=timedelta(seconds=poll_interval), ) - # - # Fetch initial data so we have data when entities subscribe - # - # If the refresh fails, async_config_entry_first_refresh will - # raise ConfigEntryNotReady and setup will try again later - # - # If you do not want to retry setup on failure, use - # coordinator.async_refresh() instead - # await coordinator.async_config_entry_first_refresh() async_add_entities( @@ -87,15 +76,7 @@ async def async_update_data(): class LaundrifyPowerPlug(CoordinatorEntity, BinarySensorEntity): - """An entity using CoordinatorEntity. - - The CoordinatorEntity class provides: - should_poll - async_update - async_added_to_hass - available - - """ + """Representation of a laundrify Power Plug.""" _attr_device_class = BinarySensorDeviceClass.RUNNING _attr_icon = "mdi:washing-machine" diff --git a/homeassistant/components/laundrify/manifest.json b/homeassistant/components/laundrify/manifest.json index 690d5bad0aee36..9f3e74c0eb45fb 100644 --- a/homeassistant/components/laundrify/manifest.json +++ b/homeassistant/components/laundrify/manifest.json @@ -6,10 +6,6 @@ "requirements": [ "laundrify_aio==1.0.0" ], - "ssdp": [], - "zeroconf": [], - "homekit": {}, - "dependencies": [], "codeowners": [ "@xLarry" ], diff --git a/homeassistant/components/laundrify/strings.json b/homeassistant/components/laundrify/strings.json index 4eb3fb71951b78..9a247096a500c0 100644 --- a/homeassistant/components/laundrify/strings.json +++ b/homeassistant/components/laundrify/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "init": { - "description": "Please enter your personal AuthCode that is shown in the laundrify-App.", + "description": "Please enter your personal Auth Code that is shown in the laundrify-App.", "data": { "code": "Auth Code (xxx-xxx)", "poll_interval": "Poll Interval (in seconds)" From 51b7daadad4e538ff97859015da1e3ba754c33c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Mon, 28 Feb 2022 15:31:57 +0000 Subject: [PATCH 04/19] Move coordinator to its own file --- .../components/laundrify/__init__.py | 13 ++++- .../components/laundrify/binary_sensor.py | 52 ++----------------- .../components/laundrify/coordinator.py | 41 +++++++++++++++ 3 files changed, 55 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/laundrify/coordinator.py diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index 2bb1f251244c0f..4fb9ebb1255c14 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -7,15 +7,24 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DOMAIN +from .coordinator import LaundrifyUpdateCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up laundrify from a config entry.""" + + api_client = LaundrifyAPI(entry.data[CONF_ACCESS_TOKEN]) + poll_interval = entry.options.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL) + coordinator = LaundrifyUpdateCoordinator(hass, api_client, poll_interval) + + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "api": LaundrifyAPI(entry.data[CONF_ACCESS_TOKEN]) + "api": api_client, + "coordinator": coordinator, } entry.async_on_unload(entry.add_update_listener(options_update_listener)) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 6541edfb4d3c2f..aedb5283659d35 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -1,34 +1,18 @@ """Platform for binary sensor integration.""" from __future__ import annotations -from datetime import timedelta import logging -import async_timeout -from laundrify_aio.errors import ApiConnectionError, ApiUnauthorized - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_POLL_INTERVAL, - DEFAULT_POLL_INTERVAL, - DOMAIN, - MANUFACTURER, - MODEL, - REQUEST_TIMEOUT, -) +from .const import DOMAIN, MANUFACTURER, MODEL _LOGGER = logging.getLogger(__name__) @@ -38,37 +22,7 @@ async def async_setup_entry( ) -> None: """Set up sensors from a config entry created in the integrations UI.""" - laundrify_api = hass.data[DOMAIN][config.entry_id]["api"] - - async def async_update_data(): - """Fetch data from API endpoint. - - This is the place to pre-process the data to lookup tables - so entities can quickly look up their data. - """ - try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with async_timeout.timeout(REQUEST_TIMEOUT): - return await laundrify_api.get_machines() - except ApiUnauthorized as err: - # Raising ConfigEntryAuthFailed will cancel future updates - # and start a config flow with SOURCE_REAUTH (async_step_reauth) - raise ConfigEntryAuthFailed from err - except ApiConnectionError as err: - raise UpdateFailed from err - - poll_interval = config.options.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL) - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="binary_sensor", - update_method=async_update_data, - update_interval=timedelta(seconds=poll_interval), - ) - - await coordinator.async_config_entry_first_refresh() + coordinator = hass.data[DOMAIN][config.entry_id]["coordinator"] async_add_entities( LaundrifyPowerPlug(coordinator, idx) for idx, ent in enumerate(coordinator.data) diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py new file mode 100644 index 00000000000000..0391ced893e83a --- /dev/null +++ b/homeassistant/components/laundrify/coordinator.py @@ -0,0 +1,41 @@ +"""Custom DataUpdateCoordinator for the laundrify integration.""" +from datetime import timedelta +import logging + +import async_timeout +from laundrify_aio.errors import ApiConnectionError, ApiUnauthorized + +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, REQUEST_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +class LaundrifyUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching laundrify API data.""" + + def __init__(self, hass, laundrify_api, poll_interval): + """Initialize laundrify coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=poll_interval), + ) + self.laundrify_api = laundrify_api + + async def _async_update_data(self): + """Fetch data from laundrify API.""" + try: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with async_timeout.timeout(REQUEST_TIMEOUT): + return await self.laundrify_api.get_machines() + except ApiUnauthorized as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise ConfigEntryAuthFailed from err + except ApiConnectionError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err From d410b77f30b95bd1a0236bc8eacdeef222411bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Tue, 1 Mar 2022 12:12:00 +0000 Subject: [PATCH 05/19] Save devices as dict and implement available prop as fn --- .../components/laundrify/binary_sensor.py | 26 ++++++++++--------- .../components/laundrify/coordinator.py | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index aedb5283659d35..cc7d358c4e5518 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config.entry_id]["coordinator"] async_add_entities( - LaundrifyPowerPlug(coordinator, idx) for idx, ent in enumerate(coordinator.data) + LaundrifyPowerPlug(coordinator, device) for device in coordinator.data.values() ) @@ -35,12 +35,11 @@ class LaundrifyPowerPlug(CoordinatorEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.RUNNING _attr_icon = "mdi:washing-machine" - def __init__(self, coordinator, idx): + def __init__(self, coordinator, device): """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) - self.idx = idx - self._attr_unique_id = coordinator.data[idx]["_id"] - self._attr_name = coordinator.data[idx]["name"] + self._attr_unique_id = device["_id"] + self._attr_name = device["name"] @property def device_info(self): @@ -53,15 +52,18 @@ def device_info(self): "name": self.name, "manufacturer": MANUFACTURER, "model": MODEL, - "sw_version": self.coordinator.data[self.idx]["firmwareVersion"], + "sw_version": self.coordinator.data[self.unique_id]["firmwareVersion"], } + @property + def available(self) -> bool: + """Check if the device is available.""" + return self.unique_id in self.coordinator.data + @property def is_on(self): """Return entity state.""" - try: - return self.coordinator.data[self.idx]["status"] == "ON" - except IndexError: - _LOGGER.warning("The backend didn't return any data for this device") - self._attr_available = False - return None + if self.available: + return self.coordinator.data[self.unique_id]["status"] == "ON" + + return None diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py index 0391ced893e83a..a99756e77116a8 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -32,7 +32,7 @@ async def _async_update_data(self): # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with async_timeout.timeout(REQUEST_TIMEOUT): - return await self.laundrify_api.get_machines() + return {m["_id"]: m for m in await self.laundrify_api.get_machines()} except ApiUnauthorized as err: # Raising ConfigEntryAuthFailed will cancel future updates # and start a config flow with SOURCE_REAUTH (async_step_reauth) From e5c59ba9df3a03a6229fe09700b7841c22dec9c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Wed, 2 Mar 2022 15:07:53 +0000 Subject: [PATCH 06/19] Validate token on init, abort if already configured --- .../components/laundrify/__init__.py | 14 +++++++++- .../components/laundrify/config_flow.py | 28 +++++++++---------- .../components/laundrify/coordinator.py | 6 ++-- .../components/laundrify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/laundrify/__init__.py | 28 ++++++++++++++----- tests/components/laundrify/const.py | 1 + .../components/laundrify/test_config_flow.py | 20 +++++++------ 9 files changed, 66 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index 4fb9ebb1255c14..ab1203c53f0a9e 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -2,10 +2,13 @@ from __future__ import annotations from laundrify_aio import LaundrifyAPI +from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DOMAIN from .coordinator import LaundrifyUpdateCoordinator @@ -16,7 +19,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up laundrify from a config entry.""" - api_client = LaundrifyAPI(entry.data[CONF_ACCESS_TOKEN]) + session = async_create_clientsession(hass) + api_client = LaundrifyAPI(entry.data[CONF_ACCESS_TOKEN], session) + + try: + await api_client.validate_token() + except UnauthorizedException as err: + raise ConfigEntryAuthFailed("Invalid authentication") from err + except ApiConnectionException as err: + raise ConfigEntryNotReady("Cannot reach laundrify API") from err + poll_interval = entry.options.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL) coordinator = LaundrifyUpdateCoordinator(hass, api_client, poll_interval) diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py index b885454613b327..94a0659755cfec 100644 --- a/homeassistant/components/laundrify/config_flow.py +++ b/homeassistant/components/laundrify/config_flow.py @@ -4,13 +4,18 @@ import logging from laundrify_aio import LaundrifyAPI -from laundrify_aio.errors import ApiConnectionError, InvalidFormat, UnknownAuthCode +from laundrify_aio.exceptions import ( + ApiConnectionException, + InvalidFormat, + UnknownAuthCode, +) from voluptuous import All, Optional, Range, Required, Schema from homeassistant import config_entries from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DOMAIN @@ -37,12 +42,16 @@ async def async_step_init(self, user_input=None) -> FlowResult: try: access_token = await LaundrifyAPI.exchange_auth_code(user_input[CONF_CODE]) + + session = async_create_clientsession(self.hass) + api_client = LaundrifyAPI(access_token, session) + + account_id = await api_client.get_account_id() except InvalidFormat: errors[CONF_CODE] = "invalid_format" except UnknownAuthCode: errors[CONF_CODE] = "invalid_auth" - except ApiConnectionError as err: - _LOGGER.warning(str(err)) + except ApiConnectionException: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") @@ -50,17 +59,8 @@ async def async_step_init(self, user_input=None) -> FlowResult: else: entry_data = {CONF_ACCESS_TOKEN: access_token} - # The integration supports only a single config entry - existing_entry = await self.async_set_unique_id(DOMAIN) - if existing_entry: - _LOGGER.info( - "%s entry already exists, going to update and reload it", DOMAIN - ) - self.hass.config_entries.async_update_entry( - existing_entry, data=entry_data - ) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="single_instance_allowed") + await self.async_set_unique_id(account_id) + self._abort_if_unique_id_configured() # Create a new entry if it doesn't exist return self.async_create_entry( diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py index a99756e77116a8..23dbb8136c9bda 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -3,7 +3,7 @@ import logging import async_timeout -from laundrify_aio.errors import ApiConnectionError, ApiUnauthorized +from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -33,9 +33,9 @@ async def _async_update_data(self): # handled by the data update coordinator. async with async_timeout.timeout(REQUEST_TIMEOUT): return {m["_id"]: m for m in await self.laundrify_api.get_machines()} - except ApiUnauthorized as err: + except UnauthorizedException as err: # Raising ConfigEntryAuthFailed will cancel future updates # and start a config flow with SOURCE_REAUTH (async_step_reauth) raise ConfigEntryAuthFailed from err - except ApiConnectionError as err: + except ApiConnectionException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/laundrify/manifest.json b/homeassistant/components/laundrify/manifest.json index 9f3e74c0eb45fb..cc27ab366491b4 100644 --- a/homeassistant/components/laundrify/manifest.json +++ b/homeassistant/components/laundrify/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/laundrify", "requirements": [ - "laundrify_aio==1.0.0" + "laundrify_aio==1.1.1" ], "codeowners": [ "@xLarry" diff --git a/requirements_all.txt b/requirements_all.txt index bfdb67435dca16..19e0b53573b499 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -915,7 +915,7 @@ krakenex==2.1.0 lakeside==0.12 # homeassistant.components.laundrify -laundrify_aio==1.0.0 +laundrify_aio==1.1.1 # homeassistant.components.foscam libpyfoscam==1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be8f50cbee0454..7a2841709cfdab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ kostal_plenticore==0.2.0 krakenex==2.1.0 # homeassistant.components.laundrify -laundrify_aio==1.0.0 +laundrify_aio==1.1.1 # homeassistant.components.foscam libpyfoscam==1.0 diff --git a/tests/components/laundrify/__init__.py b/tests/components/laundrify/__init__.py index 7923da09a940d1..60d89da3095a1e 100644 --- a/tests/components/laundrify/__init__.py +++ b/tests/components/laundrify/__init__.py @@ -2,13 +2,13 @@ import json from unittest.mock import patch -from laundrify_aio import errors +from laundrify_aio import exceptions from homeassistant.components.laundrify import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from .const import VALID_ACCESS_TOKEN +from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -16,14 +16,28 @@ def _patch_laundrify_exchange_code(): return patch( - "homeassistant.components.laundrify.config_flow.LaundrifyAPI.exchange_auth_code", + "laundrify_aio.LaundrifyAPI.exchange_auth_code", return_value=VALID_ACCESS_TOKEN, ) +def _patch_laundrify_get_account_id(): + return patch( + "laundrify_aio.LaundrifyAPI.get_account_id", + return_value=VALID_ACCOUNT_ID, + ) + + +def _patch_laundrify_validate_token(): + return patch( + "laundrify_aio.LaundrifyAPI.validate_token", + return_value=True, + ) + + def _patch_laundrify_get_machines(): return patch( - "homeassistant.components.laundrify.LaundrifyAPI.get_machines", + "laundrify_aio.LaundrifyAPI.get_machines", return_value=json.loads(load_fixture("laundrify/machines.json")), ) @@ -34,7 +48,7 @@ def create_entry( """Create laundrify entry in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id=DOMAIN, + unique_id=VALID_ACCOUNT_ID, data={CONF_ACCESS_TOKEN: access_token}, ) entry.add_to_hass(hass) @@ -61,12 +75,12 @@ async def mock_responses( access_token: str = VALID_ACCESS_TOKEN, error: bool = False, ): - """Mock responses from Efergy.""" + """Mock responses from laundrify.""" base_url = "https://test.laundrify.de/api" if error: aioclient_mock.get( f"{base_url}getInstant", - exc=errors.ApiConnectionError, + exc=exceptions.ApiConnectionException, ) return aioclient_mock.get( diff --git a/tests/components/laundrify/const.py b/tests/components/laundrify/const.py index c350d369f1f8b2..644631917c6b09 100644 --- a/tests/components/laundrify/const.py +++ b/tests/components/laundrify/const.py @@ -4,6 +4,7 @@ VALID_AUTH_CODE = "999-001" VALID_ACCESS_TOKEN = "validAccessToken1234" +VALID_ACCOUNT_ID = "1234" VALID_USER_INPUT = { CONF_CODE: VALID_AUTH_CODE, diff --git a/tests/components/laundrify/test_config_flow.py b/tests/components/laundrify/test_config_flow.py index ee6884bacf9de3..0171b2196de2cb 100644 --- a/tests/components/laundrify/test_config_flow.py +++ b/tests/components/laundrify/test_config_flow.py @@ -1,7 +1,7 @@ """Test the laundrify config flow.""" from unittest.mock import patch -from laundrify_aio import errors +from laundrify_aio import exceptions from homeassistant.components.laundrify.const import CONF_POLL_INTERVAL, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigEntryState @@ -15,7 +15,9 @@ from . import ( _patch_laundrify_exchange_code, + _patch_laundrify_get_account_id, _patch_laundrify_get_machines, + _patch_laundrify_validate_token, create_entry, ) from .const import VALID_ACCESS_TOKEN, VALID_AUTH_CODE, VALID_USER_INPUT @@ -27,7 +29,7 @@ def _patch_setup_entry(): async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" - with _patch_laundrify_exchange_code(), _patch_setup_entry() as mock_setup_entry: + with _patch_laundrify_exchange_code(), _patch_laundrify_get_account_id(), _patch_setup_entry() as mock_setup_entry: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -51,7 +53,7 @@ async def test_form(hass: HomeAssistant) -> None: async def test_form_invalid_format(hass: HomeAssistant) -> None: """Test we handle invalid format.""" with _patch_laundrify_exchange_code() as laundrify_mock: - laundrify_mock.side_effect = errors.InvalidFormat + laundrify_mock.side_effect = exceptions.InvalidFormat result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, @@ -65,7 +67,7 @@ async def test_form_invalid_format(hass: HomeAssistant) -> None: async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" with _patch_laundrify_exchange_code() as laundrify_mock: - laundrify_mock.side_effect = errors.UnknownAuthCode + laundrify_mock.side_effect = exceptions.UnknownAuthCode result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, @@ -79,7 +81,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: async def test_form_cannot_connect(hass: HomeAssistant): """Test we handle cannot connect error.""" with _patch_laundrify_exchange_code() as laundrify_mock: - laundrify_mock.side_effect = errors.ApiConnectionError + laundrify_mock.side_effect = exceptions.ApiConnectionException result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, @@ -125,7 +127,7 @@ async def test_step_reauth(hass: HomeAssistant) -> None: async def test_integration_already_exists(hass: HomeAssistant): """Test we only allow a single config flow.""" create_entry(hass) - with _patch_laundrify_exchange_code(): + with _patch_laundrify_exchange_code(), _patch_laundrify_get_account_id(): result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) @@ -138,13 +140,13 @@ async def test_integration_already_exists(hass: HomeAssistant): ) assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" async def test_setup_entry_api_unauthorized(hass): """Test that ConfigEntryAuthFailed is thrown when authentication fails.""" with _patch_setup_entry(), _patch_laundrify_get_machines() as laundrify_mock: - laundrify_mock.side_effect = errors.ApiUnauthorized + laundrify_mock.side_effect = exceptions.UnauthorizedException config_entry = create_entry(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -157,7 +159,7 @@ async def test_setup_entry_api_unauthorized(hass): async def test_options_flow(hass): """Test options flow is shown.""" - with _patch_laundrify_exchange_code(), _patch_laundrify_get_machines(): + with _patch_laundrify_exchange_code(), _patch_laundrify_validate_token(), _patch_laundrify_get_machines(): config_entry = create_entry(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 7ed2366f044ca2f56c85c60b477f385e2e930ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Wed, 20 Apr 2022 15:26:52 +0000 Subject: [PATCH 07/19] Some more cleanup after review --- .../components/laundrify/__init__.py | 3 +-- .../components/laundrify/binary_sensor.py | 24 ++++++++----------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index ab1203c53f0a9e..9ff55808fc8b42 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -53,9 +53,8 @@ async def options_update_listener(hass: HomeAssistant, config_entry: ConfigEntry async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index cc7d358c4e5518..ec3b17614aa77c 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -9,6 +9,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -38,22 +39,20 @@ class LaundrifyPowerPlug(CoordinatorEntity, BinarySensorEntity): def __init__(self, coordinator, device): """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) + self._device = device self._attr_unique_id = device["_id"] self._attr_name = device["name"] @property def device_info(self): """Configure the Device of this Entity.""" - return { - "identifiers": { - # Serial numbers are unique identifiers within a specific domain - (DOMAIN, self.unique_id) - }, - "name": self.name, - "manufacturer": MANUFACTURER, - "model": MODEL, - "sw_version": self.coordinator.data[self.unique_id]["firmwareVersion"], - } + return DeviceInfo( + identifiers={(DOMAIN, self._device["_id"])}, + name=self.name, + manufacturer=MANUFACTURER, + model=MODEL, + sw_version=self.coordinator.data[self.unique_id]["firmwareVersion"], + ) @property def available(self) -> bool: @@ -63,7 +62,4 @@ def available(self) -> bool: @property def is_on(self): """Return entity state.""" - if self.available: - return self.coordinator.data[self.unique_id]["status"] == "ON" - - return None + return self.coordinator.data[self.unique_id]["status"] == "ON" From 902fb856b42247f345a832a86edca36c2e183236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Wed, 20 Apr 2022 17:38:31 +0000 Subject: [PATCH 08/19] Add strict type hints --- .strict-typing | 1 + .../components/laundrify/__init__.py | 4 ++- .../components/laundrify/binary_sensor.py | 20 +++++++---- .../components/laundrify/config_flow.py | 35 +++++++++++++------ .../components/laundrify/coordinator.py | 9 +++-- homeassistant/components/laundrify/model.py | 13 +++++++ mypy.ini | 11 ++++++ .../components/laundrify/test_config_flow.py | 4 +-- 8 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/laundrify/model.py diff --git a/.strict-typing b/.strict-typing index 31f5c12205abd4..68e316cb69c0ad 100644 --- a/.strict-typing +++ b/.strict-typing @@ -129,6 +129,7 @@ homeassistant.components.kaleidescape.* homeassistant.components.knx.* homeassistant.components.kraken.* homeassistant.components.lametric.* +homeassistant.components.laundrify.* homeassistant.components.lcn.* homeassistant.components.light.* homeassistant.components.local_ip.* diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index 9ff55808fc8b42..c98141689dd905 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -46,7 +46,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def options_update_listener(hass: HomeAssistant, config_entry: ConfigEntry): +async def options_update_listener( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index ec3b17614aa77c..3e7fd8d04d1b02 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -14,6 +14,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, MODEL +from .coordinator import LaundrifyUpdateCoordinator +from .model import LaundrifyDevice _LOGGER = logging.getLogger(__name__) @@ -30,13 +32,19 @@ async def async_setup_entry( ) -class LaundrifyPowerPlug(CoordinatorEntity, BinarySensorEntity): +class LaundrifyPowerPlug( + CoordinatorEntity[LaundrifyUpdateCoordinator], BinarySensorEntity +): """Representation of a laundrify Power Plug.""" _attr_device_class = BinarySensorDeviceClass.RUNNING _attr_icon = "mdi:washing-machine" - def __init__(self, coordinator, device): + coordinator: LaundrifyUpdateCoordinator + + def __init__( + self, coordinator: LaundrifyUpdateCoordinator, device: LaundrifyDevice + ) -> None: """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) self._device = device @@ -44,14 +52,14 @@ def __init__(self, coordinator, device): self._attr_name = device["name"] @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Configure the Device of this Entity.""" return DeviceInfo( identifiers={(DOMAIN, self._device["_id"])}, name=self.name, manufacturer=MANUFACTURER, model=MODEL, - sw_version=self.coordinator.data[self.unique_id]["firmwareVersion"], + sw_version=self._device["firmwareVersion"], ) @property @@ -60,6 +68,6 @@ def available(self) -> bool: return self.unique_id in self.coordinator.data @property - def is_on(self): + def is_on(self) -> bool: """Return entity state.""" - return self.coordinator.data[self.unique_id]["status"] == "ON" + return bool(self.coordinator.data[self.unique_id]["status"] == "ON") diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py index 94a0659755cfec..13d242d9e8d86e 100644 --- a/homeassistant/components/laundrify/config_flow.py +++ b/homeassistant/components/laundrify/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from laundrify_aio import LaundrifyAPI from laundrify_aio.exceptions import ( @@ -11,7 +12,7 @@ ) from voluptuous import All, Optional, Range, Required, Schema -from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -24,16 +25,20 @@ CONFIG_SCHEMA = Schema({Required(CONF_CODE): str}) -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class LaundrifyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for laundrify.""" VERSION = 1 - async def async_step_user(self, user_input=None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" return await self.async_step_init(user_input) - async def async_step_init(self, user_input=None) -> FlowResult: + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" if user_input is None: return self.async_show_form(step_id="init", data_schema=CONFIG_SCHEMA) @@ -72,11 +77,15 @@ async def async_step_init(self, user_input=None) -> FlowResult: step_id="init", data_schema=CONFIG_SCHEMA, errors=errors ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Dialog that informs the user that reauth is required.""" if user_input is None: return self.async_show_form( @@ -87,19 +96,23 @@ async def async_step_reauth_confirm(self, user_input=None): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> LaundrifyOptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return LaundrifyOptionsFlowHandler(config_entry) -class OptionsFlowHandler(config_entries.OptionsFlow): +class LaundrifyOptionsFlowHandler(OptionsFlow): """Handle options flow for laundrify.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py index 23dbb8136c9bda..752b97427be8ec 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -1,10 +1,13 @@ """Custom DataUpdateCoordinator for the laundrify integration.""" from datetime import timedelta import logging +from typing import Any import async_timeout +from laundrify_aio import LaundrifyAPI from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -16,7 +19,9 @@ class LaundrifyUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching laundrify API data.""" - def __init__(self, hass, laundrify_api, poll_interval): + def __init__( + self, hass: HomeAssistant, laundrify_api: LaundrifyAPI, poll_interval: int + ) -> None: """Initialize laundrify coordinator.""" super().__init__( hass, @@ -26,7 +31,7 @@ def __init__(self, hass, laundrify_api, poll_interval): ) self.laundrify_api = laundrify_api - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, Any]: """Fetch data from laundrify API.""" try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already diff --git a/homeassistant/components/laundrify/model.py b/homeassistant/components/laundrify/model.py new file mode 100644 index 00000000000000..aa6bf77509fecd --- /dev/null +++ b/homeassistant/components/laundrify/model.py @@ -0,0 +1,13 @@ +"""Models for laundrify platform.""" +from __future__ import annotations + +from typing import TypedDict + + +class LaundrifyDevice(TypedDict): + """laundrify Power Plug.""" + + _id: str + name: str + status: str + firmwareVersion: str diff --git a/mypy.ini b/mypy.ini index c4b664733427a5..848b6f1838d661 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1221,6 +1221,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.laundrify.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lcn.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/laundrify/test_config_flow.py b/tests/components/laundrify/test_config_flow.py index 0171b2196de2cb..2b12ff837981f1 100644 --- a/tests/components/laundrify/test_config_flow.py +++ b/tests/components/laundrify/test_config_flow.py @@ -143,7 +143,7 @@ async def test_integration_already_exists(hass: HomeAssistant): assert result["reason"] == "already_configured" -async def test_setup_entry_api_unauthorized(hass): +async def test_setup_entry_api_unauthorized(hass: HomeAssistant): """Test that ConfigEntryAuthFailed is thrown when authentication fails.""" with _patch_setup_entry(), _patch_laundrify_get_machines() as laundrify_mock: laundrify_mock.side_effect = exceptions.UnauthorizedException @@ -157,7 +157,7 @@ async def test_setup_entry_api_unauthorized(hass): assert not hass.data.get(DOMAIN) -async def test_options_flow(hass): +async def test_options_flow(hass: HomeAssistant): """Test options flow is shown.""" with _patch_laundrify_exchange_code(), _patch_laundrify_validate_token(), _patch_laundrify_get_machines(): config_entry = create_entry(hass) From 6803155ad5b2491d049e57156d160efeeee7930f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Thu, 12 May 2022 15:19:40 +0000 Subject: [PATCH 09/19] Minor changes after code review --- homeassistant/components/laundrify/__init__.py | 6 +++--- homeassistant/components/laundrify/binary_sensor.py | 2 -- homeassistant/components/laundrify/config_flow.py | 4 ++-- homeassistant/components/laundrify/coordinator.py | 4 ++-- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index c98141689dd905..0d67682d07b989 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -8,18 +8,18 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DOMAIN from .coordinator import LaundrifyUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up laundrify from a config entry.""" - session = async_create_clientsession(hass) + session = async_get_clientsession(hass) api_client = LaundrifyAPI(entry.data[CONF_ACCESS_TOKEN], session) try: diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 3e7fd8d04d1b02..0509fd408d98f9 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -40,8 +40,6 @@ class LaundrifyPowerPlug( _attr_device_class = BinarySensorDeviceClass.RUNNING _attr_icon = "mdi:washing-machine" - coordinator: LaundrifyUpdateCoordinator - def __init__( self, coordinator: LaundrifyUpdateCoordinator, device: LaundrifyDevice ) -> None: diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py index 13d242d9e8d86e..927706625e7406 100644 --- a/homeassistant/components/laundrify/config_flow.py +++ b/homeassistant/components/laundrify/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DOMAIN @@ -48,7 +48,7 @@ async def async_step_init( try: access_token = await LaundrifyAPI.exchange_auth_code(user_input[CONF_CODE]) - session = async_create_clientsession(self.hass) + session = async_get_clientsession(self.hass) api_client = LaundrifyAPI(access_token, session) account_id = await api_client.get_account_id() diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py index 752b97427be8ec..3144749050609f 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -1,7 +1,6 @@ """Custom DataUpdateCoordinator for the laundrify integration.""" from datetime import timedelta import logging -from typing import Any import async_timeout from laundrify_aio import LaundrifyAPI @@ -12,6 +11,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, REQUEST_TIMEOUT +from .model import LaundrifyDevice _LOGGER = logging.getLogger(__name__) @@ -31,7 +31,7 @@ def __init__( ) self.laundrify_api = laundrify_api - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> dict[str, LaundrifyDevice]: """Fetch data from laundrify API.""" try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already From cbd5cde4cff1c2411f24e9b8eaaf9313c96d2132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Thu, 12 May 2022 15:50:23 +0000 Subject: [PATCH 10/19] Remove OptionsFlow (use default poll interval instead) --- .../components/laundrify/__init__.py | 5 +-- .../components/laundrify/config_flow.py | 44 ++----------------- homeassistant/components/laundrify/const.py | 1 - .../components/laundrify/test_config_flow.py | 26 ++++++----- 4 files changed, 19 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index 0d67682d07b989..5101e284d9f58b 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DOMAIN +from .const import DEFAULT_POLL_INTERVAL, DOMAIN from .coordinator import LaundrifyUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR] @@ -29,8 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ApiConnectionException as err: raise ConfigEntryNotReady("Cannot reach laundrify API") from err - poll_interval = entry.options.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL) - coordinator = LaundrifyUpdateCoordinator(hass, api_client, poll_interval) + coordinator = LaundrifyUpdateCoordinator(hass, api_client, DEFAULT_POLL_INTERVAL) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py index 927706625e7406..d8230863d7c174 100644 --- a/homeassistant/components/laundrify/config_flow.py +++ b/homeassistant/components/laundrify/config_flow.py @@ -10,15 +10,14 @@ InvalidFormat, UnknownAuthCode, ) -from voluptuous import All, Optional, Range, Required, Schema +from voluptuous import Required, Schema -from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE -from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -93,40 +92,3 @@ async def async_step_reauth_confirm( data_schema=Schema({}), ) return await self.async_step_init() - - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> LaundrifyOptionsFlowHandler: - """Get the options flow for this handler.""" - return LaundrifyOptionsFlowHandler(config_entry) - - -class LaundrifyOptionsFlowHandler(OptionsFlow): - """Handle options flow for laundrify.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Manage the options.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - return self.async_show_form( - step_id="init", - data_schema=Schema( - { - Optional( - CONF_POLL_INTERVAL, - default=self.config_entry.options.get( - CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL - ), - ): All(int, Range(min=10)), - } - ), - ) diff --git a/homeassistant/components/laundrify/const.py b/homeassistant/components/laundrify/const.py index 1e0e0bbcf1927f..c312b895234c68 100644 --- a/homeassistant/components/laundrify/const.py +++ b/homeassistant/components/laundrify/const.py @@ -5,7 +5,6 @@ MANUFACTURER = "laundrify" MODEL = "WLAN-Adapter (SU02)" -CONF_POLL_INTERVAL = "poll_interval" DEFAULT_POLL_INTERVAL = 60 REQUEST_TIMEOUT = 10 diff --git a/tests/components/laundrify/test_config_flow.py b/tests/components/laundrify/test_config_flow.py index 2b12ff837981f1..65eea0bb2ac087 100644 --- a/tests/components/laundrify/test_config_flow.py +++ b/tests/components/laundrify/test_config_flow.py @@ -3,7 +3,7 @@ from laundrify_aio import exceptions -from homeassistant.components.laundrify.const import CONF_POLL_INTERVAL, DOMAIN +from homeassistant.components.laundrify.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE, CONF_SOURCE from homeassistant.core import HomeAssistant @@ -157,21 +157,23 @@ async def test_setup_entry_api_unauthorized(hass: HomeAssistant): assert not hass.data.get(DOMAIN) -async def test_options_flow(hass: HomeAssistant): - """Test options flow is shown.""" +async def test_setup_entry_successful(hass: HomeAssistant): + """Test entry can be setup successfully.""" with _patch_laundrify_exchange_code(), _patch_laundrify_validate_token(), _patch_laundrify_get_machines(): config_entry = create_entry(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.LOADED - assert result["type"] == RESULT_TYPE_FORM - assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_POLL_INTERVAL: 30} - ) +async def test_setup_entry_unload(hass: HomeAssistant): + """Test unloading the laundrify entry.""" + with _patch_laundrify_exchange_code(), _patch_laundrify_validate_token(), _patch_laundrify_get_machines(): + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_unload(config_entry.entry_id) - assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert config_entry.options == {CONF_POLL_INTERVAL: 30} + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.NOT_LOADED From be025a39b426e66d3b9fa5f67f76363c4981ef38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Thu, 12 May 2022 15:57:07 +0000 Subject: [PATCH 11/19] Fix CODEOWNERS to pass hassfest job --- CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 3141ab754b7b22..63eb89a31b162a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -544,8 +544,8 @@ build.json @home-assistant/supervisor /homeassistant/components/lametric/ @robbiet480 @frenck /homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol /tests/components/launch_library/ @ludeeus @DurgNomis-drol -homeassistant/components/laundrify/* @xLarry -tests/components/laundrify/* @xLarry +/homeassistant/components/laundrify/ @xLarry +/tests/components/laundrify/ @xLarry /homeassistant/components/lcn/ @alengwenus /tests/components/lcn/ @alengwenus /homeassistant/components/lg_netcast/ @Drafteed From 8418e5dd12a24e09cdb2cd06689bd2effde92473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Thu, 12 May 2022 15:57:34 +0000 Subject: [PATCH 12/19] Fix formatting to pass prettier job --- .../components/laundrify/manifest.json | 10 ++--- .../components/laundrify/strings.json | 2 +- .../laundrify/fixtures/machines.json | 38 ++++--------------- 3 files changed, 11 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/laundrify/manifest.json b/homeassistant/components/laundrify/manifest.json index cc27ab366491b4..6a61446d31c4c9 100644 --- a/homeassistant/components/laundrify/manifest.json +++ b/homeassistant/components/laundrify/manifest.json @@ -3,11 +3,7 @@ "name": "laundrify", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/laundrify", - "requirements": [ - "laundrify_aio==1.1.1" - ], - "codeowners": [ - "@xLarry" - ], + "requirements": ["laundrify_aio==1.1.1"], + "codeowners": ["@xLarry"], "iot_class": "cloud_polling" -} \ No newline at end of file +} diff --git a/homeassistant/components/laundrify/strings.json b/homeassistant/components/laundrify/strings.json index 9a247096a500c0..31db78c105bfb1 100644 --- a/homeassistant/components/laundrify/strings.json +++ b/homeassistant/components/laundrify/strings.json @@ -33,4 +33,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/components/laundrify/fixtures/machines.json b/tests/components/laundrify/fixtures/machines.json index 38d7bc91021d6d..ab1a737cb454d9 100644 --- a/tests/components/laundrify/fixtures/machines.json +++ b/tests/components/laundrify/fixtures/machines.json @@ -1,32 +1,8 @@ [ - { - "_id": "14", - "name": "Demo Waschmaschine", - "status": "OFF", - "lastChange": null, - "time_threshold": 95, - "power_threshold": 8, - "has_creaseprotect": true, - "share_id": "demo", - "ssid": null, - "internalIP": null, - "subnet": null, - "gateway": null, - "firmwareVersion": null, - "chipID": null, - "useInterpolation": false, - "poweron_threshold": 10000, - "createdAt": "2018-09-09T06:27:08.943Z", - "updatedAt": "2022-01-25T18:57:38.977Z", - "_hub": null, - "Listing": { - "_id": "659d4a73", - "origin": "migration", - "isSubscribed": false, - "createdAt": "2022-01-20T13:28:47.824Z", - "updatedAt": "2022-01-20T13:28:47.825Z", - "_machine": "14", - "_user": 9125 - } - } -] \ No newline at end of file + { + "_id": "14", + "name": "Demo Waschmaschine", + "status": "OFF", + "firmwareVersion": "2.1.0" + } +] From 831beaaf286d3d408750791d7fdf22d32a4a00cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Thu, 12 May 2022 16:43:58 +0000 Subject: [PATCH 13/19] Fix mypy typing error --- homeassistant/components/laundrify/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 0509fd408d98f9..1a295006d2ba81 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -68,4 +68,4 @@ def available(self) -> bool: @property def is_on(self) -> bool: """Return entity state.""" - return bool(self.coordinator.data[self.unique_id]["status"] == "ON") + return self._device["status"] == "ON" From 6bb6e221b3743245249a02bf2cbca91404ece6ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Thu, 12 May 2022 18:16:56 +0000 Subject: [PATCH 14/19] Update internal device property after fetching data --- .../components/laundrify/binary_sensor.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 1a295006d2ba81..89e3b6ab6db24c 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -8,7 +8,7 @@ BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -47,7 +47,6 @@ def __init__( super().__init__(coordinator) self._device = device self._attr_unique_id = device["_id"] - self._attr_name = device["name"] @property def device_info(self) -> DeviceInfo: @@ -65,7 +64,18 @@ def available(self) -> bool: """Check if the device is available.""" return self.unique_id in self.coordinator.data + @property + def name(self) -> str: + """Name of the entity.""" + return self._device["name"] + @property def is_on(self) -> bool: """Return entity state.""" return self._device["status"] == "ON" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._device = self.coordinator.data[self.unique_id] + self.async_write_ha_state() From 95018cedae969e535cba4f82c75d63664d162291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Thu, 12 May 2022 19:42:58 +0000 Subject: [PATCH 15/19] Call parental update handler and remove obsolete code --- homeassistant/components/laundrify/__init__.py | 9 --------- homeassistant/components/laundrify/binary_sensor.py | 2 +- homeassistant/components/laundrify/strings.json | 13 +------------ .../components/laundrify/translations/en.json | 13 +------------ 4 files changed, 3 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index 5101e284d9f58b..27fc412ababcb4 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -38,20 +38,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "coordinator": coordinator, } - entry.async_on_unload(entry.add_update_listener(options_update_listener)) - hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -async def options_update_listener( - hass: HomeAssistant, config_entry: ConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 89e3b6ab6db24c..fcdb3764e15edf 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -78,4 +78,4 @@ def is_on(self) -> bool: def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._device = self.coordinator.data[self.unique_id] - self.async_write_ha_state() + super()._handle_coordinator_update() diff --git a/homeassistant/components/laundrify/strings.json b/homeassistant/components/laundrify/strings.json index 31db78c105bfb1..b2fea9f307f54e 100644 --- a/homeassistant/components/laundrify/strings.json +++ b/homeassistant/components/laundrify/strings.json @@ -4,8 +4,7 @@ "init": { "description": "Please enter your personal Auth Code that is shown in the laundrify-App.", "data": { - "code": "Auth Code (xxx-xxx)", - "poll_interval": "Poll Interval (in seconds)" + "code": "Auth Code (xxx-xxx)" } }, "reauth_confirm": { @@ -22,15 +21,5 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } - }, - "options": { - "step": { - "init": { - "title": "Configure laundrify", - "data": { - "poll_interval": "Poll Interval (in seconds)" - } - } - } } } diff --git a/homeassistant/components/laundrify/translations/en.json b/homeassistant/components/laundrify/translations/en.json index 4f70c21b70ae49..2bfb07d4041785 100644 --- a/homeassistant/components/laundrify/translations/en.json +++ b/homeassistant/components/laundrify/translations/en.json @@ -12,8 +12,7 @@ "step": { "init": { "data": { - "code": "Auth Code (xxx-xxx)", - "poll_interval": "Poll Interval (in seconds)" + "code": "Auth Code (xxx-xxx)" }, "description": "Please enter your personal AuthCode that is shown in the laundrify-App." }, @@ -22,15 +21,5 @@ "title": "Reauthenticate Integration" } } - }, - "options": { - "step": { - "init": { - "title": "Configure laundrify", - "data": { - "poll_interval": "Poll Interval (in seconds)" - } - } - } } } \ No newline at end of file From 8e681da12b43489b304f545b71e796359aefedfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Fri, 13 May 2022 18:28:44 +0000 Subject: [PATCH 16/19] Add coordinator tests and fix some config flow tests --- .../components/laundrify/test_config_flow.py | 16 +++++- .../components/laundrify/test_coordinator.py | 55 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/components/laundrify/test_coordinator.py diff --git a/tests/components/laundrify/test_config_flow.py b/tests/components/laundrify/test_config_flow.py index 65eea0bb2ac087..07fa2238714123 100644 --- a/tests/components/laundrify/test_config_flow.py +++ b/tests/components/laundrify/test_config_flow.py @@ -145,7 +145,7 @@ async def test_integration_already_exists(hass: HomeAssistant): async def test_setup_entry_api_unauthorized(hass: HomeAssistant): """Test that ConfigEntryAuthFailed is thrown when authentication fails.""" - with _patch_setup_entry(), _patch_laundrify_get_machines() as laundrify_mock: + with _patch_laundrify_validate_token() as laundrify_mock: laundrify_mock.side_effect = exceptions.UnauthorizedException config_entry = create_entry(hass) @@ -157,6 +157,20 @@ async def test_setup_entry_api_unauthorized(hass: HomeAssistant): assert not hass.data.get(DOMAIN) +async def test_setup_entry_api_cannot_connect(hass: HomeAssistant): + """Test that ApiConnectionException is thrown when connection fails.""" + with _patch_laundrify_validate_token() as laundrify_mock: + laundrify_mock.side_effect = exceptions.ApiConnectionException + config_entry = create_entry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + + async def test_setup_entry_successful(hass: HomeAssistant): """Test entry can be setup successfully.""" with _patch_laundrify_exchange_code(), _patch_laundrify_validate_token(), _patch_laundrify_get_machines(): diff --git a/tests/components/laundrify/test_coordinator.py b/tests/components/laundrify/test_coordinator.py new file mode 100644 index 00000000000000..b4db0a77c9e810 --- /dev/null +++ b/tests/components/laundrify/test_coordinator.py @@ -0,0 +1,55 @@ +"""Test the laundrify coordinator.""" + +from laundrify_aio import exceptions + +from homeassistant.components.laundrify.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import ( + _patch_laundrify_get_machines, + _patch_laundrify_validate_token, + create_entry, +) + + +async def test_coordinator_update_success(hass: HomeAssistant): + """Test the coordinator update is performed successfully.""" + with _patch_laundrify_validate_token(), _patch_laundrify_get_machines(): + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert coordinator.last_update_success + + +async def test_coordinator_update_unauthorized(hass: HomeAssistant): + """Test the coordinator update fails if an UnauthorizedException is thrown.""" + with _patch_laundrify_validate_token(), _patch_laundrify_get_machines() as coordinator_mock: + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + coordinator_mock.side_effect = exceptions.UnauthorizedException + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert not coordinator.last_update_success + + +async def test_coordinator_update_connection_failed(hass: HomeAssistant): + """Test the coordinator update fails if an ApiConnectionException is thrown.""" + with _patch_laundrify_validate_token(), _patch_laundrify_get_machines() as coordinator_mock: + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + coordinator_mock.side_effect = exceptions.ApiConnectionException + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert not coordinator.last_update_success From e54dbf5bf2c4d9def50dfb20a89c9f35a78e9622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Mon, 16 May 2022 12:01:50 +0000 Subject: [PATCH 17/19] Refactor tests --- tests/components/laundrify/__init__.py | 79 +------- tests/components/laundrify/conftest.py | 57 ++++++ .../components/laundrify/test_config_flow.py | 178 ++++++------------ .../components/laundrify/test_coordinator.py | 57 +++--- tests/components/laundrify/test_init.py | 59 ++++++ 5 files changed, 200 insertions(+), 230 deletions(-) create mode 100644 tests/components/laundrify/conftest.py create mode 100644 tests/components/laundrify/test_init.py diff --git a/tests/components/laundrify/__init__.py b/tests/components/laundrify/__init__.py index 60d89da3095a1e..c09c6290adfd83 100644 --- a/tests/components/laundrify/__init__.py +++ b/tests/components/laundrify/__init__.py @@ -1,8 +1,4 @@ """Tests for the laundrify integration.""" -import json -from unittest.mock import patch - -from laundrify_aio import exceptions from homeassistant.components.laundrify import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN @@ -10,36 +6,7 @@ from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID -from tests.common import MockConfigEntry, load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker - - -def _patch_laundrify_exchange_code(): - return patch( - "laundrify_aio.LaundrifyAPI.exchange_auth_code", - return_value=VALID_ACCESS_TOKEN, - ) - - -def _patch_laundrify_get_account_id(): - return patch( - "laundrify_aio.LaundrifyAPI.get_account_id", - return_value=VALID_ACCOUNT_ID, - ) - - -def _patch_laundrify_validate_token(): - return patch( - "laundrify_aio.LaundrifyAPI.validate_token", - return_value=True, - ) - - -def _patch_laundrify_get_machines(): - return patch( - "laundrify_aio.LaundrifyAPI.get_machines", - return_value=json.loads(load_fixture("laundrify/machines.json")), - ) +from tests.common import MockConfigEntry def create_entry( @@ -53,47 +20,3 @@ def create_entry( ) entry.add_to_hass(hass) return entry - - -async def init_integration( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - access_token: str = VALID_ACCESS_TOKEN, - error: bool = False, -) -> MockConfigEntry: - """Set up the laundrify integration in Home Assistant.""" - entry = create_entry(hass, access_token=access_token) - await mock_responses(hass, aioclient_mock, access_token=access_token, error=error) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def mock_responses( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - access_token: str = VALID_ACCESS_TOKEN, - error: bool = False, -): - """Mock responses from laundrify.""" - base_url = "https://test.laundrify.de/api" - if error: - aioclient_mock.get( - f"{base_url}getInstant", - exc=exceptions.ApiConnectionException, - ) - return - aioclient_mock.get( - f"{base_url}/machines", - text=load_fixture("laundrify/machines.json"), - ) - if access_token == VALID_ACCESS_TOKEN: - aioclient_mock.get( - f"{base_url}/getCurrentValuesSummary", - text=load_fixture("laundrify/machines.json"), - ) - else: - aioclient_mock.get( - f"{base_url}/getCurrentValuesSummary", - text=load_fixture("laundrify/machines.json"), - ) diff --git a/tests/components/laundrify/conftest.py b/tests/components/laundrify/conftest.py new file mode 100644 index 00000000000000..2b006af7b5a229 --- /dev/null +++ b/tests/components/laundrify/conftest.py @@ -0,0 +1,57 @@ +"""Configure py.test.""" +import json +from unittest.mock import patch + +import pytest + +from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID + +from tests.common import load_fixture + + +@pytest.fixture(name="laundrify_setup_entry") +def laundrify_setup_entry_fixture(): + """Mock laundrify setup entry function.""" + with patch( + "homeassistant.components.laundrify.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="laundrify_api_mock", autouse=True) +def climacell_config_entry_update_fixture(): + """Mock valid laundrify API responses.""" + with patch( + "laundrify_aio.LaundrifyAPI.exchange_auth_code", + return_value=VALID_ACCESS_TOKEN, + ), patch( + "laundrify_aio.LaundrifyAPI.get_account_id", + return_value=VALID_ACCOUNT_ID, + ), patch( + "laundrify_aio.LaundrifyAPI.validate_token", + return_value=True, + ), patch( + "laundrify_aio.LaundrifyAPI.get_machines", + return_value=json.loads(load_fixture("laundrify/machines.json")), + ) as get_machines_mock: + yield get_machines_mock + + +@pytest.fixture(name="laundrify_exchange_code") +def laundrify_exchange_code_fixture(): + """Mock laundrify exchange_auth_code function.""" + with patch( + "laundrify_aio.LaundrifyAPI.exchange_auth_code", + return_value=VALID_ACCESS_TOKEN, + ) as exchange_code_mock: + yield exchange_code_mock + + +@pytest.fixture(name="laundrify_validate_token") +def laundrify_validate_token_fixture(): + """Mock laundrify validate_token function.""" + with patch( + "laundrify_aio.LaundrifyAPI.validate_token", + return_value=True, + ) as validate_token_mock: + yield validate_token_mock diff --git a/tests/components/laundrify/test_config_flow.py b/tests/components/laundrify/test_config_flow.py index 07fa2238714123..5ee3efe1e450cd 100644 --- a/tests/components/laundrify/test_config_flow.py +++ b/tests/components/laundrify/test_config_flow.py @@ -1,10 +1,9 @@ """Test the laundrify config flow.""" -from unittest.mock import patch from laundrify_aio import exceptions from homeassistant.components.laundrify.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( @@ -13,94 +12,82 @@ RESULT_TYPE_FORM, ) -from . import ( - _patch_laundrify_exchange_code, - _patch_laundrify_get_account_id, - _patch_laundrify_get_machines, - _patch_laundrify_validate_token, - create_entry, -) +from . import create_entry from .const import VALID_ACCESS_TOKEN, VALID_AUTH_CODE, VALID_USER_INPUT -def _patch_setup_entry(): - return patch("homeassistant.components.laundrify.async_setup_entry") - - -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, laundrify_setup_entry) -> None: """Test we get the form.""" - with _patch_laundrify_exchange_code(), _patch_laundrify_get_account_id(), _patch_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] is None - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=VALID_USER_INPUT, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_USER_INPUT, + ) + await hass.async_block_till_done() assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["title"] == DOMAIN assert result["data"] == { CONF_ACCESS_TOKEN: VALID_ACCESS_TOKEN, } - assert len(mock_setup_entry.mock_calls) == 1 + assert len(laundrify_setup_entry.mock_calls) == 1 -async def test_form_invalid_format(hass: HomeAssistant) -> None: +async def test_form_invalid_format( + hass: HomeAssistant, laundrify_exchange_code +) -> None: """Test we handle invalid format.""" - with _patch_laundrify_exchange_code() as laundrify_mock: - laundrify_mock.side_effect = exceptions.InvalidFormat - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data={CONF_CODE: "invalidFormat"}, - ) + laundrify_exchange_code.side_effect = exceptions.InvalidFormat + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_CODE: "invalidFormat"}, + ) assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {CONF_CODE: "invalid_format"} -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +async def test_form_invalid_auth(hass: HomeAssistant, laundrify_exchange_code) -> None: """Test we handle invalid auth.""" - with _patch_laundrify_exchange_code() as laundrify_mock: - laundrify_mock.side_effect = exceptions.UnknownAuthCode - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data=VALID_USER_INPUT, - ) + laundrify_exchange_code.side_effect = exceptions.UnknownAuthCode + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=VALID_USER_INPUT, + ) assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {CONF_CODE: "invalid_auth"} -async def test_form_cannot_connect(hass: HomeAssistant): +async def test_form_cannot_connect(hass: HomeAssistant, laundrify_exchange_code): """Test we handle cannot connect error.""" - with _patch_laundrify_exchange_code() as laundrify_mock: - laundrify_mock.side_effect = exceptions.ApiConnectionException - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data=VALID_USER_INPUT, - ) + laundrify_exchange_code.side_effect = exceptions.ApiConnectionException + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=VALID_USER_INPUT, + ) assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {"base": "cannot_connect"} -async def test_form_unkown_exception(hass: HomeAssistant): +async def test_form_unkown_exception(hass: HomeAssistant, laundrify_exchange_code): """Test we handle all other errors.""" - with _patch_laundrify_exchange_code() as laundrify_mock: - laundrify_mock.side_effect = Exception - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data=VALID_USER_INPUT, - ) + laundrify_exchange_code.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=VALID_USER_INPUT, + ) assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {"base": "unknown"} @@ -127,67 +114,16 @@ async def test_step_reauth(hass: HomeAssistant) -> None: async def test_integration_already_exists(hass: HomeAssistant): """Test we only allow a single config flow.""" create_entry(hass) - with _patch_laundrify_exchange_code(), _patch_laundrify_get_account_id(): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_USER} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_CODE: VALID_AUTH_CODE, - }, - ) - - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_setup_entry_api_unauthorized(hass: HomeAssistant): - """Test that ConfigEntryAuthFailed is thrown when authentication fails.""" - with _patch_laundrify_validate_token() as laundrify_mock: - laundrify_mock.side_effect = exceptions.UnauthorizedException - config_entry = create_entry(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state == ConfigEntryState.SETUP_ERROR - assert not hass.data.get(DOMAIN) - - -async def test_setup_entry_api_cannot_connect(hass: HomeAssistant): - """Test that ApiConnectionException is thrown when connection fails.""" - with _patch_laundrify_validate_token() as laundrify_mock: - laundrify_mock.side_effect = exceptions.ApiConnectionException - config_entry = create_entry(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state == ConfigEntryState.SETUP_RETRY - assert not hass.data.get(DOMAIN) - - -async def test_setup_entry_successful(hass: HomeAssistant): - """Test entry can be setup successfully.""" - with _patch_laundrify_exchange_code(), _patch_laundrify_validate_token(), _patch_laundrify_get_machines(): - config_entry = create_entry(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state == ConfigEntryState.LOADED - + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) -async def test_setup_entry_unload(hass: HomeAssistant): - """Test unloading the laundrify entry.""" - with _patch_laundrify_exchange_code(), _patch_laundrify_validate_token(), _patch_laundrify_get_machines(): - config_entry = create_entry(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.config_entries.async_unload(config_entry.entry_id) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CODE: VALID_AUTH_CODE, + }, + ) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state == ConfigEntryState.NOT_LOADED + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/laundrify/test_coordinator.py b/tests/components/laundrify/test_coordinator.py index b4db0a77c9e810..58bc297cc42ff3 100644 --- a/tests/components/laundrify/test_coordinator.py +++ b/tests/components/laundrify/test_coordinator.py @@ -5,51 +5,46 @@ from homeassistant.components.laundrify.const import DOMAIN from homeassistant.core import HomeAssistant -from . import ( - _patch_laundrify_get_machines, - _patch_laundrify_validate_token, - create_entry, -) +from . import create_entry async def test_coordinator_update_success(hass: HomeAssistant): """Test the coordinator update is performed successfully.""" - with _patch_laundrify_validate_token(), _patch_laundrify_get_machines(): - config_entry = create_entry(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] - await coordinator.async_refresh() - await hass.async_block_till_done() + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + await coordinator.async_refresh() + await hass.async_block_till_done() assert coordinator.last_update_success -async def test_coordinator_update_unauthorized(hass: HomeAssistant): +async def test_coordinator_update_unauthorized(hass: HomeAssistant, laundrify_api_mock): """Test the coordinator update fails if an UnauthorizedException is thrown.""" - with _patch_laundrify_validate_token(), _patch_laundrify_get_machines() as coordinator_mock: - config_entry = create_entry(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] - coordinator_mock.side_effect = exceptions.UnauthorizedException - await coordinator.async_refresh() - await hass.async_block_till_done() + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + laundrify_api_mock.side_effect = exceptions.UnauthorizedException + await coordinator.async_refresh() + await hass.async_block_till_done() assert not coordinator.last_update_success -async def test_coordinator_update_connection_failed(hass: HomeAssistant): +async def test_coordinator_update_connection_failed( + hass: HomeAssistant, laundrify_api_mock +): """Test the coordinator update fails if an ApiConnectionException is thrown.""" - with _patch_laundrify_validate_token(), _patch_laundrify_get_machines() as coordinator_mock: - config_entry = create_entry(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] - coordinator_mock.side_effect = exceptions.ApiConnectionException - await coordinator.async_refresh() - await hass.async_block_till_done() + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + laundrify_api_mock.side_effect = exceptions.ApiConnectionException + await coordinator.async_refresh() + await hass.async_block_till_done() assert not coordinator.last_update_success diff --git a/tests/components/laundrify/test_init.py b/tests/components/laundrify/test_init.py new file mode 100644 index 00000000000000..129848e8808049 --- /dev/null +++ b/tests/components/laundrify/test_init.py @@ -0,0 +1,59 @@ +"""Test the laundrify init file.""" + +from laundrify_aio import exceptions + +from homeassistant.components.laundrify.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import create_entry + + +async def test_setup_entry_api_unauthorized( + hass: HomeAssistant, laundrify_validate_token +): + """Test that ConfigEntryAuthFailed is thrown when authentication fails.""" + laundrify_validate_token.side_effect = exceptions.UnauthorizedException + config_entry = create_entry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) + + +async def test_setup_entry_api_cannot_connect( + hass: HomeAssistant, laundrify_validate_token +): + """Test that ApiConnectionException is thrown when connection fails.""" + laundrify_validate_token.side_effect = exceptions.ApiConnectionException + config_entry = create_entry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + + +async def test_setup_entry_successful(hass: HomeAssistant): + """Test entry can be setup successfully.""" + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_setup_entry_unload(hass: HomeAssistant): + """Test unloading the laundrify entry.""" + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_unload(config_entry.entry_id) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.NOT_LOADED From 92283ac621970c560a1f186f310dbb2385236881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Tue, 17 May 2022 11:02:31 +0000 Subject: [PATCH 18/19] Refactor fixtures --- tests/components/laundrify/conftest.py | 32 +++++++++++--------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/tests/components/laundrify/conftest.py b/tests/components/laundrify/conftest.py index 2b006af7b5a229..13e408b61a9d2e 100644 --- a/tests/components/laundrify/conftest.py +++ b/tests/components/laundrify/conftest.py @@ -18,25 +18,6 @@ def laundrify_setup_entry_fixture(): yield mock_setup_entry -@pytest.fixture(name="laundrify_api_mock", autouse=True) -def climacell_config_entry_update_fixture(): - """Mock valid laundrify API responses.""" - with patch( - "laundrify_aio.LaundrifyAPI.exchange_auth_code", - return_value=VALID_ACCESS_TOKEN, - ), patch( - "laundrify_aio.LaundrifyAPI.get_account_id", - return_value=VALID_ACCOUNT_ID, - ), patch( - "laundrify_aio.LaundrifyAPI.validate_token", - return_value=True, - ), patch( - "laundrify_aio.LaundrifyAPI.get_machines", - return_value=json.loads(load_fixture("laundrify/machines.json")), - ) as get_machines_mock: - yield get_machines_mock - - @pytest.fixture(name="laundrify_exchange_code") def laundrify_exchange_code_fixture(): """Mock laundrify exchange_auth_code function.""" @@ -55,3 +36,16 @@ def laundrify_validate_token_fixture(): return_value=True, ) as validate_token_mock: yield validate_token_mock + + +@pytest.fixture(name="laundrify_api_mock", autouse=True) +def laundrify_api_fixture(laundrify_exchange_code, laundrify_validate_token): + """Mock valid laundrify API responses.""" + with patch( + "laundrify_aio.LaundrifyAPI.get_account_id", + return_value=VALID_ACCOUNT_ID, + ), patch( + "laundrify_aio.LaundrifyAPI.get_machines", + return_value=json.loads(load_fixture("laundrify/machines.json")), + ) as get_machines_mock: + yield get_machines_mock From 744424157045373311a72ee01da07ee3cd1f988f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20M=C3=BClhaupt?= Date: Tue, 17 May 2022 11:03:55 +0000 Subject: [PATCH 19/19] Device unavailable if polling fails --- homeassistant/components/laundrify/binary_sensor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index fcdb3764e15edf..2f64d8cee7806d 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -62,7 +62,10 @@ def device_info(self) -> DeviceInfo: @property def available(self) -> bool: """Check if the device is available.""" - return self.unique_id in self.coordinator.data + return ( + self.unique_id in self.coordinator.data + and self.coordinator.last_update_success + ) @property def name(self) -> str: