This commit is contained in:
Franck Nijhof 2024-09-24 09:01:55 +02:00 committed by GitHub
commit 06ea3a3014
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 659 additions and 413 deletions

View File

@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["airgradient==0.8.0"],
"requirements": ["airgradient==0.9.0"],
"zeroconf": ["_airgradient._tcp.local."]
}

View File

@ -4,13 +4,12 @@ from __future__ import annotations
import logging
from aioaseko import APIUnavailable, InvalidAuthCredentials, MobileAccount
from aioaseko import Aseko, AsekoNotLoggedIn
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN
from .coordinator import AsekoDataUpdateCoordinator
@ -22,28 +21,17 @@ PLATFORMS: list[str] = [Platform.BINARY_SENSOR, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Aseko Pool Live from a config entry."""
account = MobileAccount(
async_get_clientsession(hass),
username=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
)
aseko = Aseko(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD])
try:
units = await account.get_units()
except InvalidAuthCredentials as err:
await aseko.login()
except AsekoNotLoggedIn as err:
raise ConfigEntryAuthFailed from err
except APIUnavailable as err:
raise ConfigEntryNotReady from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = []
for unit in units:
coordinator = AsekoDataUpdateCoordinator(hass, unit)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id].append((unit, coordinator))
coordinator = AsekoDataUpdateCoordinator(hass, aseko)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@ -51,7 +39,6 @@ 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

View File

@ -8,7 +8,6 @@ from dataclasses import dataclass
from aioaseko import Unit
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
@ -25,26 +24,14 @@ from .entity import AsekoEntity
class AsekoBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes an Aseko binary sensor entity."""
value_fn: Callable[[Unit], bool]
value_fn: Callable[[Unit], bool | None]
UNIT_BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = (
BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = (
AsekoBinarySensorEntityDescription(
key="water_flow",
translation_key="water_flow",
value_fn=lambda unit: unit.water_flow,
),
AsekoBinarySensorEntityDescription(
key="has_alarm",
translation_key="alarm",
value_fn=lambda unit: unit.has_alarm,
device_class=BinarySensorDeviceClass.SAFETY,
),
AsekoBinarySensorEntityDescription(
key="has_error",
translation_key="error",
value_fn=lambda unit: unit.has_error,
device_class=BinarySensorDeviceClass.PROBLEM,
translation_key="water_flow_to_probes",
value_fn=lambda unit: unit.water_flow_to_probes,
),
)
@ -55,33 +42,22 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Aseko Pool Live binary sensors."""
data: list[tuple[Unit, AsekoDataUpdateCoordinator]] = hass.data[DOMAIN][
config_entry.entry_id
]
coordinator: AsekoDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
units = coordinator.data.values()
async_add_entities(
AsekoUnitBinarySensorEntity(unit, coordinator, description)
for unit, coordinator in data
for description in UNIT_BINARY_SENSORS
AsekoBinarySensorEntity(unit, coordinator, description)
for description in BINARY_SENSORS
for unit in units
if description.value_fn(unit) is not None
)
class AsekoUnitBinarySensorEntity(AsekoEntity, BinarySensorEntity):
"""Representation of a unit water flow binary sensor entity."""
class AsekoBinarySensorEntity(AsekoEntity, BinarySensorEntity):
"""Representation of an Aseko binary sensor entity."""
entity_description: AsekoBinarySensorEntityDescription
def __init__(
self,
unit: Unit,
coordinator: AsekoDataUpdateCoordinator,
entity_description: AsekoBinarySensorEntityDescription,
) -> None:
"""Initialize the unit binary sensor."""
super().__init__(unit, coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{self._unit.serial_number}_{entity_description.key}"
@property
def is_on(self) -> bool:
def is_on(self) -> bool | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._unit)
return self.entity_description.value_fn(self.unit)

View File

@ -6,12 +6,11 @@ from collections.abc import Mapping
import logging
from typing import Any
from aioaseko import APIUnavailable, InvalidAuthCredentials, WebAccount
from aioaseko import Aseko, AsekoAPIError, AsekoInvalidCredentials
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
@ -34,15 +33,12 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
async def get_account_info(self, email: str, password: str) -> dict:
"""Get account info from the mobile API and the web API."""
session = async_get_clientsession(self.hass)
web_account = WebAccount(session, email, password)
web_account_info = await web_account.login()
aseko = Aseko(email, password)
user = await aseko.login()
return {
CONF_EMAIL: email,
CONF_PASSWORD: password,
CONF_UNIQUE_ID: web_account_info.user_id,
CONF_UNIQUE_ID: user.user_id,
}
async def async_step_user(
@ -58,9 +54,9 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
info = await self.get_account_info(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except APIUnavailable:
except AsekoAPIError:
errors["base"] = "cannot_connect"
except InvalidAuthCredentials:
except AsekoInvalidCredentials:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
@ -122,9 +118,9 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
info = await self.get_account_info(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except APIUnavailable:
except AsekoAPIError:
errors["base"] = "cannot_connect"
except InvalidAuthCredentials:
except AsekoInvalidCredentials:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")

View File

@ -5,34 +5,31 @@ from __future__ import annotations
from datetime import timedelta
import logging
from aioaseko import Unit, Variable
from aioaseko import Aseko, Unit
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class AsekoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Variable]]):
class AsekoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Unit]]):
"""Class to manage fetching Aseko unit data from single endpoint."""
def __init__(self, hass: HomeAssistant, unit: Unit) -> None:
def __init__(self, hass: HomeAssistant, aseko: Aseko) -> None:
"""Initialize global Aseko unit data updater."""
self._unit = unit
if self._unit.name:
name = self._unit.name
else:
name = f"{self._unit.type}-{self._unit.serial_number}"
self._aseko = aseko
super().__init__(
hass,
_LOGGER,
name=name,
name=DOMAIN,
update_interval=timedelta(minutes=2),
)
async def _async_update_data(self) -> dict[str, Variable]:
async def _async_update_data(self) -> dict[str, Unit]:
"""Fetch unit data."""
await self._unit.get_state()
return {variable.type: variable for variable in self._unit.variables}
units = await self._aseko.get_units()
return {unit.serial_number: unit for unit in units}

View File

@ -3,6 +3,7 @@
from aioaseko import Unit
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@ -14,20 +15,44 @@ class AsekoEntity(CoordinatorEntity[AsekoDataUpdateCoordinator]):
_attr_has_entity_name = True
def __init__(self, unit: Unit, coordinator: AsekoDataUpdateCoordinator) -> None:
def __init__(
self,
unit: Unit,
coordinator: AsekoDataUpdateCoordinator,
description: EntityDescription,
) -> None:
"""Initialize the aseko entity."""
super().__init__(coordinator)
self.entity_description = description
self._unit = unit
if self._unit.type == "Remote":
self._device_model = "ASIN Pool"
else:
self._device_model = f"ASIN AQUA {self._unit.type}"
self._device_name = self._unit.name if self._unit.name else self._device_model
self._attr_unique_id = f"{self.unit.serial_number}{self.entity_description.key}"
self._attr_device_info = DeviceInfo(
name=self._device_name,
identifiers={(DOMAIN, str(self._unit.serial_number))},
manufacturer="Aseko",
model=self._device_model,
identifiers={(DOMAIN, self.unit.serial_number)},
serial_number=self.unit.serial_number,
name=unit.name or unit.serial_number,
manufacturer=(
self.unit.brand_name.primary
if self.unit.brand_name is not None
else None
),
model=(
self.unit.brand_name.secondary
if self.unit.brand_name is not None
else None
),
configuration_url=f"https://aseko.cloud/unit/{self.unit.serial_number}",
)
@property
def unit(self) -> Unit:
"""Return the aseko unit."""
return self.coordinator.data[self._unit.serial_number]
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available
and self.unit.serial_number in self.coordinator.data
and self.unit.online
)

View File

@ -1,16 +1,25 @@
{
"entity": {
"binary_sensor": {
"water_flow": {
"water_flow_to_probes": {
"default": "mdi:waves-arrow-right"
}
},
"sensor": {
"air_temperature": {
"default": "mdi:thermometer-lines"
},
"free_chlorine": {
"default": "mdi:flask"
"default": "mdi:pool"
},
"redox": {
"default": "mdi:pool"
},
"salinity": {
"default": "mdi:pool"
},
"water_temperature": {
"default": "mdi:coolant-temperature"
"default": "mdi:pool-thermometer"
}
}
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aseko_pool_live",
"iot_class": "cloud_polling",
"loggers": ["aioaseko"],
"requirements": ["aioaseko==0.2.0"]
"requirements": ["aioaseko==1.0.0"]
}

View File

@ -2,77 +2,104 @@
from __future__ import annotations
from aioaseko import Unit, Variable
from collections.abc import Callable
from dataclasses import dataclass
from aioaseko import Unit
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .coordinator import AsekoDataUpdateCoordinator
from .entity import AsekoEntity
@dataclass(frozen=True, kw_only=True)
class AsekoSensorEntityDescription(SensorEntityDescription):
"""Describes an Aseko sensor entity."""
value_fn: Callable[[Unit], StateType]
SENSORS: list[AsekoSensorEntityDescription] = [
AsekoSensorEntityDescription(
key="airTemp",
translation_key="air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda unit: unit.air_temperature,
),
AsekoSensorEntityDescription(
key="free_chlorine",
translation_key="free_chlorine",
native_unit_of_measurement="mg/l",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda unit: unit.cl_free,
),
AsekoSensorEntityDescription(
key="ph",
device_class=SensorDeviceClass.PH,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda unit: unit.ph,
),
AsekoSensorEntityDescription(
key="rx",
translation_key="redox",
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda unit: unit.redox,
),
AsekoSensorEntityDescription(
key="salinity",
translation_key="salinity",
native_unit_of_measurement="kg/m³",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda unit: unit.salinity,
),
AsekoSensorEntityDescription(
key="waterTemp",
translation_key="water_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda unit: unit.water_temperature,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Aseko Pool Live sensors."""
data: list[tuple[Unit, AsekoDataUpdateCoordinator]] = hass.data[DOMAIN][
config_entry.entry_id
]
coordinator: AsekoDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
units = coordinator.data.values()
async_add_entities(
VariableSensorEntity(unit, variable, coordinator)
for unit, coordinator in data
for variable in unit.variables
AsekoSensorEntity(unit, coordinator, description)
for description in SENSORS
for unit in units
if description.value_fn(unit) is not None
)
class VariableSensorEntity(AsekoEntity, SensorEntity):
"""Representation of a unit variable sensor entity."""
class AsekoSensorEntity(AsekoEntity, SensorEntity):
"""Representation of an Aseko unit sensor entity."""
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(
self, unit: Unit, variable: Variable, coordinator: AsekoDataUpdateCoordinator
) -> None:
"""Initialize the variable sensor."""
super().__init__(unit, coordinator)
self._variable = variable
translation_key = {
"Air temp.": "air_temperature",
"Cl free": "free_chlorine",
"Water temp.": "water_temperature",
}.get(self._variable.name)
if translation_key is not None:
self._attr_translation_key = translation_key
else:
self._attr_name = self._variable.name
self._attr_unique_id = f"{self._unit.serial_number}{self._variable.type}"
self._attr_native_unit_of_measurement = self._variable.unit
self._attr_icon = {
"rx": "mdi:test-tube",
"waterLevel": "mdi:waves",
}.get(self._variable.type)
self._attr_device_class = {
"airTemp": SensorDeviceClass.TEMPERATURE,
"waterTemp": SensorDeviceClass.TEMPERATURE,
"ph": SensorDeviceClass.PH,
}.get(self._variable.type)
entity_description: AsekoSensorEntityDescription
@property
def native_value(self) -> int | None:
def native_value(self) -> StateType:
"""Return the state of the sensor."""
variable = self.coordinator.data[self._variable.type]
return variable.current_value
return self.entity_description.value_fn(self.unit)

View File

@ -26,11 +26,8 @@
},
"entity": {
"binary_sensor": {
"water_flow": {
"name": "Water flow"
},
"alarm": {
"name": "Alarm"
"water_flow_to_probes": {
"name": "Water flow to probes"
}
},
"sensor": {
@ -40,6 +37,12 @@
"free_chlorine": {
"name": "Free chlorine"
},
"redox": {
"name": "Redox potential"
},
"salinity": {
"name": "Salinity"
},
"water_temperature": {
"name": "Water temperature"
}

View File

@ -17,6 +17,7 @@ from homeassistant.const import CONF_HOST, CONF_MODEL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.device_registry as dr
from homeassistant.util.ssl import get_default_context
from .const import DOMAIN
from .websocket import BangOlufsenWebsocket
@ -48,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
model=entry.data[CONF_MODEL],
)
client = MozartClient(host=entry.data[CONF_HOST])
client = MozartClient(host=entry.data[CONF_HOST], ssl_context=get_default_context())
# Check API and WebSocket connection
try:

View File

@ -14,6 +14,7 @@ from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.util.ssl import get_default_context
from .const import (
ATTR_FRIENDLY_NAME,
@ -87,7 +88,9 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
errors={"base": _exception_map[type(error)]},
)
self._client = MozartClient(self._host)
self._client = MozartClient(
host=self._host, ssl_context=get_default_context()
)
# Try to get information from Beolink self method.
async with self._client:
@ -136,7 +139,7 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="ipv6_address")
# Check connection to ensure valid address is received
self._client = MozartClient(self._host)
self._client = MozartClient(self._host, ssl_context=get_default_context())
async with self._client:
try:

View File

@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["mozart-api==3.4.1.8.6"],
"requirements": ["mozart-api==3.4.1.8.8"],
"zeroconf": ["_bangolufsen._tcp.local."]
}

View File

@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.13.6"],
"requirements": ["pydaikin==2.13.7"],
"zeroconf": ["_dkapi._tcp.local."]
}

