Convert Anova to cloud push (#109508)

* current state

* finish refactor

* Apply suggestions from code review

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* address MR comments

* Change to sensor setup to be listener based.

* remove assert for websocket handler

* added assert for log

* remove mixin

* fix linting

* fix merge change

* Add clarifying comment

* Apply suggestions from code review

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Address MR comments

* bump version and fix typing check

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Luke Lashley 2024-05-08 08:53:44 -04:00 committed by GitHub
parent de62e205dd
commit 22bc11f397
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 387 additions and 304 deletions

View file

@ -3,18 +3,25 @@
from __future__ import annotations from __future__ import annotations
import logging 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.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.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from .const import DOMAIN from .const import DOMAIN
from .coordinator import AnovaCoordinator from .coordinator import AnovaCoordinator
from .models import AnovaData from .models import AnovaData
from .util import serialize_device_list
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.SENSOR]
@ -36,36 +43,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
return False return False
assert api.jwt 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: try:
new_devices = await api.get_devices() await api.create_websocket()
except NoDevicesFound: except NoDevicesFound as err:
# get_devices raises an exception if no devices are online # Can later setup successfully and spawn a repair.
new_devices = [] raise ConfigEntryNotReady(
devices = api.existing_devices "No devices were found on the websocket, perhaps you don't have any devices on this account?"
if new_devices: ) from err
hass.config_entries.async_update_entry( except WebsocketFailure as err:
entry, raise ConfigEntryNotReady("Failed connecting to the websocket.") from err
data={ # Create a coordinator per device, if the device is offline, no data will be on the
**entry.data, # websocket, and the coordinator should auto mark as unavailable. But as long as
CONF_DEVICES: serialize_device_list(devices), # 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] 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( 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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True 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: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): 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 return unload_ok

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from anova_wifi import AnovaApi, InvalidLogin, NoDevicesFound from anova_wifi import AnovaApi, InvalidLogin
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult 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 homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
from .util import serialize_device_list
class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): class AnovaConfligFlow(ConfigFlow, domain=DOMAIN):
@ -33,22 +32,18 @@ class AnovaConfligFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
try: try:
await api.authenticate() await api.authenticate()
devices = await api.get_devices()
except InvalidLogin: except InvalidLogin:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except NoDevicesFound:
errors["base"] = "no_devices_found"
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
errors["base"] = "unknown" errors["base"] = "unknown"
else: 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( return self.async_create_entry(
title="Anova", title="Anova",
data={ data={
CONF_USERNAME: api.username, CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: api.password, CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_DEVICES: device_list, # this can be removed in a migration to 1.2 in 2024.11
CONF_DEVICES: [],
}, },
) )

View file

@ -1,14 +1,13 @@
"""Support for Anova Coordinators.""" """Support for Anova Coordinators."""
from asyncio import timeout
from datetime import timedelta
import logging 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.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN from .const import DOMAIN
@ -18,37 +17,24 @@ _LOGGER = logging.getLogger(__name__)
class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]): class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]):
"""Anova custom coordinator.""" """Anova custom coordinator."""
def __init__( config_entry: ConfigEntry
self,
hass: HomeAssistant, def __init__(self, hass: HomeAssistant, anova_device: APCWifiDevice) -> None:
anova_device: AnovaPrecisionCooker,
) -> None:
"""Set up Anova Coordinator.""" """Set up Anova Coordinator."""
super().__init__( super().__init__(
hass, hass,
name="Anova Precision Cooker", name="Anova Precision Cooker",
logger=_LOGGER, logger=_LOGGER,
update_interval=timedelta(seconds=30),
) )
assert self.config_entry is not None self.device_unique_id = anova_device.cooker_id
self.device_unique_id = anova_device.device_key
self.anova_device = anova_device self.anova_device = anova_device
self.anova_device.set_update_listener(self.async_set_updated_data)
self.device_info: DeviceInfo | None = None self.device_info: DeviceInfo | None = None
@callback
def async_setup(self, firmware_version: str) -> None:
"""Set the firmware version info."""
self.device_info = DeviceInfo( self.device_info = DeviceInfo(
identifiers={(DOMAIN, self.device_unique_id)}, identifiers={(DOMAIN, self.device_unique_id)},
name="Anova Precision Cooker", name="Anova Precision Cooker",
manufacturer="Anova", manufacturer="Anova",
model="Precision Cooker", model="Precision Cooker",
sw_version=firmware_version,
) )
self.sensor_data_set: bool = False
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

