Skip to content

Commit

Permalink
Convert Anova to cloud push (#109508)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people committed May 8, 2024
1 parent de62e20 commit 22bc11f
Show file tree
Hide file tree
Showing 17 changed files with 384 additions and 301 deletions.
63 changes: 31 additions & 32 deletions homeassistant/components/anova/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,25 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound
from anova_wifi import (
AnovaApi,
APCWifiDevice,
InvalidLogin,
NoDevicesFound,
WebsocketFailure,
)

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client

from .const import DOMAIN
from .coordinator import AnovaCoordinator
from .models import AnovaData
from .util import serialize_device_list

PLATFORMS = [Platform.SENSOR]

Expand All @@ -36,36 +43,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
return False
assert api.jwt
api.existing_devices = [
AnovaPrecisionCooker(
aiohttp_client.async_get_clientsession(hass),
device[0],
device[1],
api.jwt,
)
for device in entry.data[CONF_DEVICES]
]
try:
new_devices = await api.get_devices()
except NoDevicesFound:
# get_devices raises an exception if no devices are online
new_devices = []
devices = api.existing_devices
if new_devices:
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_DEVICES: serialize_device_list(devices),
},
)
await api.create_websocket()
except NoDevicesFound as err:
# Can later setup successfully and spawn a repair.
raise ConfigEntryNotReady(
"No devices were found on the websocket, perhaps you don't have any devices on this account?"
) from err
except WebsocketFailure as err:
raise ConfigEntryNotReady("Failed connecting to the websocket.") from err
# Create a coordinator per device, if the device is offline, no data will be on the
# websocket, and the coordinator should auto mark as unavailable. But as long as
# the websocket successfully connected, config entry should setup.
devices: list[APCWifiDevice] = []
if TYPE_CHECKING:
# api.websocket_handler can't be None after successfully creating the
# websocket client
assert api.websocket_handler is not None
devices = list(api.websocket_handler.devices.values())
coordinators = [AnovaCoordinator(hass, device) for device in devices]
for coordinator in coordinators:
await coordinator.async_config_entry_first_refresh()
firmware_version = coordinator.data.sensor.firmware_version
coordinator.async_setup(str(firmware_version))
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnovaData(
api_jwt=api.jwt, precision_cookers=devices, coordinators=coordinators
api_jwt=api.jwt, coordinators=coordinators, api=api
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
Expand All @@ -74,6 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)

anova_data: AnovaData = hass.data[DOMAIN].pop(entry.entry_id)
# Disconnect from WS
await anova_data.api.disconnect_websocket()
return unload_ok
15 changes: 5 additions & 10 deletions homeassistant/components/anova/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@

from __future__ import annotations

from anova_wifi import AnovaApi, InvalidLogin, NoDevicesFound
from anova_wifi import AnovaApi, InvalidLogin
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import DOMAIN
from .util import serialize_device_list


class AnovaConfligFlow(ConfigFlow, domain=DOMAIN):
Expand All @@ -33,22 +32,18 @@ async def async_step_user(
self._abort_if_unique_id_configured()
try:
await api.authenticate()
devices = await api.get_devices()
except InvalidLogin:
errors["base"] = "invalid_auth"
except NoDevicesFound:
errors["base"] = "no_devices_found"
except Exception: # noqa: BLE001
errors["base"] = "unknown"
else:
# We store device list in config flow in order to persist found devices on restart, as the Anova api get_devices does not return any devices that are offline.
device_list = serialize_device_list(devices)
return self.async_create_entry(
title="Anova",
data={
CONF_USERNAME: api.username,
CONF_PASSWORD: api.password,
CONF_DEVICES: device_list,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
# this can be removed in a migration to 1.2 in 2024.11
CONF_DEVICES: [],
},
)

Expand Down
34 changes: 10 additions & 24 deletions homeassistant/components/anova/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
"""Support for Anova Coordinators."""

from asyncio import timeout
from datetime import timedelta
import logging

from anova_wifi import AnovaOffline, AnovaPrecisionCooker, APCUpdate
from anova_wifi import APCUpdate, APCWifiDevice

from homeassistant.core import HomeAssistant, callback
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from .const import DOMAIN

Expand All @@ -18,37 +17,24 @@
class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]):
"""Anova custom coordinator."""

def __init__(
self,
hass: HomeAssistant,
anova_device: AnovaPrecisionCooker,
) -> None:
config_entry: ConfigEntry

def __init__(self, hass: HomeAssistant, anova_device: APCWifiDevice) -> None:
"""Set up Anova Coordinator."""
super().__init__(
hass,
name="Anova Precision Cooker",
logger=_LOGGER,
update_interval=timedelta(seconds=30),
)
assert self.config_entry is not None
self.device_unique_id = anova_device.device_key
self.device_unique_id = anova_device.cooker_id
self.anova_device = anova_device
self.anova_device.set_update_listener(self.async_set_updated_data)
self.device_info: DeviceInfo | None = None

@callback
def async_setup(self, firmware_version: str) -> None:
"""Set the firmware version info."""
self.device_info = DeviceInfo(
identifiers={(DOMAIN, self.device_unique_id)},
name="Anova Precision Cooker",
manufacturer="Anova",
model="Precision Cooker",
sw_version=firmware_version,
)

async def _async_update_data(self) -> APCUpdate:
try:
async with timeout(5):
return await self.anova_device.update()
except AnovaOffline as err:
raise UpdateFailed(err) from err
self.sensor_data_set: bool = False
5 changes: 5 additions & 0 deletions homeassistant/components/anova/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ def __init__(self, coordinator: AnovaCoordinator) -> None:
self.device = coordinator.anova_device
self._attr_device_info = coordinator.device_info