View File

@ -83,20 +83,38 @@ def entity_category_temperature(device: FritzhomeDevice) -> EntityCategory | Non
return None
def value_nextchange_preset(device: FritzhomeDevice) -> str:
def value_nextchange_preset(device: FritzhomeDevice) -> str | None:
"""Return native value for next scheduled preset sensor."""
if not device.nextchange_endperiod:
return None
if device.nextchange_temperature == device.eco_temperature:
return PRESET_ECO
return PRESET_COMFORT
def value_scheduled_preset(device: FritzhomeDevice) -> str:
def value_scheduled_preset(device: FritzhomeDevice) -> str | None:
"""Return native value for current scheduled preset sensor."""
if not device.nextchange_endperiod:
return None
if device.nextchange_temperature == device.eco_temperature:
return PRESET_COMFORT
return PRESET_ECO
def value_nextchange_temperature(device: FritzhomeDevice) -> float | None:
"""Return native value for next scheduled temperature time sensor."""
if device.nextchange_endperiod and isinstance(device.nextchange_temperature, float):
return device.nextchange_temperature
return None
def value_nextchange_time(device: FritzhomeDevice) -> datetime | None:
"""Return native value for next scheduled changed time sensor."""
if device.nextchange_endperiod:
return utc_from_timestamp(device.nextchange_endperiod)
return None
SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = (
FritzSensorEntityDescription(
key="temperature",
@ -181,7 +199,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
suitable=suitable_nextchange_temperature,
native_value=lambda device: device.nextchange_temperature,
native_value=value_nextchange_temperature,
),
FritzSensorEntityDescription(
key="nextchange_time",
@ -189,7 +207,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = (
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
suitable=suitable_nextchange_time,
native_value=lambda device: utc_from_timestamp(device.nextchange_endperiod),
native_value=value_nextchange_time,
),
FritzSensorEntityDescription(
key="nextchange_preset",

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import datetime
from typing import Any
from typing import TYPE_CHECKING, Any
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
@ -14,25 +14,44 @@ from homeassistant.util import dt as dt_util
def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None:
"""Calculate due date for dailies and yesterdailies."""
today = to_date(last_cron)
startdate = to_date(task["startDate"])
if TYPE_CHECKING:
assert today
assert startdate
if task["isDue"] and not task["completed"]:
return dt_util.as_local(datetime.datetime.fromisoformat(last_cron)).date()
return to_date(last_cron)
if startdate > today:
if task["frequency"] == "daily" or (
task["frequency"] in ("monthly", "yearly") and task["daysOfMonth"]
):
return startdate
if (
task["frequency"] in ("weekly", "monthly")
and (nextdue := to_date(task["nextDue"][0]))
and startdate > nextdue
):
return to_date(task["nextDue"][1])
return to_date(task["nextDue"][0])
def to_date(date: str) -> datetime.date | None:
"""Convert an iso date to a datetime.date object."""
try:
return dt_util.as_local(
datetime.datetime.fromisoformat(task["nextDue"][0])
).date()
return dt_util.as_local(datetime.datetime.fromisoformat(date)).date()
except ValueError:
# sometimes nextDue dates are in this format instead of iso:
# sometimes nextDue dates are JavaScript datetime strings instead of iso:
# "Mon May 06 2024 00:00:00 GMT+0200"
try:
return dt_util.as_local(
datetime.datetime.strptime(
task["nextDue"][0], "%a %b %d %Y %H:%M:%S %Z%z"
)
datetime.datetime.strptime(date, "%a %b %d %Y %H:%M:%S %Z%z")
).date()
except ValueError:
return None
except IndexError:
return None
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:

View File

@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.56", "babel==2.15.0"]
"requirements": ["holidays==0.57", "babel==2.15.0"]
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
"requirements": ["pydrawise==2024.8.0"]
"requirements": ["pydrawise==2024.9.0"]
}

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from jvcprojector import (
JvcProjector,
@ -40,7 +41,7 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
self.device = device
self.unique_id = format_mac(device.mac)
async def _async_update_data(self) -> dict[str, str]:
async def _async_update_data(self) -> dict[str, Any]:
"""Get the latest state data."""
try:
state = await self.device.get_state()

View File

@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["jvcprojector"],
"requirements": ["pyjvcprojector==1.0.12"]
"requirements": ["pyjvcprojector==1.1.0"]
}

View File

@ -2,20 +2,23 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from xknx.devices import Device as XknxDevice
from homeassistant.const import CONF_ENTITY_CATEGORY, EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.entity_registry import RegistryEntry
from .const import DOMAIN
from .storage.config_store import PlatformControllerBase
from .storage.const import CONF_DEVICE_INFO
if TYPE_CHECKING:
from . import KNXModule
from .storage.config_store import PlatformControllerBase
class KnxUiEntityPlatformController(PlatformControllerBase):
"""Class to manage dynamic adding and reloading of UI entities."""
@ -93,13 +96,19 @@ class KnxYamlEntity(_KnxEntityBase):
self._device = device
class KnxUiEntity(_KnxEntityBase, ABC):
class KnxUiEntity(_KnxEntityBase):
"""Representation of a KNX UI entity."""
_attr_unique_id: str
_attr_has_entity_name = True
@abstractmethod
def __init__(
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
self, knx_module: KNXModule, unique_id: str, entity_config: dict[str, Any]
) -> None:
"""Initialize the UI entity."""
self._knx_module = knx_module
self._attr_unique_id = unique_id
if entity_category := entity_config.get(CONF_ENTITY_CATEGORY):
self._attr_entity_category = EntityCategory(entity_category)
if device_info := entity_config.get(CONF_DEVICE_INFO):
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)})