View file

@ -19,6 +19,11 @@ class AnovaEntity(CoordinatorEntity[AnovaCoordinator], Entity):
self.device = coordinator.anova_device self.device = coordinator.anova_device
self._attr_device_info = coordinator.device_info 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): class AnovaDescriptionEntity(AnovaEntity):
"""Defines an Anova entity that uses a description.""" """Defines an Anova entity that uses a description."""

View file

@ -4,7 +4,7 @@
"codeowners": ["@Lash-L"], "codeowners": ["@Lash-L"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anova", "documentation": "https://www.home-assistant.io/integrations/anova",
"iot_class": "cloud_polling", "iot_class": "cloud_push",
"loggers": ["anova_wifi"], "loggers": ["anova_wifi"],
"requirements": ["anova-wifi==0.10.0"] "requirements": ["anova-wifi==0.12.0"]
} }

View file

@ -2,7 +2,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from anova_wifi import AnovaPrecisionCooker from anova_wifi import AnovaApi
from .coordinator import AnovaCoordinator from .coordinator import AnovaCoordinator
@ -12,5 +12,5 @@ class AnovaData:
"""Data for the Anova integration.""" """Data for the Anova integration."""
api_jwt: str api_jwt: str
precision_cookers: list[AnovaPrecisionCooker]
coordinators: list[AnovaCoordinator] coordinators: list[AnovaCoordinator]
api: AnovaApi

View file

