diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index fed4fafdc74b9..c0472131357df 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -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."] } diff --git a/homeassistant/components/aseko_pool_live/__init__.py b/homeassistant/components/aseko_pool_live/__init__.py index 5773b3eb5b913..5985af4d02346 100644 --- a/homeassistant/components/aseko_pool_live/__init__.py +++ b/homeassistant/components/aseko_pool_live/__init__.py @@ -4,13 +4,12 @@ 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 @@ 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 diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index 799535657697c..90be61b230df5 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -8,7 +8,6 @@ from aioaseko import Unit from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -25,26 +24,14 @@ 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) diff --git a/homeassistant/components/aseko_pool_live/config_flow.py b/homeassistant/components/aseko_pool_live/config_flow.py index ce6de3683d554..c0edee694bedc 100644 --- a/homeassistant/components/aseko_pool_live/config_flow.py +++ b/homeassistant/components/aseko_pool_live/config_flow.py @@ -6,12 +6,11 @@ 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 @@ async def async_step_user( 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 @@ async def async_step_reauth_confirm( 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") diff --git a/homeassistant/components/aseko_pool_live/coordinator.py b/homeassistant/components/aseko_pool_live/coordinator.py index a7f2d5ad5acea..eb7ccf9ec4226 100644 --- a/homeassistant/components/aseko_pool_live/coordinator.py +++ b/homeassistant/components/aseko_pool_live/coordinator.py @@ -5,34 +5,31 @@ 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} diff --git a/homeassistant/components/aseko_pool_live/entity.py b/homeassistant/components/aseko_pool_live/entity.py index 6f0979da2e7c6..038e0a175d357 100644 --- a/homeassistant/components/aseko_pool_live/entity.py +++ b/homeassistant/components/aseko_pool_live/entity.py @@ -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 ) diff --git a/homeassistant/components/aseko_pool_live/icons.json b/homeassistant/components/aseko_pool_live/icons.json index 2f8a77fc417c3..23a8459d8578c 100644 --- a/homeassistant/components/aseko_pool_live/icons.json +++ b/homeassistant/components/aseko_pool_live/icons.json @@ -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" } } } diff --git a/homeassistant/components/aseko_pool_live/manifest.json b/homeassistant/components/aseko_pool_live/manifest.json index a340408ad7116..628a973218813 100644 --- a/homeassistant/components/aseko_pool_live/manifest.json +++ b/homeassistant/components/aseko_pool_live/manifest.json @@ -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"] } diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index a4ddea9ad89c4..d140d2a474f2c 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -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.""" - - _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) +class AsekoSensorEntity(AsekoEntity, SensorEntity): + """Representation of an Aseko unit sensor entity.""" - 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) diff --git a/homeassistant/components/aseko_pool_live/strings.json b/homeassistant/components/aseko_pool_live/strings.json index 7f77b9ec69b4b..9ac341a7989a9 100644 --- a/homeassistant/components/aseko_pool_live/strings.json +++ b/homeassistant/components/aseko_pool_live/strings.json @@ -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" } diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index 07b9d0befe1c6..e11df6ad5ed4d 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -17,6 +17,7 @@ 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: diff --git a/homeassistant/components/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py index 76e4656129eef..a051e9ba3add1 100644 --- a/homeassistant/components/bang_olufsen/config_flow.py +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -14,6 +14,7 @@ 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 @@ async def async_step_user( 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 @@ async def async_step_zeroconf( 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: diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json index 3cc9fdb5cd11d..a93a6e7a6244c 100644 --- a/homeassistant/components/bang_olufsen/manifest.json +++ b/homeassistant/components/bang_olufsen/manifest.json @@ -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."] } diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 88c29a2043572..f6e9cb78efbcc 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -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."] } diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index d28727c01f5a6..dbfdc2f9c95e7 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -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 @@ def value_scheduled_preset(device: FritzhomeDevice) -> str: 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 @@ def value_scheduled_preset(device: FritzhomeDevice) -> str: 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", diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index b3241aa578712..0ac3ea2a4e2e5 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -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 @@ 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]: diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 0a2d98e71c529..30cfd34e0fb50 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -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"] } diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 9b733cb73d0cb..9678dc83e5f27 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -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"] } diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py index 874253b3324b8..a2ecfa8eb5296 100644 --- a/homeassistant/components/jvc_projector/coordinator.py +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +from typing import Any from jvcprojector import ( JvcProjector, @@ -40,7 +41,7 @@ def __init__(self, hass: HomeAssistant, device: JvcProjector) -> None: 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() diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index 5d83e93749481..f24ec4df51c60 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==1.0.12"] + "requirements": ["pyjvcprojector==1.1.0"] } diff --git a/homeassistant/components/knx/knx_entity.py b/homeassistant/components/knx/knx_entity.py index c81a6ee06db87..6574e5d5860dc 100644 --- a/homeassistant/components/knx/knx_entity.py +++ b/homeassistant/components/knx/knx_entity.py @@ -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 @@ def __init__(self, knx_module: KNXModule, device: XknxDevice) -> None: 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)}) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 0caa3f0a7992a..6abfd3fa9c8f3 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -20,7 +20,6 @@ ) 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 .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 @@ def _device_unique_id(self) -> str: 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)}) diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index ebe930957d6b2..64e21a4d2b3a5 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -18,7 +18,6 @@ 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 .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 @@ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: 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 @@ def __init__( 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)}) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index ff00e4ee495f0..4eec539c0db9e 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -190,48 +190,56 @@ def _update_from_device(self) -> None: # 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 diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index bf0fbcac406bf..443c8fdd991c2 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -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: diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index b1871e9dabb7f..fe30dc0eaa481 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -173,7 +173,9 @@ def __init__(self, netatmo_device: NetatmoDevice) -> None: 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 @@ def async_update_callback(self) -> None: 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 diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 77477ba790155..be0eed7ea2522 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -96,11 +96,10 @@ def __init__( 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 @@ async def async_update(self) -> None: 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.""" diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index cd65fb766e430..68de7e1d5e555 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -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] ) diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index 6626b1d6deedf..a993e9a47f17c 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -10,9 +10,8 @@ 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 @@ ) -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 @@ async def async_step_reauth( 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 @@ async def async_step_reauth_confirm( _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, ) diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index 74c1fdd52b148..bba678f754bc9 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -192,3 +192,10 @@ def _value(self) -> int: .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, {} + ) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 90b37cc1dace1..b63f6b905b497 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -379,18 +379,18 @@ class TeslemetryTimeEntityDescription(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 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription): suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetrySensorEntityDescription( key="vin", + value_fn=lambda vin: vin or "disconnected", ), ) @@ -525,13 +526,13 @@ def _async_update_attrs(self) -> None: 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 @@ def __init__( 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): diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 48eb4aae8bcd1..29c9ef3bbb7d1 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -420,7 +420,10 @@ "name": "version" }, "vin": { - "name": "Vehicle" + "name": "Vehicle", + "state": { + "disconnected": "Disconnected" + } }, "vpp_backup_reserve_percent": { "name": "VPP backup reserve" diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 527364b6866c8..eb59d2456fb91 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.30.1"] + "requirements": ["pyTibber==0.30.2"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 297b20b8c0e40..1201354bab2ed 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.56"] + "requirements": ["holidays==0.57"] } diff --git a/homeassistant/const.py b/homeassistant/const.py index eec4530576d56..2b67710aab24c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ 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) diff --git a/pyproject.toml b/pyproject.toml index ee32a56651eb5..d1ebd40237079 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/requirements_all.txt b/requirements_all.txt index 71a87379f21a3..17857d4fada5c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1f4adafbb675..b13ca6df8053b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index ff83fdcc1112f..941369ff2660b 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -305,7 +305,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '48.0', + 'state': '47.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_led_bar_brightness-entry] @@ -912,7 +912,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '27.96', + 'state': '22.17', }) # --- # name: test_all_entities[indoor][sensor.airgradient_voc_index-entry] diff --git a/tests/components/aseko_pool_live/conftest.py b/tests/components/aseko_pool_live/conftest.py new file mode 100644 index 0000000000000..f3bbddb2cab11 --- /dev/null +++ b/tests/components/aseko_pool_live/conftest.py @@ -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, + ) diff --git a/tests/components/aseko_pool_live/test_config_flow.py b/tests/components/aseko_pool_live/test_config_flow.py index e4dedf36da471..de1bf0912f82d 100644 --- a/tests/components/aseko_pool_live/test_config_flow.py +++ b/tests/components/aseko_pool_live/test_config_flow.py @@ -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( diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index bd68615212db9..034b86497dbe7 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -5,7 +5,6 @@ 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): diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 358eeaa714eb1..2f1e0d3700144 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -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" diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 63d0b67d7f45d..094bec30b647a 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -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 @@ ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, PERCENTAGE, + STATE_UNKNOWN, EntityCategory, UnitOfTemperature, ) @@ -19,7 +22,12 @@ 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] diff --git a/tests/components/knx/README.md b/tests/components/knx/README.md index 8778feb2251cd..ef8398b3d17e7 100644 --- a/tests/components/knx/README.md +++ b/tests/components/knx/README.md @@ -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. diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 19f2bc4d845d7..c0ec1dd9b9a18 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -57,9 +57,9 @@ def __init__(self, hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> N 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 @@ async def setup_integration( 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 @@ def fish_xknx(*args, **kwargs): ######################## 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 @@ async def assert_telegram( 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 diff --git a/tests/components/knx/fixtures/config_store.json b/tests/components/knx/fixtures/config_store.json index 971b692ade138..5eabcfa87f9f3 100644 --- a/tests/components/knx/fixtures/config_store.json +++ b/tests/components/knx/fixtures/config_store.json @@ -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 + } + } + } } } } diff --git a/tests/components/knx/test_device.py b/tests/components/knx/test_device.py index 330fd854a5022..04ff02f06112d 100644 --- a/tests/components/knx/test_device.py +++ b/tests/components/knx/test_device.py @@ -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( diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index e2e4a673a0d9f..88f76a163d585 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -19,8 +19,9 @@ 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 diff --git a/tests/components/surepetcare/test_config_flow.py b/tests/components/surepetcare/test_config_flow.py index c4055ebe65857..1140a2c54ef8d 100644 --- a/tests/components/surepetcare/test_config_flow.py +++ b/tests/components/surepetcare/test_config_flow.py @@ -6,6 +6,7 @@ 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."""