Skip to content

Commit

Permalink
Add Anova integration (#86254)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
Lash-L and bdraco committed Apr 22, 2023
1 parent 68ce59e commit 498e696
Show file tree
Hide file tree
Showing 22 changed files with 884 additions and 0 deletions.
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions homeassistant/components/anova/__init__.py
Original file line number Diff line number Diff line change
@@ -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 changes: 61 additions & 0 deletions homeassistant/components/anova/config_flow.py
Original file line number Diff line number Diff line change
@@ -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 changes: 6 additions & 0 deletions homeassistant/components/anova/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Constants for the Anova integration."""

DOMAIN = "anova"

ANOVA_CLIENT = "anova_api_client"
ANOVA_FIRMWARE_VERSION = "anova_firmware_version"
55 changes: 55 additions & 0 deletions homeassistant/components/anova/coordinator.py
Original file line number Diff line number Diff line change
@@ -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 changes: 30 additions & 0 deletions homeassistant/components/anova/entity.py
Original file line number Diff line number Diff line change
@@ -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 changes: 10 additions & 0 deletions homeassistant/components/anova/manifest.json
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions homeassistant/components/anova/models.py
Original file line number Diff line number Diff line change
@@ -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 changes: 97 additions & 0 deletions homeassistant/components/anova/sensor.py
Original file line number Diff line number Diff line change
@@ -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]
Loading

0 comments on commit 498e696

Please sign in to comment.