View File

@ -20,7 +20,6 @@ from homeassistant.components.light import (
)
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_current_platform,
@ -35,7 +34,6 @@ from .schema import LightSchema
from .storage.const import (
CONF_COLOR_TEMP_MAX,
CONF_COLOR_TEMP_MIN,
CONF_DEVICE_INFO,
CONF_DPT,
CONF_ENTITY,
CONF_GA_BLUE_BRIGHTNESS,
@ -554,21 +552,19 @@ class KnxYamlLight(_KnxLight, KnxYamlEntity):
class KnxUiLight(_KnxLight, KnxUiEntity):
"""Representation of a KNX light."""
_attr_has_entity_name = True
_device: XknxLight
def __init__(
self, knx_module: KNXModule, unique_id: str, config: ConfigType
) -> None:
"""Initialize of KNX light."""
self._knx_module = knx_module
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
self._device = _create_ui_light(
knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME]
)
self._attr_max_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MAX]
self._attr_min_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MIN]
self._attr_entity_category = config[CONF_ENTITY][CONF_ENTITY_CATEGORY]
self._attr_unique_id = unique_id
if device_info := config[CONF_ENTITY].get(CONF_DEVICE_INFO):
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)})

View File

@ -18,7 +18,6 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_current_platform,
@ -38,7 +37,6 @@ from .const import (
from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .schema import SwitchSchema
from .storage.const import (
CONF_DEVICE_INFO,
CONF_ENTITY,
CONF_GA_PASSIVE,
CONF_GA_STATE,
@ -133,14 +131,17 @@ class KnxYamlSwitch(_KnxSwitch, KnxYamlEntity):
class KnxUiSwitch(_KnxSwitch, KnxUiEntity):
"""Representation of a KNX switch configured from UI."""
_attr_has_entity_name = True
_device: XknxSwitch
def __init__(
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
) -> None:
"""Initialize KNX switch."""
self._knx_module = knx_module
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
self._device = XknxSwitch(
knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
@ -153,7 +154,3 @@ class KnxUiSwitch(_KnxSwitch, KnxUiEntity):
sync_state=config[DOMAIN][CONF_SYNC_STATE],
invert=config[DOMAIN][CONF_INVERT],
)
self._attr_entity_category = config[CONF_ENTITY][CONF_ENTITY_CATEGORY]
self._attr_unique_id = unique_id
if device_info := config[CONF_ENTITY].get(CONF_DEVICE_INFO):
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)})

