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:
Luke
2023-04-22 18:05:14 -05:00
committed by GitHub
co-authored by J. Nick Koston
parent 68ce59e9c1
commit 498e69695b
22 changed files with 884 additions and 0 deletions
+85
View 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
View 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
View 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
View 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
View 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