Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Anova integration #86254

Merged
merged 82 commits into from
Apr 22, 2023
Merged
Show file tree
Hide file tree
Changes from 73 commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
f43253e
init setup of Anova Sous Vide
Lash-L Jan 19, 2023
3429b05
bump anova-wifi to 0.2.4
Lash-L Jan 19, 2023
6611972
Removed yaml support
Lash-L Jan 20, 2023
2faa442
Bump to anova-wifi 0.2.5
Lash-L Jan 20, 2023
b446cbd
Added support for adding sous vide while offline
Lash-L Jan 20, 2023
64d593c
Added basic test for sensor
Lash-L Jan 20, 2023
03ff1b6
added better tests for sensors and init
Lash-L Jan 20, 2023
4e645e2
expanded code coverage
Lash-L Jan 21, 2023
9cc286e
Merge branch 'dev' into anova_precission_cooker
Lash-L Jan 21, 2023
9e74755
Merge branch 'dev' into anova_precission_cooker
Lash-L Jan 21, 2023
7019bc3
Decreased timedelta to lowest functioning value.
Lash-L Jan 22, 2023
5b72aa0
Updating my username
Lash-L Jan 22, 2023
b8155d6
migrate to async_forward_entry_setups
Lash-L Jan 24, 2023
61d9f17
Merge branch 'dev' into anova_precission_cooker
Lash-L Jan 27, 2023
d86cc11
applying pr recommended changes
Lash-L Jan 27, 2023
c5d4fd9
bump anova-wifi to 0.2.7
Lash-L Jan 29, 2023
5f435bd
Improvements to hopefully get this review ready
Lash-L Feb 10, 2023
b33460e
formatting changes
Lash-L Feb 10, 2023
c2d6b11
Merge branch 'dev' into anova_precission_cooker
Lash-L Feb 21, 2023
308f2ea
clean ups for pr review
Lash-L Feb 21, 2023
997862f
remove unneeded unique id check.
Lash-L Feb 21, 2023
5bfb8e7
bump ao anova_wifi 0.3.0
Lash-L Feb 21, 2023
b9c4007
rename device_id to device_unique_id
Lash-L Feb 21, 2023
2771671
renamed to 'anova'
Lash-L Feb 21, 2023
9f5c130
added unique_id to MockConfigEntry
Lash-L Feb 21, 2023
4db39d0
removed leftover anova sous vides
Lash-L Feb 22, 2023
25ac5ac
added device id to strings
Lash-L Feb 22, 2023
0d7d42d
added error for incorrect device id
Lash-L Feb 22, 2023
3eb28f8
add has_entity_name
Lash-L Feb 22, 2023
b7c18a4
added attr name for tests
Lash-L Feb 22, 2023
316675a
Merge branch 'dev' into anova_precission_cooker
Lash-L Mar 2, 2023
990efb1
added authentication functionality
Lash-L Mar 15, 2023
ae67dd7
Merge branch 'dev' into anova_precission_cooker
Lash-L Mar 15, 2023
0c46977
bump to 0.4.3
Lash-L Mar 15, 2023
0138d3e
Merge branch 'anova_precission_cooker' of https://github.com/Lash-L/c…
Lash-L Mar 16, 2023
cfb27e5
split entity into its own class/object
Lash-L Mar 17, 2023
d59ed95
pulling firmware version out of async_setup
Lash-L Mar 17, 2023
2869571
addressed pr changes
Lash-L Mar 18, 2023
ca36cf8
Merge branch 'dev' into anova_precission_cooker
Lash-L Mar 18, 2023
1b9bca5
fixed pytest
Lash-L Mar 18, 2023
2767613
Merge branch 'anova_precission_cooker' of https://github.com/Lash-L/c…
Lash-L Mar 18, 2023
8690124
added anova data model
Lash-L Mar 23, 2023
d7be2f7
Merge branch 'dev' into anova_precission_cooker
Lash-L Mar 23, 2023
e72a494
removed unneeded time change
Lash-L Mar 25, 2023
e14d4e1
Merge branch 'dev' into anova_precission_cooker
Lash-L Mar 25, 2023
e5a707b
add logging in package
Lash-L Mar 28, 2023
a834bda
Merge branch 'dev' into anova_precission_cooker
Lash-L Mar 28, 2023
cf2f155
rework step_user
Lash-L Mar 28, 2023
cbc0d34
Merge branch 'anova_precission_cooker' of https://github.com/Lash-L/c…
Lash-L Mar 28, 2023
f1a33e4
Update homeassistant/components/anova/sensor.py
Lash-L Mar 28, 2023
b527a0a
Removed lower from attr unique id
Lash-L Mar 28, 2023
ce6a061
Removed unneeded member variables in sensor
Lash-L Mar 29, 2023
af2d3fb
removed repeated subclass attr
Lash-L Mar 29, 2023
59eed51
simplify update_failed test
Lash-L Mar 29, 2023
5f7cd5d
created descriptionentity
Lash-L Mar 29, 2023
72aab37
bump to 0.6.1 limit ws connect
Lash-L Mar 29, 2023
a0f76a3
Merge branch 'dev' into anova_precission_cooker
Lash-L Mar 30, 2023
b6d2a78
Merge branch 'dev' into anova_precission_cooker
Lash-L Mar 30, 2023
ab2c462
add translation for sensor entities
Lash-L Apr 2, 2023
a3eba10
Merge branch 'dev' into anova_precission_cooker
Lash-L Apr 2, 2023
eb74d67
Merge branch 'dev' into anova_precission_cooker
Lash-L Apr 2, 2023
b81d1ed
Merge branch 'dev' into anova_precission_cooker
Lash-L Apr 2, 2023
3096abe
version bump - support pro model
Lash-L Apr 4, 2023
26765c4
Merge branch 'dev' into anova_precission_cooker
Lash-L Apr 7, 2023
02917df
add anova to strict typing
Lash-L Apr 19, 2023
bb1182b
fixed sensor not getting datas type
Lash-L Apr 19, 2023
e1bbf95
Apply suggestions from code review
Lash-L Apr 20, 2023
4616d9a
Check for new devices in init
Lash-L Apr 20, 2023
e039391
Merge branch 'anova_precission_cooker' of https://github.com/Lash-L/c…
Lash-L Apr 20, 2023
b796766
style changes
Lash-L Apr 20, 2023
1d4189b
return false instead of config entry not ready
Lash-L Apr 21, 2023
ad803d1
move serialize_device_list to utils
Lash-L Apr 21, 2023
1999ca8
Merge branch 'dev' into anova_precission_cooker
Lash-L Apr 21, 2023
95d0389
move repeating device check into api
Lash-L Apr 21, 2023
34974a0
Merge branch 'dev' into anova_precission_cooker
Lash-L Apr 21, 2023
0c0ca93
moved unneeded code out of try except
Lash-L Apr 21, 2023
f47c46c
Merge branch 'anova_precission_cooker' of https://github.com/Lash-L/c…
Lash-L Apr 21, 2023
9a8cecb
fixed tests to get 100% cov
Lash-L Apr 21, 2023
1256e6c
Merge branch 'dev' into anova_precission_cooker
Lash-L Apr 21, 2023
634cfbc
Merge branch 'dev' into anova_precission_cooker
bdraco Apr 22, 2023
287c2b2
Merge branch 'dev' into anova_precission_cooker
Lash-L Apr 22, 2023
b6bab99
Update homeassistant/components/anova/strings.json
Lash-L Apr 22, 2023
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
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
91 changes: 91 additions & 0 deletions homeassistant/components/anova/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""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()
online_devices = await api.get_devices()
Lash-L marked this conversation as resolved.
Show resolved Hide resolved
except InvalidLogin as err:
_LOGGER.error(

Check warning on line 40 in homeassistant/components/anova/__init__.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/anova/__init__.py#L39-L40

Added lines #L39 - L40 were not covered by tests
"Login was incorrect - please log back in through the config flow. %s", err
)
return False
except NoDevicesFound:

Check warning on line 44 in homeassistant/components/anova/__init__.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/anova/__init__.py#L43-L44

Added lines #L43 - L44 were not covered by tests
# get_devices raises an exception if no devices are online
online_devices = []

Check warning on line 46 in homeassistant/components/anova/__init__.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/anova/__init__.py#L46

Added line #L46 was not covered by tests
Lash-L marked this conversation as resolved.
Show resolved Hide resolved
assert api.jwt
cached_devices = [
AnovaPrecisionCooker(
aiohttp_client.async_get_clientsession(hass),
device[0],
device[1],
api.jwt,
)
for device in entry.data["devices"]
]
existing_device_keys = [device[0] for device in entry.data["devices"]]
new_devices = [
device
for device in online_devices
if device.device_key not in existing_device_keys
]
devices = cached_devices + new_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],
Lash-L marked this conversation as resolved.
Show resolved Hide resolved
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.6.2"]
}
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