diff --git a/.strict-typing b/.strict-typing index 7d2bedc1a46..a5f084116a2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -57,6 +57,7 @@ homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.analytics.* +homeassistant.components.anova.* homeassistant.components.anthemav.* homeassistant.components.apcupsd.* homeassistant.components.aqualogic.* diff --git a/CODEOWNERS b/CODEOWNERS index d21f260c286..c6a589a70db 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -82,6 +82,8 @@ build.json @home-assistant/supervisor /tests/components/androidtv/ @JeffLIrion @ollo69 /homeassistant/components/androidtv_remote/ @tronikos /tests/components/androidtv_remote/ @tronikos +/homeassistant/components/anova/ @Lash-L +/tests/components/anova/ @Lash-L /homeassistant/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex /homeassistant/components/apache_kafka/ @bachya diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py new file mode 100644 index 00000000000..7810e00ded0 --- /dev/null +++ b/homeassistant/components/anova/__init__.py @@ -0,0 +1,86 @@ +"""The Anova integration.""" +from __future__ import annotations + +import logging + +from anova_wifi import ( + AnovaApi, + AnovaPrecisionCooker, + AnovaPrecisionCookerSensor, + InvalidLogin, + NoDevicesFound, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +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] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Anova from a config entry.""" + api = AnovaApi( + aiohttp_client.async_get_clientsession(hass), + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + try: + await api.authenticate() + except InvalidLogin as err: + _LOGGER.error( + "Login was incorrect - please log back in through the config flow. %s", err + ) + 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["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, + **{"devices": serialize_device_list(devices)}, + }, + ) + coordinators = [AnovaCoordinator(hass, device) for device in devices] + for coordinator in coordinators: + await coordinator.async_config_entry_first_refresh() + firmware_version = coordinator.data["sensors"][ + AnovaPrecisionCookerSensor.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 + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +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) + + return unload_ok diff --git a/homeassistant/components/anova/config_flow.py b/homeassistant/components/anova/config_flow.py new file mode 100644 index 00000000000..5d0d2dbf628 --- /dev/null +++ b/homeassistant/components/anova/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for Anova.""" +from __future__ import annotations + +from anova_wifi import AnovaApi, InvalidLogin, NoDevicesFound +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .util import serialize_device_list + + +class AnovaConfligFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Sets up a config flow for Anova.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + api = AnovaApi( + aiohttp_client.async_get_clientsession(self.hass), + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + 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: # pylint: disable=broad-except + 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, + "devices": device_list, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) diff --git a/homeassistant/components/anova/const.py b/homeassistant/components/anova/const.py new file mode 100644 index 00000000000..0e3de12aca6 --- /dev/null +++ b/homeassistant/components/anova/const.py @@ -0,0 +1,6 @@ +"""Constants for the Anova integration.""" + +DOMAIN = "anova" + +ANOVA_CLIENT = "anova_api_client" +ANOVA_FIRMWARE_VERSION = "anova_firmware_version" diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py new file mode 100644 index 00000000000..cd4eab9c2e5 --- /dev/null +++ b/homeassistant/components/anova/coordinator.py @@ -0,0 +1,55 @@ +"""Support for Anova Coordinators.""" +from datetime import timedelta +import logging + +from anova_wifi import AnovaOffline, AnovaPrecisionCooker +import async_timeout + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AnovaCoordinator(DataUpdateCoordinator): + """Anova custom coordinator.""" + + data: dict[str, dict[str, str | int | float]] + + def __init__( + self, + hass: HomeAssistant, + anova_device: AnovaPrecisionCooker, + ) -> 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.anova_device = anova_device + 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) -> dict[str, dict[str, str | int | float]]: + try: + async with async_timeout.timeout(5): + return await self.anova_device.update() + except AnovaOffline as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/anova/entity.py b/homeassistant/components/anova/entity.py new file mode 100644 index 00000000000..fd104e194f1 --- /dev/null +++ b/homeassistant/components/anova/entity.py @@ -0,0 +1,30 @@ +"""Base entity for the Anova integration.""" +from __future__ import annotations + +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import AnovaCoordinator + + +class AnovaEntity(CoordinatorEntity[AnovaCoordinator], Entity): + """Defines a Anova entity.""" + + def __init__(self, coordinator: AnovaCoordinator) -> None: + """Initialize the Anova entity.""" + super().__init__(coordinator) + self.device = coordinator.anova_device + self._attr_device_info = coordinator.device_info + self._attr_has_entity_name = True + + +class AnovaDescriptionEntity(AnovaEntity, Entity): + """Defines a Anova entity that uses a description.""" + + def __init__( + self, coordinator: AnovaCoordinator, description: EntityDescription + ) -> None: + """Initialize the entity and declare unique id based on description key.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator._device_unique_id}_{description.key}" diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json new file mode 100644 index 00000000000..d307a9314f9 --- /dev/null +++ b/homeassistant/components/anova/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "anova", + "name": "Anova", + "codeowners": ["@Lash-L"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/anova", + "iot_class": "cloud_polling", + "loggers": ["anova_wifi"], + "requirements": ["anova-wifi==0.8.0"] +} diff --git a/homeassistant/components/anova/models.py b/homeassistant/components/anova/models.py new file mode 100644 index 00000000000..a63355b2bbd --- /dev/null +++ b/homeassistant/components/anova/models.py @@ -0,0 +1,15 @@ +"""Dataclass models for the Anova integration.""" +from dataclasses import dataclass + +from anova_wifi import AnovaPrecisionCooker + +from .coordinator import AnovaCoordinator + + +@dataclass +class AnovaData: + """Data for the Anova integration.""" + + api_jwt: str + precision_cookers: list[AnovaPrecisionCooker] + coordinators: list[AnovaCoordinator] diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py new file mode 100644 index 00000000000..a5ea3ee2fd8 --- /dev/null +++ b/homeassistant/components/anova/sensor.py @@ -0,0 +1,97 @@ +"""Support for Anova Sensors.""" +from __future__ import annotations + +from anova_wifi import AnovaPrecisionCookerSensor + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .entity import AnovaDescriptionEntity +from .models import AnovaData + +SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.COOK_TIME, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.SECONDS, + icon="mdi:clock-outline", + translation_key="cook_time", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.STATE, translation_key="state" + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.MODE, translation_key="mode" + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.TARGET_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="target_temperature", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.COOK_TIME_REMAINING, + native_unit_of_measurement=UnitOfTime.SECONDS, + icon="mdi:clock-outline", + translation_key="cook_time_remaining", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.HEATER_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="heater_temperature", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.TRIAC_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="triac_temperature", + ), + SensorEntityDescription( + key=AnovaPrecisionCookerSensor.WATER_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:thermometer", + translation_key="water_temperature", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> 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 + ) + + +class AnovaSensor(AnovaDescriptionEntity, SensorEntity): + """A sensor using Anova coordinator.""" + + @property + def native_value(self) -> StateType: + """Return the state.""" + return self.coordinator.data["sensors"][self.entity_description.key] diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json new file mode 100644 index 00000000000..19d0e52b7d2 --- /dev/null +++ b/homeassistant/components/anova/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "confirm": { + "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" + } + }, + "entity": { + "sensor": { + "cook_time": { + "name": "Cook time" + }, + "state": { + "name": "State" + }, + "mode": { + "name": "Mode" + }, + "target_temperature": { + "name": "Target temperature" + }, + "cook_time_remaining": { + "name": "Cook time remaining" + }, + "heater_temperature": { + "name": "Heater temperature" + }, + "triac_temperature": { + "name": "Triac temperature" + }, + "water_temperature": { + "name": "Water temperature" + } + } + } +} diff --git a/homeassistant/components/anova/util.py b/homeassistant/components/anova/util.py new file mode 100644 index 00000000000..10e8fa0fef9 --- /dev/null +++ b/homeassistant/components/anova/util.py @@ -0,0 +1,8 @@ +"""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/config_flows.py b/homeassistant/generated/config_flows.py index bf034053ffc..066fb6fb8b0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -40,6 +40,7 @@ FLOWS = { "android_ip_webcam", "androidtv", "androidtv_remote", + "anova", "anthemav", "apcupsd", "apple_tv", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bae8aa3fd39..b0c164da1ed 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -258,6 +258,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "anova": { + "name": "Anova", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "anthemav": { "name": "Anthem A/V Receivers", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 6c16e661ee2..2fe9310907f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -331,6 +331,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.anova.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.anthemav.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 0233eb00651..cba0c07faf2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -338,6 +338,9 @@ androidtvremote2==0.0.7 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 +# homeassistant.components.anova +anova-wifi==0.8.0 + # homeassistant.components.anthemav anthemav==1.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd4bda308db..00868ce0680 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -310,6 +310,9 @@ androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote androidtvremote2==0.0.7 +# homeassistant.components.anova +anova-wifi==0.8.0 + # homeassistant.components.anthemav anthemav==1.4.1 diff --git a/tests/components/anova/__init__.py b/tests/components/anova/__init__.py new file mode 100644 index 00000000000..e0e31c84b7b --- /dev/null +++ b/tests/components/anova/__init__.py @@ -0,0 +1,85 @@ +"""Tests for the Anova integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from anova_wifi import ( + AnovaPrecisionCooker, + AnovaPrecisionCookerBinarySensor, + AnovaPrecisionCookerSensor, +) + +from homeassistant.components.anova.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +DEVICE_UNIQUE_ID = "abc123def" + +CONF_INPUT = {CONF_USERNAME: "sample@gmail.com", CONF_PASSWORD: "sample"} + +ONLINE_UPDATE = { + "sensors": { + AnovaPrecisionCookerSensor.COOK_TIME: 0, + AnovaPrecisionCookerSensor.MODE: "Low water", + AnovaPrecisionCookerSensor.STATE: "No state", + AnovaPrecisionCookerSensor.TARGET_TEMPERATURE: 23.33, + AnovaPrecisionCookerSensor.COOK_TIME_REMAINING: 0, + AnovaPrecisionCookerSensor.FIRMWARE_VERSION: "2.2.0", + AnovaPrecisionCookerSensor.HEATER_TEMPERATURE: 20.87, + AnovaPrecisionCookerSensor.TRIAC_TEMPERATURE: 21.79, + AnovaPrecisionCookerSensor.WATER_TEMPERATURE: 21.33, + }, + "binary_sensors": { + AnovaPrecisionCookerBinarySensor.COOKING: False, + AnovaPrecisionCookerBinarySensor.DEVICE_SAFE: True, + AnovaPrecisionCookerBinarySensor.WATER_LEAK: False, + AnovaPrecisionCookerBinarySensor.WATER_LEVEL_CRITICAL: True, + AnovaPrecisionCookerBinarySensor.WATER_TEMP_TOO_HIGH: False, + }, +} + + +def create_entry(hass: HomeAssistant, device_id: str = DEVICE_UNIQUE_ID) -> ConfigEntry: + """Add config entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Anova", + data={ + CONF_USERNAME: "sample@gmail.com", + CONF_PASSWORD: "sample", + "devices": [(device_id, "type_sample")], + }, + unique_id="sample@gmail.com", + ) + entry.add_to_hass(hass) + return entry + + +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) + ] + + entry = create_entry(hass) + + if not skip_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/anova/conftest.py b/tests/components/anova/conftest.py new file mode 100644 index 00000000000..34f713502dd --- /dev/null +++ b/tests/components/anova/conftest.py @@ -0,0 +1,85 @@ +"""Common fixtures for Anova.""" +from unittest.mock import AsyncMock, patch + +from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound +import pytest + +from homeassistant.core import HomeAssistant + +from . import DEVICE_UNIQUE_ID + + +@pytest.fixture +async def anova_api( + hass: HomeAssistant, +) -> AnovaApi: + """Mock the api for Anova.""" + api_mock = AsyncMock() + + 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): + api = AnovaApi( + None, + "sample@gmail.com", + "sample", + ) + yield api + + +@pytest.fixture +async def anova_api_no_devices( + hass: HomeAssistant, +) -> AnovaApi: + """Mock the api for Anova with no online devices.""" + api_mock = AsyncMock() + + 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): + api = AnovaApi( + None, + "sample@gmail.com", + "sample", + ) + yield api + + +@pytest.fixture +async def anova_api_wrong_login( + hass: HomeAssistant, +) -> AnovaApi: + """Mock the api for Anova with a wrong login.""" + api_mock = AsyncMock() + + async def authenticate_side_effect(): + raise InvalidLogin() + + api_mock.authenticate.side_effect = authenticate_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 new file mode 100644 index 00000000000..d1255876137 --- /dev/null +++ b/tests/components/anova/test_config_flow.py @@ -0,0 +1,133 @@ +"""Test Anova config flow.""" + +from unittest.mock import patch + +from anova_wifi import AnovaPrecisionCooker, InvalidLogin, NoDevicesFound + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.anova.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import CONF_INPUT, DEVICE_UNIQUE_ID, create_entry + + +async def test_flow_user( + hass: HomeAssistant, +) -> 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"] == data_entry_flow.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"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_wrong_login(hass: HomeAssistant) -> None: + """Test incorrect login throwing error.""" + with patch( + "homeassistant.components.anova.config_flow.AnovaApi.authenticate", + side_effect=InvalidLogin, + ): + 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"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_flow_unknown_error(hass: HomeAssistant) -> None: + """Test unknown error throwing error.""" + with patch( + "homeassistant.components.anova.config_flow.AnovaApi.authenticate", + side_effect=Exception(), + ): + 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"] == data_entry_flow.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"] == data_entry_flow.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 new file mode 100644 index 00000000000..cbd7231f366 --- /dev/null +++ b/tests/components/anova/test_init.py @@ -0,0 +1,75 @@ +"""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 + + +async def test_async_setup_entry(hass: HomeAssistant, anova_api: AnovaApi) -> None: + """Test a successful setup entry.""" + 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" + + +async def test_wrong_login( + hass: HomeAssistant, anova_api_wrong_login: AnovaApi +) -> None: + """Test for setup failure if connection to Anova is missing.""" + entry = create_entry(hass) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + 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) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/anova/test_sensor.py b/tests/components/anova/test_sensor.py new file mode 100644 index 00000000000..94ce61e5b21 --- /dev/null +++ b/tests/components/anova/test_sensor.py @@ -0,0 +1,61 @@ +"""Test the Anova sensors.""" + +from datetime import timedelta +import logging +from unittest.mock import patch + +from anova_wifi import AnovaApi, AnovaOffline + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from . import async_init_integration + +from tests.common import async_fire_time_changed + +LOGGER = logging.getLogger(__name__) + + +async def test_sensors(hass: HomeAssistant, anova_api: AnovaApi) -> None: + """Test setting up creates the sensors.""" + await async_init_integration(hass) + assert len(hass.states.async_all("sensor")) == 8 + assert ( + hass.states.get("sensor.anova_precision_cooker_cook_time_remaining").state + == "0" + ) + 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" + ) + 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_target_temperature").state + == "23.33" + ) + assert ( + hass.states.get("sensor.anova_precision_cooker_water_temperature").state + == "21.33" + ) + assert ( + hass.states.get("sensor.anova_precision_cooker_triac_temperature").state + == "21.79" + ) + + +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.""" + 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.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