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", "documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["airgradient==0.8.0"], "requirements": ["airgradient==0.9.0"],
"zeroconf": ["_airgradient._tcp.local."] "zeroconf": ["_airgradient._tcp.local."]
} }

View File

@ -4,13 +4,12 @@ from __future__ import annotations
import logging import logging
from aioaseko import APIUnavailable, InvalidAuthCredentials, MobileAccount from aioaseko import Aseko, AsekoNotLoggedIn
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
from .coordinator import AsekoDataUpdateCoordinator 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Aseko Pool Live from a config entry.""" """Set up Aseko Pool Live from a config entry."""
account = MobileAccount( aseko = Aseko(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD])
async_get_clientsession(hass),
username=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
)
try: try:
units = await account.get_units() await aseko.login()
except InvalidAuthCredentials as err: except AsekoNotLoggedIn as err:
raise ConfigEntryAuthFailed from 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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
@ -51,7 +39,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok return unload_ok

View File

@ -8,7 +8,6 @@ from dataclasses import dataclass
from aioaseko import Unit from aioaseko import Unit
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
@ -25,26 +24,14 @@ from .entity import AsekoEntity
class AsekoBinarySensorEntityDescription(BinarySensorEntityDescription): class AsekoBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes an Aseko binary sensor entity.""" """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( AsekoBinarySensorEntityDescription(
key="water_flow", key="water_flow",
translation_key="water_flow", translation_key="water_flow_to_probes",
value_fn=lambda unit: unit.water_flow, value_fn=lambda unit: unit.water_flow_to_probes,
),
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,
), ),
) )
@ -55,33 +42,22 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Aseko Pool Live binary sensors.""" """Set up the Aseko Pool Live binary sensors."""
data: list[tuple[Unit, AsekoDataUpdateCoordinator]] = hass.data[DOMAIN][ coordinator: AsekoDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id units = coordinator.data.values()
]
async_add_entities( async_add_entities(
AsekoUnitBinarySensorEntity(unit, coordinator, description) AsekoBinarySensorEntity(unit, coordinator, description)
for unit, coordinator in data for description in BINARY_SENSORS
for description in UNIT_BINARY_SENSORS for unit in units
if description.value_fn(unit) is not None
) )
class AsekoUnitBinarySensorEntity(AsekoEntity, BinarySensorEntity): class AsekoBinarySensorEntity(AsekoEntity, BinarySensorEntity):
"""Representation of a unit water flow binary sensor entity.""" """Representation of an Aseko binary sensor entity."""
entity_description: AsekoBinarySensorEntityDescription 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 @property
def is_on(self) -> bool: def is_on(self) -> bool | None:
"""Return the state of the sensor.""" """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 import logging
from typing import Any from typing import Any
from aioaseko import APIUnavailable, InvalidAuthCredentials, WebAccount from aioaseko import Aseko, AsekoAPIError, AsekoInvalidCredentials
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
@ -34,15 +33,12 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
async def get_account_info(self, email: str, password: str) -> dict: async def get_account_info(self, email: str, password: str) -> dict:
"""Get account info from the mobile API and the web API.""" """Get account info from the mobile API and the web API."""
session = async_get_clientsession(self.hass) aseko = Aseko(email, password)
user = await aseko.login()
web_account = WebAccount(session, email, password)
web_account_info = await web_account.login()
return { return {
CONF_EMAIL: email, CONF_EMAIL: email,
CONF_PASSWORD: password, CONF_PASSWORD: password,
CONF_UNIQUE_ID: web_account_info.user_id, CONF_UNIQUE_ID: user.user_id,
} }
async def async_step_user( async def async_step_user(
@ -58,9 +54,9 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
info = await self.get_account_info( info = await self.get_account_info(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD] user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
) )
except APIUnavailable: except AsekoAPIError:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuthCredentials: except AsekoInvalidCredentials:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except Exception: except Exception:
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
@ -122,9 +118,9 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN):
info = await self.get_account_info( info = await self.get_account_info(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD] user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
) )
except APIUnavailable: except AsekoAPIError:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuthCredentials: except AsekoInvalidCredentials:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except Exception: except Exception:
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")

View File

@ -5,34 +5,31 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging import logging
from aioaseko import Unit, Variable from aioaseko import Aseko, Unit
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _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.""" """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.""" """Initialize global Aseko unit data updater."""
self._unit = unit self._aseko = aseko
if self._unit.name:
name = self._unit.name
else:
name = f"{self._unit.type}-{self._unit.serial_number}"
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name=name, name=DOMAIN,
update_interval=timedelta(minutes=2), 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.""" """Fetch unit data."""
await self._unit.get_state() units = await self._aseko.get_units()
return {variable.type: variable for variable in self._unit.variables} return {unit.serial_number: unit for unit in units}

View File

@ -3,6 +3,7 @@
from aioaseko import Unit from aioaseko import Unit
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
@ -14,20 +15,44 @@ class AsekoEntity(CoordinatorEntity[AsekoDataUpdateCoordinator]):
_attr_has_entity_name = True _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.""" """Initialize the aseko entity."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description
self._unit = unit self._unit = unit
self._attr_unique_id = f"{self.unit.serial_number}{self.entity_description.key}"
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_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
name=self._device_name, identifiers={(DOMAIN, self.unit.serial_number)},
identifiers={(DOMAIN, str(self._unit.serial_number))}, serial_number=self.unit.serial_number,
manufacturer="Aseko", name=unit.name or unit.serial_number,
model=self._device_model, 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": { "entity": {
"binary_sensor": { "binary_sensor": {
"water_flow": { "water_flow_to_probes": {
"default": "mdi:waves-arrow-right" "default": "mdi:waves-arrow-right"
} }
}, },
"sensor": { "sensor": {
"air_temperature": {
"default": "mdi:thermometer-lines"
},
"free_chlorine": { "free_chlorine": {
"default": "mdi:flask" "default": "mdi:pool"
},
"redox": {
"default": "mdi:pool"
},
"salinity": {
"default": "mdi:pool"
}, },
"water_temperature": { "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", "documentation": "https://www.home-assistant.io/integrations/aseko_pool_live",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioaseko"], "loggers": ["aioaseko"],
"requirements": ["aioaseko==0.2.0"] "requirements": ["aioaseko==1.0.0"]
} }

View File

@ -2,77 +2,104 @@
from __future__ import annotations 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 ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN from .const import DOMAIN
from .coordinator import AsekoDataUpdateCoordinator from .coordinator import AsekoDataUpdateCoordinator
from .entity import AsekoEntity 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Aseko Pool Live sensors.""" """Set up the Aseko Pool Live sensors."""
data: list[tuple[Unit, AsekoDataUpdateCoordinator]] = hass.data[DOMAIN][ coordinator: AsekoDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
config_entry.entry_id units = coordinator.data.values()
]
async_add_entities( async_add_entities(
VariableSensorEntity(unit, variable, coordinator) AsekoSensorEntity(unit, coordinator, description)
for unit, coordinator in data for description in SENSORS
for variable in unit.variables for unit in units
if description.value_fn(unit) is not None
) )
class VariableSensorEntity(AsekoEntity, SensorEntity): class AsekoSensorEntity(AsekoEntity, SensorEntity):
"""Representation of a unit variable sensor entity.""" """Representation of an Aseko unit sensor entity."""
_attr_state_class = SensorStateClass.MEASUREMENT entity_description: AsekoSensorEntityDescription
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)
@property @property
def native_value(self) -> int | None: def native_value(self) -> StateType:
"""Return the state of the sensor.""" """Return the state of the sensor."""
variable = self.coordinator.data[self._variable.type] return self.entity_description.value_fn(self.unit)
return variable.current_value

