Add Anova integration (#86254)
* init setup of Anova Sous Vide * bump anova-wifi to 0.2.4 * Removed yaml support * Bump to anova-wifi 0.2.5 * Added support for adding sous vide while offline * Added basic test for sensor * added better tests for sensors and init * expanded code coverage * Decreased timedelta to lowest functioning value. * Updating my username * migrate to async_forward_entry_setups * applying pr recommended changes * bump anova-wifi to 0.2.7 * Improvements to hopefully get this review ready * formatting changes * clean ups for pr review * remove unneeded unique id check. * bump ao anova_wifi 0.3.0 * rename device_id to device_unique_id * renamed to 'anova' * added unique_id to MockConfigEntry * removed leftover anova sous vides * added device id to strings * added error for incorrect device id * add has_entity_name * added attr name for tests * added authentication functionality * bump to 0.4.3 * split entity into its own class/object * pulling firmware version out of async_setup Co-authored-by: J. Nick Koston <nick@koston.org> * addressed pr changes * fixed pytest * added anova data model * removed unneeded time change * add logging in package * rework step_user * Update homeassistant/components/anova/sensor.py Co-authored-by: J. Nick Koston <nick@koston.org> * Removed lower from attr unique id Co-authored-by: J. Nick Koston <nick@koston.org> * Removed unneeded member variables in sensor Co-authored-by: J. Nick Koston <nick@koston.org> * removed repeated subclass attr Co-authored-by: J. Nick Koston <nick@koston.org> * simplify update_failed test * created descriptionentity * bump to 0.6.1 limit ws connect * add translation for sensor entities * version bump - support pro model * add anova to strict typing * fixed sensor not getting datas type * Apply suggestions from code review Co-authored-by: J. Nick Koston <nick@koston.org> * Check for new devices in init * style changes * return false instead of config entry not ready * move serialize_device_list to utils * move repeating device check into api * moved unneeded code out of try except * fixed tests to get 100% cov * Update homeassistant/components/anova/strings.json Co-authored-by: J. Nick Koston <nick@koston.org> --------- Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
68ce59e9c1
commit
498e69695b
22 changed files with 884 additions and 0 deletions
|
@ -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.*
|
||||
|
|
|
@ -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
|
||||
|
|
86
homeassistant/components/anova/__init__.py
Normal file
86
homeassistant/components/anova/__init__.py
Normal file
|
@ -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
|
61
homeassistant/components/anova/config_flow.py
Normal file
61
homeassistant/components/anova/config_flow.py
Normal file
|
@ -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,
|
||||
)
|
6
homeassistant/components/anova/const.py
Normal file
6
homeassistant/components/anova/const.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
"""Constants for the Anova integration."""
|
||||
|
||||
DOMAIN = "anova"
|
||||
|
||||
ANOVA_CLIENT = "anova_api_client"
|
||||
ANOVA_FIRMWARE_VERSION = "anova_firmware_version"
|
55
homeassistant/components/anova/coordinator.py
Normal file
55
homeassistant/components/anova/coordinator.py
Normal file
|
@ -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
|
30
homeassistant/components/anova/entity.py
Normal file
30
homeassistant/components/anova/entity.py
Normal file
|
@ -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}"
|
10
homeassistant/components/anova/manifest.json
Normal file
10
homeassistant/components/anova/manifest.json
Normal file
|
@ -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"]
|
||||
}
|
15
homeassistant/components/anova/models.py
Normal file
15
homeassistant/components/anova/models.py
Normal file
|
@ -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]
|
97
homeassistant/components/anova/sensor.py
Normal file
97
homeassistant/components/anova/sensor.py
Normal file
|
@ -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]
|
51
homeassistant/components/anova/strings.json
Normal file
51
homeassistant/components/anova/strings.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
8
homeassistant/components/anova/util.py
Normal file
8
homeassistant/components/anova/util.py
Normal file
|
@ -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]
|
|
@ -40,6 +40,7 @@ FLOWS = {
|
|||
"android_ip_webcam",
|
||||
"androidtv",
|
||||
"androidtv_remote",
|
||||
"anova",
|
||||
"anthemav",
|
||||
"apcupsd",
|
||||
"apple_tv",
|
||||
|
|
|
@ -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",
|
||||
|
|
10
mypy.ini
10
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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
85
tests/components/anova/__init__.py
Normal file
85
tests/components/anova/__init__.py
Normal file
|
@ -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
|
85
tests/components/anova/conftest.py
Normal file
85
tests/components/anova/conftest.py
Normal file
|
@ -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
|
133
tests/components/anova/test_config_flow.py
Normal file
133
tests/components/anova/test_config_flow.py
Normal file
|
@ -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"}
|
75
tests/components/anova/test_init.py
Normal file
75
tests/components/anova/test_init.py
Normal file
|
@ -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
|
61
tests/components/anova/test_sensor.py
Normal file
61
tests/components/anova/test_sensor.py
Normal file
|
@ -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
|
Loading…
Add table
Reference in a new issue