View File

@ -190,48 +190,56 @@ class MatterClimate(MatterEntity, ClimateEntity):
# if the mains power is off - treat it as if the HVAC mode is off
self._attr_hvac_mode = HVACMode.OFF
self._attr_hvac_action = None
return
# update hvac_mode from SystemMode
system_mode_value = int(
self.get_matter_attribute_value(clusters.Thermostat.Attributes.SystemMode)
)
match system_mode_value:
case SystemModeEnum.kAuto:
self._attr_hvac_mode = HVACMode.HEAT_COOL
case SystemModeEnum.kDry:
self._attr_hvac_mode = HVACMode.DRY
case SystemModeEnum.kFanOnly:
self._attr_hvac_mode = HVACMode.FAN_ONLY
case SystemModeEnum.kCool | SystemModeEnum.kPrecooling:
self._attr_hvac_mode = HVACMode.COOL
case SystemModeEnum.kHeat | SystemModeEnum.kEmergencyHeat:
self._attr_hvac_mode = HVACMode.HEAT
case SystemModeEnum.kFanOnly:
self._attr_hvac_mode = HVACMode.FAN_ONLY
case SystemModeEnum.kDry:
self._attr_hvac_mode = HVACMode.DRY
case _:
self._attr_hvac_mode = HVACMode.OFF
# running state is an optional attribute
# which we map to hvac_action if it exists (its value is not None)
self._attr_hvac_action = None
if running_state_value := self.get_matter_attribute_value(
clusters.Thermostat.Attributes.ThermostatRunningState
):
match running_state_value:
case ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2:
self._attr_hvac_action = HVACAction.HEATING
case ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2:
self._attr_hvac_action = HVACAction.COOLING
case (
ThermostatRunningState.Fan
| ThermostatRunningState.FanStage2
| ThermostatRunningState.FanStage3
):
self._attr_hvac_action = HVACAction.FAN
else:
# update hvac_mode from SystemMode
system_mode_value = int(
self.get_matter_attribute_value(
clusters.Thermostat.Attributes.SystemMode
)
)
match system_mode_value:
case SystemModeEnum.kAuto:
self._attr_hvac_mode = HVACMode.HEAT_COOL
case SystemModeEnum.kDry:
self._attr_hvac_mode = HVACMode.DRY
case SystemModeEnum.kFanOnly:
self._attr_hvac_mode = HVACMode.FAN_ONLY
case SystemModeEnum.kCool | SystemModeEnum.kPrecooling:
self._attr_hvac_mode = HVACMode.COOL
case SystemModeEnum.kHeat | SystemModeEnum.kEmergencyHeat:
self._attr_hvac_mode = HVACMode.HEAT
case SystemModeEnum.kFanOnly:
self._attr_hvac_mode = HVACMode.FAN_ONLY
case SystemModeEnum.kDry:
self._attr_hvac_mode = HVACMode.DRY
case _:
self._attr_hvac_action = HVACAction.OFF
self._attr_hvac_mode = HVACMode.OFF
# running state is an optional attribute
# which we map to hvac_action if it exists (its value is not None)
self._attr_hvac_action = None
if running_state_value := self.get_matter_attribute_value(
clusters.Thermostat.Attributes.ThermostatRunningState
):
match running_state_value:
case (
ThermostatRunningState.Heat
| ThermostatRunningState.HeatStage2
):
self._attr_hvac_action = HVACAction.HEATING
case (
ThermostatRunningState.Cool
| ThermostatRunningState.CoolStage2
):
self._attr_hvac_action = HVACAction.COOLING
case (
ThermostatRunningState.Fan
| ThermostatRunningState.FanStage2
| ThermostatRunningState.FanStage3
):
self._attr_hvac_action = HVACAction.FAN
case _:
self._attr_hvac_action = HVACAction.OFF
# update target temperature high/low
supports_range = (
self._attr_supported_features

View File

@ -2,7 +2,7 @@
from __future__ import annotations
from aiomealie import MealieAuthenticationError, MealieClient, MealieConnectionError
from aiomealie import MealieAuthenticationError, MealieClient, MealieError
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo
version = create_version(about.version)
except MealieAuthenticationError as error:
raise ConfigEntryAuthFailed from error
except MealieConnectionError as error:
except MealieError as error:
raise ConfigEntryNotReady(error) from error
if not version.valid:

View File

@ -173,7 +173,9 @@ class NetatmoLight(NetatmoModuleEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn light on."""
if ATTR_BRIGHTNESS in kwargs:
await self.device.async_set_brightness(kwargs[ATTR_BRIGHTNESS])
await self.device.async_set_brightness(
round(kwargs[ATTR_BRIGHTNESS] / 2.55)
)
else:
await self.device.async_on()
@ -194,6 +196,6 @@ class NetatmoLight(NetatmoModuleEntity, LightEntity):
if (brightness := self.device.brightness) is not None:
# Netatmo uses a range of [0, 100] to control brightness
self._attr_brightness = round((brightness / 100) * 255)
self._attr_brightness = round(brightness * 2.55)
else:
self._attr_brightness = None

View File

@ -96,11 +96,10 @@ class PS4Device(MediaPlayerEntity):
self._retry = 0
self._disconnected = False
@callback
def status_callback(self) -> None:
"""Handle status callback. Parse status."""
self._parse_status()
self.async_write_ha_state()
self.schedule_update_ha_state()
@callback
def subscribe_to_protocol(self) -> None:
@ -157,7 +156,7 @@ class PS4Device(MediaPlayerEntity):
self._ps4.ddp_protocol = self.hass.data[PS4_DATA].protocol
self.subscribe_to_protocol()
self._parse_status()
await self.hass.async_add_executor_job(self._parse_status)
def _parse_status(self) -> None:
"""Parse status."""

View File

@ -177,8 +177,12 @@ def count_torrents_in_states(
# When torrents are not in the returned data, there are none, return 0.
try:
torrents = cast(Mapping[str, Mapping], coordinator.data.get("torrents"))
if torrents is None:
return 0
if not states:
return len(torrents)
return len(
[torrent for torrent in torrents.values() if torrent.get("state") in states]
)

View File

@ -10,9 +10,8 @@ import surepy
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, SURE_API_TIMEOUT
@ -27,57 +26,43 @@ USER_DATA_SCHEMA = vol.Schema(
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
surepy_client = surepy.Surepy(
data[CONF_USERNAME],
data[CONF_PASSWORD],
auth_token=None,
api_timeout=SURE_API_TIMEOUT,
session=async_get_clientsession(hass),
)
token = await surepy_client.sac.get_token()
return {CONF_TOKEN: token}
class SurePetCareConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sure Petcare."""
VERSION = 1
def __init__(self) -> None:
"""Initialize."""
self._username: str | None = None
reauth_entry: ConfigEntry | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(step_id="user", data_schema=USER_DATA_SCHEMA)
errors = {}
try:
info = await validate_input(self.hass, user_input)
except SurePetcareAuthenticationError:
errors["base"] = "invalid_auth"
except SurePetcareError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
self._abort_if_unique_id_configured()
user_input[CONF_TOKEN] = info[CONF_TOKEN]
return self.async_create_entry(
title="Sure Petcare",
data=user_input,
if user_input is not None:
client = surepy.Surepy(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
auth_token=None,
api_timeout=SURE_API_TIMEOUT,
session=async_get_clientsession(self.hass),
)
try:
token = await client.sac.get_token()
except SurePetcareAuthenticationError:
errors["base"] = "invalid_auth"
except SurePetcareError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Sure Petcare",
data={**user_input, CONF_TOKEN: token},
)
return self.async_show_form(
step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors
@ -87,18 +72,27 @@ class SurePetCareConfigFlow(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle configuration by re-auth."""
self._username = entry_data[CONF_USERNAME]
self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
assert self.reauth_entry
errors = {}
if user_input is not None:
user_input[CONF_USERNAME] = self._username
client = surepy.Surepy(
self.reauth_entry.data[CONF_USERNAME],
user_input[CONF_PASSWORD],
auth_token=None,
api_timeout=SURE_API_TIMEOUT,
session=async_get_clientsession(self.hass),
)
try:
await validate_input(self.hass, user_input)
token = await client.sac.get_token()
except SurePetcareAuthenticationError:
errors["base"] = "invalid_auth"
except SurePetcareError:
@ -107,16 +101,20 @@ class SurePetCareConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
existing_entry = await self.async_set_unique_id(
user_input[CONF_USERNAME].lower()
return self.async_update_reload_and_abort(
self.reauth_entry,
data={
**self.reauth_entry.data,
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_TOKEN: token,
},
)
if existing_entry:
await self.hass.config_entries.async_reload(existing_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={"username": self._username},
description_placeholders={
"username": self.reauth_entry.data[CONF_USERNAME]
},
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
errors=errors,
)

View File

@ -192,3 +192,10 @@ class TeslemetryWallConnectorEntity(
.get(self.din, {})
.get(self.key)
)
@property
def exists(self) -> bool:
"""Return True if it exists in the wall connector coordinator data."""
return self.key in self.coordinator.data.get("wall_connectors", {}).get(
self.din, {}
)

View File

@ -379,18 +379,18 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(key="island_status", device_class=SensorDeviceClass.ENUM),
)
WALL_CONNECTOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
WALL_CONNECTOR_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = (
TeslemetrySensorEntityDescription(
key="wall_connector_state",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
TeslemetrySensorEntityDescription(
key="wall_connector_fault_state",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
TeslemetrySensorEntityDescription(
key="wall_connector_power",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
@ -398,8 +398,9 @@ WALL_CONNECTOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
),
SensorEntityDescription(
TeslemetrySensorEntityDescription(
key="vin",
value_fn=lambda vin: vin or "disconnected",
),
)
@ -525,13 +526,13 @@ class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity)
class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity):
"""Base class for Teslemetry energy site metric sensors."""
entity_description: SensorEntityDescription
entity_description: TeslemetrySensorEntityDescription
def __init__(
self,
data: TeslemetryEnergyData,
din: str,
description: SensorEntityDescription,
description: TeslemetrySensorEntityDescription,
) -> None:
"""Initialize the sensor."""
self.entity_description = description
@ -543,8 +544,8 @@ class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorE
def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor."""
self._attr_available = not self.is_none
self._attr_native_value = self._value
if self.exists:
self._attr_native_value = self.entity_description.value_fn(self._value)
class TeslemetryEnergyInfoSensorEntity(TeslemetryEnergyInfoEntity, SensorEntity):

View File

@ -420,7 +420,10 @@
"name": "version"
},
"vin": {
"name": "Vehicle"
"name": "Vehicle",
"state": {
"disconnected": "Disconnected"
}
},
"vpp_backup_reserve_percent": {
"name": "VPP backup reserve"

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"quality_scale": "silver",
"requirements": ["pyTibber==0.30.1"]
"requirements": ["pyTibber==0.30.2"]
}

View File

@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.56"]
"requirements": ["holidays==0.57"]
}

View File

@ -24,7 +24,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 9
PATCH_VERSION: Final = "2"
PATCH_VERSION: Final = "3"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2024.9.2"
version = "2024.9.3"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"

View File

@ -192,7 +192,7 @@ aioapcaccess==0.4.2
aioaquacell==0.2.0
# homeassistant.components.aseko_pool_live
aioaseko==0.2.0
aioaseko==1.0.0
# homeassistant.components.asuswrt
aioasuswrt==1.4.0
@ -410,7 +410,7 @@ aiowithings==3.0.3
aioymaps==1.2.5
# homeassistant.components.airgradient
airgradient==0.8.0
airgradient==0.9.0
# homeassistant.components.airly
airly==1.1.0
@ -1099,7 +1099,7 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.56
holidays==0.57
# homeassistant.components.frontend
home-assistant-frontend==20240909.1
@ -1375,7 +1375,7 @@ motionblindsble==0.1.1
motioneye-client==0.3.14
# homeassistant.components.bang_olufsen
mozart-api==3.4.1.8.6
mozart-api==3.4.1.8.8
# homeassistant.components.mullvad
mullvad-api==1.0.0
@ -1707,7 +1707,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
pyTibber==0.30.1
pyTibber==0.30.2
# homeassistant.components.dlink
pyW215==0.7.0
@ -1798,7 +1798,7 @@ pycsspeechtts==1.0.8
# pycups==1.9.73
# homeassistant.components.daikin
pydaikin==2.13.6
pydaikin==2.13.7
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
@ -1819,7 +1819,7 @@ pydiscovergy==3.0.1
pydoods==1.0.2
# homeassistant.components.hydrawise
pydrawise==2024.8.0
pydrawise==2024.9.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==2.0.0
@ -1954,7 +1954,7 @@ pyisy==3.1.14
pyitachip2ir==0.0.7
# homeassistant.components.jvc_projector
pyjvcprojector==1.0.12
pyjvcprojector==1.1.0
# homeassistant.components.kaleidescape
pykaleidescape==1.0.1

View File

@ -180,7 +180,7 @@ aioapcaccess==0.4.2
aioaquacell==0.2.0
# homeassistant.components.aseko_pool_live
aioaseko==0.2.0
aioaseko==1.0.0
# homeassistant.components.asuswrt
aioasuswrt==1.4.0
@ -392,7 +392,7 @@ aiowithings==3.0.3
aioymaps==1.2.5
# homeassistant.components.airgradient
airgradient==0.8.0
airgradient==0.9.0
# homeassistant.components.airly
airly==1.1.0
@ -922,7 +922,7 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.56
holidays==0.57
# homeassistant.components.frontend
home-assistant-frontend==20240909.1
@ -1141,7 +1141,7 @@ motionblindsble==0.1.1
motioneye-client==0.3.14
# homeassistant.components.bang_olufsen
mozart-api==3.4.1.8.6
mozart-api==3.4.1.8.8
# homeassistant.components.mullvad
mullvad-api==1.0.0
@ -1381,7 +1381,7 @@ pyElectra==1.2.4
pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.30.1
pyTibber==0.30.2
# homeassistant.components.dlink
pyW215==0.7.0
@ -1445,7 +1445,7 @@ pycoolmasternet-async==0.2.2
pycsspeechtts==1.0.8
# homeassistant.components.daikin
pydaikin==2.13.6
pydaikin==2.13.7
# homeassistant.components.deconz
pydeconz==116
@ -1457,7 +1457,7 @@ pydexcom==0.2.3
pydiscovergy==3.0.1
# homeassistant.components.hydrawise
pydrawise==2024.8.0
pydrawise==2024.9.0
# homeassistant.components.android_ip_webcam
pydroid-ipcam==2.0.0
@ -1559,7 +1559,7 @@ pyiss==1.0.1
pyisy==3.1.14
# homeassistant.components.jvc_projector
pyjvcprojector==1.0.12
pyjvcprojector==1.1.0
# homeassistant.components.kaleidescape
pykaleidescape==1.0.1

View File

@ -305,7 +305,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '48.0',
'state': '47.0',
})
# ---
# name: test_all_entities[indoor][sensor.airgradient_led_bar_brightness-entry]
@ -912,7 +912,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '27.96',
'state': '22.17',
})
# ---
# name: test_all_entities[indoor][sensor.airgradient_voc_index-entry]

