Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5fd7787
current state
Lash-L Jan 16, 2024
e10a0c3
Merge branch 'home-assistant:dev' into anova_websocket_conversion
Lash-L Jan 19, 2024
6eafd72
finish refactor
Lash-L Feb 3, 2024
eaaa018
Merge branch 'dev' into anova_websocket_conversion
Lash-L Feb 3, 2024
081830e
Apply suggestions from code review
Lash-L Feb 3, 2024
7de5e23
address MR comments
Lash-L Feb 3, 2024
24fd8d2
Change to sensor setup to be listener based.
Lash-L Feb 3, 2024
d407f7d
remove assert for websocket handler
Lash-L Feb 26, 2024
792b4ed
added assert for log
Lash-L Feb 26, 2024
c216b9a
Merge branch 'dev' into anova_websocket_conversion
Lash-L Mar 4, 2024
6110fc2
remove mixin
Lash-L Mar 6, 2024
5a56775
Merge branch 'dev' into anova_websocket_conversion
Lash-L Mar 31, 2024
74b9050
fix linting
Lash-L Mar 31, 2024
18a3adc
Merge branch 'dev' into anova_websocket_conversion
Lash-L Apr 4, 2024
8fe407e
fix merge change
Lash-L Apr 4, 2024
f1613bf
Merge branch 'dev' into anova_websocket_conversion
Lash-L Apr 9, 2024
63498c6
Merge branch 'dev' into anova_websocket_conversion
Lash-L Apr 9, 2024
5048b2f
Add clarifying comment
Lash-L Apr 18, 2024
e9207d1
Merge branch 'dev' into anova_websocket_conversion
Lash-L Apr 18, 2024
82a74a9
Merge branch 'dev' into anova_websocket_conversion
emontnemery Apr 23, 2024
51d1a0c
Merge branch 'dev' into anova_websocket_conversion
Lash-L Apr 23, 2024
71f589f
Apply suggestions from code review
Lash-L Apr 24, 2024
bec5a30
Address MR comments
Lash-L Apr 25, 2024
b8f13c1
Merge branch 'dev' into anova_websocket_conversion
Lash-L Apr 25, 2024
1135a28
bump version and fix typing check
Lash-L Apr 25, 2024
c65a87b
Merge branch 'dev' into anova_websocket_conversion
Lash-L Apr 25, 2024
dcf9058
Merge branch 'dev' into anova_websocket_conversion
Lash-L May 7, 2024
c020996
Merge branch 'dev' into anova_websocket_conversion
emontnemery May 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 28 additions & 32 deletions homeassistant/components/anova/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,23 @@

import logging

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 +42,25 @@ 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
Comment thread
Lash-L marked this conversation as resolved.
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 api.websocket_handler is not None:
Comment thread
Lash-L marked this conversation as resolved.
Outdated
# This should not be None as this point - but typing views it as a possibility.
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 +69,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: # 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,
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.11.6"]
}
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],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should we do in the case of "no_state"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That should be fine I think. "no_state" should get turned into "No state" through translations. It's why I use name instead of value.

I create the stateby doing AnovaState(response_from_api) - when state is "" - it sets state = AnovaState.no_state

Then the value is saved to AnovaUpdateSensor as the state.name

Comment thread
Lash-L marked this conversation as resolved.
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,
Comment thread
Lash-L marked this conversation as resolved.
),
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 @@ -308,7 +308,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
Loading