@property
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.data is not None and super().available


class AnovaDescriptionEntity(AnovaEntity):
"""Defines an Anova entity that uses a description."""
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/anova/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"codeowners": ["@Lash-L"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anova",
"iot_class": "cloud_polling",
"iot_class": "cloud_push",
"loggers": ["anova_wifi"],
"requirements": ["anova-wifi==0.10.0"]
"requirements": ["anova-wifi==0.12.0"]
}
4 changes: 2 additions & 2 deletions homeassistant/components/anova/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from dataclasses import dataclass

from anova_wifi import AnovaPrecisionCooker
from anova_wifi import AnovaApi

from .coordinator import AnovaCoordinator

Expand All @@ -12,5 +12,5 @@ class AnovaData:
"""Data for the Anova integration."""

api_jwt: str
precision_cookers: list[AnovaPrecisionCooker]
coordinators: list[AnovaCoordinator]
api: AnovaApi
57 changes: 39 additions & 18 deletions homeassistant/components/anova/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from collections.abc import Callable
from dataclasses import dataclass

from anova_wifi import APCUpdateSensor
from anova_wifi import AnovaMode, AnovaState, APCUpdateSensor

from homeassistant import config_entries
from homeassistant.components.sensor import (
Expand All @@ -20,25 +20,19 @@
from homeassistant.helpers.typing import StateType

from .const import DOMAIN
from .coordinator import AnovaCoordinator
from .entity import AnovaDescriptionEntity
from .models import AnovaData


@dataclass(frozen=True)
class AnovaSensorEntityDescriptionMixin:
"""Describes the mixin variables for anova sensors."""

value_fn: Callable[[APCUpdateSensor], float | int | str]


@dataclass(frozen=True)
class AnovaSensorEntityDescription(
SensorEntityDescription, AnovaSensorEntityDescriptionMixin
):
@dataclass(frozen=True, kw_only=True)
class AnovaSensorEntityDescription(SensorEntityDescription):
"""Describes a Anova sensor."""

value_fn: Callable[[APCUpdateSensor], StateType]

SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [

SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [
AnovaSensorEntityDescription(
key="cook_time",
state_class=SensorStateClass.TOTAL_INCREASING,
Expand All @@ -50,11 +44,15 @@ class AnovaSensorEntityDescription(
AnovaSensorEntityDescription(
key="state",
translation_key="state",
device_class=SensorDeviceClass.ENUM,
options=[state.name for state in AnovaState],
value_fn=lambda data: data.state,
),
AnovaSensorEntityDescription(
key="mode",
translation_key="mode",
device_class=SensorDeviceClass.ENUM,
options=[mode.name for mode in AnovaMode],
value_fn=lambda data: data.mode,
),
AnovaSensorEntityDescription(
Expand Down Expand Up @@ -106,11 +104,34 @@ async def async_setup_entry(
) -> None:
"""Set up Anova device."""
anova_data: AnovaData = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
AnovaSensor(coordinator, description)
for coordinator in anova_data.coordinators
for description in SENSOR_DESCRIPTIONS
)

for coordinator in anova_data.coordinators:
setup_coordinator(coordinator, async_add_entities)


def setup_coordinator(
coordinator: AnovaCoordinator,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up an individual Anova Coordinator."""

def _async_sensor_listener() -> None:
"""Listen for new sensor data and add sensors if they did not exist."""
if not coordinator.sensor_data_set:
valid_entities: set[AnovaSensor] = set()
for description in SENSOR_DESCRIPTIONS:
if description.value_fn(coordinator.data.sensor) is not None:
valid_entities.add(AnovaSensor(coordinator, description))
async_add_entities(valid_entities)
coordinator.sensor_data_set = True

if coordinator.data is not None:
_async_sensor_listener()
# It is possible that we don't have any data, but the device exists,
# i.e. slow network, offline device, etc.
# We want to set up sensors after the fact as we don't know what sensors
# are valid until runtime.
coordinator.async_add_listener(_async_sensor_listener)


class AnovaSensor(AnovaDescriptionEntity, SensorEntity):
Expand Down
28 changes: 21 additions & 7 deletions homeassistant/components/anova/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,9 @@
"description": "[%key:common::config_flow::description::confirm_setup%]"
}
},
"abort": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"no_devices_found": "No devices were found. Make sure you have at least one Anova device online."
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"entity": {
Expand All @@ -26,10 +22,28 @@
"name": "Cook time"
},
"state": {
"name": "State"
"name": "State",
"state": {
"preheating": "Preheating",
"cooking": "Cooking",
"maintaining": "Maintaining",
"timer_expired": "Timer expired",
"set_timer": "Set timer",
"no_state": "No state"
}
},
"mode": {
"name": "[%key:common::config_flow::data::mode%]"
"name": "[%key:common::config_flow::data::mode%]",
"state": {
"startup": "Startup",
"idle": "[%key:common::state::idle%]",
"cook": "Cooking",
"low_water": "Low water",
"ota": "Ota",
"provisioning": "Provisioning",
"high_temp": "High temperature",
"device_failure": "Device failure"
}
},
"target_temperature": {
"name": "Target temperature"
Expand Down
8 changes: 0 additions & 8 deletions homeassistant/components/anova/util.py

This file was deleted.

2 changes: 1 addition & 1 deletion homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@
"name": "Anova",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
"iot_class": "cloud_push"
},
"anthemav": {
"name": "Anthem A/V Receivers",
Expand Down

0 comments on commit 22bc11f

Please sign in to comment.