@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from anova_wifi import APCUpdateSensor from anova_wifi import AnovaMode, AnovaState, APCUpdateSensor
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@ -20,25 +20,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from .const import DOMAIN from .const import DOMAIN
from .coordinator import AnovaCoordinator
from .entity import AnovaDescriptionEntity from .entity import AnovaDescriptionEntity
from .models import AnovaData from .models import AnovaData
@dataclass(frozen=True) @dataclass(frozen=True, kw_only=True)
class AnovaSensorEntityDescriptionMixin: class AnovaSensorEntityDescription(SensorEntityDescription):
"""Describes the mixin variables for anova sensors."""
value_fn: Callable[[APCUpdateSensor], float | int | str]
@dataclass(frozen=True)
class AnovaSensorEntityDescription(
SensorEntityDescription, AnovaSensorEntityDescriptionMixin
):
"""Describes a Anova sensor.""" """Describes a Anova sensor."""
value_fn: Callable[[APCUpdateSensor], StateType]
SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [
SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [
AnovaSensorEntityDescription( AnovaSensorEntityDescription(
key="cook_time", key="cook_time",
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
@ -50,11 +44,15 @@ SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [
AnovaSensorEntityDescription( AnovaSensorEntityDescription(
key="state", key="state",
translation_key="state", translation_key="state",
device_class=SensorDeviceClass.ENUM,
options=[state.name for state in AnovaState],
value_fn=lambda data: data.state, value_fn=lambda data: data.state,
), ),
AnovaSensorEntityDescription( AnovaSensorEntityDescription(
key="mode", key="mode",
translation_key="mode", translation_key="mode",
device_class=SensorDeviceClass.ENUM,
options=[mode.name for mode in AnovaMode],
value_fn=lambda data: data.mode, value_fn=lambda data: data.mode,
), ),
AnovaSensorEntityDescription( AnovaSensorEntityDescription(
@ -106,11 +104,34 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Anova device.""" """Set up Anova device."""
anova_data: AnovaData = hass.data[DOMAIN][entry.entry_id] anova_data: AnovaData = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
AnovaSensor(coordinator, description) for coordinator in anova_data.coordinators:
for coordinator in anova_data.coordinators setup_coordinator(coordinator, async_add_entities)
for description in SENSOR_DESCRIPTIONS
)
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): class AnovaSensor(AnovaDescriptionEntity, SensorEntity):

View file

@ -11,13 +11,9 @@
"description": "[%key:common::config_flow::description::confirm_setup%]" "description": "[%key:common::config_flow::description::confirm_setup%]"
} }
}, },
"abort": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": { "error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]", "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": { "entity": {
@ -26,10 +22,28 @@
"name": "Cook time" "name": "Cook time"
}, },
"state": { "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": { "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": { "target_temperature": {
"name": "Target temperature" "name": "Target temperature"

View file

@ -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]

View file

@ -302,7 +302,7 @@
"name": "Anova", "name": "Anova",
"integration_type": "hub", "integration_type": "hub",
"config_flow": true, "config_flow": true,
"iot_class": "cloud_polling" "iot_class": "cloud_push"
}, },
"anthemav": { "anthemav": {
"name": "Anthem A/V Receivers", "name": "Anthem A/V Receivers",

View file

@ -440,7 +440,7 @@ androidtvremote2==0.0.15
anel-pwrctrl-homeassistant==0.0.1.dev2 anel-pwrctrl-homeassistant==0.0.1.dev2
# homeassistant.components.anova # homeassistant.components.anova
anova-wifi==0.10.0 anova-wifi==0.12.0
# homeassistant.components.anthemav # homeassistant.components.anthemav
anthemav==1.4.1 anthemav==1.4.1

View file

@ -404,7 +404,7 @@ androidtv[async]==0.0.73
androidtvremote2==0.0.15 androidtvremote2==0.0.15
# homeassistant.components.anova # homeassistant.components.anova
anova-wifi==0.10.0 anova-wifi==0.12.0
# homeassistant.components.anthemav # homeassistant.components.anthemav
anthemav==1.4.1 anthemav==1.4.1

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from unittest.mock import patch 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.components.anova.const import DOMAIN
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -21,7 +21,7 @@ ONLINE_UPDATE = APCUpdate(
sensor=APCUpdateSensor( sensor=APCUpdateSensor(
0, "Low water", "No state", 23.33, 0, "2.2.0", 20.87, 21.79, 21.33 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={ data={
CONF_USERNAME: "sample@gmail.com", CONF_USERNAME: "sample@gmail.com",
CONF_PASSWORD: "sample", CONF_PASSWORD: "sample",
"devices": [(device_id, "type_sample")],
}, },
unique_id="sample@gmail.com", unique_id="sample@gmail.com",
version=1,
) )
entry.add_to_hass(hass) entry.add_to_hass(hass)
return entry return entry
@ -44,23 +44,10 @@ def create_entry(hass: HomeAssistant, device_id: str = DEVICE_UNIQUE_ID) -> Conf
async def async_init_integration( async def async_init_integration(
hass: HomeAssistant, hass: HomeAssistant,
skip_setup: bool = False, skip_setup: bool = False,
error: str | None = None,
) -> ConfigEntry: ) -> ConfigEntry:
"""Set up the Anova integration in Home Assistant.""" """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) entry = create_entry(hass)
if not skip_setup: if not skip_setup:

View file

@ -1,13 +1,176 @@
"""Common fixtures for Anova.""" """Common fixtures for Anova."""
import asyncio
from dataclasses import dataclass
import json
from typing import Any
from unittest.mock import AsyncMock, patch 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 import pytest
from homeassistant.core import HomeAssistant 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 @pytest.fixture
@ -15,23 +178,14 @@ async def anova_api(
hass: HomeAssistant, hass: HomeAssistant,
) -> AnovaApi: ) -> AnovaApi:
"""Mock the api for Anova.""" """Mock the api for Anova."""
api_mock = AsyncMock() api_mock = anova_api_mock()
new_device = AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) with (
patch("homeassistant.components.anova.AnovaApi", return_value=api_mock),
async def authenticate_side_effect(): patch(
api_mock.jwt = "my_test_jwt" "homeassistant.components.anova.config_flow.AnovaApi", return_value=api_mock
),
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( api = AnovaApi(
None, None,
"sample@gmail.com", "sample@gmail.com",
@ -45,18 +199,14 @@ async def anova_api_no_devices(
hass: HomeAssistant, hass: HomeAssistant,
) -> AnovaApi: ) -> AnovaApi:
"""Mock the api for Anova with no online devices.""" """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(): with (
api_mock.jwt = "my_test_jwt" patch("homeassistant.components.anova.AnovaApi", return_value=api_mock),
patch(
async def get_devices_side_effect(): "homeassistant.components.anova.config_flow.AnovaApi", return_value=api_mock
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( api = AnovaApi(
None, None,
"sample@gmail.com", "sample@gmail.com",
@ -70,7 +220,7 @@ async def anova_api_wrong_login(
hass: HomeAssistant, hass: HomeAssistant,
) -> AnovaApi: ) -> AnovaApi:
"""Mock the api for Anova with a wrong login.""" """Mock the api for Anova with a wrong login."""
api_mock = AsyncMock() api_mock = anova_api_mock()
async def authenticate_side_effect(): async def authenticate_side_effect():
raise InvalidLogin raise InvalidLogin
@ -84,3 +234,40 @@ async def anova_api_wrong_login(
"sample", "sample",
) )
yield api 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

View file

@ -2,83 +2,33 @@
from unittest.mock import patch 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 import config_entries
from homeassistant.components.anova.const import DOMAIN 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.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType 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( async def test_flow_user(hass: HomeAssistant, anova_api: AnovaApi) -> None:
hass: HomeAssistant,
) -> None:
"""Test user initialized flow.""" """Test user initialized flow."""
with ( result = await hass.config_entries.flow.async_init(
patch( DOMAIN,
"homeassistant.components.anova.config_flow.AnovaApi.authenticate", context={"source": config_entries.SOURCE_USER},
) as auth_patch, )
patch("homeassistant.components.anova.AnovaApi.get_devices") as device_patch, result = await hass.config_entries.flow.async_configure(
patch("homeassistant.components.anova.AnovaApi.authenticate"), result["flow_id"],
patch( user_input=CONF_INPUT,
"homeassistant.components.anova.config_flow.AnovaApi.get_devices" )
) as config_flow_device_patch, assert result["type"] == FlowResultType.CREATE_ENTRY
): assert result["data"] == {
auth_patch.return_value = True CONF_USERNAME: "sample@gmail.com",
device_patch.return_value = [ CONF_PASSWORD: "sample",
AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) CONF_DEVICES: [],
] }
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"
async def test_flow_wrong_login(hass: HomeAssistant) -> None: 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["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "unknown"} 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"}

View file

@ -1,15 +1,12 @@
"""Test init for Anova.""" """Test init for Anova."""
from unittest.mock import patch
from anova_wifi import AnovaApi from anova_wifi import AnovaApi
from homeassistant.components.anova import DOMAIN from homeassistant.components.anova import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant 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: 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) await async_init_integration(hass)
state = hass.states.get("sensor.anova_precision_cooker_mode") state = hass.states.get("sensor.anova_precision_cooker_mode")
assert state is not None assert state is not None
assert state.state != STATE_UNAVAILABLE assert state.state == "idle"
assert state.state == "Low water"
async def test_wrong_login( async def test_wrong_login(
@ -30,37 +26,6 @@ async def test_wrong_login(
assert entry.state is ConfigEntryState.SETUP_ERROR 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: async def test_unload_entry(hass: HomeAssistant, anova_api: AnovaApi) -> None:
"""Test successful unload of entry.""" """Test successful unload of entry."""
entry = await async_init_integration(hass) 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() await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED 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

View file

@ -1,19 +1,13 @@
"""Test the Anova sensors.""" """Test the Anova sensors."""
from datetime import timedelta
import logging 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.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import async_init_integration from . import async_init_integration
from tests.common import async_fire_time_changed
LOGGER = logging.getLogger(__name__) 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_cook_time").state == "0"
assert ( assert (
hass.states.get("sensor.anova_precision_cooker_heater_temperature").state 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_mode").state == "idle"
assert hass.states.get("sensor.anova_precision_cooker_state").state == "No state" assert hass.states.get("sensor.anova_precision_cooker_state").state == "no_state"
assert ( assert (
hass.states.get("sensor.anova_precision_cooker_target_temperature").state hass.states.get("sensor.anova_precision_cooker_target_temperature").state
== "23.33" == "54.72"
) )
assert ( assert (
hass.states.get("sensor.anova_precision_cooker_water_temperature").state hass.states.get("sensor.anova_precision_cooker_water_temperature").state
== "21.33" == "18.33"
) )
assert ( assert (
hass.states.get("sensor.anova_precision_cooker_triac_temperature").state 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: async def test_no_data_sensors(hass: HomeAssistant, anova_api_no_data: AnovaApi):
"""Test updating data after the coordinator has been set up, but anova is offline.""" """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 async_init_integration(hass)
await hass.async_block_till_done() assert hass.states.get("sensor.anova_precision_cooker_triac_temperature") is None
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