diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py index 9b0f649dad9..7503de8ea10 100644 --- a/homeassistant/components/anova/__init__.py +++ b/homeassistant/components/anova/__init__.py @@ -3,18 +3,25 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING -from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound +from anova_wifi import ( + AnovaApi, + APCWifiDevice, + InvalidLogin, + NoDevicesFound, + WebsocketFailure, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from .const import DOMAIN from .coordinator import AnovaCoordinator from .models import AnovaData -from .util import serialize_device_list PLATFORMS = [Platform.SENSOR] @@ -36,36 +43,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False assert api.jwt - api.existing_devices = [ - AnovaPrecisionCooker( - aiohttp_client.async_get_clientsession(hass), - device[0], - device[1], - api.jwt, - ) - for device in entry.data[CONF_DEVICES] - ] try: - new_devices = await api.get_devices() - except NoDevicesFound: - # get_devices raises an exception if no devices are online - new_devices = [] - devices = api.existing_devices - if new_devices: - hass.config_entries.async_update_entry( - entry, - data={ - **entry.data, - CONF_DEVICES: serialize_device_list(devices), - }, - ) + await api.create_websocket() + except NoDevicesFound as err: + # Can later setup successfully and spawn a repair. + raise ConfigEntryNotReady( + "No devices were found on the websocket, perhaps you don't have any devices on this account?" + ) from err + except WebsocketFailure as err: + raise ConfigEntryNotReady("Failed connecting to the websocket.") from err + # Create a coordinator per device, if the device is offline, no data will be on the + # websocket, and the coordinator should auto mark as unavailable. But as long as + # the websocket successfully connected, config entry should setup. + devices: list[APCWifiDevice] = [] + if TYPE_CHECKING: + # api.websocket_handler can't be None after successfully creating the + # websocket client + assert api.websocket_handler is not None + devices = list(api.websocket_handler.devices.values()) coordinators = [AnovaCoordinator(hass, device) for device in devices] - for coordinator in coordinators: - await coordinator.async_config_entry_first_refresh() - firmware_version = coordinator.data.sensor.firmware_version - coordinator.async_setup(str(firmware_version)) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnovaData( - api_jwt=api.jwt, precision_cookers=devices, coordinators=coordinators + api_jwt=api.jwt, coordinators=coordinators, api=api ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -74,6 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - + anova_data: AnovaData = hass.data[DOMAIN].pop(entry.entry_id) + # Disconnect from WS + await anova_data.api.disconnect_websocket() return unload_ok diff --git a/homeassistant/components/anova/config_flow.py b/homeassistant/components/anova/config_flow.py index 0015d5ea13f..6e331ccf4a2 100644 --- a/homeassistant/components/anova/config_flow.py +++ b/homeassistant/components/anova/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from anova_wifi import AnovaApi, InvalidLogin, NoDevicesFound +from anova_wifi import AnovaApi, InvalidLogin import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -10,7 +10,6 @@ from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -from .util import serialize_device_list class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): @@ -33,22 +32,18 @@ class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() try: await api.authenticate() - devices = await api.get_devices() except InvalidLogin: errors["base"] = "invalid_auth" - except NoDevicesFound: - errors["base"] = "no_devices_found" except Exception: # noqa: BLE001 errors["base"] = "unknown" else: - # We store device list in config flow in order to persist found devices on restart, as the Anova api get_devices does not return any devices that are offline. - device_list = serialize_device_list(devices) return self.async_create_entry( title="Anova", data={ - CONF_USERNAME: api.username, - CONF_PASSWORD: api.password, - CONF_DEVICES: device_list, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + # this can be removed in a migration to 1.2 in 2024.11 + CONF_DEVICES: [], }, ) diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py index c0261c139c1..93c6fdbf1c5 100644 --- a/homeassistant/components/anova/coordinator.py +++ b/homeassistant/components/anova/coordinator.py @@ -1,14 +1,13 @@ """Support for Anova Coordinators.""" -from asyncio import timeout -from datetime import timedelta import logging -from anova_wifi import AnovaOffline, AnovaPrecisionCooker, APCUpdate +from anova_wifi import APCUpdate, APCWifiDevice -from homeassistant.core import HomeAssistant, callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -18,37 +17,24 @@ _LOGGER = logging.getLogger(__name__) class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]): """Anova custom coordinator.""" - def __init__( - self, - hass: HomeAssistant, - anova_device: AnovaPrecisionCooker, - ) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, anova_device: APCWifiDevice) -> None: """Set up Anova Coordinator.""" super().__init__( hass, name="Anova Precision Cooker", logger=_LOGGER, - update_interval=timedelta(seconds=30), ) - assert self.config_entry is not None - self.device_unique_id = anova_device.device_key + self.device_unique_id = anova_device.cooker_id self.anova_device = anova_device + self.anova_device.set_update_listener(self.async_set_updated_data) self.device_info: DeviceInfo | None = None - @callback - def async_setup(self, firmware_version: str) -> None: - """Set the firmware version info.""" self.device_info = DeviceInfo( identifiers={(DOMAIN, self.device_unique_id)}, name="Anova Precision Cooker", manufacturer="Anova", model="Precision Cooker", - sw_version=firmware_version, ) - - async def _async_update_data(self) -> APCUpdate: - try: - async with timeout(5): - return await self.anova_device.update() - except AnovaOffline as err: - raise UpdateFailed(err) from err + self.sensor_data_set: bool = False diff --git a/homeassistant/components/anova/entity.py b/homeassistant/components/anova/entity.py index a8e3ce0ae70..54492f3775e 100644 --- a/homeassistant/components/anova/entity.py +++ b/homeassistant/components/anova/entity.py @@ -19,6 +19,11 @@ class AnovaEntity(CoordinatorEntity[AnovaCoordinator], Entity): self.device = coordinator.anova_device self._attr_device_info = coordinator.device_info + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data is not None and super().available + class AnovaDescriptionEntity(AnovaEntity): """Defines an Anova entity that uses a description.""" diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json index 7c4509e2f25..331a4f61118 100644 --- a/homeassistant/components/anova/manifest.json +++ b/homeassistant/components/anova/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@Lash-L"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/anova", - "iot_class": "cloud_polling", + "iot_class": "cloud_push", "loggers": ["anova_wifi"], - "requirements": ["anova-wifi==0.10.0"] + "requirements": ["anova-wifi==0.12.0"] } diff --git a/homeassistant/components/anova/models.py b/homeassistant/components/anova/models.py index 4a6338eb081..8caf16eeae1 100644 --- a/homeassistant/components/anova/models.py +++ b/homeassistant/components/anova/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from anova_wifi import AnovaPrecisionCooker +from anova_wifi import AnovaApi from .coordinator import AnovaCoordinator @@ -12,5 +12,5 @@ class AnovaData: """Data for the Anova integration.""" api_jwt: str - precision_cookers: list[AnovaPrecisionCooker] coordinators: list[AnovaCoordinator] + api: AnovaApi diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index 7e94f8f4b0b..e5fe9ededfd 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from anova_wifi import APCUpdateSensor +from anova_wifi import AnovaMode, AnovaState, APCUpdateSensor from homeassistant import config_entries from homeassistant.components.sensor import ( @@ -20,25 +20,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN +from .coordinator import AnovaCoordinator from .entity import AnovaDescriptionEntity from .models import AnovaData -@dataclass(frozen=True) -class AnovaSensorEntityDescriptionMixin: - """Describes the mixin variables for anova sensors.""" - - value_fn: Callable[[APCUpdateSensor], float | int | str] - - -@dataclass(frozen=True) -class AnovaSensorEntityDescription( - SensorEntityDescription, AnovaSensorEntityDescriptionMixin -): +@dataclass(frozen=True, kw_only=True) +class AnovaSensorEntityDescription(SensorEntityDescription): """Describes a Anova sensor.""" + value_fn: Callable[[APCUpdateSensor], StateType] -SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ + +SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [ AnovaSensorEntityDescription( key="cook_time", state_class=SensorStateClass.TOTAL_INCREASING, @@ -50,11 +44,15 @@ SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ AnovaSensorEntityDescription( key="state", translation_key="state", + device_class=SensorDeviceClass.ENUM, + options=[state.name for state in AnovaState], value_fn=lambda data: data.state, ), AnovaSensorEntityDescription( key="mode", translation_key="mode", + device_class=SensorDeviceClass.ENUM, + options=[mode.name for mode in AnovaMode], value_fn=lambda data: data.mode, ), AnovaSensorEntityDescription( @@ -106,11 +104,34 @@ async def async_setup_entry( ) -> None: """Set up Anova device.""" anova_data: AnovaData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - AnovaSensor(coordinator, description) - for coordinator in anova_data.coordinators - for description in SENSOR_DESCRIPTIONS - ) + + for coordinator in anova_data.coordinators: + setup_coordinator(coordinator, async_add_entities) + + +def setup_coordinator( + coordinator: AnovaCoordinator, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up an individual Anova Coordinator.""" + + def _async_sensor_listener() -> None: + """Listen for new sensor data and add sensors if they did not exist.""" + if not coordinator.sensor_data_set: + valid_entities: set[AnovaSensor] = set() + for description in SENSOR_DESCRIPTIONS: + if description.value_fn(coordinator.data.sensor) is not None: + valid_entities.add(AnovaSensor(coordinator, description)) + async_add_entities(valid_entities) + coordinator.sensor_data_set = True + + if coordinator.data is not None: + _async_sensor_listener() + # It is possible that we don't have any data, but the device exists, + # i.e. slow network, offline device, etc. + # We want to set up sensors after the fact as we don't know what sensors + # are valid until runtime. + coordinator.async_add_listener(_async_sensor_listener) class AnovaSensor(AnovaDescriptionEntity, SensorEntity): diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json index b7762732303..bfe3a61282e 100644 --- a/homeassistant/components/anova/strings.json +++ b/homeassistant/components/anova/strings.json @@ -11,13 +11,9 @@ "description": "[%key:common::config_flow::description::confirm_setup%]" } }, - "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" - }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "no_devices_found": "No devices were found. Make sure you have at least one Anova device online." + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "entity": { @@ -26,10 +22,28 @@ "name": "Cook time" }, "state": { - "name": "State" + "name": "State", + "state": { + "preheating": "Preheating", + "cooking": "Cooking", + "maintaining": "Maintaining", + "timer_expired": "Timer expired", + "set_timer": "Set timer", + "no_state": "No state" + } }, "mode": { - "name": "[%key:common::config_flow::data::mode%]" + "name": "[%key:common::config_flow::data::mode%]", + "state": { + "startup": "Startup", + "idle": "[%key:common::state::idle%]", + "cook": "Cooking", + "low_water": "Low water", + "ota": "Ota", + "provisioning": "Provisioning", + "high_temp": "High temperature", + "device_failure": "Device failure" + } }, "target_temperature": { "name": "Target temperature" diff --git a/homeassistant/components/anova/util.py b/homeassistant/components/anova/util.py deleted file mode 100644 index 10e8fa0fef9..00000000000 --- a/homeassistant/components/anova/util.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Anova utilities.""" - -from anova_wifi import AnovaPrecisionCooker - - -def serialize_device_list(devices: list[AnovaPrecisionCooker]) -> list[tuple[str, str]]: - """Turn the device list into a serializable list that can be reconstructed.""" - return [(device.device_key, device.type) for device in devices] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ceb3d9955d4..baec734a058 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -302,7 +302,7 @@ "name": "Anova", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_push" }, "anthemav": { "name": "Anthem A/V Receivers", diff --git a/requirements_all.txt b/requirements_all.txt index 889ebe82320..0d97ac1514a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -440,7 +440,7 @@ androidtvremote2==0.0.15 anel-pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.anova -anova-wifi==0.10.0 +anova-wifi==0.12.0 # homeassistant.components.anthemav anthemav==1.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c77fd5af508..e49c292fcc7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ androidtv[async]==0.0.73 androidtvremote2==0.0.15 # homeassistant.components.anova -anova-wifi==0.10.0 +anova-wifi==0.12.0 # homeassistant.components.anthemav anthemav==1.4.1 diff --git a/tests/components/anova/__init__.py b/tests/components/anova/__init__.py index 03cfb7589d0..887f5b3b05b 100644 --- a/tests/components/anova/__init__.py +++ b/tests/components/anova/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from anova_wifi import AnovaPrecisionCooker, APCUpdate, APCUpdateBinary, APCUpdateSensor +from anova_wifi import APCUpdate, APCUpdateBinary, APCUpdateSensor from homeassistant.components.anova.const import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -21,7 +21,7 @@ ONLINE_UPDATE = APCUpdate( sensor=APCUpdateSensor( 0, "Low water", "No state", 23.33, 0, "2.2.0", 20.87, 21.79, 21.33 ), - binary_sensor=APCUpdateBinary(False, False, False, True, False, True, False), + binary_sensor=APCUpdateBinary(False, False, False, True, False, True, False, False), ) @@ -33,9 +33,9 @@ def create_entry(hass: HomeAssistant, device_id: str = DEVICE_UNIQUE_ID) -> Conf data={ CONF_USERNAME: "sample@gmail.com", CONF_PASSWORD: "sample", - "devices": [(device_id, "type_sample")], }, unique_id="sample@gmail.com", + version=1, ) entry.add_to_hass(hass) return entry @@ -44,23 +44,10 @@ def create_entry(hass: HomeAssistant, device_id: str = DEVICE_UNIQUE_ID) -> Conf async def async_init_integration( hass: HomeAssistant, skip_setup: bool = False, - error: str | None = None, ) -> ConfigEntry: """Set up the Anova integration in Home Assistant.""" - with ( - patch( - "homeassistant.components.anova.coordinator.AnovaPrecisionCooker.update" - ) as update_patch, - patch("homeassistant.components.anova.AnovaApi.authenticate"), - patch( - "homeassistant.components.anova.AnovaApi.get_devices", - ) as device_patch, - ): - update_patch.return_value = ONLINE_UPDATE - device_patch.return_value = [ - AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - ] + with patch("homeassistant.components.anova.AnovaApi.authenticate"): entry = create_entry(hass) if not skip_setup: diff --git a/tests/components/anova/conftest.py b/tests/components/anova/conftest.py index 3e904bb1415..c59aeb76cdd 100644 --- a/tests/components/anova/conftest.py +++ b/tests/components/anova/conftest.py @@ -1,13 +1,176 @@ """Common fixtures for Anova.""" +import asyncio +from dataclasses import dataclass +import json +from typing import Any from unittest.mock import AsyncMock, patch -from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound +from aiohttp import ClientSession +from anova_wifi import ( + AnovaApi, + AnovaWebsocketHandler, + InvalidLogin, + NoDevicesFound, + WebsocketFailure, +) import pytest from homeassistant.core import HomeAssistant -from . import DEVICE_UNIQUE_ID +DUMMY_ID = "anova_id" + + +@dataclass +class MockedanovaWebsocketMessage: + """Mock the websocket message for Anova.""" + + input_data: dict[str, Any] + data: str = "" + + def __post_init__(self) -> None: + """Set up data after creation.""" + self.data = json.dumps(self.input_data) + + +class MockedAnovaWebsocketStream: + """Mock the websocket stream for Anova.""" + + def __init__(self, messages: list[MockedanovaWebsocketMessage]) -> None: + """Initialize a Anova Websocket Stream that can be manipulated for tests.""" + self.messages = messages + + def __aiter__(self) -> "MockedAnovaWebsocketStream": + """Handle async iteration.""" + return self + + async def __anext__(self) -> MockedanovaWebsocketMessage: + """Get the next message in the websocket stream.""" + if self.messages: + return self.messages.pop(0) + raise StopAsyncIteration + + def clear(self) -> None: + """Clear the Websocket stream.""" + self.messages.clear() + + +class MockedAnovaWebsocketHandler(AnovaWebsocketHandler): + """Mock the Anova websocket handler.""" + + def __init__( + self, + firebase_jwt: str, + jwt: str, + session: ClientSession, + connect_messages: list[MockedanovaWebsocketMessage], + post_connect_messages: list[MockedanovaWebsocketMessage], + ) -> None: + """Initialize the websocket handler with whatever messages you want.""" + super().__init__(firebase_jwt, jwt, session) + self.connect_messages = connect_messages + self.post_connect_messages = post_connect_messages + + async def connect(self) -> None: + """Create a future for the message listener.""" + self.ws = MockedAnovaWebsocketStream(self.connect_messages) + await self.message_listener() + self.ws = MockedAnovaWebsocketStream(self.post_connect_messages) + self.fut = asyncio.ensure_future(self.message_listener()) + + +def anova_api_mock( + connect_messages: list[MockedanovaWebsocketMessage] | None = None, + post_connect_messages: list[MockedanovaWebsocketMessage] | None = None, +) -> AsyncMock: + """Mock the api for Anova.""" + api_mock = AsyncMock() + + async def authenticate_side_effect() -> None: + api_mock.jwt = "my_test_jwt" + api_mock._firebase_jwt = "my_test_firebase_jwt" + + async def create_websocket_side_effect() -> None: + api_mock.websocket_handler = MockedAnovaWebsocketHandler( + firebase_jwt=api_mock._firebase_jwt, + jwt=api_mock.jwt, + session=AsyncMock(), + connect_messages=connect_messages + if connect_messages is not None + else [ + MockedanovaWebsocketMessage( + { + "command": "EVENT_APC_WIFI_LIST", + "payload": [ + { + "cookerId": DUMMY_ID, + "type": "a5", + "pairedAt": "2023-08-12T02:33:20.917716Z", + "name": "Anova Precision Cooker", + } + ], + } + ), + ], + post_connect_messages=post_connect_messages + if post_connect_messages is not None + else [ + MockedanovaWebsocketMessage( + { + "command": "EVENT_APC_STATE", + "payload": { + "cookerId": DUMMY_ID, + "state": { + "boot-id": "8620610049456548422", + "job": { + "cook-time-seconds": 0, + "id": "8759286e3125b0c547", + "mode": "IDLE", + "ota-url": "", + "target-temperature": 54.72, + "temperature-unit": "F", + }, + "job-status": { + "cook-time-remaining": 0, + "job-start-systick": 599679, + "provisioning-pairing-code": 7514, + "state": "", + "state-change-systick": 599679, + }, + "pin-info": { + "device-safe": 0, + "water-leak": 0, + "water-level-critical": 0, + "water-temp-too-high": 0, + }, + "system-info": { + "class": "A5", + "firmware-version": "2.2.0", + "type": "RA2L1-128", + }, + "system-info-details": { + "firmware-version-raw": "VM178_A_02.02.00_MKE15-128", + "systick": 607026, + "version-string": "VM171_A_02.02.00 RA2L1-128", + }, + "temperature-info": { + "heater-temperature": 22.37, + "triac-temperature": 36.04, + "water-temperature": 18.33, + }, + }, + }, + } + ), + ], + ) + await api_mock.websocket_handler.connect() + if not api_mock.websocket_handler.devices: + raise NoDevicesFound("No devices were found on the websocket.") + + api_mock.authenticate.side_effect = authenticate_side_effect + api_mock.create_websocket.side_effect = create_websocket_side_effect + return api_mock @pytest.fixture @@ -15,23 +178,14 @@ async def anova_api( hass: HomeAssistant, ) -> AnovaApi: """Mock the api for Anova.""" - api_mock = AsyncMock() + api_mock = anova_api_mock() - new_device = AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - - async def authenticate_side_effect(): - api_mock.jwt = "my_test_jwt" - - async def get_devices_side_effect(): - if not api_mock.existing_devices: - api_mock.existing_devices = [] - api_mock.existing_devices = [*api_mock.existing_devices, new_device] - return [new_device] - - api_mock.authenticate.side_effect = authenticate_side_effect - api_mock.get_devices.side_effect = get_devices_side_effect - - with patch("homeassistant.components.anova.AnovaApi", return_value=api_mock): + with ( + patch("homeassistant.components.anova.AnovaApi", return_value=api_mock), + patch( + "homeassistant.components.anova.config_flow.AnovaApi", return_value=api_mock + ), + ): api = AnovaApi( None, "sample@gmail.com", @@ -45,18 +199,14 @@ async def anova_api_no_devices( hass: HomeAssistant, ) -> AnovaApi: """Mock the api for Anova with no online devices.""" - api_mock = AsyncMock() + api_mock = anova_api_mock(connect_messages=[], post_connect_messages=[]) - async def authenticate_side_effect(): - api_mock.jwt = "my_test_jwt" - - async def get_devices_side_effect(): - raise NoDevicesFound - - api_mock.authenticate.side_effect = authenticate_side_effect - api_mock.get_devices.side_effect = get_devices_side_effect - - with patch("homeassistant.components.anova.AnovaApi", return_value=api_mock): + with ( + patch("homeassistant.components.anova.AnovaApi", return_value=api_mock), + patch( + "homeassistant.components.anova.config_flow.AnovaApi", return_value=api_mock + ), + ): api = AnovaApi( None, "sample@gmail.com", @@ -70,7 +220,7 @@ async def anova_api_wrong_login( hass: HomeAssistant, ) -> AnovaApi: """Mock the api for Anova with a wrong login.""" - api_mock = AsyncMock() + api_mock = anova_api_mock() async def authenticate_side_effect(): raise InvalidLogin @@ -84,3 +234,40 @@ async def anova_api_wrong_login( "sample", ) yield api + + +@pytest.fixture +async def anova_api_no_data( + hass: HomeAssistant, +) -> AnovaApi: + """Mock the api for Anova with a wrong login.""" + api_mock = anova_api_mock(post_connect_messages=[]) + + with patch("homeassistant.components.anova.AnovaApi", return_value=api_mock): + api = AnovaApi( + None, + "sample@gmail.com", + "sample", + ) + yield api + + +@pytest.fixture +async def anova_api_websocket_failure( + hass: HomeAssistant, +) -> AnovaApi: + """Mock the api for Anova with a websocket failure.""" + api_mock = anova_api_mock() + + async def create_websocket_side_effect(): + raise WebsocketFailure + + api_mock.create_websocket.side_effect = create_websocket_side_effect + + with patch("homeassistant.components.anova.AnovaApi", return_value=api_mock): + api = AnovaApi( + None, + "sample@gmail.com", + "sample", + ) + yield api diff --git a/tests/components/anova/test_config_flow.py b/tests/components/anova/test_config_flow.py index b92c50c40b0..0f93b869296 100644 --- a/tests/components/anova/test_config_flow.py +++ b/tests/components/anova/test_config_flow.py @@ -2,83 +2,33 @@ from unittest.mock import patch -from anova_wifi import AnovaPrecisionCooker, InvalidLogin, NoDevicesFound +from anova_wifi import AnovaApi, InvalidLogin from homeassistant import config_entries from homeassistant.components.anova.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import CONF_INPUT, DEVICE_UNIQUE_ID, create_entry +from . import CONF_INPUT -async def test_flow_user( - hass: HomeAssistant, -) -> None: +async def test_flow_user(hass: HomeAssistant, anova_api: AnovaApi) -> None: """Test user initialized flow.""" - with ( - patch( - "homeassistant.components.anova.config_flow.AnovaApi.authenticate", - ) as auth_patch, - patch("homeassistant.components.anova.AnovaApi.get_devices") as device_patch, - patch("homeassistant.components.anova.AnovaApi.authenticate"), - patch( - "homeassistant.components.anova.config_flow.AnovaApi.get_devices" - ) as config_flow_device_patch, - ): - auth_patch.return_value = True - device_patch.return_value = [ - AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - ] - config_flow_device_patch.return_value = [ - AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - ] - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_INPUT, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_USERNAME: "sample@gmail.com", - CONF_PASSWORD: "sample", - "devices": [(DEVICE_UNIQUE_ID, "type_sample")], - } - - -async def test_flow_user_already_configured(hass: HomeAssistant) -> None: - """Test user initialized flow with duplicate device.""" - with ( - patch( - "homeassistant.components.anova.config_flow.AnovaApi.authenticate", - ) as auth_patch, - patch("homeassistant.components.anova.AnovaApi.get_devices") as device_patch, - patch( - "homeassistant.components.anova.config_flow.AnovaApi.get_devices" - ) as config_flow_device_patch, - ): - auth_patch.return_value = True - device_patch.return_value = [ - AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - ] - config_flow_device_patch.return_value = [ - AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - ] - create_entry(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_INPUT, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_INPUT, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_USERNAME: "sample@gmail.com", + CONF_PASSWORD: "sample", + CONF_DEVICES: [], + } async def test_flow_wrong_login(hass: HomeAssistant) -> None: @@ -115,24 +65,3 @@ async def test_flow_unknown_error(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "unknown"} - - -async def test_flow_no_devices(hass: HomeAssistant) -> None: - """Test unknown error throwing error.""" - with ( - patch("homeassistant.components.anova.config_flow.AnovaApi.authenticate"), - patch( - "homeassistant.components.anova.config_flow.AnovaApi.get_devices", - side_effect=NoDevicesFound(), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_INPUT, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "no_devices_found"} diff --git a/tests/components/anova/test_init.py b/tests/components/anova/test_init.py index 631a69e103b..5fc63fcaf93 100644 --- a/tests/components/anova/test_init.py +++ b/tests/components/anova/test_init.py @@ -1,15 +1,12 @@ """Test init for Anova.""" -from unittest.mock import patch - from anova_wifi import AnovaApi from homeassistant.components.anova import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from . import ONLINE_UPDATE, async_init_integration, create_entry +from . import async_init_integration, create_entry async def test_async_setup_entry(hass: HomeAssistant, anova_api: AnovaApi) -> None: @@ -17,8 +14,7 @@ async def test_async_setup_entry(hass: HomeAssistant, anova_api: AnovaApi) -> No await async_init_integration(hass) state = hass.states.get("sensor.anova_precision_cooker_mode") assert state is not None - assert state.state != STATE_UNAVAILABLE - assert state.state == "Low water" + assert state.state == "idle" async def test_wrong_login( @@ -30,37 +26,6 @@ async def test_wrong_login( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_new_devices(hass: HomeAssistant, anova_api: AnovaApi) -> None: - """Test for if we find a new device on init.""" - entry = create_entry(hass, "test_device_2") - with patch( - "homeassistant.components.anova.coordinator.AnovaPrecisionCooker.update" - ) as update_patch: - update_patch.return_value = ONLINE_UPDATE - assert len(entry.data["devices"]) == 1 - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert len(entry.data["devices"]) == 2 - - -async def test_device_cached_but_offline( - hass: HomeAssistant, anova_api_no_devices: AnovaApi -) -> None: - """Test if we have previously seen a device, but it was offline on startup.""" - entry = create_entry(hass) - - with patch( - "homeassistant.components.anova.coordinator.AnovaPrecisionCooker.update" - ) as update_patch: - update_patch.return_value = ONLINE_UPDATE - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert len(entry.data["devices"]) == 1 - state = hass.states.get("sensor.anova_precision_cooker_mode") - assert state is not None - assert state.state == "Low water" - - async def test_unload_entry(hass: HomeAssistant, anova_api: AnovaApi) -> None: """Test successful unload of entry.""" entry = await async_init_integration(hass) @@ -72,3 +37,21 @@ async def test_unload_entry(hass: HomeAssistant, anova_api: AnovaApi) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_no_devices_found( + hass: HomeAssistant, + anova_api_no_devices: AnovaApi, +) -> None: + """Test when there don't seem to be any devices on the account.""" + entry = await async_init_integration(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_websocket_failure( + hass: HomeAssistant, + anova_api_websocket_failure: AnovaApi, +) -> None: + """Test that we successfully handle a websocket failure on setup.""" + entry = await async_init_integration(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/anova/test_sensor.py b/tests/components/anova/test_sensor.py index 0ce5c7a4d0a..a60f87c56a0 100644 --- a/tests/components/anova/test_sensor.py +++ b/tests/components/anova/test_sensor.py @@ -1,19 +1,13 @@ """Test the Anova sensors.""" -from datetime import timedelta import logging -from unittest.mock import patch -from anova_wifi import AnovaApi, AnovaOffline +from anova_wifi import AnovaApi -from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from . import async_init_integration -from tests.common import async_fire_time_changed - LOGGER = logging.getLogger(__name__) @@ -28,34 +22,25 @@ async def test_sensors(hass: HomeAssistant, anova_api: AnovaApi) -> None: assert hass.states.get("sensor.anova_precision_cooker_cook_time").state == "0" assert ( hass.states.get("sensor.anova_precision_cooker_heater_temperature").state - == "20.87" + == "22.37" ) - assert hass.states.get("sensor.anova_precision_cooker_mode").state == "Low water" - assert hass.states.get("sensor.anova_precision_cooker_state").state == "No state" + assert hass.states.get("sensor.anova_precision_cooker_mode").state == "idle" + assert hass.states.get("sensor.anova_precision_cooker_state").state == "no_state" assert ( hass.states.get("sensor.anova_precision_cooker_target_temperature").state - == "23.33" + == "54.72" ) assert ( hass.states.get("sensor.anova_precision_cooker_water_temperature").state - == "21.33" + == "18.33" ) assert ( hass.states.get("sensor.anova_precision_cooker_triac_temperature").state - == "21.79" + == "36.04" ) -async def test_update_failed(hass: HomeAssistant, anova_api: AnovaApi) -> None: - """Test updating data after the coordinator has been set up, but anova is offline.""" +async def test_no_data_sensors(hass: HomeAssistant, anova_api_no_data: AnovaApi): + """Test that if we have no data for the device, and we have not set it up previously, It is not immediately set up.""" await async_init_integration(hass) - await hass.async_block_till_done() - with patch( - "homeassistant.components.anova.AnovaPrecisionCooker.update", - side_effect=AnovaOffline(), - ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=61)) - await hass.async_block_till_done() - - state = hass.states.get("sensor.anova_precision_cooker_water_temperature") - assert state.state == STATE_UNAVAILABLE + assert hass.states.get("sensor.anova_precision_cooker_triac_temperature") is None