View File

@ -26,11 +26,8 @@
}, },
"entity": { "entity": {
"binary_sensor": { "binary_sensor": {
"water_flow": { "water_flow_to_probes": {
"name": "Water flow" "name": "Water flow to probes"
},
"alarm": {
"name": "Alarm"
} }
}, },
"sensor": { "sensor": {
@ -40,6 +37,12 @@
"free_chlorine": { "free_chlorine": {
"name": "Free chlorine" "name": "Free chlorine"
}, },
"redox": {
"name": "Redox potential"
},
"salinity": {
"name": "Salinity"
},
"water_temperature": { "water_temperature": {
"name": "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.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.device_registry as dr import homeassistant.helpers.device_registry as dr
from homeassistant.util.ssl import get_default_context
from .const import DOMAIN from .const import DOMAIN
from .websocket import BangOlufsenWebsocket from .websocket import BangOlufsenWebsocket
@ -48,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
model=entry.data[CONF_MODEL], 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 # Check API and WebSocket connection
try: try:

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
from typing import Any from typing import TYPE_CHECKING, Any
from homeassistant.components.automation import automations_with_entity from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_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: def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None:
"""Calculate due date for dailies and yesterdailies.""" """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"]: 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: try:
return dt_util.as_local( return dt_util.as_local(datetime.datetime.fromisoformat(date)).date()
datetime.datetime.fromisoformat(task["nextDue"][0])
).date()
except ValueError: 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" # "Mon May 06 2024 00:00:00 GMT+0200"
try: try:
return dt_util.as_local( return dt_util.as_local(
datetime.datetime.strptime( datetime.datetime.strptime(date, "%a %b %d %Y %H:%M:%S %Z%z")
task["nextDue"][0], "%a %b %d %Y %H:%M:%S %Z%z"
)
).date() ).date()
except ValueError: except ValueError:
return None return None
except IndexError:
return None
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday", "documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling", "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", "documentation": "https://www.home-assistant.io/integrations/hydrawise",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pydrawise"], "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 from datetime import timedelta
import logging import logging
from typing import Any
from jvcprojector import ( from jvcprojector import (
JvcProjector, JvcProjector,
@ -40,7 +41,7 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
self.device = device self.device = device
self.unique_id = format_mac(device.mac) 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.""" """Get the latest state data."""
try: try:
state = await self.device.get_state() state = await self.device.get_state()

View File

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

View File

@ -2,20 +2,23 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from xknx.devices import Device as XknxDevice 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 import Entity
from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.entity_registry import RegistryEntry 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: if TYPE_CHECKING:
from . import KNXModule from . import KNXModule
from .storage.config_store import PlatformControllerBase
class KnxUiEntityPlatformController(PlatformControllerBase): class KnxUiEntityPlatformController(PlatformControllerBase):
"""Class to manage dynamic adding and reloading of UI entities.""" """Class to manage dynamic adding and reloading of UI entities."""
@ -93,13 +96,19 @@ class KnxYamlEntity(_KnxEntityBase):
self._device = device self._device = device
class KnxUiEntity(_KnxEntityBase, ABC): class KnxUiEntity(_KnxEntityBase):
"""Representation of a KNX UI entity.""" """Representation of a KNX UI entity."""
_attr_unique_id: str _attr_unique_id: str
_attr_has_entity_name = True
@abstractmethod
def __init__( 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: ) -> None:
"""Initialize the UI entity.""" """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.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.entity_platform import (
AddEntitiesCallback, AddEntitiesCallback,
async_get_current_platform, async_get_current_platform,
@ -35,7 +34,6 @@ from .schema import LightSchema
from .storage.const import ( from .storage.const import (
CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MAX,
CONF_COLOR_TEMP_MIN, CONF_COLOR_TEMP_MIN,
CONF_DEVICE_INFO,
CONF_DPT, CONF_DPT,
CONF_ENTITY, CONF_ENTITY,
CONF_GA_BLUE_BRIGHTNESS, CONF_GA_BLUE_BRIGHTNESS,
@ -554,21 +552,19 @@ class KnxYamlLight(_KnxLight, KnxYamlEntity):
class KnxUiLight(_KnxLight, KnxUiEntity): class KnxUiLight(_KnxLight, KnxUiEntity):
"""Representation of a KNX light.""" """Representation of a KNX light."""
_attr_has_entity_name = True
_device: XknxLight _device: XknxLight
def __init__( def __init__(
self, knx_module: KNXModule, unique_id: str, config: ConfigType self, knx_module: KNXModule, unique_id: str, config: ConfigType
) -> None: ) -> None:
"""Initialize of KNX light.""" """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( self._device = _create_ui_light(
knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME] 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_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_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, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.entity_platform import (
AddEntitiesCallback, AddEntitiesCallback,
async_get_current_platform, async_get_current_platform,
@ -38,7 +37,6 @@ from .const import (
from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .schema import SwitchSchema from .schema import SwitchSchema
from .storage.const import ( from .storage.const import (
CONF_DEVICE_INFO,
CONF_ENTITY, CONF_ENTITY,
CONF_GA_PASSIVE, CONF_GA_PASSIVE,
CONF_GA_STATE, CONF_GA_STATE,
@ -133,14 +131,17 @@ class KnxYamlSwitch(_KnxSwitch, KnxYamlEntity):
class KnxUiSwitch(_KnxSwitch, KnxUiEntity): class KnxUiSwitch(_KnxSwitch, KnxUiEntity):
"""Representation of a KNX switch configured from UI.""" """Representation of a KNX switch configured from UI."""
_attr_has_entity_name = True
_device: XknxSwitch _device: XknxSwitch
def __init__( def __init__(
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
) -> None: ) -> None:
"""Initialize KNX switch.""" """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( self._device = XknxSwitch(
knx_module.xknx, knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME], name=config[CONF_ENTITY][CONF_NAME],
@ -153,7 +154,3 @@ class KnxUiSwitch(_KnxSwitch, KnxUiEntity):
sync_state=config[DOMAIN][CONF_SYNC_STATE], sync_state=config[DOMAIN][CONF_SYNC_STATE],
invert=config[DOMAIN][CONF_INVERT], 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 # if the mains power is off - treat it as if the HVAC mode is off
self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_mode = HVACMode.OFF
self._attr_hvac_action = None self._attr_hvac_action = None
return else:
# update hvac_mode from SystemMode
# update hvac_mode from SystemMode system_mode_value = int(
system_mode_value = int( self.get_matter_attribute_value(
self.get_matter_attribute_value(clusters.Thermostat.Attributes.SystemMode) clusters.Thermostat.Attributes.SystemMode
) )
match system_mode_value: )
case SystemModeEnum.kAuto: match system_mode_value:
self._attr_hvac_mode = HVACMode.HEAT_COOL case SystemModeEnum.kAuto:
case SystemModeEnum.kDry: self._attr_hvac_mode = HVACMode.HEAT_COOL
self._attr_hvac_mode = HVACMode.DRY case SystemModeEnum.kDry:
case SystemModeEnum.kFanOnly: self._attr_hvac_mode = HVACMode.DRY
self._attr_hvac_mode = HVACMode.FAN_ONLY case SystemModeEnum.kFanOnly:
case SystemModeEnum.kCool | SystemModeEnum.kPrecooling: self._attr_hvac_mode = HVACMode.FAN_ONLY
self._attr_hvac_mode = HVACMode.COOL case SystemModeEnum.kCool | SystemModeEnum.kPrecooling:
case SystemModeEnum.kHeat | SystemModeEnum.kEmergencyHeat: self._attr_hvac_mode = HVACMode.COOL
self._attr_hvac_mode = HVACMode.HEAT case SystemModeEnum.kHeat | SystemModeEnum.kEmergencyHeat:
case SystemModeEnum.kFanOnly: self._attr_hvac_mode = HVACMode.HEAT
self._attr_hvac_mode = HVACMode.FAN_ONLY case SystemModeEnum.kFanOnly:
case SystemModeEnum.kDry: self._attr_hvac_mode = HVACMode.FAN_ONLY
self._attr_hvac_mode = HVACMode.DRY case SystemModeEnum.kDry:
case _: self._attr_hvac_mode = HVACMode.DRY
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 _: 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 # update target temperature high/low
supports_range = ( supports_range = (
self._attr_supported_features self._attr_supported_features

View File

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

View File

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

View File

@ -96,11 +96,10 @@ class PS4Device(MediaPlayerEntity):
self._retry = 0 self._retry = 0
self._disconnected = False self._disconnected = False
@callback
def status_callback(self) -> None: def status_callback(self) -> None:
"""Handle status callback. Parse status.""" """Handle status callback. Parse status."""
self._parse_status() self._parse_status()
self.async_write_ha_state() self.schedule_update_ha_state()
@callback @callback
def subscribe_to_protocol(self) -> None: def subscribe_to_protocol(self) -> None:
@ -157,7 +156,7 @@ class PS4Device(MediaPlayerEntity):
self._ps4.ddp_protocol = self.hass.data[PS4_DATA].protocol self._ps4.ddp_protocol = self.hass.data[PS4_DATA].protocol
self.subscribe_to_protocol() self.subscribe_to_protocol()
self._parse_status() await self.hass.async_add_executor_job(self._parse_status)
def _parse_status(self) -> None: def _parse_status(self) -> None:
"""Parse status.""" """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. # When torrents are not in the returned data, there are none, return 0.
try: try:
torrents = cast(Mapping[str, Mapping], coordinator.data.get("torrents")) torrents = cast(Mapping[str, Mapping], coordinator.data.get("torrents"))
if torrents is None:
return 0
if not states: if not states:
return len(torrents) return len(torrents)
return len( return len(
[torrent for torrent in torrents.values() if torrent.get("state") in states] [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 from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
import voluptuous as vol 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.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, SURE_API_TIMEOUT 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): class SurePetCareConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sure Petcare.""" """Handle a config flow for Sure Petcare."""
VERSION = 1 VERSION = 1
def __init__(self) -> None: reauth_entry: ConfigEntry | None = None
"""Initialize."""
self._username: str | None = None
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
if user_input is None:
return self.async_show_form(step_id="user", data_schema=USER_DATA_SCHEMA)
errors = {} errors = {}
if user_input is not None:
try: client = surepy.Surepy(
info = await validate_input(self.hass, user_input) user_input[CONF_USERNAME],
except SurePetcareAuthenticationError: user_input[CONF_PASSWORD],
errors["base"] = "invalid_auth" auth_token=None,
except SurePetcareError: api_timeout=SURE_API_TIMEOUT,
errors["base"] = "cannot_connect" session=async_get_clientsession(self.hass),
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,
) )
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( return self.async_show_form(
step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors 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] self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle configuration by re-auth.""" """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() return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm( async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required.""" """Dialog that informs the user that reauth is required."""
assert self.reauth_entry
errors = {} errors = {}
if user_input is not None: 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: try:
await validate_input(self.hass, user_input) token = await client.sac.get_token()
except SurePetcareAuthenticationError: except SurePetcareAuthenticationError:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except SurePetcareError: except SurePetcareError:
@ -107,16 +101,20 @@ class SurePetCareConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
existing_entry = await self.async_set_unique_id( return self.async_update_reload_and_abort(
user_input[CONF_USERNAME].lower() 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( return self.async_show_form(
step_id="reauth_confirm", 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}), data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
errors=errors, errors=errors,
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -305,7 +305,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': '48.0', 'state': '47.0',
}) })
# --- # ---
# name: test_all_entities[indoor][sensor.airgradient_led_bar_brightness-entry] # name: test_all_entities[indoor][sensor.airgradient_led_bar_brightness-entry]
@ -912,7 +912,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': '27.96', 'state': '22.17',
}) })
# --- # ---
# name: test_all_entities[indoor][sensor.airgradient_voc_index-entry] # 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 unittest.mock import patch
from aioaseko import AccountInfo, APIUnavailable, InvalidAuthCredentials from aioaseko import AsekoAPIError, AsekoInvalidCredentials, User
import pytest import pytest
from homeassistant import config_entries from homeassistant import config_entries
@ -23,7 +23,7 @@ async def test_async_step_user_form(hass: HomeAssistant) -> None:
assert result["errors"] == {} 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.""" """Test we get the form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -31,8 +31,8 @@ async def test_async_step_user_success(hass: HomeAssistant) -> None:
with ( with (
patch( patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", "homeassistant.components.aseko_pool_live.config_flow.Aseko.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), return_value=user,
), ),
patch( patch(
"homeassistant.components.aseko_pool_live.async_setup_entry", "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( @pytest.mark.parametrize(
("error_web", "reason"), ("error_web", "reason"),
[ [
(APIUnavailable, "cannot_connect"), (AsekoAPIError, "cannot_connect"),
(InvalidAuthCredentials, "invalid_auth"), (AsekoInvalidCredentials, "invalid_auth"),
(Exception, "unknown"), (Exception, "unknown"),
], ],
) )
async def test_async_step_user_exception( async def test_async_step_user_exception(
hass: HomeAssistant, error_web: Exception, reason: str hass: HomeAssistant, user: User, error_web: Exception, reason: str
) -> None: ) -> None:
"""Test we get the form.""" """Test we get the form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -74,8 +74,8 @@ async def test_async_step_user_exception(
) )
with patch( with patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", "homeassistant.components.aseko_pool_live.config_flow.Aseko.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), return_value=user,
side_effect=error_web, side_effect=error_web,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
@ -93,13 +93,13 @@ async def test_async_step_user_exception(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("error_web", "reason"), ("error_web", "reason"),
[ [
(APIUnavailable, "cannot_connect"), (AsekoAPIError, "cannot_connect"),
(InvalidAuthCredentials, "invalid_auth"), (AsekoInvalidCredentials, "invalid_auth"),
(Exception, "unknown"), (Exception, "unknown"),
], ],
) )
async def test_get_account_info_exceptions( async def test_get_account_info_exceptions(
hass: HomeAssistant, error_web: Exception, reason: str hass: HomeAssistant, user: User, error_web: Exception, reason: str
) -> None: ) -> None:
"""Test we handle config flow exceptions.""" """Test we handle config flow exceptions."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -107,8 +107,8 @@ async def test_get_account_info_exceptions(
) )
with patch( with patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", "homeassistant.components.aseko_pool_live.config_flow.Aseko.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), return_value=user,
side_effect=error_web, side_effect=error_web,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
@ -123,7 +123,7 @@ async def test_get_account_info_exceptions(
assert result2["errors"] == {"base": reason} 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.""" """Test successful reauthentication."""
mock_entry = MockConfigEntry( 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["step_id"] == "reauth_confirm"
assert result["errors"] == {} assert result["errors"] == {}
with patch( with (
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", patch(
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), "homeassistant.components.aseko_pool_live.config_flow.Aseko.login",
) as mock_setup_entry: 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 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{CONF_EMAIL: "aseko@example.com", CONF_PASSWORD: "passw0rd"}, {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( @pytest.mark.parametrize(
("error_web", "reason"), ("error_web", "reason"),
[ [
(APIUnavailable, "cannot_connect"), (AsekoAPIError, "cannot_connect"),
(InvalidAuthCredentials, "invalid_auth"), (AsekoInvalidCredentials, "invalid_auth"),
(Exception, "unknown"), (Exception, "unknown"),
], ],
) )
async def test_async_step_reauth_exception( async def test_async_step_reauth_exception(
hass: HomeAssistant, error_web: Exception, reason: str hass: HomeAssistant, user: User, error_web: Exception, reason: str
) -> None: ) -> None:
"""Test we get the form.""" """Test we get the form."""
@ -176,8 +182,8 @@ async def test_async_step_reauth_exception(
result = await mock_entry.start_reauth_flow(hass) result = await mock_entry.start_reauth_flow(hass)
with patch( with patch(
"homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", "homeassistant.components.aseko_pool_live.config_flow.Aseko.login",
return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), return_value=user,
side_effect=error_web, side_effect=error_web,
): ):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(

View File

@ -5,7 +5,6 @@ from __future__ import annotations
from typing import Any from typing import Any
from unittest.mock import Mock from unittest.mock import Mock
from homeassistant.components.climate import PRESET_COMFORT, PRESET_ECO
from homeassistant.components.fritzbox.const import DOMAIN from homeassistant.components.fritzbox.const import DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -110,9 +109,7 @@ class FritzDeviceClimateMock(FritzEntityBaseMock):
target_temperature = 19.5 target_temperature = 19.5
window_open = "fake_window" window_open = "fake_window"
nextchange_temperature = 22.0 nextchange_temperature = 22.0
nextchange_endperiod = 0 nextchange_endperiod = 1726855200
nextchange_preset = PRESET_COMFORT
scheduled_preset = PRESET_ECO
class FritzDeviceClimateWithoutTempSensorMock(FritzDeviceClimateMock): 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" f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_change_time"
) )
assert state assert state
assert state.state == "1970-01-01T00:00:00+00:00" assert state.state == "2024-09-20T18:00:00+00:00"
assert ( assert (
state.attributes[ATTR_FRIENDLY_NAME] state.attributes[ATTR_FRIENDLY_NAME]
== f"{CONF_FAKE_NAME} Next scheduled change time" == f"{CONF_FAKE_NAME} Next scheduled change time"

View File

@ -3,8 +3,10 @@
from datetime import timedelta from datetime import timedelta
from unittest.mock import Mock from unittest.mock import Mock
import pytest
from requests.exceptions import HTTPError 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.fritzbox.const import DOMAIN as FB_DOMAIN
from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN, SensorStateClass from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN, SensorStateClass
from homeassistant.const import ( from homeassistant.const import (
@ -12,6 +14,7 @@ from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT, ATTR_UNIT_OF_MEASUREMENT,
CONF_DEVICES, CONF_DEVICES,
PERCENTAGE, PERCENTAGE,
STATE_UNKNOWN,
EntityCategory, EntityCategory,
UnitOfTemperature, UnitOfTemperature,
) )
@ -19,7 +22,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
import homeassistant.util.dt as dt_util 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 .const import CONF_FAKE_NAME, MOCK_CONFIG
from tests.common import async_fire_time_changed 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") state = hass.states.get(f"{DOMAIN}.new_device_temperature")
assert state 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 ## 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` - `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)` - `knx.assert_telegram_count(count: int)`
Asserts that `count` telegrams were sent. 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`. 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`. 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`. Asserts that a GroupValueResponse 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.
- `knx.assert_write(group_address: str, payload: int | tuple[int, ...])` - `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`. 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. Change some states or call some services and assert outgoing telegrams.

View File

@ -57,9 +57,9 @@ class KNXTestKit:
self.hass: HomeAssistant = hass self.hass: HomeAssistant = hass
self.mock_config_entry: MockConfigEntry = mock_config_entry self.mock_config_entry: MockConfigEntry = mock_config_entry
self.xknx: XKNX 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 # 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: def assert_state(self, entity_id: str, state: str, **attributes) -> None:
"""Assert the state of an entity.""" """Assert the state of an entity."""
@ -76,7 +76,7 @@ class KNXTestKit:
async def patch_xknx_start(): async def patch_xknx_start():
"""Patch `xknx.start` for unittests.""" """Patch `xknx.start` for unittests."""
self.xknx.cemi_handler.send_telegram = AsyncMock( 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 # after XKNX.__init__() to not overwrite it by the config entry again
# before StateUpdater starts to avoid slow down of tests # before StateUpdater starts to avoid slow down of tests
@ -117,24 +117,22 @@ class KNXTestKit:
######################## ########################
def _list_remaining_telegrams(self) -> str: def _list_remaining_telegrams(self) -> str:
"""Return a string containing remaining outgoing telegrams in test Queue. One per line.""" """Return a string containing remaining outgoing telegrams in test List."""
remaining_telegrams = [] return "\n".join(map(str, self._outgoing_telegrams))
while not self._outgoing_telegrams.empty():
remaining_telegrams.append(self._outgoing_telegrams.get_nowait())
return "\n".join(map(str, remaining_telegrams))
async def assert_no_telegram(self) -> None: 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() await self.hass.async_block_till_done()
assert self._outgoing_telegrams.empty(), ( remaining_telegram_count = len(self._outgoing_telegrams)
f"Found remaining unasserted Telegrams: {self._outgoing_telegrams.qsize()}\n" assert not remaining_telegram_count, (
f"Found remaining unasserted Telegrams: {remaining_telegram_count}\n"
f"{self._list_remaining_telegrams()}" f"{self._list_remaining_telegrams()}"
) )
async def assert_telegram_count(self, count: int) -> None: 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() await self.hass.async_block_till_done()
actual_count = self._outgoing_telegrams.qsize() actual_count = len(self._outgoing_telegrams)
assert actual_count == count, ( assert actual_count == count, (
f"Outgoing telegrams: {actual_count} - Expected: {count}\n" f"Outgoing telegrams: {actual_count} - Expected: {count}\n"
f"{self._list_remaining_telegrams()}" f"{self._list_remaining_telegrams()}"
@ -149,52 +147,79 @@ class KNXTestKit:
group_address: str, group_address: str,
payload: int | tuple[int, ...] | None, payload: int | tuple[int, ...] | None,
apci_type: type[APCI], apci_type: type[APCI],
ignore_order: bool = False,
) -> None: ) -> None:
"""Assert outgoing telegram. One by one in timely order.""" """Assert outgoing telegram. Optionally in timely order."""
await self.xknx.telegrams.join() await self.xknx.telegrams.join()
try: if not self._outgoing_telegrams:
telegram = self._outgoing_telegrams.get_nowait()
except asyncio.QueueEmpty as err:
raise AssertionError( raise AssertionError(
f"No Telegram found. Expected: {apci_type.__name__} -" f"No Telegram found. Expected: {apci_type.__name__} -"
f" {group_address} - {payload}" 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( assert isinstance(
telegram.payload, apci_type telegram.payload, apci_type
), f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" ), f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}"
assert ( assert (
str(telegram.destination_address) == group_address telegram.destination_address == _expected_ga
), f"Group address mismatch in {telegram} - Expected: {group_address}" ), f"Group address mismatch in {telegram} - Expected: {group_address}"
if payload is not None: if payload is not None:
assert ( assert (
telegram.payload.value.value == payload # type: ignore[attr-defined] telegram.payload.value.value == payload # type: ignore[attr-defined]
), f"Payload mismatch in {telegram} - Expected: {payload}" ), f"Payload mismatch in {telegram} - Expected: {payload}"
async def assert_read( 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: ) -> 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. 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: if response is not None:
await self.receive_response(group_address, response) await self.receive_response(group_address, response)
async def assert_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: ) -> None:
"""Assert outgoing GroupValueResponse telegram. One by one in timely order.""" """Assert outgoing GroupValueResponse telegram. Optionally in timely order."""
await self.assert_telegram(group_address, payload, GroupValueResponse) await self.assert_telegram(
group_address, payload, GroupValueResponse, ignore_order
)
async def assert_write( 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: ) -> None:
"""Assert outgoing GroupValueWrite telegram. One by one in timely order.""" """Assert outgoing GroupValueWrite telegram. Optionally in timely order."""
await self.assert_telegram(group_address, payload, GroupValueWrite) await self.assert_telegram(
group_address, payload, GroupValueWrite, ignore_order
)
#################### ####################
# Incoming telegrams # 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({}) await knx.setup_integration({})
client = await hass_ws_client(hass) 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") assert hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"].get("switch")
test_device = device_registry.async_get_device( test_device = device_registry.async_get_device(

View File

@ -19,8 +19,9 @@ from homeassistant.components.light import (
ATTR_RGBW_COLOR, ATTR_RGBW_COLOR,
ColorMode, 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.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import KnxEntityGenerator from . import KnxEntityGenerator
from .conftest import KNXTestKit from .conftest import KNXTestKit
@ -1159,7 +1160,7 @@ async def test_light_ui_create(
knx: KNXTestKit, knx: KNXTestKit,
create_ui_entity: KnxEntityGenerator, create_ui_entity: KnxEntityGenerator,
) -> None: ) -> None:
"""Test creating a switch.""" """Test creating a light."""
await knx.setup_integration({}) await knx.setup_integration({})
await create_ui_entity( await create_ui_entity(
platform=Platform.LIGHT, platform=Platform.LIGHT,
@ -1192,7 +1193,7 @@ async def test_light_ui_color_temp(
color_temp_mode: str, color_temp_mode: str,
raw_ct: tuple[int, ...], raw_ct: tuple[int, ...],
) -> None: ) -> None:
"""Test creating a switch.""" """Test creating a color-temp light."""
await knx.setup_integration({}) await knx.setup_integration({})
await create_ui_entity( await create_ui_entity(
platform=Platform.LIGHT, platform=Platform.LIGHT,
@ -1218,3 +1219,23 @@ async def test_light_ui_color_temp(
state = hass.states.get("light.test") state = hass.states.get("light.test")
assert state.state is STATE_ON assert state.state is STATE_ON
assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == pytest.approx(4200, abs=1) 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 import config_entries
from homeassistant.components.surepetcare.const import DOMAIN from homeassistant.components.surepetcare.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType 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} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["errors"] is None assert not result["errors"]
with patch( with patch(
"homeassistant.components.surepetcare.async_setup_entry", "homeassistant.components.surepetcare.async_setup_entry",
@ -146,11 +147,17 @@ async def test_flow_entry_already_exists(
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
async def test_reauthentication(hass: HomeAssistant) -> None: async def test_reauthentication(
hass: HomeAssistant, surepetcare: NonCallableMagicMock
) -> None:
"""Test surepetcare reauthentication.""" """Test surepetcare reauthentication."""
old_entry = MockConfigEntry( old_entry = MockConfigEntry(
domain="surepetcare", domain="surepetcare",
data=INPUT_DATA, data={
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_TOKEN: "token",
},
unique_id="test-username", unique_id="test-username",
) )
old_entry.add_to_hass(hass) old_entry.add_to_hass(hass)
@ -161,19 +168,23 @@ async def test_reauthentication(hass: HomeAssistant) -> None:
assert result["errors"] == {} assert result["errors"] == {}
assert result["step_id"] == "reauth_confirm" assert result["step_id"] == "reauth_confirm"
with patch( surepetcare.get_token.return_value = "token2"
"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"],
result2 = await hass.config_entries.flow.async_configure( {"password": "test-password2"},
result["flow_id"], )
{"password": "test-password"}, await hass.async_block_till_done()
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.ABORT assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reauth_successful" 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: async def test_reauthentication_failure(hass: HomeAssistant) -> None:
"""Test surepetcare reauthentication failure.""" """Test surepetcare reauthentication failure."""