View File

@ -0,0 +1,20 @@
"""Aseko Pool Live conftest."""
from datetime import datetime
from aioaseko import User
import pytest
@pytest.fixture
def user() -> User:
"""Aseko User fixture."""
return User(
user_id="a_user_id",
created_at=datetime.now(),
updated_at=datetime.now(),
name="John",
surname="Doe",
language="any_language",
is_active=True,
)

View File

@ -2,7 +2,7 @@
from unittest.mock import patch
from aioaseko import AccountInfo, APIUnavailable, InvalidAuthCredentials
from aioaseko import AsekoAPIError, AsekoInvalidCredentials, User
import pytest
from homeassistant import config_entries
@ -23,7 +23,7 @@ async def test_async_step_user_form(hass: HomeAssistant) -> None:
assert result["errors"] == {}
async def test_async_step_user_success(hass: HomeAssistant) -> None:
async def test_async_step_user_success(hass: HomeAssistant, user: User) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -31,8 +31,8 @@ async def test_async_step_user_success(hass: HomeAssistant) -> None:
with (
patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"),
"homeassistant.components.aseko_pool_live.config_flow.Aseko.login",
return_value=user,
),
patch(
"homeassistant.components.aseko_pool_live.async_setup_entry",
@ -60,13 +60,13 @@ async def test_async_step_user_success(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("error_web", "reason"),
[
(APIUnavailable, "cannot_connect"),
(InvalidAuthCredentials, "invalid_auth"),
(AsekoAPIError, "cannot_connect"),
(AsekoInvalidCredentials, "invalid_auth"),
(Exception, "unknown"),
],
)
async def test_async_step_user_exception(
hass: HomeAssistant, error_web: Exception, reason: str
hass: HomeAssistant, user: User, error_web: Exception, reason: str
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
@ -74,8 +74,8 @@ async def test_async_step_user_exception(
)
with patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"),
"homeassistant.components.aseko_pool_live.config_flow.Aseko.login",
return_value=user,
side_effect=error_web,
):
result2 = await hass.config_entries.flow.async_configure(
@ -93,13 +93,13 @@ async def test_async_step_user_exception(
@pytest.mark.parametrize(
("error_web", "reason"),
[
(APIUnavailable, "cannot_connect"),
(InvalidAuthCredentials, "invalid_auth"),
(AsekoAPIError, "cannot_connect"),
(AsekoInvalidCredentials, "invalid_auth"),
(Exception, "unknown"),
],
)
async def test_get_account_info_exceptions(
hass: HomeAssistant, error_web: Exception, reason: str
hass: HomeAssistant, user: User, error_web: Exception, reason: str
) -> None:
"""Test we handle config flow exceptions."""
result = await hass.config_entries.flow.async_init(
@ -107,8 +107,8 @@ async def test_get_account_info_exceptions(
)
with patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"),
"homeassistant.components.aseko_pool_live.config_flow.Aseko.login",
return_value=user,
side_effect=error_web,
):
result2 = await hass.config_entries.flow.async_configure(
@ -123,7 +123,7 @@ async def test_get_account_info_exceptions(
assert result2["errors"] == {"base": reason}
async def test_async_step_reauth_success(hass: HomeAssistant) -> None:
async def test_async_step_reauth_success(hass: HomeAssistant, user: User) -> None:
"""Test successful reauthentication."""
mock_entry = MockConfigEntry(
@ -139,10 +139,16 @@ async def test_async_step_reauth_success(hass: HomeAssistant) -> None:
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {}
with patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"),
) as mock_setup_entry:
with (
patch(
"homeassistant.components.aseko_pool_live.config_flow.Aseko.login",
return_value=user,
),
patch(
"homeassistant.components.aseko_pool_live.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_EMAIL: "aseko@example.com", CONF_PASSWORD: "passw0rd"},
@ -156,13 +162,13 @@ async def test_async_step_reauth_success(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("error_web", "reason"),
[
(APIUnavailable, "cannot_connect"),
(InvalidAuthCredentials, "invalid_auth"),
(AsekoAPIError, "cannot_connect"),
(AsekoInvalidCredentials, "invalid_auth"),
(Exception, "unknown"),
],
)
async def test_async_step_reauth_exception(
hass: HomeAssistant, error_web: Exception, reason: str
hass: HomeAssistant, user: User, error_web: Exception, reason: str
) -> None:
"""Test we get the form."""
@ -176,8 +182,8 @@ async def test_async_step_reauth_exception(
result = await mock_entry.start_reauth_flow(hass)
with patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"),
"homeassistant.components.aseko_pool_live.config_flow.Aseko.login",
return_value=user,
side_effect=error_web,
):
result2 = await hass.config_entries.flow.async_configure(

View File

@ -5,7 +5,6 @@ from __future__ import annotations
from typing import Any
from unittest.mock import Mock
from homeassistant.components.climate import PRESET_COMFORT, PRESET_ECO
from homeassistant.components.fritzbox.const import DOMAIN
from homeassistant.core import HomeAssistant
@ -110,9 +109,7 @@ class FritzDeviceClimateMock(FritzEntityBaseMock):
target_temperature = 19.5
window_open = "fake_window"
nextchange_temperature = 22.0
nextchange_endperiod = 0
nextchange_preset = PRESET_COMFORT
scheduled_preset = PRESET_ECO
nextchange_endperiod = 1726855200
class FritzDeviceClimateWithoutTempSensorMock(FritzDeviceClimateMock):

View File

@ -123,7 +123,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None:
f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_change_time"
)
assert state
assert state.state == "1970-01-01T00:00:00+00:00"
assert state.state == "2024-09-20T18:00:00+00:00"
assert (
state.attributes[ATTR_FRIENDLY_NAME]
== f"{CONF_FAKE_NAME} Next scheduled change time"

View File

@ -3,8 +3,10 @@
from datetime import timedelta
from unittest.mock import Mock
import pytest
from requests.exceptions import HTTPError
from homeassistant.components.climate import PRESET_COMFORT, PRESET_ECO
from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN
from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN, SensorStateClass
from homeassistant.const import (
@ -12,6 +14,7 @@ from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
CONF_DEVICES,
PERCENTAGE,
STATE_UNKNOWN,
EntityCategory,
UnitOfTemperature,
)
@ -19,7 +22,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
import homeassistant.util.dt as dt_util
from . import FritzDeviceSensorMock, set_devices, setup_config_entry
from . import (
FritzDeviceClimateMock,
FritzDeviceSensorMock,
set_devices,
setup_config_entry,
)
from .const import CONF_FAKE_NAME, MOCK_CONFIG
from tests.common import async_fire_time_changed
@ -132,3 +140,55 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None:
state = hass.states.get(f"{DOMAIN}.new_device_temperature")
assert state
@pytest.mark.parametrize(
("next_changes", "expected_states"),
[
(
[0, 16],
[STATE_UNKNOWN, STATE_UNKNOWN, STATE_UNKNOWN, STATE_UNKNOWN],
),
(
[0, 22],
[STATE_UNKNOWN, STATE_UNKNOWN, STATE_UNKNOWN, STATE_UNKNOWN],
),
(
[1726855200, 16.0],
["2024-09-20T18:00:00+00:00", "16.0", PRESET_ECO, PRESET_COMFORT],
),
(
[1726855200, 22.0],
["2024-09-20T18:00:00+00:00", "22.0", PRESET_COMFORT, PRESET_ECO],
),
],
)
async def test_next_change_sensors(
hass: HomeAssistant, fritz: Mock, next_changes: list, expected_states: list
) -> None:
"""Test next change sensors."""
device = FritzDeviceClimateMock()
device.nextchange_endperiod = next_changes[0]
device.nextchange_temperature = next_changes[1]
assert await setup_config_entry(
hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz
)
base_name = f"{DOMAIN}.{CONF_FAKE_NAME}"
state = hass.states.get(f"{base_name}_next_scheduled_change_time")
assert state
assert state.state == expected_states[0]
state = hass.states.get(f"{base_name}_next_scheduled_temperature")
assert state
assert state.state == expected_states[1]
state = hass.states.get(f"{base_name}_next_scheduled_preset")
assert state
assert state.state == expected_states[2]
state = hass.states.get(f"{base_name}_current_scheduled_preset")
assert state
assert state.state == expected_states[3]

View File

@ -18,22 +18,22 @@ async def test_something(hass, knx):
## Asserting outgoing telegrams
All outgoing telegrams are pushed to an assertion queue. Assert them in order they were sent.
All outgoing telegrams are appended to an assertion list. Assert them in order they were sent or pass `ignore_order=True` to the assertion method.
- `knx.assert_no_telegram`
Asserts that no telegram was sent (assertion queue is empty).
Asserts that no telegram was sent (assertion list is empty).
- `knx.assert_telegram_count(count: int)`
Asserts that `count` telegrams were sent.
- `knx.assert_read(group_address: str, response: int | tuple[int, ...] | None = None)`
- `knx.assert_read(group_address: str, response: int | tuple[int, ...] | None = None, ignore_order: bool = False)`
Asserts that a GroupValueRead telegram was sent to `group_address`.
The telegram will be removed from the assertion queue.
The telegram will be removed from the assertion list.
Optionally inject incoming GroupValueResponse telegram after reception to clear the value reader waiting task. This can also be done manually with `knx.receive_response`.
- `knx.assert_response(group_address: str, payload: int | tuple[int, ...])`
- `knx.assert_response(group_address: str, payload: int | tuple[int, ...], ignore_order: bool = False)`
Asserts that a GroupValueResponse telegram with `payload` was sent to `group_address`.
The telegram will be removed from the assertion queue.
- `knx.assert_write(group_address: str, payload: int | tuple[int, ...])`
The telegram will be removed from the assertion list.
- `knx.assert_write(group_address: str, payload: int | tuple[int, ...], ignore_order: bool = False)`
Asserts that a GroupValueWrite telegram with `payload` was sent to `group_address`.
The telegram will be removed from the assertion queue.
The telegram will be removed from the assertion list.
Change some states or call some services and assert outgoing telegrams.

View File

@ -57,9 +57,9 @@ class KNXTestKit:
self.hass: HomeAssistant = hass
self.mock_config_entry: MockConfigEntry = mock_config_entry
self.xknx: XKNX
# outgoing telegrams will be put in the Queue instead of sent to the interface
# outgoing telegrams will be put in the List instead of sent to the interface
# telegrams to an InternalGroupAddress won't be queued here
self._outgoing_telegrams: asyncio.Queue = asyncio.Queue()
self._outgoing_telegrams: list[Telegram] = []
def assert_state(self, entity_id: str, state: str, **attributes) -> None:
"""Assert the state of an entity."""
@ -76,7 +76,7 @@ class KNXTestKit:
async def patch_xknx_start():
"""Patch `xknx.start` for unittests."""
self.xknx.cemi_handler.send_telegram = AsyncMock(
side_effect=self._outgoing_telegrams.put
side_effect=self._outgoing_telegrams.append
)
# after XKNX.__init__() to not overwrite it by the config entry again
# before StateUpdater starts to avoid slow down of tests
@ -117,24 +117,22 @@ class KNXTestKit:
########################
def _list_remaining_telegrams(self) -> str:
"""Return a string containing remaining outgoing telegrams in test Queue. One per line."""
remaining_telegrams = []
while not self._outgoing_telegrams.empty():
remaining_telegrams.append(self._outgoing_telegrams.get_nowait())
return "\n".join(map(str, remaining_telegrams))
"""Return a string containing remaining outgoing telegrams in test List."""
return "\n".join(map(str, self._outgoing_telegrams))
async def assert_no_telegram(self) -> None:
"""Assert if every telegram in test Queue was checked."""
"""Assert if every telegram in test List was checked."""
await self.hass.async_block_till_done()
assert self._outgoing_telegrams.empty(), (
f"Found remaining unasserted Telegrams: {self._outgoing_telegrams.qsize()}\n"
remaining_telegram_count = len(self._outgoing_telegrams)
assert not remaining_telegram_count, (
f"Found remaining unasserted Telegrams: {remaining_telegram_count}\n"
f"{self._list_remaining_telegrams()}"
)
async def assert_telegram_count(self, count: int) -> None:
"""Assert outgoing telegram count in test Queue."""
"""Assert outgoing telegram count in test List."""
await self.hass.async_block_till_done()
actual_count = self._outgoing_telegrams.qsize()
actual_count = len(self._outgoing_telegrams)
assert actual_count == count, (
f"Outgoing telegrams: {actual_count} - Expected: {count}\n"
f"{self._list_remaining_telegrams()}"
@ -149,52 +147,79 @@ class KNXTestKit:
group_address: str,
payload: int | tuple[int, ...] | None,
apci_type: type[APCI],
ignore_order: bool = False,
) -> None:
"""Assert outgoing telegram. One by one in timely order."""
"""Assert outgoing telegram. Optionally in timely order."""
await self.xknx.telegrams.join()
try:
telegram = self._outgoing_telegrams.get_nowait()
except asyncio.QueueEmpty as err:
if not self._outgoing_telegrams:
raise AssertionError(
f"No Telegram found. Expected: {apci_type.__name__} -"
f" {group_address} - {payload}"
) from err
)
_expected_ga = GroupAddress(group_address)
if ignore_order:
for telegram in self._outgoing_telegrams:
if (
telegram.destination_address == _expected_ga
and isinstance(telegram.payload, apci_type)
and (payload is None or telegram.payload.value.value == payload)
):
self._outgoing_telegrams.remove(telegram)
return
raise AssertionError(
f"Telegram not found. Expected: {apci_type.__name__} -"
f" {group_address} - {payload}"
f"\nUnasserted telegrams:\n{self._list_remaining_telegrams()}"
)
telegram = self._outgoing_telegrams.pop(0)
assert isinstance(
telegram.payload, apci_type
), f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}"
assert (
str(telegram.destination_address) == group_address
telegram.destination_address == _expected_ga
), f"Group address mismatch in {telegram} - Expected: {group_address}"
if payload is not None:
assert (
telegram.payload.value.value == payload # type: ignore[attr-defined]
), f"Payload mismatch in {telegram} - Expected: {payload}"
async def assert_read(
self, group_address: str, response: int | tuple[int, ...] | None = None
self,
group_address: str,
response: int | tuple[int, ...] | None = None,
ignore_order: bool = False,
) -> None:
"""Assert outgoing GroupValueRead telegram. One by one in timely order.
"""Assert outgoing GroupValueRead telegram. Optionally in timely order.
Optionally inject incoming GroupValueResponse telegram after reception.
"""
await self.assert_telegram(group_address, None, GroupValueRead)
await self.assert_telegram(group_address, None, GroupValueRead, ignore_order)
if response is not None:
await self.receive_response(group_address, response)
async def assert_response(
self, group_address: str, payload: int | tuple[int, ...]
self,
group_address: str,
payload: int | tuple[int, ...],
ignore_order: bool = False,
) -> None:
"""Assert outgoing GroupValueResponse telegram. One by one in timely order."""
await self.assert_telegram(group_address, payload, GroupValueResponse)
"""Assert outgoing GroupValueResponse telegram. Optionally in timely order."""
await self.assert_telegram(
group_address, payload, GroupValueResponse, ignore_order
)
async def assert_write(
self, group_address: str, payload: int | tuple[int, ...]
self,
group_address: str,
payload: int | tuple[int, ...],
ignore_order: bool = False,
) -> None:
"""Assert outgoing GroupValueWrite telegram. One by one in timely order."""
await self.assert_telegram(group_address, payload, GroupValueWrite)
"""Assert outgoing GroupValueWrite telegram. Optionally in timely order."""
await self.assert_telegram(
group_address, payload, GroupValueWrite, ignore_order
)
####################
# Incoming telegrams

View File

@ -23,7 +23,26 @@
}
}
},
"light": {}
"light": {
"knx_es_01J85ZKTFHSZNG4X9DYBE592TF": {
"entity": {
"name": "test",
"device_info": null,
"entity_category": "config"
},
"knx": {
"color_temp_min": 2700,
"color_temp_max": 6000,
"_light_color_mode_schema": "default",
"ga_switch": {
"write": "1/1/21",
"state": "1/0/21",
"passive": []
},
"sync_state": true
}
}
}
}
}
}

View File

@ -58,7 +58,8 @@ async def test_remove_device(
await knx.setup_integration({})
client = await hass_ws_client(hass)
await knx.assert_read("1/0/45", response=True)
await knx.assert_read("1/0/21", response=True, ignore_order=True) # test light
await knx.assert_read("1/0/45", response=True, ignore_order=True) # test switch
assert hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"].get("switch")
test_device = device_registry.async_get_device(

View File

@ -19,8 +19,9 @@ from homeassistant.components.light import (
ATTR_RGBW_COLOR,
ColorMode,
)
from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON
from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import KnxEntityGenerator
from .conftest import KNXTestKit
@ -1159,7 +1160,7 @@ async def test_light_ui_create(
knx: KNXTestKit,
create_ui_entity: KnxEntityGenerator,
) -> None:
"""Test creating a switch."""
"""Test creating a light."""
await knx.setup_integration({})
await create_ui_entity(
platform=Platform.LIGHT,
@ -1192,7 +1193,7 @@ async def test_light_ui_color_temp(
color_temp_mode: str,
raw_ct: tuple[int, ...],
) -> None:
"""Test creating a switch."""
"""Test creating a color-temp light."""
await knx.setup_integration({})
await create_ui_entity(
platform=Platform.LIGHT,
@ -1218,3 +1219,23 @@ async def test_light_ui_color_temp(
state = hass.states.get("light.test")
assert state.state is STATE_ON
assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == pytest.approx(4200, abs=1)
async def test_light_ui_load(
hass: HomeAssistant,
knx: KNXTestKit,
load_config_store: None,
entity_registry: er.EntityRegistry,
) -> None:
"""Test loading a light from storage."""
await knx.setup_integration({})
await knx.assert_read("1/0/21", response=True, ignore_order=True)
# unrelated switch in config store
await knx.assert_read("1/0/45", response=True, ignore_order=True)
state = hass.states.get("light.test")
assert state.state is STATE_ON
entity = entity_registry.async_get("light.test")
assert entity.entity_category is EntityCategory.CONFIG

View File

@ -6,6 +6,7 @@ from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
from homeassistant import config_entries
from homeassistant.components.surepetcare.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -24,7 +25,7 @@ async def test_form(hass: HomeAssistant, surepetcare: NonCallableMagicMock) -> N
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
assert not result["errors"]
with patch(
"homeassistant.components.surepetcare.async_setup_entry",
@ -146,11 +147,17 @@ async def test_flow_entry_already_exists(
assert result["reason"] == "already_configured"
async def test_reauthentication(hass: HomeAssistant) -> None:
async def test_reauthentication(
hass: HomeAssistant, surepetcare: NonCallableMagicMock
) -> None:
"""Test surepetcare reauthentication."""
old_entry = MockConfigEntry(
domain="surepetcare",
data=INPUT_DATA,
data={
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_TOKEN: "token",
},
unique_id="test-username",
)
old_entry.add_to_hass(hass)
@ -161,19 +168,23 @@ async def test_reauthentication(hass: HomeAssistant) -> None:
assert result["errors"] == {}
assert result["step_id"] == "reauth_confirm"
with patch(
"homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token",
return_value={"token": "token"},
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"password": "test-password"},
)
await hass.async_block_till_done()
surepetcare.get_token.return_value = "token2"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"password": "test-password2"},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reauth_successful"
assert old_entry.data == {
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password2",
CONF_TOKEN: "token2",
}
async def test_reauthentication_failure(hass: HomeAssistant) -> None:
"""Test surepetcare reauthentication failure."""