From fbc456c4c7dd4e56f8344ecb0d43b43ad4962aac Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 28 Feb 2024 20:52:40 +0100 Subject: [PATCH 01/95] Bump version to 2024.3.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index cd68b98b2e3..d8a3651ab04 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 003cb73231e..f9a3ad007a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.3.0.dev0" +version = "2024.3.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 27b5a79fa51b67dfa0cea8a569f9dde88c56a6e7 Mon Sep 17 00:00:00 2001 From: Jeremy TRUFIER Date: Wed, 28 Feb 2024 23:16:03 +0100 Subject: [PATCH 02/95] Add overkiz support for Atlantic Shogun ZoneControl 2.0 (AtlanticPassAPCHeatingAndCoolingZone) (#110510) * Add Overkiz support for AtlanticPassAPCHeatingAndCoolingZone widget * Add support for AUTO HVAC mode for Atlantic Pass APC ZC devices that support it * Add support for multiple IO controllers for same widget (mainly for Atlantic APC) * Implement PR feedback * Small PR fixes * Fix constant inversion typo --- homeassistant/components/overkiz/climate.py | 16 ++ .../overkiz/climate_entities/__init__.py | 27 +- .../atlantic_pass_apc_heating_zone.py | 10 +- .../atlantic_pass_apc_zone_control.py | 47 +++- .../atlantic_pass_apc_zone_control_zone.py | 252 ++++++++++++++++++ 5 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py diff --git a/homeassistant/components/overkiz/climate.py b/homeassistant/components/overkiz/climate.py index b6d31a8e685..2c24ca4f832 100644 --- a/homeassistant/components/overkiz/climate.py +++ b/homeassistant/components/overkiz/climate.py @@ -1,6 +1,8 @@ """Support for Overkiz climate devices.""" from __future__ import annotations +from typing import cast + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -8,8 +10,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData from .climate_entities import ( + WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY, WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY, WIDGET_TO_CLIMATE_ENTITY, + Controllable, ) from .const import DOMAIN @@ -28,6 +32,18 @@ async def async_setup_entry( if device.widget in WIDGET_TO_CLIMATE_ENTITY ) + # Match devices based on the widget and controllableName + # This is for example used for Atlantic APC, where devices with different functionality share the same uiClass and widget. + async_add_entities( + WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget][ + cast(Controllable, device.controllable_name) + ](device.device_url, data.coordinator) + for device in data.platforms[Platform.CLIMATE] + if device.widget in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY + and device.controllable_name + in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget] + ) + # Hitachi Air To Air Heat Pumps async_add_entities( WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol]( diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index c74ff2829cc..331823c594a 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -1,4 +1,6 @@ """Climate entities for the Overkiz (by Somfy) integration.""" +from enum import StrEnum, unique + from pyoverkiz.enums import Protocol from pyoverkiz.enums.ui import UIWidget @@ -10,18 +12,30 @@ from .atlantic_electrical_towel_dryer import AtlanticElectricalTowelDryer from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl +from .atlantic_pass_apc_zone_control_zone import AtlanticPassAPCZoneControlZone from .hitachi_air_to_air_heat_pump_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI from .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface from .somfy_thermostat import SomfyThermostat from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface + +@unique +class Controllable(StrEnum): + """Enum for widget controllables.""" + + IO_ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE = ( + "io:AtlanticPassAPCHeatingAndCoolingZoneComponent" + ) + IO_ATLANTIC_PASS_APC_ZONE_CONTROL_ZONE = ( + "io:AtlanticPassAPCZoneControlZoneComponent" + ) + + WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, UIWidget.ATLANTIC_ELECTRICAL_HEATER_WITH_ADJUSTABLE_TEMPERATURE_SETPOINT: AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint, UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: AtlanticElectricalTowelDryer, UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: AtlanticHeatRecoveryVentilation, - # ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE works exactly the same as ATLANTIC_PASS_APC_HEATING_ZONE - UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, @@ -29,6 +43,15 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, } +# For Atlantic APC, some devices are standalone and control themselves, some others needs to be +# managed by a ZoneControl device. Widget name is the same in the two cases. +WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY = { + UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: { + Controllable.IO_ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: AtlanticPassAPCHeatingZone, + Controllable.IO_ATLANTIC_PASS_APC_ZONE_CONTROL_ZONE: AtlanticPassAPCZoneControlZone, + } +} + # Hitachi air-to-air heatpumps come in 2 flavors (HLRRWIFI and OVP) that are separated in 2 classes WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: { diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py index 25dab7c1d7e..157ec72a249 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py @@ -49,7 +49,15 @@ OVERKIZ_TO_PRESET_MODES: dict[str, str] = { OverkizCommandParam.INTERNAL_SCHEDULING: PRESET_HOME, } -PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} +PRESET_MODES_TO_OVERKIZ: dict[str, str] = { + PRESET_COMFORT: OverkizCommandParam.COMFORT, + PRESET_AWAY: OverkizCommandParam.ABSENCE, + PRESET_ECO: OverkizCommandParam.ECO, + PRESET_FROST_PROTECTION: OverkizCommandParam.FROSTPROTECTION, + PRESET_EXTERNAL: OverkizCommandParam.EXTERNAL_SCHEDULING, + PRESET_HOME: OverkizCommandParam.INTERNAL_SCHEDULING, +} + OVERKIZ_TO_PROFILE_MODES: dict[str, str] = { OverkizCommandParam.OFF: PRESET_SLEEP, diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py index fe9f20b05fc..cfb92067875 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py @@ -10,6 +10,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import UnitOfTemperature +from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { @@ -25,16 +26,48 @@ HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): """Representation of Atlantic Pass APC Zone Control.""" - _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) _enable_turn_on_off_backwards_compatibility = False + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + self._attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] + + # Cooling is supported by a separate command + if self.is_auto_hvac_mode_available: + self._attr_hvac_modes.append(HVACMode.AUTO) + + @property + def is_auto_hvac_mode_available(self) -> bool: + """Check if auto mode is available on the ZoneControl.""" + + return self.executor.has_command( + OverkizCommand.SET_HEATING_COOLING_AUTO_SWITCH + ) and self.executor.has_state(OverkizState.CORE_HEATING_COOLING_AUTO_SWITCH) + @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" + + if ( + self.is_auto_hvac_mode_available + and cast( + str, + self.executor.select_state( + OverkizState.CORE_HEATING_COOLING_AUTO_SWITCH + ), + ) + == OverkizCommandParam.ON + ): + return HVACMode.AUTO + return OVERKIZ_TO_HVAC_MODE[ cast( str, self.executor.select_state(OverkizState.IO_PASS_APC_OPERATING_MODE) @@ -43,6 +76,18 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" + + if self.is_auto_hvac_mode_available: + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_COOLING_AUTO_SWITCH, + OverkizCommandParam.ON + if hvac_mode == HVACMode.AUTO + else OverkizCommandParam.OFF, + ) + + if hvac_mode == HVACMode.AUTO: + return + await self.executor.async_execute_command( OverkizCommand.SET_PASS_APC_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] ) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py new file mode 100644 index 00000000000..a30cb93f287 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py @@ -0,0 +1,252 @@ +"""Support for Atlantic Pass APC Heating Control.""" +from __future__ import annotations + +from asyncio import sleep +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import PRESET_NONE, HVACMode +from homeassistant.const import ATTR_TEMPERATURE + +from ..coordinator import OverkizDataUpdateCoordinator +from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone +from .atlantic_pass_apc_zone_control import OVERKIZ_TO_HVAC_MODE + +PRESET_SCHEDULE = "schedule" +PRESET_MANUAL = "manual" + +OVERKIZ_MODE_TO_PRESET_MODES: dict[str, str] = { + OverkizCommandParam.MANU: PRESET_MANUAL, + OverkizCommandParam.INTERNAL_SCHEDULING: PRESET_SCHEDULE, +} + +PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_MODE_TO_PRESET_MODES.items()} + +TEMPERATURE_ZONECONTROL_DEVICE_INDEX = 1 + + +# Those device depends on a main probe that choose the operating mode (heating, cooling, ...) +class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone): + """Representation of Atlantic Pass APC Heating And Cooling Zone Control.""" + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + # There is less supported functions, because they depend on the ZoneControl. + if not self.is_using_derogated_temperature_fallback: + # Modes are not configurable, they will follow current HVAC Mode of Zone Control. + self._attr_hvac_modes = [] + + # Those are available and tested presets on Shogun. + self._attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + + # Those APC Heating and Cooling probes depends on the zone control device (main probe). + # Only the base device (#1) can be used to get/set some states. + # Like to retrieve and set the current operating mode (heating, cooling, drying, off). + self.zone_control_device = self.executor.linked_device( + TEMPERATURE_ZONECONTROL_DEVICE_INDEX + ) + + @property + def is_using_derogated_temperature_fallback(self) -> bool: + """Check if the device behave like the Pass APC Heating Zone.""" + + return self.executor.has_command( + OverkizCommand.SET_DEROGATED_TARGET_TEMPERATURE + ) + + @property + def zone_control_hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool, dry, off mode.""" + + if ( + state := self.zone_control_device.states[ + OverkizState.IO_PASS_APC_OPERATING_MODE + ] + ) is not None and (value := state.value_as_str) is not None: + return OVERKIZ_TO_HVAC_MODE[value] + return HVACMode.OFF + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool, dry, off mode.""" + + if self.is_using_derogated_temperature_fallback: + return super().hvac_mode + + zone_control_hvac_mode = self.zone_control_hvac_mode + + # Should be same, because either thermostat or this integration change both. + on_off_state = cast( + str, + self.executor.select_state( + OverkizState.CORE_COOLING_ON_OFF + if zone_control_hvac_mode == HVACMode.COOL + else OverkizState.CORE_HEATING_ON_OFF + ), + ) + + # Device is Stopped, it means the air flux is flowing but its venting door is closed. + if on_off_state == OverkizCommandParam.OFF: + hvac_mode = HVACMode.OFF + else: + hvac_mode = zone_control_hvac_mode + + # It helps keep it consistent with the Zone Control, within the interface. + if self._attr_hvac_modes != [zone_control_hvac_mode, HVACMode.OFF]: + self._attr_hvac_modes = [zone_control_hvac_mode, HVACMode.OFF] + self.async_write_ha_state() + + return hvac_mode + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + + if self.is_using_derogated_temperature_fallback: + return await super().async_set_hvac_mode(hvac_mode) + + # They are mainly managed by the Zone Control device + # However, it make sense to map the OFF Mode to the Overkiz STOP Preset + + if hvac_mode == HVACMode.OFF: + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_ON_OFF, + OverkizCommandParam.OFF, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_ON_OFF, + OverkizCommandParam.OFF, + ) + else: + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_ON_OFF, + OverkizCommandParam.ON, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_ON_OFF, + OverkizCommandParam.ON, + ) + + await self.async_refresh_modes() + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., schedule, manual.""" + + if self.is_using_derogated_temperature_fallback: + return super().preset_mode + + mode = OVERKIZ_MODE_TO_PRESET_MODES[ + cast( + str, + self.executor.select_state( + OverkizState.IO_PASS_APC_COOLING_MODE + if self.zone_control_hvac_mode == HVACMode.COOL + else OverkizState.IO_PASS_APC_HEATING_MODE + ), + ) + ] + + return mode if mode is not None else PRESET_NONE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + + if self.is_using_derogated_temperature_fallback: + return await super().async_set_preset_mode(preset_mode) + + mode = PRESET_MODES_TO_OVERKIZ[preset_mode] + + # For consistency, it is better both are synced like on the Thermostat. + await self.executor.async_execute_command( + OverkizCommand.SET_PASS_APC_HEATING_MODE, mode + ) + await self.executor.async_execute_command( + OverkizCommand.SET_PASS_APC_COOLING_MODE, mode + ) + + await self.async_refresh_modes() + + @property + def target_temperature(self) -> float: + """Return hvac target temperature.""" + + if self.is_using_derogated_temperature_fallback: + return super().target_temperature + + if self.zone_control_hvac_mode == HVACMode.COOL: + return cast( + float, + self.executor.select_state( + OverkizState.CORE_COOLING_TARGET_TEMPERATURE + ), + ) + + if self.zone_control_hvac_mode == HVACMode.HEAT: + return cast( + float, + self.executor.select_state( + OverkizState.CORE_HEATING_TARGET_TEMPERATURE + ), + ) + + return cast( + float, self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE) + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + + if self.is_using_derogated_temperature_fallback: + return await super().async_set_temperature(**kwargs) + + temperature = kwargs[ATTR_TEMPERATURE] + + # Change both (heating/cooling) temperature is a good way to have consistency + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_TARGET_TEMPERATURE, + temperature, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_TARGET_TEMPERATURE, + temperature, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION_ON_OFF_STATE, + OverkizCommandParam.OFF, + ) + + # Target temperature may take up to 1 minute to get refreshed. + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE + ) + + async def async_refresh_modes(self) -> None: + """Refresh the device modes to have new states.""" + + # The device needs a bit of time to update everything before a refresh. + await sleep(2) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_HEATING_MODE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_HEATING_PROFILE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_COOLING_MODE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_COOLING_PROFILE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE + ) From 812afc1bd0df5b6f798d46b28db91e901424e0ae Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 28 Feb 2024 18:45:51 -0800 Subject: [PATCH 03/95] Fix calendar trigger to survive config entry reloads (#111334) * Fix calendar trigger to survive config entry reloads * Apply suggestions from code review --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/calendar/trigger.py | 31 ++++++--- tests/components/calendar/conftest.py | 40 ++++++----- tests/components/calendar/test_init.py | 7 +- tests/components/calendar/test_recorder.py | 9 ++- tests/components/calendar/test_trigger.py | 71 ++++++++++++++++++-- 5 files changed, 119 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index 073c41fc0df..e4fe5d22efd 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -91,11 +91,24 @@ EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]] QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]] -def event_fetcher(hass: HomeAssistant, entity: CalendarEntity) -> EventFetcher: +def get_entity(hass: HomeAssistant, entity_id: str) -> CalendarEntity: + """Get the calendar entity for the provided entity_id.""" + component: EntityComponent[CalendarEntity] = hass.data[DOMAIN] + if not (entity := component.get_entity(entity_id)) or not isinstance( + entity, CalendarEntity + ): + raise HomeAssistantError( + f"Entity does not exist {entity_id} or is not a calendar entity" + ) + return entity + + +def event_fetcher(hass: HomeAssistant, entity_id: str) -> EventFetcher: """Build an async_get_events wrapper to fetch events during a time span.""" async def async_get_events(timespan: Timespan) -> list[CalendarEvent]: """Return events active in the specified time span.""" + entity = get_entity(hass, entity_id) # Expand by one second to make the end time exclusive end_time = timespan.end + datetime.timedelta(seconds=1) return await entity.async_get_events(hass, timespan.start, end_time) @@ -237,7 +250,10 @@ class CalendarEventListener: self._dispatch_events(now) self._clear_event_listener() self._timespan = self._timespan.next_upcoming(now, UPDATE_INTERVAL) - self._events.extend(await self._fetcher(self._timespan)) + try: + self._events.extend(await self._fetcher(self._timespan)) + except HomeAssistantError as ex: + _LOGGER.error("Calendar trigger failed to fetch events: %s", ex) self._listen_next_calendar_event() @@ -252,13 +268,8 @@ async def async_attach_trigger( event_type = config[CONF_EVENT] offset = config[CONF_OFFSET] - component: EntityComponent[CalendarEntity] = hass.data[DOMAIN] - if not (entity := component.get_entity(entity_id)) or not isinstance( - entity, CalendarEntity - ): - raise HomeAssistantError( - f"Entity does not exist {entity_id} or is not a calendar entity" - ) + # Validate the entity id is valid + get_entity(hass, entity_id) trigger_data = { **trigger_info["trigger_data"], @@ -270,7 +281,7 @@ async def async_attach_trigger( hass, HassJob(action), trigger_data, - queued_event_fetcher(event_fetcher(hass, entity), event_type, offset), + queued_event_fetcher(event_fetcher(hass, entity_id), event_type, offset), ) await listener.async_attach() return listener.async_detach diff --git a/tests/components/calendar/conftest.py b/tests/components/calendar/conftest.py index f42cc6fd508..29d4bb9f5ff 100644 --- a/tests/components/calendar/conftest.py +++ b/tests/components/calendar/conftest.py @@ -99,8 +99,20 @@ def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: yield +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create a mock config entry.""" + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + return config_entry + + @pytest.fixture -def mock_setup_integration(hass: HomeAssistant, config_flow_fixture: None) -> None: +def mock_setup_integration( + hass: HomeAssistant, + config_flow_fixture: None, + test_entities: list[CalendarEntity], +) -> None: """Fixture to set up a mock integration.""" async def async_setup_entry_init( @@ -129,20 +141,16 @@ def mock_setup_integration(hass: HomeAssistant, config_flow_fixture: None) -> No ), ) - -async def create_mock_platform( - hass: HomeAssistant, - entities: list[CalendarEntity], -) -> MockConfigEntry: - """Create a calendar platform with the specified entities.""" - async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" - async_add_entities(entities) + new_entities = create_test_entities() + test_entities.clear() + test_entities.extend(new_entities) + async_add_entities(test_entities) mock_platform( hass, @@ -150,17 +158,15 @@ async def create_mock_platform( MockPlatform(async_setup_entry=async_setup_entry_platform), ) - config_entry = MockConfigEntry(domain=TEST_DOMAIN) - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - return config_entry - @pytest.fixture(name="test_entities") def mock_test_entities() -> list[MockCalendarEntity]: - """Fixture to create fake entities used in the test.""" + """Fixture that holdes the fake entities created during the test.""" + return [] + + +def create_test_entities() -> list[MockCalendarEntity]: + """Create test entities used during the test.""" half_hour_from_now = dt_util.now() + datetime.timedelta(minutes=30) entity1 = MockCalendarEntity( "Calendar 1", diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 52d5855271d..d786ce8d8ad 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.issue_registry import IssueRegistry import homeassistant.util.dt as dt_util -from .conftest import TEST_DOMAIN, MockCalendarEntity, create_mock_platform +from .conftest import TEST_DOMAIN, MockCalendarEntity, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -51,10 +51,11 @@ async def mock_setup_platform( set_time_zone: Any, frozen_time: Any, mock_setup_integration: Any, - test_entities: list[MockCalendarEntity], + config_entry: MockConfigEntry, ) -> None: """Fixture to setup platforms used in the test and fixtures are set up in the right order.""" - await create_mock_platform(hass, test_entities) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() async def test_events_http_api( diff --git a/tests/components/calendar/test_recorder.py b/tests/components/calendar/test_recorder.py index 441d757aa4e..ef6c7658a89 100644 --- a/tests/components/calendar/test_recorder.py +++ b/tests/components/calendar/test_recorder.py @@ -10,9 +10,7 @@ from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from .conftest import MockCalendarEntity, create_mock_platform - -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done @@ -22,10 +20,11 @@ async def mock_setup_dependencies( hass: HomeAssistant, set_time_zone: Any, mock_setup_integration: None, - test_entities: list[MockCalendarEntity], + config_entry: MockConfigEntry, ) -> None: """Fixture that ensures the recorder is setup in the right order.""" - await create_mock_platform(hass, test_entities) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() async def test_exclude_attributes(hass: HomeAssistant) -> None: diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 120d2e8bfca..0111f11c27b 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -27,9 +27,9 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import MockCalendarEntity, create_mock_platform +from .conftest import MockCalendarEntity -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service _LOGGER = logging.getLogger(__name__) @@ -105,10 +105,11 @@ def mock_test_entity(test_entities: list[MockCalendarEntity]) -> MockCalendarEnt async def mock_setup_platform( hass: HomeAssistant, mock_setup_integration: Any, - test_entities: list[MockCalendarEntity], + config_entry: MockConfigEntry, ) -> None: """Fixture to setup platforms used in the test.""" - await create_mock_platform(hass, test_entities) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() @asynccontextmanager @@ -745,3 +746,65 @@ async def test_event_start_trigger_dst( "calendar_event": event3_data, }, ] + + +async def test_config_entry_reload( + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, + test_entities: list[MockCalendarEntity], + setup_platform: None, + config_entry: MockConfigEntry, +) -> None: + """Test the a calendar trigger after a config entry reload. + + This sets ups a config entry, sets up an automation for an entity in that + config entry, then reloads the config entry. This reproduces a bug where + the automation kept a reference to the specific entity which would be + invalid after a config entry was reloaded. + """ + async with create_automation(hass, EVENT_START): + assert len(calls()) == 0 + + assert await hass.config_entries.async_reload(config_entry.entry_id) + + # Ensure the reloaded entity has events upcoming. + test_entity = test_entities[1] + event_data = test_entity.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + ) + + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), + ) + + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data, + } + ] + + +async def test_config_entry_unload( + hass: HomeAssistant, + calls: Callable[[], list[dict[str, Any]]], + fake_schedule: FakeSchedule, + test_entities: list[MockCalendarEntity], + setup_platform: None, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test an automation that references a calendar entity that is unloaded.""" + async with create_automation(hass, EVENT_START): + assert len(calls()) == 0 + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), + ) + + assert "Entity does not exist calendar.calendar_2" in caplog.text From e1be10994738c70f3092c2e70d4bebe96dc6afa3 Mon Sep 17 00:00:00 2001 From: yanuino <36410910+yanuino@users.noreply.github.com> Date: Wed, 28 Feb 2024 22:32:46 +0100 Subject: [PATCH 04/95] Read min/max number of showers from state for DomesticHotWaterProduction in Overkiz integration (#111535) * Read min/max number of showers from state * Rewrite code for Read min/max number of showers from state * Set _attr_ instead of inherited value --- homeassistant/components/overkiz/number.py | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index c15a7bd3acc..b53dbb5db75 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -20,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES +from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity BOOST_MODE_DURATION_DELAY = 1 @@ -37,6 +38,8 @@ class OverkizNumberDescriptionMixin: class OverkizNumberDescription(NumberEntityDescription, OverkizNumberDescriptionMixin): """Class to describe an Overkiz number.""" + min_value_state_name: str | None = None + max_value_state_name: str | None = None inverted: bool = False set_native_value: Callable[ [float, Callable[..., Awaitable[None]]], Awaitable[None] @@ -94,6 +97,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ command=OverkizCommand.SET_EXPECTED_NUMBER_OF_SHOWER, native_min_value=2, native_max_value=4, + min_value_state_name=OverkizState.CORE_MINIMAL_SHOWER_MANUAL_MODE, + max_value_state_name=OverkizState.CORE_MAXIMAL_SHOWER_MANUAL_MODE, entity_category=EntityCategory.CONFIG, ), # SomfyHeatingTemperatureInterface @@ -200,6 +205,29 @@ class OverkizNumber(OverkizDescriptiveEntity, NumberEntity): entity_description: OverkizNumberDescription + def __init__( + self, + device_url: str, + coordinator: OverkizDataUpdateCoordinator, + description: OverkizNumberDescription, + ) -> None: + """Initialize a device.""" + super().__init__(device_url, coordinator, description) + + if self.entity_description.min_value_state_name and ( + state := self.device.states.get( + self.entity_description.min_value_state_name + ) + ): + self._attr_native_min_value = cast(float, state.value) + + if self.entity_description.max_value_state_name and ( + state := self.device.states.get( + self.entity_description.max_value_state_name + ) + ): + self._attr_native_max_value = cast(float, state.value) + @property def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" From feea4071d0b653b1d7b527f898621c87353fada2 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 28 Feb 2024 17:20:19 -0500 Subject: [PATCH 05/95] Improve ZHA group color modes (#111669) * Set the color mode based on supported color modes * Replace `zha` with `tuya` in unit test --- homeassistant/components/light/__init__.py | 4 +-- homeassistant/components/zha/light.py | 40 +++++++++++++++------- tests/components/light/test_init.py | 2 +- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 562789abd26..795975b5c3e 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -1336,5 +1336,5 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return if light color mode issues should be reported.""" if not self.platform: return True - # philips_js, tuya and zha have known issues, we don't need users to open issues - return self.platform.platform_name not in {"philips_js", "tuya", "zha"} + # philips_js and tuya have known issues, we don't need users to open issues + return self.platform.platform_name not in {"philips_js", "tuya"} diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 84399f3da32..aa117c7ef9b 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -1185,7 +1185,7 @@ class LightGroup(BaseLight, ZhaGroupEntity): self._zha_config_enhanced_light_transition = False self._attr_color_mode = ColorMode.UNKNOWN - self._attr_supported_color_modes = set() + self._attr_supported_color_modes = {ColorMode.ONOFF} # remove this when all ZHA platforms and base entities are updated @property @@ -1285,6 +1285,19 @@ class LightGroup(BaseLight, ZhaGroupEntity): effects_count = Counter(itertools.chain(all_effects)) self._attr_effect = effects_count.most_common(1)[0][0] + supported_color_modes = {ColorMode.ONOFF} + all_supported_color_modes: list[set[ColorMode]] = list( + helpers.find_state_attributes(states, light.ATTR_SUPPORTED_COLOR_MODES) + ) + if all_supported_color_modes: + # Merge all color modes. + supported_color_modes = filter_supported_color_modes( + set().union(*all_supported_color_modes) + ) + + self._attr_supported_color_modes = supported_color_modes + + self._attr_color_mode = ColorMode.UNKNOWN all_color_modes = list( helpers.find_state_attributes(on_states, light.ATTR_COLOR_MODE) ) @@ -1292,25 +1305,26 @@ class LightGroup(BaseLight, ZhaGroupEntity): # Report the most common color mode, select brightness and onoff last color_mode_count = Counter(itertools.chain(all_color_modes)) if ColorMode.ONOFF in color_mode_count: - color_mode_count[ColorMode.ONOFF] = -1 + if ColorMode.ONOFF in supported_color_modes: + color_mode_count[ColorMode.ONOFF] = -1 + else: + color_mode_count.pop(ColorMode.ONOFF) if ColorMode.BRIGHTNESS in color_mode_count: - color_mode_count[ColorMode.BRIGHTNESS] = 0 - self._attr_color_mode = color_mode_count.most_common(1)[0][0] + if ColorMode.BRIGHTNESS in supported_color_modes: + color_mode_count[ColorMode.BRIGHTNESS] = 0 + else: + color_mode_count.pop(ColorMode.BRIGHTNESS) + if color_mode_count: + self._attr_color_mode = color_mode_count.most_common(1)[0][0] + else: + self._attr_color_mode = next(iter(supported_color_modes)) + if self._attr_color_mode == ColorMode.HS and ( color_mode_count[ColorMode.HS] != len(self._group.members) or self._zha_config_always_prefer_xy_color_mode ): # switch to XY if all members do not support HS self._attr_color_mode = ColorMode.XY - all_supported_color_modes: list[set[ColorMode]] = list( - helpers.find_state_attributes(states, light.ATTR_SUPPORTED_COLOR_MODES) - ) - if all_supported_color_modes: - # Merge all color modes. - self._attr_supported_color_modes = filter_supported_color_modes( - set().union(*all_supported_color_modes) - ) - self._attr_supported_features = LightEntityFeature(0) for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES): # Merge supported features by emulating support for every feature diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 4becbd87c1b..ca25611f890 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -2791,7 +2791,7 @@ def test_report_invalid_color_mode( ( light.ColorMode.ONOFF, {light.ColorMode.ONOFF, light.ColorMode.BRIGHTNESS}, - "zha", # We don't log issues for zha + "tuya", # We don't log issues for tuya False, ), ], From acfd1c27556ca8a9c9bff39a309e464b95594dbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 15:40:16 -1000 Subject: [PATCH 06/95] Pre-import api, config, and lovelace in bootstrap to avoid loading them at runtime (#111752) --- homeassistant/bootstrap.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 428220685eb..574bce49aa3 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -18,7 +18,18 @@ import voluptuous as vol import yarl from . import config as conf_util, config_entries, core, loader, requirements -from .components import http + +# Pre-import config and lovelace which have no requirements here to avoid +# loading them at run time and blocking the event loop. We do this ahead +# of time so that we do not have to flag frontends deps with `import_executor` +# as it would create a thundering heard of executor jobs trying to import +# frontend deps at the same time. +from .components import ( + api as api_pre_import, # noqa: F401 + config as config_pre_import, # noqa: F401 + http, + lovelace as lovelace_pre_import, # noqa: F401 +) from .const import ( FORMAT_DATETIME, REQUIRED_NEXT_PYTHON_HA_RELEASE, From a9fd113a80debd3ad036fa618222c729f3f6c0c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 14:09:51 -1000 Subject: [PATCH 07/95] Move DATA_LOGGING constant to homeassistant.const (#111763) --- homeassistant/bootstrap.py | 2 +- homeassistant/components/api/__init__.py | 2 +- homeassistant/const.py | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 574bce49aa3..2bac86ca05d 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -32,6 +32,7 @@ from .components import ( ) from .const import ( FORMAT_DATETIME, + KEY_DATA_LOGGING as DATA_LOGGING, REQUIRED_NEXT_PYTHON_HA_RELEASE, REQUIRED_NEXT_PYTHON_VER, SIGNAL_BOOTSTRAP_INTEGRATIONS, @@ -73,7 +74,6 @@ _LOGGER = logging.getLogger(__name__) ERROR_LOG_FILENAME = "home-assistant.log" # hass.data key for logging information. -DATA_LOGGING = "logging" DATA_REGISTRIES_LOADED = "bootstrap_registries_loaded" LOG_SLOW_STARTUP_INTERVAL = 60 diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index ad162d143dd..01a84cf606a 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -12,7 +12,6 @@ import voluptuous as vol from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ -from homeassistant.bootstrap import DATA_LOGGING from homeassistant.components.http import ( KEY_HASS, KEY_HASS_USER, @@ -23,6 +22,7 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, + KEY_DATA_LOGGING as DATA_LOGGING, MATCH_ALL, URL_API, URL_API_COMPONENTS, diff --git a/homeassistant/const.py b/homeassistant/const.py index d8a3651ab04..ff993e1342c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1602,6 +1602,11 @@ HASSIO_USER_NAME = "Supervisor" SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations" + +# hass.data key for logging information. +KEY_DATA_LOGGING = "logging" + + # Date/Time formats FORMAT_DATE: Final = "%Y-%m-%d" FORMAT_TIME: Final = "%H:%M:%S" From 99a70ba959765a0a0830537f49da7e4c023ca605 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 29 Feb 2024 01:01:11 +0100 Subject: [PATCH 08/95] Bump Python Matter Server to 5.7.0 (#111765) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index f8ce353f894..0e1ed4e80b6 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/matter", "import_executor": true, "iot_class": "local_push", - "requirements": ["python-matter-server==5.5.0"] + "requirements": ["python-matter-server==5.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8b456bcef33..872b0f0d7d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2251,7 +2251,7 @@ python-kasa[speedups]==0.6.2.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.5.0 +python-matter-server==5.7.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6dde980cb46..32c97206ba7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1727,7 +1727,7 @@ python-izone==1.2.9 python-kasa[speedups]==0.6.2.1 # homeassistant.components.matter -python-matter-server==5.5.0 +python-matter-server==5.7.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From cb7116126c11e14fc429f4cab1277fb433064440 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 12:39:19 -1000 Subject: [PATCH 09/95] Import isy994 in the executor to avoid blocking the event loop (#111766) --- homeassistant/components/isy994/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 3aa81027b4f..8c9815cd425 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -21,6 +21,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/isy994", + "import_executor": true, "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], From db584857c8d5ad0920a83c3a2ce5f96bf73eb0cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 14:00:17 -1000 Subject: [PATCH 10/95] Import cryptography early since importing openssl is not thread-safe (#111768) --- homeassistant/bootstrap.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 2bac86ca05d..1a26f8e25c7 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -14,6 +14,9 @@ import threading from time import monotonic from typing import TYPE_CHECKING, Any +# Import cryptography early since import openssl is not thread-safe +# _frozen_importlib._DeadlockError: deadlock detected by _ModuleLock('cryptography.hazmat.backends.openssl.backend') +import cryptography # noqa: F401 import voluptuous as vol import yarl From 4a66727bfffe1f5abe34f7f51208c9686c501df2 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 29 Feb 2024 00:59:44 +0100 Subject: [PATCH 11/95] Bump aiohue to 4.7.1 (#111770) bump aiohue to 4.7.1 --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 4cd6ca143cb..e8d214da3c8 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -11,6 +11,6 @@ "iot_class": "local_push", "loggers": ["aiohue"], "quality_scale": "platinum", - "requirements": ["aiohue==4.7.0"], + "requirements": ["aiohue==4.7.1"], "zeroconf": ["_hue._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 872b0f0d7d6..0035b29c0ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ aiohttp-zlib-ng==0.3.1 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.7.0 +aiohue==4.7.1 # homeassistant.components.imap aioimaplib==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 32c97206ba7..d647d11dab9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -252,7 +252,7 @@ aiohttp-zlib-ng==0.3.1 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.7.0 +aiohue==4.7.1 # homeassistant.components.imap aioimaplib==1.0.1 From 7ff6627e078e03c9c37466c0d2b753ed8858cbc4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 13:59:26 -1000 Subject: [PATCH 12/95] Import blink in the executor to avoid blocking the event loop (#111772) fixes #111771 --- homeassistant/components/blink/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 445a469b141..48db78b572c 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -18,6 +18,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/blink", + "import_executor": true, "iot_class": "cloud_polling", "loggers": ["blinkpy"], "requirements": ["blinkpy==0.22.6"] From 10a1a450a34c1d6b4c39f4eb188a681d431dfc1a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 13:59:13 -1000 Subject: [PATCH 13/95] Import coinbase in the executor to avoid blocking the event loop (#111774) fixes #111773 --- homeassistant/components/coinbase/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json index 515fe9f9abb..dbb40b24fcc 100644 --- a/homeassistant/components/coinbase/manifest.json +++ b/homeassistant/components/coinbase/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@tombrien"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/coinbase", + "import_executor": true, "iot_class": "cloud_polling", "loggers": ["coinbase"], "requirements": ["coinbase==2.1.0"] From c9ea72ba7dae17849831892933c78dc1d009f386 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 14:01:39 -1000 Subject: [PATCH 14/95] Import androidtv_remote in the executor to avoid blocking the event loop (#111776) fixes #111775 --- homeassistant/components/androidtv_remote/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index f45dee34afe..02197a61681 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@tronikos", "@Drafteed"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/androidtv_remote", + "import_executor": true, "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], From ff0fbcb30923e6c7874bcdb714b6abdcf2beb13b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 13:58:23 -1000 Subject: [PATCH 15/95] Import opower in the executor to avoid blocking the event loop (#111778) fixes #111777 --- homeassistant/components/opower/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 418f2a5723b..820aac5d20a 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", + "import_executor": true, "iot_class": "cloud_polling", "loggers": ["opower"], "requirements": ["opower==0.3.1"] From 536addc5ff9a8f3dc49a3d54bd47a4ca2e52b1e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 14:20:10 -1000 Subject: [PATCH 16/95] Import backup in the executor to avoid blocking the event loop (#111781) --- homeassistant/components/backup/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index afa4483e95a..2ba9cebb7dd 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@home-assistant/core"], "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/backup", + "import_executor": true, "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", From 7e80eb551e3387fd0108b3871b6503949e7c66a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 14:31:41 -1000 Subject: [PATCH 17/95] Bump securetar to 2024.2.1 (#111782) --- homeassistant/components/backup/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 2ba9cebb7dd..be75e4717ef 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2024.2.0"] + "requirements": ["securetar==2024.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0035b29c0ce..ef645cd7c95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2502,7 +2502,7 @@ screenlogicpy==0.10.0 scsgate==0.1.0 # homeassistant.components.backup -securetar==2024.2.0 +securetar==2024.2.1 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d647d11dab9..ef254d56aac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1915,7 +1915,7 @@ samsungtvws[async,encrypted]==2.6.0 screenlogicpy==0.10.0 # homeassistant.components.backup -securetar==2024.2.0 +securetar==2024.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense From 77fd02da1d9723c63891a6a8db48b02065c93d56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 14:51:27 -1000 Subject: [PATCH 18/95] Fix time trigger tests with leap year (#111785) --- .../homeassistant/triggers/test_time.py | 4 +- .../triggers/test_time_pattern.py | 40 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 513827b5432..ab5eb383f96 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -210,7 +210,7 @@ async def test_if_not_fires_using_wrong_at( now = dt_util.utcnow() time_that_will_not_match_right_away = now.replace( - year=now.year + 1, hour=1, minute=0, second=0 + year=now.year + 1, day=1, hour=1, minute=0, second=0 ) freezer.move_to(time_that_will_not_match_right_away) @@ -233,7 +233,7 @@ async def test_if_not_fires_using_wrong_at( assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE async_fire_time_changed( - hass, now.replace(year=now.year + 1, hour=1, minute=0, second=5) + hass, now.replace(year=now.year + 1, day=1, hour=1, minute=0, second=5) ) await hass.async_block_till_done() diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index 0f6a075eb6e..e505dd4f3f5 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -33,7 +33,7 @@ async def test_if_fires_when_hour_matches( """Test for firing if hour is matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( - year=now.year + 1, hour=3 + year=now.year + 1, day=1, hour=3 ) freezer.move_to(time_that_will_not_match_right_away) assert await async_setup_component( @@ -55,7 +55,7 @@ async def test_if_fires_when_hour_matches( }, ) - async_fire_time_changed(hass, now.replace(year=now.year + 2, hour=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 2, day=1, hour=0)) await hass.async_block_till_done() assert len(calls) == 1 @@ -66,7 +66,7 @@ async def test_if_fires_when_hour_matches( blocking=True, ) - async_fire_time_changed(hass, now.replace(year=now.year + 1, hour=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 1, day=1, hour=0)) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["id"] == 0 @@ -78,7 +78,7 @@ async def test_if_fires_when_minute_matches( """Test for firing if minutes are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( - year=now.year + 1, minute=30 + year=now.year + 1, day=1, minute=30 ) freezer.move_to(time_that_will_not_match_right_away) assert await async_setup_component( @@ -97,7 +97,7 @@ async def test_if_fires_when_minute_matches( }, ) - async_fire_time_changed(hass, now.replace(year=now.year + 2, minute=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 2, day=1, minute=0)) await hass.async_block_till_done() assert len(calls) == 1 @@ -109,7 +109,7 @@ async def test_if_fires_when_second_matches( """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( - year=now.year + 1, second=30 + year=now.year + 1, day=1, second=30 ) freezer.move_to(time_that_will_not_match_right_away) assert await async_setup_component( @@ -128,7 +128,7 @@ async def test_if_fires_when_second_matches( }, ) - async_fire_time_changed(hass, now.replace(year=now.year + 2, second=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 2, day=1, second=0)) await hass.async_block_till_done() assert len(calls) == 1 @@ -140,7 +140,7 @@ async def test_if_fires_when_second_as_string_matches( """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( - year=now.year + 1, second=15 + year=now.year + 1, day=1, second=15 ) freezer.move_to(time_that_will_not_match_right_away) assert await async_setup_component( @@ -173,7 +173,7 @@ async def test_if_fires_when_all_matches( """Test for firing if everything matches.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( - year=now.year + 1, hour=4 + year=now.year + 1, day=1, hour=4 ) freezer.move_to(time_that_will_not_match_right_away) assert await async_setup_component( @@ -193,7 +193,7 @@ async def test_if_fires_when_all_matches( ) async_fire_time_changed( - hass, now.replace(year=now.year + 2, hour=1, minute=2, second=3) + hass, now.replace(year=now.year + 2, day=1, hour=1, minute=2, second=3) ) await hass.async_block_till_done() @@ -206,7 +206,7 @@ async def test_if_fires_periodic_seconds( """Test for firing periodically every second.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( - year=now.year + 1, second=1 + year=now.year + 1, day=1, second=1 ) freezer.move_to(time_that_will_not_match_right_away) assert await async_setup_component( @@ -226,7 +226,7 @@ async def test_if_fires_periodic_seconds( ) async_fire_time_changed( - hass, now.replace(year=now.year + 2, hour=0, minute=0, second=10) + hass, now.replace(year=now.year + 2, day=1, hour=0, minute=0, second=10) ) await hass.async_block_till_done() @@ -240,7 +240,7 @@ async def test_if_fires_periodic_minutes( now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( - year=now.year + 1, minute=1 + year=now.year + 1, day=1, minute=1 ) freezer.move_to(time_that_will_not_match_right_away) assert await async_setup_component( @@ -260,7 +260,7 @@ async def test_if_fires_periodic_minutes( ) async_fire_time_changed( - hass, now.replace(year=now.year + 2, hour=0, minute=2, second=0) + hass, now.replace(year=now.year + 2, day=1, hour=0, minute=2, second=0) ) await hass.async_block_till_done() @@ -273,7 +273,7 @@ async def test_if_fires_periodic_hours( """Test for firing periodically every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( - year=now.year + 1, hour=1 + year=now.year + 1, day=1, hour=1 ) freezer.move_to(time_that_will_not_match_right_away) assert await async_setup_component( @@ -293,7 +293,7 @@ async def test_if_fires_periodic_hours( ) async_fire_time_changed( - hass, now.replace(year=now.year + 2, hour=2, minute=0, second=0) + hass, now.replace(year=now.year + 2, day=1, hour=2, minute=0, second=0) ) await hass.async_block_till_done() @@ -306,7 +306,7 @@ async def test_default_values( """Test for firing at 2 minutes every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( - year=now.year + 1, minute=1 + year=now.year + 1, day=1, minute=1 ) freezer.move_to(time_that_will_not_match_right_away) assert await async_setup_component( @@ -321,21 +321,21 @@ async def test_default_values( ) async_fire_time_changed( - hass, now.replace(year=now.year + 2, hour=1, minute=2, second=0) + hass, now.replace(year=now.year + 2, day=1, hour=1, minute=2, second=0) ) await hass.async_block_till_done() assert len(calls) == 1 async_fire_time_changed( - hass, now.replace(year=now.year + 2, hour=1, minute=2, second=1) + hass, now.replace(year=now.year + 2, day=1, hour=1, minute=2, second=1) ) await hass.async_block_till_done() assert len(calls) == 1 async_fire_time_changed( - hass, now.replace(year=now.year + 2, hour=2, minute=2, second=0) + hass, now.replace(year=now.year + 2, day=1, hour=2, minute=2, second=0) ) await hass.async_block_till_done() From 39deab74b3ea5bd2113e6c87cc301f53425caf7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 15:09:12 -1000 Subject: [PATCH 19/95] Import analytics_insights in the executor to avoid blocking the event loop (#111786) fixes #111780 --- homeassistant/components/analytics_insights/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/analytics_insights/manifest.json b/homeassistant/components/analytics_insights/manifest.json index d33bb23b1b7..b55e08a8141 100644 --- a/homeassistant/components/analytics_insights/manifest.json +++ b/homeassistant/components/analytics_insights/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@joostlek"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/analytics_insights", + "import_executor": true, "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["python_homeassistant_analytics"], From 209e61f734ca2ce6bc1c57d9afaa309fd110cf9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 16:16:38 -1000 Subject: [PATCH 20/95] Fix flux_led blocking startup by waiting for discovery (#111787) * Avoid blocking startup by waiting for discovery in flux_led * remove started discovery --- homeassistant/components/flux_led/__init__.py | 16 ++++++---------- tests/components/flux_led/test_init.py | 12 ++---------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 100d63d8bf7..2d9dddd3684 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -11,7 +11,7 @@ from flux_led.const import ATTR_ID, WhiteChannelType from flux_led.scanner import FluxLEDDiscovery from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( @@ -37,7 +37,6 @@ from .const import ( FLUX_LED_DISCOVERY_SIGNAL, FLUX_LED_EXCEPTIONS, SIGNAL_STATE_UPDATED, - STARTUP_SCAN_TIMEOUT, ) from .coordinator import FluxLedUpdateCoordinator from .discovery import ( @@ -89,24 +88,21 @@ def async_wifi_bulb_for_host( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the flux_led component.""" domain_data = hass.data.setdefault(DOMAIN, {}) - domain_data[FLUX_LED_DISCOVERY] = await async_discover_devices( - hass, STARTUP_SCAN_TIMEOUT - ) + domain_data[FLUX_LED_DISCOVERY] = [] @callback def _async_start_background_discovery(*_: Any) -> None: """Run discovery in the background.""" - hass.async_create_background_task(_async_discovery(), "flux_led-discovery") + hass.async_create_background_task( + _async_discovery(), "flux_led-discovery", eager_start=True + ) async def _async_discovery(*_: Any) -> None: async_trigger_discovery( hass, await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT) ) - async_trigger_discovery(hass, domain_data[FLUX_LED_DISCOVERY]) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, _async_start_background_discovery - ) + _async_start_background_discovery() async_track_time_interval( hass, _async_start_background_discovery, diff --git a/tests/components/flux_led/test_init.py b/tests/components/flux_led/test_init.py index 7c709bafe73..d75644c7599 100644 --- a/tests/components/flux_led/test_init.py +++ b/tests/components/flux_led/test_init.py @@ -19,7 +19,6 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_HOST, CONF_NAME, - EVENT_HOMEASSISTANT_STARTED, STATE_ON, STATE_UNAVAILABLE, ) @@ -57,13 +56,10 @@ async def test_configuring_flux_led_causes_discovery(hass: HomeAssistant) -> Non await hass.async_block_till_done() assert len(scan.mock_calls) == 1 - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - assert len(scan.mock_calls) == 2 async_fire_time_changed(hass, utcnow() + flux_led.DISCOVERY_INTERVAL) await hass.async_block_till_done() - assert len(scan.mock_calls) == 3 + assert len(scan.mock_calls) == 2 @pytest.mark.usefixtures("mock_multiple_broadcast_addresses") @@ -79,15 +75,11 @@ async def test_configuring_flux_led_causes_discovery_multiple_addresses( discover.return_value = [FLUX_DISCOVERY] await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) await hass.async_block_till_done() - assert len(scan.mock_calls) == 2 - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - assert len(scan.mock_calls) == 4 async_fire_time_changed(hass, utcnow() + flux_led.DISCOVERY_INTERVAL) await hass.async_block_till_done() - assert len(scan.mock_calls) == 6 + assert len(scan.mock_calls) == 4 async def test_config_entry_reload(hass: HomeAssistant) -> None: From 19837055bf16e5a49ba9eb6e9cb8a79003c9b0c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 16:34:08 -1000 Subject: [PATCH 21/95] Fix tplink blocking startup by waiting for discovery (#111788) * Fix tplink blocking statup by waiting for discovery * remove started --- homeassistant/components/tplink/__init__.py | 8 +++----- tests/components/tplink/test_init.py | 9 ++------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index e2342e617de..b8510f7ef81 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -28,7 +28,6 @@ from homeassistant.const import ( CONF_MODEL, CONF_PASSWORD, CONF_USERNAME, - EVENT_HOMEASSISTANT_STARTED, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -112,14 +111,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the TP-Link component.""" hass.data.setdefault(DOMAIN, {}) - if discovered_devices := await async_discover_devices(hass): - async_trigger_discovery(hass, discovered_devices) - async def _async_discovery(*_: Any) -> None: if discovered := await async_discover_devices(hass): async_trigger_discovery(hass, discovered) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_discovery) + hass.async_create_background_task( + _async_discovery(), "tplink first discovery", eager_start=True + ) async_track_time_interval( hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True ) diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 7bee7823013..4af4a80c927 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -17,7 +17,6 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, - EVENT_HOMEASSISTANT_STARTED, STATE_ON, STATE_UNAVAILABLE, ) @@ -52,17 +51,13 @@ async def test_configuring_tplink_causes_discovery(hass: HomeAssistant) -> None: call_count = len(discover.mock_calls) assert discover.mock_calls - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=15)) await hass.async_block_till_done() assert len(discover.mock_calls) == call_count * 2 - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=15)) - await hass.async_block_till_done() - assert len(discover.mock_calls) == call_count * 3 - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=30)) await hass.async_block_till_done() - assert len(discover.mock_calls) == call_count * 4 + assert len(discover.mock_calls) == call_count * 3 async def test_config_entry_reload(hass: HomeAssistant) -> None: From 3cd07aacaddd87dff185765fdbe612702398a8fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 16:00:47 -1000 Subject: [PATCH 22/95] Fix steamist blocking startup by waiting for discovery (#111789) Fix steamist blocking statup by waiting for discovery --- homeassistant/components/steamist/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/steamist/__init__.py b/homeassistant/components/steamist/__init__.py index ee46d644847..e84615b9352 100644 --- a/homeassistant/components/steamist/__init__.py +++ b/homeassistant/components/steamist/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from .const import DISCOVER_SCAN_TIMEOUT, DISCOVERY, DOMAIN, STARTUP_SCAN_TIMEOUT +from .const import DISCOVER_SCAN_TIMEOUT, DISCOVERY, DOMAIN from .coordinator import SteamistDataUpdateCoordinator from .discovery import ( async_discover_device, @@ -32,14 +32,16 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the steamist component.""" domain_data = hass.data.setdefault(DOMAIN, {}) - domain_data[DISCOVERY] = await async_discover_devices(hass, STARTUP_SCAN_TIMEOUT) + domain_data[DISCOVERY] = [] async def _async_discovery(*_: Any) -> None: async_trigger_discovery( hass, await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT) ) - async_trigger_discovery(hass, domain_data[DISCOVERY]) + hass.async_create_background_task( + _async_discovery(), "steamist-discovery", eager_start=True + ) async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) return True From 3d4291fc59565d61a2712abe104adf138252c08c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Feb 2024 16:02:25 -1000 Subject: [PATCH 23/95] Import discord in the executor to avoid blocking the event loop (#111790) `2024-02-28 19:20:04.485 DEBUG (MainThread) [homeassistant.loader] Component discord import took 1.181 seconds (loaded_executor=False)` --- homeassistant/components/discord/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 5f1ba2a13ef..78d4dc203e2 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@tkdrob"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/discord", + "import_executor": true, "integration_type": "service", "iot_class": "cloud_push", "loggers": ["discord"], From b19b5dc45129f6e7050019397c55a3dafc5d6a3e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 28 Feb 2024 22:25:33 -0600 Subject: [PATCH 24/95] Bump intents and add sentence tests (#111791) --- .../components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../test_default_agent_intents.py | 250 ++++++++++++++++++ 5 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 tests/components/conversation/test_default_agent_intents.py diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index e4317052b04..6f484941a3d 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.2.2"] + "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.2.28"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 69dd386482c..976c6c514f5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,7 +31,7 @@ hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240228.0 -home-assistant-intents==2024.2.2 +home-assistant-intents==2024.2.28 httpx==0.27.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index ef645cd7c95..a0c3ea77efa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1077,7 +1077,7 @@ holidays==0.43 home-assistant-frontend==20240228.0 # homeassistant.components.conversation -home-assistant-intents==2024.2.2 +home-assistant-intents==2024.2.28 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef254d56aac..692e4d397a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -876,7 +876,7 @@ holidays==0.43 home-assistant-frontend==20240228.0 # homeassistant.components.conversation -home-assistant-intents==2024.2.2 +home-assistant-intents==2024.2.28 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py new file mode 100644 index 00000000000..e796a6893a8 --- /dev/null +++ b/tests/components/conversation/test_default_agent_intents.py @@ -0,0 +1,250 @@ +"""Test intents for the default agent.""" + + +import pytest + +from homeassistant.components import conversation, cover, media_player, vacuum, valve +from homeassistant.components.cover import intent as cover_intent +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.media_player import intent as media_player_intent +from homeassistant.components.vacuum import intent as vaccum_intent +from homeassistant.components.valve import intent as valve_intent +from homeassistant.const import STATE_CLOSED +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service + + +@pytest.fixture +async def init_components(hass: HomeAssistant): + """Initialize relevant components with empty configs.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "intent", {}) + + +async def test_cover_set_position( + hass: HomeAssistant, + init_components, +) -> None: + """Test the open/close/set position for covers.""" + await cover_intent.async_setup_intents(hass) + + entity_id = f"{cover.DOMAIN}.garage_door" + hass.states.async_set(entity_id, STATE_CLOSED) + async_expose_entity(hass, conversation.DOMAIN, entity_id, True) + + # open + calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + result = await conversation.async_converse( + hass, "open the garage door", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Opened" + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id} + + # close + calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER) + result = await conversation.async_converse( + hass, "close garage door", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Closed" + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id} + + # set position + calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + result = await conversation.async_converse( + hass, "set garage door to 50%", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Position set" + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id, cover.ATTR_POSITION: 50} + + +async def test_valve_intents( + hass: HomeAssistant, + init_components, +) -> None: + """Test open/close/set position for valves.""" + await valve_intent.async_setup_intents(hass) + + entity_id = f"{valve.DOMAIN}.main_valve" + hass.states.async_set(entity_id, STATE_CLOSED) + async_expose_entity(hass, conversation.DOMAIN, entity_id, True) + + # open + calls = async_mock_service(hass, valve.DOMAIN, valve.SERVICE_OPEN_VALVE) + result = await conversation.async_converse( + hass, "open the main valve", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Opened" + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id} + + # close + calls = async_mock_service(hass, valve.DOMAIN, valve.SERVICE_CLOSE_VALVE) + result = await conversation.async_converse( + hass, "close main valve", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Closed" + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id} + + # set position + calls = async_mock_service(hass, valve.DOMAIN, valve.SERVICE_SET_VALVE_POSITION) + result = await conversation.async_converse( + hass, "set main valve position to 25", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Position set" + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id, valve.ATTR_POSITION: 25} + + +async def test_vacuum_intents( + hass: HomeAssistant, + init_components, +) -> None: + """Test start/return to base for vacuums.""" + await vaccum_intent.async_setup_intents(hass) + + entity_id = f"{vacuum.DOMAIN}.rover" + hass.states.async_set(entity_id, STATE_CLOSED) + async_expose_entity(hass, conversation.DOMAIN, entity_id, True) + + # start + calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_START) + result = await conversation.async_converse( + hass, "start rover", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Started" + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id} + + # return to base + calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_RETURN_TO_BASE) + result = await conversation.async_converse( + hass, "return rover to base", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Returning" + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id} + + +async def test_media_player_intents( + hass: HomeAssistant, + init_components, +) -> None: + """Test pause/unpause/next/set volume for media players.""" + await media_player_intent.async_setup_intents(hass) + + entity_id = f"{media_player.DOMAIN}.tv" + hass.states.async_set(entity_id, media_player.STATE_PLAYING) + async_expose_entity(hass, conversation.DOMAIN, entity_id, True) + + # pause + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PAUSE + ) + result = await conversation.async_converse(hass, "pause tv", None, Context(), None) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Paused" + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id} + + # unpause + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PLAY + ) + result = await conversation.async_converse( + hass, "unpause tv", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Unpaused" + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id} + + # next + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_NEXT_TRACK + ) + result = await conversation.async_converse( + hass, "next item on tv", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Playing next" + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id} + + # volume + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET + ) + result = await conversation.async_converse( + hass, "set tv volume to 75 percent", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.speech["plain"]["speech"] == "Volume set" + assert len(calls) == 1 + call = calls[0] + assert call.data == { + "entity_id": entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.75, + } From dd85a97a484841dba96c8046a17e7287836823c6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Feb 2024 23:09:48 -0500 Subject: [PATCH 25/95] get_matter_device_info: Test the Matter config entry is set up (#111792) Ensure the Matter config entry is set up --- homeassistant/components/matter/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index aa89856074f..06c205859bb 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -1,4 +1,5 @@ """The Matter integration.""" + from __future__ import annotations import asyncio @@ -45,7 +46,10 @@ def get_matter_device_info( hass: HomeAssistant, device_id: str ) -> MatterDeviceInfo | None: """Return Matter device info or None if device does not exist.""" - if not (node := node_from_ha_device_id(hass, device_id)): + # Test hass.data[DOMAIN] to ensure config entry is set up + if not hass.data.get(DOMAIN, False) or not ( + node := node_from_ha_device_id(hass, device_id) + ): return None return MatterDeviceInfo( From fba331fd7e0cad43e2187448fbf8e746274a599d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Feb 2024 23:26:32 -0500 Subject: [PATCH 26/95] Bump version to 2024.3.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index ff993e1342c..afb4da90aa0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index f9a3ad007a6..a9e4cefd9ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.3.0b0" +version = "2024.3.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5c2fc8d7a029be5e8189187c16088c41809c2558 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 29 Feb 2024 10:38:21 -0500 Subject: [PATCH 27/95] Add support for ZHA entities exposed by Zigpy quirks (#111176) * Add counter entities to the ZHA coordinator device * rework to prepare for non coordinator device counters * Initial scaffolding to support quirks v2 entities * update for zigpy changes * add assertion error message * clean up test * update group entity discovery kwargs * constants and clearer names * apply custom device configuration * quirks switches * quirks select entities * quirks sensor entities * update discovery * move call to super * add complex quirks v2 discovery test * remove duplicate replaces * add quirks v2 button entity support * add quirks v2 binary sensor entity support * fix exception in counter entitiy discovery * oops * update formatting * support custom on and off values * logging * don't filter out entities quirks says should be created * fix type alias warnings * sync up with zigpy changes and additions * add a binary sensor test * button coverage * switch coverage * initial select coverage * number coverage * sensor coverage * update discovery after rebase * coverage * single line * line lengths * fix double underscore * review comments * set category from quirks in base entity * line lengths * move comment * imports * simplify * simplify --- homeassistant/components/zha/binary_sensor.py | 12 +- homeassistant/components/zha/button.py | 52 ++- homeassistant/components/zha/core/const.py | 6 + homeassistant/components/zha/core/device.py | 4 + .../components/zha/core/discovery.py | 211 ++++++++++- homeassistant/components/zha/core/endpoint.py | 15 +- homeassistant/components/zha/entity.py | 29 +- homeassistant/components/zha/number.py | 23 +- homeassistant/components/zha/select.py | 17 +- homeassistant/components/zha/sensor.py | 55 ++- homeassistant/components/zha/switch.py | 39 +- tests/components/zha/test_button.py | 150 +++++++- tests/components/zha/test_discover.py | 349 +++++++++++++++++- tests/components/zha/test_select.py | 85 ++++- tests/components/zha/test_sensor.py | 78 ++++ tests/components/zha/test_switch.py | 269 +++++++++++++- 16 files changed, 1340 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 5ec829fcb05..aed0a16a681 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import functools from typing import Any +from zigpy.quirks.v2 import BinarySensorMetadata, EntityMetadata import zigpy.types as t from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasZone @@ -26,6 +27,7 @@ from .core.const import ( CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_ZONE, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -76,8 +78,16 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: """Initialize the ZHA binary sensor.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cluster_handler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + binary_sensor_metadata: BinarySensorMetadata = entity_metadata.entity_metadata + self._attribute_name = binary_sensor_metadata.attribute_name async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index e16ae082eda..2c0028cd3d1 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -1,11 +1,16 @@ """Support for ZHA button.""" from __future__ import annotations -import abc import functools import logging from typing import TYPE_CHECKING, Any, Self +from zigpy.quirks.v2 import ( + EntityMetadata, + WriteAttributeButtonMetadata, + ZCLCommandButtonMetadata, +) + from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform @@ -14,7 +19,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery -from .core.const import CLUSTER_HANDLER_IDENTIFY, SIGNAL_ADD_ENTITIES +from .core.const import CLUSTER_HANDLER_IDENTIFY, QUIRK_METADATA, SIGNAL_ADD_ENTITIES from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -58,6 +63,8 @@ class ZHAButton(ZhaEntity, ButtonEntity): """Defines a ZHA button.""" _command_name: str + _args: list[Any] + _kwargs: dict[str, Any] def __init__( self, @@ -67,18 +74,33 @@ class ZHAButton(ZhaEntity, ButtonEntity): **kwargs: Any, ) -> None: """Init this button.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + button_metadata: ZCLCommandButtonMetadata = entity_metadata.entity_metadata + self._command_name = button_metadata.command_name + self._args = button_metadata.args + self._kwargs = button_metadata.kwargs - @abc.abstractmethod def get_args(self) -> list[Any]: """Return the arguments to use in the command.""" + return list(self._args) if self._args else [] + + def get_kwargs(self) -> dict[str, Any]: + """Return the keyword arguments to use in the command.""" + return self._kwargs async def async_press(self) -> None: """Send out a update command.""" command = getattr(self._cluster_handler, self._command_name) - arguments = self.get_args() - await command(*arguments) + arguments = self.get_args() or [] + kwargs = self.get_kwargs() or {} + await command(*arguments, **kwargs) @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_IDENTIFY) @@ -106,11 +128,8 @@ class ZHAIdentifyButton(ZHAButton): _attr_device_class = ButtonDeviceClass.IDENTIFY _attr_entity_category = EntityCategory.DIAGNOSTIC _command_name = "identify" - - def get_args(self) -> list[Any]: - """Return the arguments to use in the command.""" - - return [DEFAULT_DURATION] + _kwargs = {} + _args = [DEFAULT_DURATION] class ZHAAttributeButton(ZhaEntity, ButtonEntity): @@ -127,8 +146,17 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): **kwargs: Any, ) -> None: """Init this button.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + button_metadata: WriteAttributeButtonMetadata = entity_metadata.entity_metadata + self._attribute_name = button_metadata.attribute_name + self._attribute_value = button_metadata.attribute_value async def async_press(self) -> None: """Write attribute with defined value.""" diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index cb0aa466046..fd54351739e 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -64,6 +64,8 @@ ATTR_WARNING_DEVICE_STROBE_INTENSITY = "intensity" BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000] BINDINGS = "bindings" +CLUSTER_DETAILS = "cluster_details" + CLUSTER_HANDLER_ACCELEROMETER = "accelerometer" CLUSTER_HANDLER_BINARY_INPUT = "binary_input" CLUSTER_HANDLER_ANALOG_INPUT = "analog_input" @@ -230,6 +232,10 @@ PRESET_SCHEDULE = "Schedule" PRESET_COMPLEX = "Complex" PRESET_TEMP_MANUAL = "Temporary manual" +QUIRK_METADATA = "quirk_metadata" + +ZCL_INIT_ATTRS = "ZCL_INIT_ATTRS" + ZHA_ALARM_OPTIONS = "zha_alarm_options" ZHA_OPTIONS = "zha_options" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 0d473bf0810..f1b7ec60728 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -15,6 +15,7 @@ from zigpy.device import Device as ZigpyDevice import zigpy.exceptions from zigpy.profiles import PROFILES import zigpy.quirks +from zigpy.quirks.v2 import CustomDeviceV2 from zigpy.types.named import EUI64, NWK from zigpy.zcl.clusters import Cluster from zigpy.zcl.clusters.general import Groups, Identify @@ -582,6 +583,9 @@ class ZHADevice(LogMixin): await asyncio.gather( *(endpoint.async_configure() for endpoint in self._endpoints.values()) ) + if isinstance(self._zigpy_device, CustomDeviceV2): + self.debug("applying quirks v2 custom device configuration") + await self._zigpy_device.apply_custom_configuration() async_dispatcher_send( self.hass, const.ZHA_CLUSTER_HANDLER_MSG, diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 5575d633593..221c601827e 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -4,10 +4,22 @@ from __future__ import annotations from collections import Counter from collections.abc import Callable import logging -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from slugify import slugify +from zigpy.quirks.v2 import ( + BinarySensorMetadata, + CustomDeviceV2, + EntityType, + NumberMetadata, + SwitchMetadata, + WriteAttributeButtonMetadata, + ZCLCommandButtonMetadata, + ZCLEnumMetadata, + ZCLSensorMetadata, +) from zigpy.state import State +from zigpy.zcl import ClusterType from zigpy.zcl.clusters.general import Ota from homeassistant.const import CONF_TYPE, Platform @@ -66,6 +78,59 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +QUIRKS_ENTITY_META_TO_ENTITY_CLASS = { + ( + Platform.BUTTON, + WriteAttributeButtonMetadata, + EntityType.CONFIG, + ): button.ZHAAttributeButton, + (Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.CONFIG): button.ZHAButton, + ( + Platform.BUTTON, + ZCLCommandButtonMetadata, + EntityType.DIAGNOSTIC, + ): button.ZHAButton, + ( + Platform.BINARY_SENSOR, + BinarySensorMetadata, + EntityType.CONFIG, + ): binary_sensor.BinarySensor, + ( + Platform.BINARY_SENSOR, + BinarySensorMetadata, + EntityType.DIAGNOSTIC, + ): binary_sensor.BinarySensor, + ( + Platform.BINARY_SENSOR, + BinarySensorMetadata, + EntityType.STANDARD, + ): binary_sensor.BinarySensor, + (Platform.SENSOR, ZCLEnumMetadata, EntityType.DIAGNOSTIC): sensor.EnumSensor, + (Platform.SENSOR, ZCLEnumMetadata, EntityType.STANDARD): sensor.EnumSensor, + (Platform.SENSOR, ZCLSensorMetadata, EntityType.DIAGNOSTIC): sensor.Sensor, + (Platform.SENSOR, ZCLSensorMetadata, EntityType.STANDARD): sensor.Sensor, + (Platform.SELECT, ZCLEnumMetadata, EntityType.CONFIG): select.ZCLEnumSelectEntity, + ( + Platform.SELECT, + ZCLEnumMetadata, + EntityType.DIAGNOSTIC, + ): select.ZCLEnumSelectEntity, + ( + Platform.NUMBER, + NumberMetadata, + EntityType.CONFIG, + ): number.ZHANumberConfigurationEntity, + (Platform.NUMBER, NumberMetadata, EntityType.DIAGNOSTIC): number.ZhaNumber, + (Platform.NUMBER, NumberMetadata, EntityType.STANDARD): number.ZhaNumber, + ( + Platform.SWITCH, + SwitchMetadata, + EntityType.CONFIG, + ): switch.ZHASwitchConfigurationEntity, + (Platform.SWITCH, SwitchMetadata, EntityType.STANDARD): switch.Switch, +} + + @callback async def async_add_entities( _async_add_entities: AddEntitiesCallback, @@ -73,6 +138,7 @@ async def async_add_entities( tuple[ type[ZhaEntity], tuple[str, ZHADevice, list[ClusterHandler]], + dict[str, Any], ] ], **kwargs, @@ -80,7 +146,11 @@ async def async_add_entities( """Add entities helper.""" if not entities: return - to_add = [ent_cls.create_entity(*args, **kwargs) for ent_cls, args in entities] + + to_add = [ + ent_cls.create_entity(*args, **{**kwargs, **kw_args}) + for ent_cls, args, kw_args in entities + ] entities_to_add = [entity for entity in to_add if entity is not None] _async_add_entities(entities_to_add, update_before_add=False) entities.clear() @@ -118,6 +188,129 @@ class ProbeEndpoint: if device.is_coordinator: self.discover_coordinator_device_entities(device) + return + + self.discover_quirks_v2_entities(device) + zha_regs.ZHA_ENTITIES.clean_up() + + @callback + def discover_quirks_v2_entities(self, device: ZHADevice) -> None: + """Discover entities for a ZHA device exposed by quirks v2.""" + _LOGGER.debug( + "Attempting to discover quirks v2 entities for device: %s-%s", + str(device.ieee), + device.name, + ) + + if not isinstance(device.device, CustomDeviceV2): + _LOGGER.debug( + "Device: %s-%s is not a quirks v2 device - skipping " + "discover_quirks_v2_entities", + str(device.ieee), + device.name, + ) + return + + zigpy_device: CustomDeviceV2 = device.device + + if not zigpy_device.exposes_metadata: + _LOGGER.debug( + "Device: %s-%s does not expose any quirks v2 entities", + str(device.ieee), + device.name, + ) + return + + for ( + cluster_details, + quirk_metadata_list, + ) in zigpy_device.exposes_metadata.items(): + endpoint_id, cluster_id, cluster_type = cluster_details + + if endpoint_id not in device.endpoints: + _LOGGER.warning( + "Device: %s-%s does not have an endpoint with id: %s - unable to " + "create entity with cluster details: %s", + str(device.ieee), + device.name, + endpoint_id, + cluster_details, + ) + continue + + endpoint: Endpoint = device.endpoints[endpoint_id] + cluster = ( + endpoint.zigpy_endpoint.in_clusters.get(cluster_id) + if cluster_type is ClusterType.Server + else endpoint.zigpy_endpoint.out_clusters.get(cluster_id) + ) + + if cluster is None: + _LOGGER.warning( + "Device: %s-%s does not have a cluster with id: %s - " + "unable to create entity with cluster details: %s", + str(device.ieee), + device.name, + cluster_id, + cluster_details, + ) + continue + + cluster_handler_id = f"{endpoint.id}:0x{cluster.cluster_id:04x}" + cluster_handler = ( + endpoint.all_cluster_handlers.get(cluster_handler_id) + if cluster_type is ClusterType.Server + else endpoint.client_cluster_handlers.get(cluster_handler_id) + ) + assert cluster_handler + + for quirk_metadata in quirk_metadata_list: + platform = Platform(quirk_metadata.entity_platform.value) + metadata_type = type(quirk_metadata.entity_metadata) + entity_class = QUIRKS_ENTITY_META_TO_ENTITY_CLASS.get( + (platform, metadata_type, quirk_metadata.entity_type) + ) + + if entity_class is None: + _LOGGER.warning( + "Device: %s-%s has an entity with details: %s that does not" + " have an entity class mapping - unable to create entity", + str(device.ieee), + device.name, + { + zha_const.CLUSTER_DETAILS: cluster_details, + zha_const.QUIRK_METADATA: quirk_metadata, + }, + ) + continue + + # automatically add the attribute to ZCL_INIT_ATTRS for the cluster + # handler if it is not already in the list + if ( + hasattr(quirk_metadata.entity_metadata, "attribute_name") + and quirk_metadata.entity_metadata.attribute_name + not in cluster_handler.ZCL_INIT_ATTRS + ): + init_attrs = cluster_handler.ZCL_INIT_ATTRS.copy() + init_attrs[ + quirk_metadata.entity_metadata.attribute_name + ] = quirk_metadata.attribute_initialized_from_cache + cluster_handler.__dict__[zha_const.ZCL_INIT_ATTRS] = init_attrs + + endpoint.async_new_entity( + platform, + entity_class, + endpoint.unique_id, + [cluster_handler], + quirk_metadata=quirk_metadata, + ) + + _LOGGER.debug( + "'%s' platform -> '%s' using %s", + platform, + entity_class.__name__, + [cluster_handler.name], + ) @callback def discover_coordinator_device_entities(self, device: ZHADevice) -> None: @@ -144,14 +337,20 @@ class ProbeEndpoint: counter_group, counter, ), + {}, ) ) + _LOGGER.debug( + "'%s' platform -> '%s' using %s", + Platform.SENSOR, + sensor.DeviceCounterSensor.__name__, + f"counter groups[{counter_groups}] counter group[{counter_group}] counter[{counter}]", + ) process_counters("counters") process_counters("broadcast_counters") process_counters("device_counters") process_counters("group_counters") - zha_regs.ZHA_ENTITIES.clean_up() @callback def discover_by_device_type(self, endpoint: Endpoint) -> None: @@ -309,7 +508,7 @@ class ProbeEndpoint: for platform, ent_n_handler_list in matches.items(): for entity_and_handler in ent_n_handler_list: _LOGGER.debug( - "'%s' component -> '%s' using %s", + "'%s' platform -> '%s' using %s", platform, entity_and_handler.entity_class.__name__, [ch.name for ch in entity_and_handler.claimed_cluster_handlers], @@ -317,7 +516,8 @@ class ProbeEndpoint: for platform, ent_n_handler_list in matches.items(): for entity_and_handler in ent_n_handler_list: if platform == cmpt_by_dev_type: - # for well known device types, like thermostats we'll take only 1st class + # for well known device types, + # like thermostats we'll take only 1st class endpoint.async_new_entity( platform, entity_and_handler.entity_class, @@ -405,6 +605,7 @@ class GroupProbe: group.group_id, zha_gateway.coordinator_zha_device, ), + {}, ) ) async_dispatcher_send(self._hass, zha_const.SIGNAL_ADD_ENTITIES) diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index 490a4e05ea2..37a2c951a7f 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -7,8 +7,6 @@ import functools import logging from typing import TYPE_CHECKING, Any, Final, TypeVar -from zigpy.typing import EndpointType as ZigpyEndpointType - from homeassistant.const import Platform from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -19,6 +17,8 @@ from .cluster_handlers import ClusterHandler from .helpers import get_zha_data if TYPE_CHECKING: + from zigpy import Endpoint as ZigpyEndpoint + from .cluster_handlers import ClientClusterHandler from .device import ZHADevice @@ -34,11 +34,11 @@ CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) class Endpoint: """Endpoint for a zha device.""" - def __init__(self, zigpy_endpoint: ZigpyEndpointType, device: ZHADevice) -> None: + def __init__(self, zigpy_endpoint: ZigpyEndpoint, device: ZHADevice) -> None: """Initialize instance.""" assert zigpy_endpoint is not None assert device is not None - self._zigpy_endpoint: ZigpyEndpointType = zigpy_endpoint + self._zigpy_endpoint: ZigpyEndpoint = zigpy_endpoint self._device: ZHADevice = device self._all_cluster_handlers: dict[str, ClusterHandler] = {} self._claimed_cluster_handlers: dict[str, ClusterHandler] = {} @@ -66,7 +66,7 @@ class Endpoint: return self._client_cluster_handlers @property - def zigpy_endpoint(self) -> ZigpyEndpointType: + def zigpy_endpoint(self) -> ZigpyEndpoint: """Return endpoint of zigpy device.""" return self._zigpy_endpoint @@ -104,7 +104,7 @@ class Endpoint: ) @classmethod - def new(cls, zigpy_endpoint: ZigpyEndpointType, device: ZHADevice) -> Endpoint: + def new(cls, zigpy_endpoint: ZigpyEndpoint, device: ZHADevice) -> Endpoint: """Create new endpoint and populate cluster handlers.""" endpoint = cls(zigpy_endpoint, device) endpoint.add_all_cluster_handlers() @@ -211,6 +211,7 @@ class Endpoint: entity_class: CALLABLE_T, unique_id: str, cluster_handlers: list[ClusterHandler], + **kwargs: Any, ) -> None: """Create a new entity.""" from .device import DeviceStatus # pylint: disable=import-outside-toplevel @@ -220,7 +221,7 @@ class Endpoint: zha_data = get_zha_data(self.device.hass) zha_data.platforms[platform].append( - (entity_class, (unique_id, self.device, cluster_handlers)) + (entity_class, (unique_id, self.device, cluster_handlers), kwargs or {}) ) @callback diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index ef1b89f1095..3f127c74c0e 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -7,7 +7,9 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self -from homeassistant.const import ATTR_NAME +from zigpy.quirks.v2 import EntityMetadata, EntityType + +from homeassistant.const import ATTR_NAME, EntityCategory from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers import entity from homeassistant.helpers.debounce import Debouncer @@ -175,6 +177,31 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): """ return cls(unique_id, zha_device, cluster_handlers, **kwargs) + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + if entity_metadata.initially_disabled: + self._attr_entity_registry_enabled_default = False + + if entity_metadata.translation_key: + self._attr_translation_key = entity_metadata.translation_key + + if hasattr(entity_metadata.entity_metadata, "attribute_name"): + if not entity_metadata.translation_key: + self._attr_translation_key = ( + entity_metadata.entity_metadata.attribute_name + ) + self._unique_id_suffix = entity_metadata.entity_metadata.attribute_name + elif hasattr(entity_metadata.entity_metadata, "command_name"): + if not entity_metadata.translation_key: + self._attr_translation_key = ( + entity_metadata.entity_metadata.command_name + ) + self._unique_id_suffix = entity_metadata.entity_metadata.command_name + if entity_metadata.entity_type is EntityType.CONFIG: + self._attr_entity_category = EntityCategory.CONFIG + elif entity_metadata.entity_type is EntityType.DIAGNOSTIC: + self._attr_entity_category = EntityCategory.DIAGNOSTIC + @property def available(self) -> bool: """Return entity availability.""" diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index a4568b5a14c..c452752f14b 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -5,6 +5,7 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self +from zigpy.quirks.v2 import EntityMetadata, NumberMetadata from zigpy.zcl.clusters.hvac import Thermostat from homeassistant.components.number import NumberEntity, NumberMode @@ -24,6 +25,7 @@ from .core.const import ( CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_THERMOSTAT, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -400,7 +402,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if ( + if QUIRK_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._attribute_name) is None @@ -423,8 +425,27 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): ) -> None: """Init this number configuration entity.""" self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + number_metadata: NumberMetadata = entity_metadata.entity_metadata + self._attribute_name = number_metadata.attribute_name + + if number_metadata.min is not None: + self._attr_native_min_value = number_metadata.min + if number_metadata.max is not None: + self._attr_native_max_value = number_metadata.max + if number_metadata.step is not None: + self._attr_native_step = number_metadata.step + if number_metadata.unit is not None: + self._attr_native_unit_of_measurement = number_metadata.unit + if number_metadata.multiplier is not None: + self._attr_multiplier = number_metadata.multiplier + @property def native_value(self) -> float: """Return the current value.""" diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 3736858d599..53acc5cdd02 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -10,6 +10,7 @@ from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, TUYA_PLUG_ONOFF from zhaquirks.xiaomi.aqara.magnet_ac01 import OppleCluster as MagnetAC01OppleCluster from zhaquirks.xiaomi.aqara.switch_acn047 import OppleCluster as T2RelayOppleCluster from zigpy import types +from zigpy.quirks.v2 import EntityMetadata, ZCLEnumMetadata from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasWd @@ -27,6 +28,7 @@ from .core.const import ( CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, Strobe, @@ -82,9 +84,9 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): **kwargs: Any, ) -> None: """Init this select entity.""" + self._cluster_handler: ClusterHandler = cluster_handlers[0] self._attribute_name = self._enum.__name__ self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - self._cluster_handler: ClusterHandler = cluster_handlers[0] super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @property @@ -176,7 +178,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if ( + if QUIRK_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._attribute_name) is None @@ -198,10 +200,19 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): **kwargs: Any, ) -> None: """Init this select entity.""" - self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + zcl_enum_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata + self._attribute_name = zcl_enum_metadata.attribute_name + self._enum = zcl_enum_metadata.enum + @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index c4e620a8b0e..6a68b55a8be 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -6,11 +6,13 @@ from dataclasses import dataclass from datetime import timedelta import enum import functools +import logging import numbers import random from typing import TYPE_CHECKING, Any, Self from zigpy import types +from zigpy.quirks.v2 import EntityMetadata, ZCLEnumMetadata, ZCLSensorMetadata from zigpy.state import Counter, State from zigpy.zcl.clusters.closures import WindowCovering from zigpy.zcl.clusters.general import Basic @@ -68,6 +70,7 @@ from .core.const import ( CLUSTER_HANDLER_TEMPERATURE, CLUSTER_HANDLER_THERMOSTAT, DATA_ZHA, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -95,6 +98,8 @@ BATTERY_SIZES = { 255: "Unknown", } +_LOGGER = logging.getLogger(__name__) + CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER = ( f"cluster_handler_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" ) @@ -135,17 +140,6 @@ class Sensor(ZhaEntity, SensorEntity): _divisor: int = 1 _multiplier: int | float = 1 - def __init__( - self, - unique_id: str, - zha_device: ZHADevice, - cluster_handlers: list[ClusterHandler], - **kwargs: Any, - ) -> None: - """Init this sensor.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._cluster_handler: ClusterHandler = cluster_handlers[0] - @classmethod def create_entity( cls, @@ -159,14 +153,44 @@ class Sensor(ZhaEntity, SensorEntity): Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if ( + if QUIRK_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name ): + _LOGGER.debug( + "%s is not supported - skipping %s entity creation", + cls._attribute_name, + cls.__name__, + ) return None return cls(unique_id, zha_device, cluster_handlers, **kwargs) + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this sensor.""" + self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + sensor_metadata: ZCLSensorMetadata = entity_metadata.entity_metadata + self._attribute_name = sensor_metadata.attribute_name + if sensor_metadata.divisor is not None: + self._divisor = sensor_metadata.divisor + if sensor_metadata.multiplier is not None: + self._multiplier = sensor_metadata.multiplier + if sensor_metadata.unit is not None: + self._attr_native_unit_of_measurement = sensor_metadata.unit + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() @@ -330,6 +354,13 @@ class EnumSensor(Sensor): _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENUM _enum: type[enum.Enum] + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + ZhaEntity._init_from_quirks_metadata(self, entity_metadata) # pylint: disable=protected-access + sensor_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata + self._attribute_name = sensor_metadata.attribute_name + self._enum = sensor_metadata.enum + def formatter(self, value: int) -> str | None: """Use name of enum.""" assert self._enum is not None diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index afc73baca70..960124c4a8a 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -6,6 +6,7 @@ import logging from typing import TYPE_CHECKING, Any, Self from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF +from zigpy.quirks.v2 import EntityMetadata, SwitchMetadata from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status @@ -23,6 +24,7 @@ from .core.const import ( CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -173,6 +175,8 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): _attribute_name: str _inverter_attribute_name: str | None = None _force_inverted: bool = False + _off_value: int = 0 + _on_value: int = 1 @classmethod def create_entity( @@ -187,7 +191,7 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if ( + if QUIRK_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._attribute_name) is None @@ -210,8 +214,22 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): ) -> None: """Init this number configuration entity.""" self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + switch_metadata: SwitchMetadata = entity_metadata.entity_metadata + self._attribute_name = switch_metadata.attribute_name + if switch_metadata.invert_attribute_name: + self._inverter_attribute_name = switch_metadata.invert_attribute_name + if switch_metadata.force_inverted: + self._force_inverted = switch_metadata.force_inverted + self._off_value = switch_metadata.off_value + self._on_value = switch_metadata.on_value + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() @@ -236,14 +254,25 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" - val = bool(self._cluster_handler.cluster.get(self._attribute_name)) + if self._on_value != 1: + val = self._cluster_handler.cluster.get(self._attribute_name) + val = val == self._on_value + else: + val = bool(self._cluster_handler.cluster.get(self._attribute_name)) return (not val) if self.inverted else val async def async_turn_on_off(self, state: bool) -> None: """Turn the entity on or off.""" - await self._cluster_handler.write_attributes_safe( - {self._attribute_name: not state if self.inverted else state} - ) + if self.inverted: + state = not state + if state: + await self._cluster_handler.write_attributes_safe( + {self._attribute_name: self._on_value} + ) + else: + await self._cluster_handler.write_attributes_safe( + {self._attribute_name: self._off_value} + ) self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/tests/components/zha/test_button.py b/tests/components/zha/test_button.py index cc0b5079fd3..9eab72b435b 100644 --- a/tests/components/zha/test_button.py +++ b/tests/components/zha/test_button.py @@ -1,4 +1,5 @@ """Test ZHA button.""" +from typing import Final from unittest.mock import call, patch from freezegun import freeze_time @@ -15,6 +16,7 @@ from zigpy.const import SIG_EP_PROFILE from zigpy.exceptions import ZigbeeException import zigpy.profiles.zha as zha from zigpy.quirks import CustomCluster, CustomDevice +from zigpy.quirks.v2 import add_to_registry_v2 import zigpy.types as t import zigpy.zcl.clusters.general as general from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster @@ -33,7 +35,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .common import find_entity_id +from .common import find_entity_id, update_attribute_cache from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE @@ -56,7 +58,9 @@ def button_platform_only(): @pytest.fixture -async def contact_sensor(hass, zigpy_device_mock, zha_device_joined_restored): +async def contact_sensor( + hass: HomeAssistant, zigpy_device_mock, zha_device_joined_restored +): """Contact sensor fixture.""" zigpy_device = zigpy_device_mock( @@ -102,7 +106,9 @@ class FrostLockQuirk(CustomDevice): @pytest.fixture -async def tuya_water_valve(hass, zigpy_device_mock, zha_device_joined_restored): +async def tuya_water_valve( + hass: HomeAssistant, zigpy_device_mock, zha_device_joined_restored +): """Tuya Water Valve fixture.""" zigpy_device = zigpy_device_mock( @@ -224,3 +230,141 @@ async def test_frost_unlock(hass: HomeAssistant, tuya_water_valve) -> None: call({"frost_lock_reset": 0}, manufacturer=None), call({"frost_lock_reset": 0}, manufacturer=None), ] + + +class FakeManufacturerCluster(CustomCluster, ManufacturerSpecificCluster): + """Fake manufacturer cluster.""" + + cluster_id: Final = 0xFFF3 + ep_attribute: Final = "mfg_identify" + + class AttributeDefs(zcl_f.BaseAttributeDefs): + """Attribute definitions.""" + + feed: Final = zcl_f.ZCLAttributeDef( + id=0x0000, type=t.uint8_t, access="rw", is_manufacturer_specific=True + ) + + class ServerCommandDefs(zcl_f.BaseCommandDefs): + """Server command definitions.""" + + self_test: Final = zcl_f.ZCLCommandDef( + id=0x00, schema={"identify_time": t.uint16_t}, direction=False + ) + + +( + add_to_registry_v2("Fake_Model", "Fake_Manufacturer") + .replaces(FakeManufacturerCluster) + .command_button( + FakeManufacturerCluster.ServerCommandDefs.self_test.name, + FakeManufacturerCluster.cluster_id, + command_args=(5,), + ) + .write_attr_button( + FakeManufacturerCluster.AttributeDefs.feed.name, + 2, + FakeManufacturerCluster.cluster_id, + ) +) + + +@pytest.fixture +async def custom_button_device( + hass: HomeAssistant, zigpy_device_mock, zha_device_joined_restored +): + """Button device fixture for quirks button tests.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + FakeManufacturerCluster.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.REMOTE_CONTROL, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + manufacturer="Fake_Model", + model="Fake_Manufacturer", + ) + + zigpy_device.endpoints[1].mfg_identify.PLUGGED_ATTR_READS = { + FakeManufacturerCluster.AttributeDefs.feed.name: 0, + } + update_attribute_cache(zigpy_device.endpoints[1].mfg_identify) + zha_device = await zha_device_joined_restored(zigpy_device) + return zha_device, zigpy_device.endpoints[1].mfg_identify + + +@freeze_time("2021-11-04 17:37:00", tz_offset=-1) +async def test_quirks_command_button(hass: HomeAssistant, custom_button_device) -> None: + """Test ZHA button platform.""" + + zha_device, cluster = custom_button_device + assert cluster is not None + entity_id = find_entity_id(DOMAIN, zha_device, hass, qualifier="self_test") + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + with patch( + "zigpy.zcl.Cluster.request", + return_value=[0x00, zcl_f.Status.SUCCESS], + ): + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 5 # duration in seconds + + state = hass.states.get(entity_id) + assert state + assert state.state == "2021-11-04T16:37:00+00:00" + + +@freeze_time("2021-11-04 17:37:00", tz_offset=-1) +async def test_quirks_write_attr_button( + hass: HomeAssistant, custom_button_device +) -> None: + """Test ZHA button platform.""" + + zha_device, cluster = custom_button_device + assert cluster is not None + entity_id = find_entity_id(DOMAIN, zha_device, hass, qualifier="feed") + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + assert cluster.get(cluster.AttributeDefs.feed.name) == 0 + + with patch( + "zigpy.zcl.Cluster.request", + return_value=[0x00, zcl_f.Status.SUCCESS], + ): + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({cluster.AttributeDefs.feed.name: 2}, manufacturer=None) + ] + + state = hass.states.get(entity_id) + assert state + assert state.state == "2021-11-04T16:37:00+00:00" + assert cluster.get(cluster.AttributeDefs.feed.name) == 2 diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 1491b46005b..c8eba90a372 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -6,10 +6,23 @@ from unittest import mock from unittest.mock import AsyncMock, Mock, patch import pytest +from zhaquirks.ikea import PowerConfig1CRCluster, ScenesCluster +from zhaquirks.xiaomi import ( + BasicCluster, + LocalIlluminanceMeasurementCluster, + XiaomiPowerConfigurationPercent, +) +from zhaquirks.xiaomi.aqara.driver_curtain_e1 import ( + WindowCoveringE1, + XiaomiAqaraDriverE1, +) from zigpy.const import SIG_ENDPOINTS, SIG_MANUFACTURER, SIG_MODEL, SIG_NODE_DESC import zigpy.profiles.zha import zigpy.quirks +from zigpy.quirks.v2 import EntityType, add_to_registry_v2 +from zigpy.quirks.v2.homeassistant import UnitOfTime import zigpy.types +from zigpy.zcl import ClusterType import zigpy.zcl.clusters.closures import zigpy.zcl.clusters.general import zigpy.zcl.clusters.security @@ -22,11 +35,12 @@ import homeassistant.components.zha.core.discovery as disc from homeassistant.components.zha.core.endpoint import Endpoint from homeassistant.components.zha.core.helpers import get_zha_gateway import homeassistant.components.zha.core.registries as zha_regs -from homeassistant.const import Platform +from homeassistant.const import STATE_OFF, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import EntityPlatform +from .common import find_entity_id, update_attribute_cache from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from .zha_devices_list import ( DEV_SIG_ATTRIBUTES, @@ -147,7 +161,9 @@ async def test_devices( for (platform, unique_id), ent_info in device[DEV_SIG_ENT_MAP].items(): no_tail_id = NO_TAIL_ID.sub("", ent_info[DEV_SIG_ENT_MAP_ID]) ha_entity_id = entity_registry.async_get_entity_id(platform, "zha", unique_id) - assert ha_entity_id is not None + message1 = f"No entity found for platform[{platform}] unique_id[{unique_id}]" + message2 = f"no_tail_id[{no_tail_id}] with entity_id[{ha_entity_id}]" + assert ha_entity_id is not None, f"{message1} {message2}" assert ha_entity_id.startswith(no_tail_id) entity = created_entities[ha_entity_id] @@ -461,3 +477,332 @@ async def test_group_probe_cleanup_called( await config_entry.async_unload(hass_disable_services) await hass_disable_services.async_block_till_done() disc.GROUP_PROBE.cleanup.assert_called() + + +async def test_quirks_v2_entity_discovery( + hass, + zigpy_device_mock, + zha_device_joined, +) -> None: + """Test quirks v2 discovery.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.PowerConfiguration.cluster_id, + zigpy.zcl.clusters.general.Groups.cluster_id, + zigpy.zcl.clusters.general.OnOff.cluster_id, + ], + SIG_EP_OUTPUT: [ + zigpy.zcl.clusters.general.Scenes.cluster_id, + ], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.NON_COLOR_CONTROLLER, + } + }, + ieee="01:2d:6f:00:0a:90:69:e8", + manufacturer="Ikea of Sweden", + model="TRADFRI remote control", + ) + + ( + add_to_registry_v2( + "Ikea of Sweden", "TRADFRI remote control", zigpy.quirks._DEVICE_REGISTRY + ) + .replaces(PowerConfig1CRCluster) + .replaces(ScenesCluster, cluster_type=ClusterType.Client) + .number( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, + zigpy.zcl.clusters.general.OnOff.cluster_id, + min_value=1, + max_value=100, + step=1, + unit=UnitOfTime.SECONDS, + multiplier=1, + ) + ) + + zigpy_device = zigpy.quirks._DEVICE_REGISTRY.get_device(zigpy_device) + zigpy_device.endpoints[1].power.PLUGGED_ATTR_READS = { + "battery_voltage": 3, + "battery_percentage_remaining": 100, + } + update_attribute_cache(zigpy_device.endpoints[1].power) + zigpy_device.endpoints[1].on_off.PLUGGED_ATTR_READS = { + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name: 3, + } + update_attribute_cache(zigpy_device.endpoints[1].on_off) + + zha_device = await zha_device_joined(zigpy_device) + + entity_id = find_entity_id( + Platform.NUMBER, + zha_device, + hass, + ) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + + +async def test_quirks_v2_entity_discovery_e1_curtain( + hass, + zigpy_device_mock, + zha_device_joined, +) -> None: + """Test quirks v2 discovery for e1 curtain motor.""" + aqara_E1_device = zigpy_device_mock( + { + 1: { + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.WINDOW_COVERING_DEVICE, + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.PowerConfiguration.cluster_id, + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.general.Time.cluster_id, + WindowCoveringE1.cluster_id, + XiaomiAqaraDriverE1.cluster_id, + ], + SIG_EP_OUTPUT: [ + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.general.Time.cluster_id, + zigpy.zcl.clusters.general.Ota.cluster_id, + XiaomiAqaraDriverE1.cluster_id, + ], + } + }, + ieee="01:2d:6f:00:0a:90:69:e8", + manufacturer="LUMI", + model="lumi.curtain.agl006", + ) + + class AqaraE1HookState(zigpy.types.enum8): + """Aqara hook state.""" + + Unlocked = 0x00 + Locked = 0x01 + Locking = 0x02 + Unlocking = 0x03 + + class FakeXiaomiAqaraDriverE1(XiaomiAqaraDriverE1): + """Fake XiaomiAqaraDriverE1 cluster.""" + + attributes = XiaomiAqaraDriverE1.attributes.copy() + attributes.update( + { + 0x9999: ("error_detected", zigpy.types.Bool, True), + } + ) + + ( + add_to_registry_v2("LUMI", "lumi.curtain.agl006") + .adds(LocalIlluminanceMeasurementCluster) + .replaces(BasicCluster) + .replaces(XiaomiPowerConfigurationPercent) + .replaces(WindowCoveringE1) + .replaces(FakeXiaomiAqaraDriverE1) + .removes(FakeXiaomiAqaraDriverE1, cluster_type=ClusterType.Client) + .enum( + BasicCluster.AttributeDefs.power_source.name, + BasicCluster.PowerSource, + BasicCluster.cluster_id, + entity_platform=Platform.SENSOR, + entity_type=EntityType.DIAGNOSTIC, + ) + .enum( + "hooks_state", + AqaraE1HookState, + FakeXiaomiAqaraDriverE1.cluster_id, + entity_platform=Platform.SENSOR, + entity_type=EntityType.DIAGNOSTIC, + ) + .binary_sensor("error_detected", FakeXiaomiAqaraDriverE1.cluster_id) + ) + + aqara_E1_device = zigpy.quirks._DEVICE_REGISTRY.get_device(aqara_E1_device) + + aqara_E1_device.endpoints[1].opple_cluster.PLUGGED_ATTR_READS = { + "hand_open": 0, + "positions_stored": 0, + "hooks_lock": 0, + "hooks_state": AqaraE1HookState.Unlocked, + "light_level": 0, + "error_detected": 0, + } + update_attribute_cache(aqara_E1_device.endpoints[1].opple_cluster) + + aqara_E1_device.endpoints[1].basic.PLUGGED_ATTR_READS = { + BasicCluster.AttributeDefs.power_source.name: BasicCluster.PowerSource.Mains_single_phase, + } + update_attribute_cache(aqara_E1_device.endpoints[1].basic) + + WCAttrs = zigpy.zcl.clusters.closures.WindowCovering.AttributeDefs + WCT = zigpy.zcl.clusters.closures.WindowCovering.WindowCoveringType + WCCS = zigpy.zcl.clusters.closures.WindowCovering.ConfigStatus + aqara_E1_device.endpoints[1].window_covering.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 0, + WCAttrs.window_covering_type.name: WCT.Drapery, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + } + update_attribute_cache(aqara_E1_device.endpoints[1].window_covering) + + zha_device = await zha_device_joined(aqara_E1_device) + + power_source_entity_id = find_entity_id( + Platform.SENSOR, + zha_device, + hass, + qualifier=BasicCluster.AttributeDefs.power_source.name, + ) + assert power_source_entity_id is not None + state = hass.states.get(power_source_entity_id) + assert state is not None + assert state.state == BasicCluster.PowerSource.Mains_single_phase.name + + hook_state_entity_id = find_entity_id( + Platform.SENSOR, + zha_device, + hass, + qualifier="hooks_state", + ) + assert hook_state_entity_id is not None + state = hass.states.get(hook_state_entity_id) + assert state is not None + assert state.state == AqaraE1HookState.Unlocked.name + + error_detected_entity_id = find_entity_id( + Platform.BINARY_SENSOR, + zha_device, + hass, + ) + assert error_detected_entity_id is not None + state = hass.states.get(error_detected_entity_id) + assert state is not None + assert state.state == STATE_OFF + + +def _get_test_device(zigpy_device_mock, manufacturer: str, model: str): + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.PowerConfiguration.cluster_id, + zigpy.zcl.clusters.general.Groups.cluster_id, + zigpy.zcl.clusters.general.OnOff.cluster_id, + ], + SIG_EP_OUTPUT: [ + zigpy.zcl.clusters.general.Scenes.cluster_id, + ], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.NON_COLOR_CONTROLLER, + } + }, + ieee="01:2d:6f:00:0a:90:69:e8", + manufacturer=manufacturer, + model=model, + ) + + ( + add_to_registry_v2(manufacturer, model, zigpy.quirks._DEVICE_REGISTRY) + .replaces(PowerConfig1CRCluster) + .replaces(ScenesCluster, cluster_type=ClusterType.Client) + .number( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, + zigpy.zcl.clusters.general.OnOff.cluster_id, + endpoint_id=3, + min_value=1, + max_value=100, + step=1, + unit=UnitOfTime.SECONDS, + multiplier=1, + ) + .number( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, + zigpy.zcl.clusters.general.Time.cluster_id, + min_value=1, + max_value=100, + step=1, + unit=UnitOfTime.SECONDS, + multiplier=1, + ) + .sensor( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, + zigpy.zcl.clusters.general.OnOff.cluster_id, + entity_type=EntityType.CONFIG, + ) + ) + + zigpy_device = zigpy.quirks._DEVICE_REGISTRY.get_device(zigpy_device) + zigpy_device.endpoints[1].power.PLUGGED_ATTR_READS = { + "battery_voltage": 3, + "battery_percentage_remaining": 100, + } + update_attribute_cache(zigpy_device.endpoints[1].power) + zigpy_device.endpoints[1].on_off.PLUGGED_ATTR_READS = { + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name: 3, + } + update_attribute_cache(zigpy_device.endpoints[1].on_off) + return zigpy_device + + +async def test_quirks_v2_entity_no_metadata( + hass: HomeAssistant, + zigpy_device_mock, + zha_device_joined, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test quirks v2 discovery skipped - no metadata.""" + + zigpy_device = _get_test_device( + zigpy_device_mock, "Ikea of Sweden2", "TRADFRI remote control2" + ) + setattr(zigpy_device, "_exposes_metadata", {}) + zha_device = await zha_device_joined(zigpy_device) + assert ( + f"Device: {str(zigpy_device.ieee)}-{zha_device.name} does not expose any quirks v2 entities" + in caplog.text + ) + + +async def test_quirks_v2_entity_discovery_errors( + hass: HomeAssistant, + zigpy_device_mock, + zha_device_joined, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test quirks v2 discovery skipped - errors.""" + + zigpy_device = _get_test_device( + zigpy_device_mock, "Ikea of Sweden3", "TRADFRI remote control3" + ) + zha_device = await zha_device_joined(zigpy_device) + + m1 = f"Device: {str(zigpy_device.ieee)}-{zha_device.name} does not have an" + m2 = " endpoint with id: 3 - unable to create entity with cluster" + m3 = " details: (3, 6, )" + assert f"{m1}{m2}{m3}" in caplog.text + + time_cluster_id = zigpy.zcl.clusters.general.Time.cluster_id + + m1 = f"Device: {str(zigpy_device.ieee)}-{zha_device.name} does not have a" + m2 = f" cluster with id: {time_cluster_id} - unable to create entity with " + m3 = f"cluster details: (1, {time_cluster_id}, )" + assert f"{m1}{m2}{m3}" in caplog.text + + # fmt: off + entity_details = ( + "{'cluster_details': (1, 6, ), " + "'quirk_metadata': EntityMetadata(entity_metadata=ZCLSensorMetadata(" + "attribute_name='off_wait_time', divisor=1, multiplier=1, unit=None, " + "device_class=None, state_class=None), entity_platform=, entity_type=, " + "cluster_id=6, endpoint_id=1, cluster_type=, " + "initially_disabled=False, attribute_initialized_from_cache=True, " + "translation_key=None)}" + ) + # fmt: on + + m1 = f"Device: {str(zigpy_device.ieee)}-{zha_device.name} has an entity with " + m2 = f"details: {entity_details} that does not have an entity class mapping - " + m3 = "unable to create entity" + assert f"{m1}{m2}{m3}" in caplog.text diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index d2d1b79c92f..549a123aefb 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -12,6 +12,7 @@ from zhaquirks import ( from zigpy.const import SIG_EP_PROFILE import zigpy.profiles.zha as zha from zigpy.quirks import CustomCluster, CustomDevice +from zigpy.quirks.v2 import CustomDeviceV2, add_to_registry_v2 import zigpy.types as t import zigpy.zcl.clusters.general as general from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster @@ -349,13 +350,19 @@ class MotionSensitivityQuirk(CustomDevice): ep_attribute = "opple_cluster" attributes = { 0x010C: ("motion_sensitivity", t.uint8_t, True), + 0x020C: ("motion_sensitivity_disabled", t.uint8_t, True), } def __init__(self, *args, **kwargs): """Initialize.""" super().__init__(*args, **kwargs) # populate cache to create config entity - self._attr_cache.update({0x010C: AqaraMotionSensitivities.Medium}) + self._attr_cache.update( + { + 0x010C: AqaraMotionSensitivities.Medium, + 0x020C: AqaraMotionSensitivities.Medium, + } + ) replacement = { ENDPOINTS: { @@ -413,3 +420,79 @@ async def test_on_off_select_attribute_report( hass, cluster, {"motion_sensitivity": AqaraMotionSensitivities.Low} ) assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Low.name + + +( + add_to_registry_v2("Fake_Manufacturer", "Fake_Model") + .replaces(MotionSensitivityQuirk.OppleCluster) + .enum( + "motion_sensitivity", + AqaraMotionSensitivities, + MotionSensitivityQuirk.OppleCluster.cluster_id, + ) + .enum( + "motion_sensitivity_disabled", + AqaraMotionSensitivities, + MotionSensitivityQuirk.OppleCluster.cluster_id, + translation_key="motion_sensitivity_translation_key", + initially_disabled=True, + ) +) + + +@pytest.fixture +async def zigpy_device_aqara_sensor_v2( + hass: HomeAssistant, zigpy_device_mock, zha_device_joined_restored +): + """Device tracker zigpy Aqara motion sensor device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + MotionSensitivityQuirk.OppleCluster.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + } + }, + manufacturer="Fake_Manufacturer", + model="Fake_Model", + ) + + zha_device = await zha_device_joined_restored(zigpy_device) + return zha_device, zigpy_device.endpoints[1].opple_cluster + + +async def test_on_off_select_attribute_report_v2( + hass: HomeAssistant, zigpy_device_aqara_sensor_v2 +) -> None: + """Test ZHA attribute report parsing for select platform.""" + + zha_device, cluster = zigpy_device_aqara_sensor_v2 + assert isinstance(zha_device.device, CustomDeviceV2) + entity_id = find_entity_id( + Platform.SELECT, zha_device, hass, qualifier="motion_sensitivity" + ) + assert entity_id is not None + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state is in default medium state + assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Medium.name + + # send attribute report from device + await send_attributes_report( + hass, cluster, {"motion_sensitivity": AqaraMotionSensitivities.Low} + ) + assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Low.name + + entity_registry = er.async_get(hass) + # none in id because the translation key does not exist + entity_entry = entity_registry.async_get("select.fake_manufacturer_fake_model_none") + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled is True + assert entity_entry.translation_key == "motion_sensitivity_translation_key" diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 7b96d43aed3..a7047b8dcd4 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -5,8 +5,13 @@ from unittest.mock import MagicMock, patch import pytest import zigpy.profiles.zha +from zigpy.quirks import CustomCluster +from zigpy.quirks.v2 import CustomDeviceV2, add_to_registry_v2 +from zigpy.quirks.v2.homeassistant import UnitOfMass +import zigpy.types as t from zigpy.zcl.clusters import general, homeautomation, hvac, measurement, smartenergy from zigpy.zcl.clusters.hvac import Thermostat +from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.zha.core import ZHADevice @@ -1187,6 +1192,79 @@ async def test_elec_measurement_skip_unsupported_attribute( assert read_attrs == supported_attributes +class OppleCluster(CustomCluster, ManufacturerSpecificCluster): + """Aqara manufacturer specific cluster.""" + + cluster_id = 0xFCC0 + ep_attribute = "opple_cluster" + attributes = { + 0x010C: ("last_feeding_size", t.uint16_t, True), + } + + def __init__(self, *args, **kwargs) -> None: + """Initialize.""" + super().__init__(*args, **kwargs) + # populate cache to create config entity + self._attr_cache.update({0x010C: 10}) + + +( + add_to_registry_v2("Fake_Manufacturer_sensor", "Fake_Model_sensor") + .replaces(OppleCluster) + .sensor( + "last_feeding_size", + OppleCluster.cluster_id, + divisor=1, + multiplier=1, + unit=UnitOfMass.GRAMS, + ) +) + + +@pytest.fixture +async def zigpy_device_aqara_sensor_v2( + hass: HomeAssistant, zigpy_device_mock, zha_device_joined_restored +): + """Device tracker zigpy Aqara motion sensor device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + OppleCluster.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.OCCUPANCY_SENSOR, + } + }, + manufacturer="Fake_Manufacturer_sensor", + model="Fake_Model_sensor", + ) + + zha_device = await zha_device_joined_restored(zigpy_device) + return zha_device, zigpy_device.endpoints[1].opple_cluster + + +async def test_last_feeding_size_sensor_v2( + hass: HomeAssistant, zigpy_device_aqara_sensor_v2 +) -> None: + """Test quirks defined sensor.""" + + zha_device, cluster = zigpy_device_aqara_sensor_v2 + assert isinstance(zha_device.device, CustomDeviceV2) + entity_id = find_entity_id( + Platform.SENSOR, zha_device, hass, qualifier="last_feeding_size" + ) + assert entity_id is not None + + await send_attributes_report(hass, cluster, {0x010C: 1}) + assert_state(hass, entity_id, "1.0", UnitOfMass.GRAMS) + + await send_attributes_report(hass, cluster, {0x010C: 5}) + assert_state(hass, entity_id, "5.0", UnitOfMass.GRAMS) + + @pytest.fixture async def coordinator(hass: HomeAssistant, zigpy_device_mock, zha_device_joined): """Test ZHA fan platform.""" diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 6bfd7e051f1..cb1d87210a7 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -11,7 +11,8 @@ from zhaquirks.const import ( ) from zigpy.exceptions import ZigbeeException import zigpy.profiles.zha as zha -from zigpy.quirks import CustomCluster, CustomDevice +from zigpy.quirks import _DEVICE_REGISTRY, CustomCluster, CustomDevice +from zigpy.quirks.v2 import CustomDeviceV2, add_to_registry_v2 import zigpy.types as t import zigpy.zcl.clusters.closures as closures import zigpy.zcl.clusters.general as general @@ -564,6 +565,272 @@ async def test_switch_configurable( await async_test_rejoin(hass, zigpy_device_tuya, [cluster], (0,)) +async def test_switch_configurable_custom_on_off_values( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device_mock +) -> None: + """Test ZHA configurable switch platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + } + }, + manufacturer="manufacturer", + model="model", + ) + + ( + add_to_registry_v2(zigpy_device.manufacturer, zigpy_device.model) + .adds(WindowDetectionFunctionQuirk.TuyaManufCluster) + .switch( + "window_detection_function", + WindowDetectionFunctionQuirk.TuyaManufCluster.cluster_id, + on_value=3, + off_value=5, + ) + ) + + zigpy_device = _DEVICE_REGISTRY.get_device(zigpy_device) + + assert isinstance(zigpy_device, CustomDeviceV2) + cluster = zigpy_device.endpoints[1].tuya_manufacturer + cluster.PLUGGED_ATTR_READS = {"window_detection_function": 5} + update_attribute_cache(cluster) + + zha_device = await zha_device_joined_restored(zigpy_device) + + entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) + assert entity_id is not None + + assert hass.states.get(entity_id).state == STATE_OFF + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the switch was created and that its state is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state has changed from unavailable to off + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on at switch + await send_attributes_report(hass, cluster, {"window_detection_function": 3}) + assert hass.states.get(entity_id).state == STATE_ON + + # turn off at switch + await send_attributes_report(hass, cluster, {"window_detection_function": 5}) + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn on via UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 3}, manufacturer=None) + ] + cluster.write_attributes.reset_mock() + + # turn off from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn off via UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 5}, manufacturer=None) + ] + + +async def test_switch_configurable_custom_on_off_values_force_inverted( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device_mock +) -> None: + """Test ZHA configurable switch platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + } + }, + manufacturer="manufacturer2", + model="model2", + ) + + ( + add_to_registry_v2(zigpy_device.manufacturer, zigpy_device.model) + .adds(WindowDetectionFunctionQuirk.TuyaManufCluster) + .switch( + "window_detection_function", + WindowDetectionFunctionQuirk.TuyaManufCluster.cluster_id, + on_value=3, + off_value=5, + force_inverted=True, + ) + ) + + zigpy_device = _DEVICE_REGISTRY.get_device(zigpy_device) + + assert isinstance(zigpy_device, CustomDeviceV2) + cluster = zigpy_device.endpoints[1].tuya_manufacturer + cluster.PLUGGED_ATTR_READS = {"window_detection_function": 5} + update_attribute_cache(cluster) + + zha_device = await zha_device_joined_restored(zigpy_device) + + entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) + assert entity_id is not None + + assert hass.states.get(entity_id).state == STATE_ON + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the switch was created and that its state is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state has changed from unavailable to off + assert hass.states.get(entity_id).state == STATE_ON + + # turn on at switch + await send_attributes_report(hass, cluster, {"window_detection_function": 3}) + assert hass.states.get(entity_id).state == STATE_OFF + + # turn off at switch + await send_attributes_report(hass, cluster, {"window_detection_function": 5}) + assert hass.states.get(entity_id).state == STATE_ON + + # turn on from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn on via UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 5}, manufacturer=None) + ] + cluster.write_attributes.reset_mock() + + # turn off from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn off via UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 3}, manufacturer=None) + ] + + +async def test_switch_configurable_custom_on_off_values_inverter_attribute( + hass: HomeAssistant, zha_device_joined_restored, zigpy_device_mock +) -> None: + """Test ZHA configurable switch platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + } + }, + manufacturer="manufacturer3", + model="model3", + ) + + ( + add_to_registry_v2(zigpy_device.manufacturer, zigpy_device.model) + .adds(WindowDetectionFunctionQuirk.TuyaManufCluster) + .switch( + "window_detection_function", + WindowDetectionFunctionQuirk.TuyaManufCluster.cluster_id, + on_value=3, + off_value=5, + invert_attribute_name="window_detection_function_inverter", + ) + ) + + zigpy_device = _DEVICE_REGISTRY.get_device(zigpy_device) + + assert isinstance(zigpy_device, CustomDeviceV2) + cluster = zigpy_device.endpoints[1].tuya_manufacturer + cluster.PLUGGED_ATTR_READS = { + "window_detection_function": 5, + "window_detection_function_inverter": t.Bool(True), + } + update_attribute_cache(cluster) + + zha_device = await zha_device_joined_restored(zigpy_device) + + entity_id = find_entity_id(Platform.SWITCH, zha_device, hass) + assert entity_id is not None + + assert hass.states.get(entity_id).state == STATE_ON + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the switch was created and that its state is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state has changed from unavailable to off + assert hass.states.get(entity_id).state == STATE_ON + + # turn on at switch + await send_attributes_report(hass, cluster, {"window_detection_function": 3}) + assert hass.states.get(entity_id).state == STATE_OFF + + # turn off at switch + await send_attributes_report(hass, cluster, {"window_detection_function": 5}) + assert hass.states.get(entity_id).state == STATE_ON + + # turn on from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn on via UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 5}, manufacturer=None) + ] + cluster.write_attributes.reset_mock() + + # turn off from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn off via UI + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 3}, manufacturer=None) + ] + + WCAttrs = closures.WindowCovering.AttributeDefs WCT = closures.WindowCovering.WindowCoveringType WCCS = closures.WindowCovering.ConfigStatus From 52ea1a9deb6f9153ed8abbcefea07947c079f256 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 29 Feb 2024 12:25:46 +0100 Subject: [PATCH 28/95] Deprecate `hass.components` and log warning if used inside custom component (#111508) * Deprecate @bind_hass and log error if used inside custom component * Log also when accessing `hass.components` * Log warning only when `hass.components` is used * Change version * Process code review --- homeassistant/helpers/frame.py | 7 +++++-- homeassistant/loader.py | 13 +++++++++++++ tests/conftest.py | 27 +++++++++++++++++++++++++++ tests/helpers/test_frame.py | 33 +++++---------------------------- tests/test_loader.py | 34 +++++++++++++++++++++++++++++++++- 5 files changed, 83 insertions(+), 31 deletions(-) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 920c7150f6d..9c17e17a2c6 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -86,6 +86,7 @@ def report( exclude_integrations: set | None = None, error_if_core: bool = True, level: int = logging.WARNING, + log_custom_component_only: bool = False, ) -> None: """Report incorrect usage. @@ -99,10 +100,12 @@ def report( msg = f"Detected code that {what}. Please report this issue." if error_if_core: raise RuntimeError(msg) from err - _LOGGER.warning(msg, stack_info=True) + if not log_custom_component_only: + _LOGGER.warning(msg, stack_info=True) return - _report_integration(what, integration_frame, level) + if not log_custom_component_only or integration_frame.custom_integration: + _report_integration(what, integration_frame, level) def _report_integration( diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 8aac185cac0..1bbd22d4070 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1247,6 +1247,19 @@ class Components: if component is None: raise ImportError(f"Unable to load {comp_name}") + # Local import to avoid circular dependencies + from .helpers.frame import report # pylint: disable=import-outside-toplevel + + report( + ( + f"accesses hass.components.{comp_name}." + " This is deprecated and will stop working in Home Assistant 2024.6, it" + f" should be updated to import functions used from {comp_name} directly" + ), + error_if_core=False, + log_custom_component_only=True, + ) + wrapped = ModuleWrapper(self._hass, component) setattr(self, comp_name, wrapped) return wrapped diff --git a/tests/conftest.py b/tests/conftest.py index 18645034c29..3be03e1e3ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1579,6 +1579,33 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: yield mock_bleak_scanner_start +@pytest.fixture +def mock_integration_frame() -> Generator[Mock, None, None]: + """Mock as if we're calling code from inside an integration.""" + correct_frame = Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ) + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + yield correct_frame + + @pytest.fixture def mock_bluetooth( mock_bleak_scanner_start: MagicMock, mock_bluetooth_adapters: None diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index f1547f36e39..5010c459345 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -1,6 +1,5 @@ """Test the frame helper.""" -from collections.abc import Generator from unittest.mock import ANY, Mock, patch import pytest @@ -9,33 +8,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import frame -@pytest.fixture -def mock_integration_frame() -> Generator[Mock, None, None]: - """Mock as if we're calling code from inside an integration.""" - correct_frame = Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", - lineno="23", - line="self.light.is_on", - ) - with patch( - "homeassistant.helpers.frame.extract_stack", - return_value=[ - Mock( - filename="/home/paulus/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - correct_frame, - Mock( - filename="/home/paulus/aiohue/lights.py", - lineno="2", - line="something()", - ), - ], - ): - yield correct_frame - - async def test_extract_frame_integration( caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock ) -> None: @@ -174,3 +146,8 @@ async def test_report_missing_integration_frame( frame.report(what, error_if_core=False) assert what in caplog.text assert caplog.text.count(what) == 1 + + caplog.clear() + + frame.report(what, error_if_core=False, log_custom_component_only=True) + assert caplog.text == "" diff --git a/tests/test_loader.py b/tests/test_loader.py index 27fe3b94cf2..d173e3e8aa6 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,6 +1,6 @@ """Test to verify that we can load components.""" import asyncio -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -8,6 +8,7 @@ from homeassistant import loader from homeassistant.components import http, hue from homeassistant.components.hue import light as hue_light from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import frame from .common import MockModule, async_get_persistent_notifications, mock_integration @@ -287,6 +288,7 @@ async def test_get_integration_custom_component( ) -> None: """Test resolving integration.""" integration = await loader.async_get_integration(hass, "test_package") + assert integration.get_component().DOMAIN == "test_package" assert integration.name == "Test Package" @@ -1001,3 +1003,33 @@ async def test_config_folder_not_in_path(hass): # Verify that we are able to load the file with absolute path import tests.testing_config.check_config_not_in_path # noqa: F401 + + +async def test_hass_components_use_reported( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock +) -> None: + """Test that use of hass.components is reported.""" + mock_integration_frame.filename = ( + "/home/paulus/homeassistant/custom_components/demo/light.py" + ) + integration_frame = frame.IntegrationFrame( + custom_integration=True, + frame=mock_integration_frame, + integration="test_integration_frame", + module="custom_components.test_integration_frame", + relative_filename="custom_components/test_integration_frame/__init__.py", + ) + + with patch( + "homeassistant.helpers.frame.get_integration_frame", + return_value=integration_frame, + ), patch( + "homeassistant.components.http.start_http_server_and_save_config", + return_value=None, + ): + hass.components.http.start_http_server_and_save_config(hass, [], None) + + assert ( + "Detected that custom integration 'test_integration_frame'" + " accesses hass.components.http. This is deprecated" + ) in caplog.text From 868f19e8467bab94998537247e0732d40607d33a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Feb 2024 04:47:36 -1000 Subject: [PATCH 29/95] Fix race in config entry setup again (#111800) Because the setup again was scheduled as a task, it would not unset self._async_cancel_retry_setup in time and we would try to unsub self._async_cancel_retry_setup after it had already fired. Change it to call a callback that runs right away so it unsets self._async_cancel_retry_setup as soon as its called so there is no race fixes #111796 --- homeassistant/config_entries.py | 37 ++++++++++++++++++--------------- tests/test_config_entries.py | 3 ++- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 864ad90344a..1ca40886da2 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -33,6 +33,7 @@ from .core import ( CoreState, Event, HassJob, + HassJobType, HomeAssistant, callback, ) @@ -363,7 +364,6 @@ class ConfigEntry: self._integration_for_domain: loader.Integration | None = None self._tries = 0 - self._setup_again_job: HassJob | None = None def __repr__(self) -> str: """Representation of ConfigEntry.""" @@ -555,12 +555,18 @@ class ConfigEntry: if hass.state is CoreState.running: self._async_cancel_retry_setup = async_call_later( - hass, wait_time, self._async_get_setup_again_job(hass) + hass, + wait_time, + HassJob( + functools.partial(self._async_setup_again, hass), + job_type=HassJobType.Callback, + ), ) else: - self._async_cancel_retry_setup = hass.bus.async_listen_once( + self._async_cancel_retry_setup = hass.bus.async_listen( EVENT_HOMEASSISTANT_STARTED, functools.partial(self._async_setup_again, hass), + run_immediately=True, ) await self._async_process_on_unload(hass) @@ -585,28 +591,25 @@ class ConfigEntry: if not domain_is_integration: return + self.async_cancel_retry_setup() + if result: self._async_set_state(hass, ConfigEntryState.LOADED, None) else: self._async_set_state(hass, ConfigEntryState.SETUP_ERROR, error_reason) - async def _async_setup_again(self, hass: HomeAssistant, *_: Any) -> None: - """Run setup again.""" + @callback + def _async_setup_again(self, hass: HomeAssistant, *_: Any) -> None: + """Schedule setup again. + + This method is a callback to ensure that _async_cancel_retry_setup + is unset as soon as its callback is called. + """ + self._async_cancel_retry_setup = None # Check again when we fire in case shutdown # has started so we do not block shutdown if not hass.is_stopping: - self._async_cancel_retry_setup = None - await self.async_setup(hass) - - @callback - def _async_get_setup_again_job(self, hass: HomeAssistant) -> HassJob: - """Get a job that will call setup again.""" - if not self._setup_again_job: - self._setup_again_job = HassJob( - functools.partial(self._async_setup_again, hass), - cancel_on_shutdown=True, - ) - return self._setup_again_job + hass.async_create_task(self.async_setup(hass), eager_start=True) @callback def async_shutdown(self) -> None: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index ab1942b5c43..672dbb9ae64 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1174,7 +1174,8 @@ async def test_setup_raise_not_ready( mock_setup_entry.side_effect = None mock_setup_entry.return_value = True - await hass.async_run_hass_job(p_setup, None) + hass.async_run_hass_job(p_setup, None) + await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.LOADED assert entry.reason is None From 640de7dbc9bab190433a85e6570ba74ed535e743 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Feb 2024 05:30:29 -1000 Subject: [PATCH 30/95] Include filename in exception when loading a json file fails (#111802) * Include filename in exception when loading a json file fails * fix --- homeassistant/util/json.py | 6 +++--- tests/helpers/test_storage.py | 8 ++++---- tests/util/test_json.py | 7 ++++--- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 65f93020cc6..3a337cf0e18 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -79,12 +79,12 @@ def load_json( except FileNotFoundError: # This is not a fatal error _LOGGER.debug("JSON file not found: %s", filename) - except ValueError as error: + except JSON_DECODE_EXCEPTIONS as error: _LOGGER.exception("Could not parse JSON content: %s", filename) - raise HomeAssistantError(error) from error + raise HomeAssistantError(f"Error while loading {filename}: {error}") from error except OSError as error: _LOGGER.exception("JSON file reading failed: %s", filename) - raise HomeAssistantError(error) from error + raise HomeAssistantError(f"Error while loading {filename}: {error}") from error return {} if default is _SENTINEL else default diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 363f6051b96..66dd8c10463 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -706,8 +706,8 @@ async def test_loading_corrupt_core_file( assert issue_entry.translation_placeholders["storage_key"] == storage_key assert issue_entry.issue_domain == HOMEASSISTANT_DOMAIN assert ( - issue_entry.translation_placeholders["error"] - == "unexpected character: line 1 column 1 (char 0)" + "unexpected character: line 1 column 1 (char 0)" + in issue_entry.translation_placeholders["error"] ) files = await hass.async_add_executor_job( @@ -767,8 +767,8 @@ async def test_loading_corrupt_file_known_domain( assert issue_entry.translation_placeholders["storage_key"] == storage_key assert issue_entry.issue_domain == "testdomain" assert ( - issue_entry.translation_placeholders["error"] - == "unexpected content after document: line 1 column 17 (char 16)" + "unexpected content after document: line 1 column 17 (char 16)" + in issue_entry.translation_placeholders["error"] ) files = await hass.async_add_executor_job( diff --git a/tests/util/test_json.py b/tests/util/test_json.py index ff0f1ed8392..ba07c7cbb6c 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -1,5 +1,6 @@ """Test Home Assistant json utility functions.""" from pathlib import Path +import re import orjson import pytest @@ -21,11 +22,11 @@ TEST_BAD_SERIALIED = "THIS IS NOT JSON\n" def test_load_bad_data(tmp_path: Path) -> None: - """Test error from trying to load unserialisable data.""" + """Test error from trying to load unserializable data.""" fname = tmp_path / "test5.json" with open(fname, "w") as fh: fh.write(TEST_BAD_SERIALIED) - with pytest.raises(HomeAssistantError) as err: + with pytest.raises(HomeAssistantError, match=re.escape(str(fname))) as err: load_json(fname) assert isinstance(err.value.__cause__, ValueError) @@ -33,7 +34,7 @@ def test_load_bad_data(tmp_path: Path) -> None: def test_load_json_os_error() -> None: """Test trying to load JSON data from a directory.""" fname = "/" - with pytest.raises(HomeAssistantError) as err: + with pytest.raises(HomeAssistantError, match=re.escape(str(fname))) as err: load_json(fname) assert isinstance(err.value.__cause__, OSError) From 10cc0e6b2b0fe1d357d08029ce580198f2f8ba0a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Feb 2024 10:28:32 -0500 Subject: [PATCH 31/95] Import cryptography OpenSSL backend (#111840) * Import cryptography OpenSSL backend * No need to impor top-level. Included. * Update homeassistant/bootstrap.py --- homeassistant/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 1a26f8e25c7..4fc9073b146 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any # Import cryptography early since import openssl is not thread-safe # _frozen_importlib._DeadlockError: deadlock detected by _ModuleLock('cryptography.hazmat.backends.openssl.backend') -import cryptography # noqa: F401 +import cryptography.hazmat.backends.openssl.backend # noqa: F401 import voluptuous as vol import yarl From 51716290bbafa85c5f38668f7c60765c74992c96 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Feb 2024 10:43:47 -0500 Subject: [PATCH 32/95] Bump version to 2024.3.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index afb4da90aa0..859a85ee1b4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index a9e4cefd9ae..1ac98da33b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.3.0b1" +version = "2024.3.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From b6503f53bcf048dce8bf22c562fc06c9ed15274d Mon Sep 17 00:00:00 2001 From: dotvav Date: Thu, 29 Feb 2024 19:18:59 +0100 Subject: [PATCH 33/95] Support HitachiAirToAirHeatPump (ovp:HLinkMainController) in Overkiz integration (#102159) * Support OVP devices Support OVP devices * Fix coding style * Fix coding style and unnecessary constants * Move fanmodes inside class * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Remove duplicate widget * Update homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py Co-authored-by: Joost Lekkerkerker * Format ruff * Fix mypy --------- Co-authored-by: Mick Vleeshouwer Co-authored-by: Joost Lekkerkerker --- .../overkiz/climate_entities/__init__.py | 2 + .../hitachi_air_to_air_heat_pump_ovp.py | 357 ++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index 331823c594a..72230c99a05 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -14,6 +14,7 @@ from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl from .atlantic_pass_apc_zone_control_zone import AtlanticPassAPCZoneControlZone from .hitachi_air_to_air_heat_pump_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI +from .hitachi_air_to_air_heat_pump_ovp import HitachiAirToAirHeatPumpOVP from .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface from .somfy_thermostat import SomfyThermostat from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface @@ -56,5 +57,6 @@ WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY = { WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: { Protocol.HLRR_WIFI: HitachiAirToAirHeatPumpHLRRWIFI, + Protocol.OVP: HitachiAirToAirHeatPumpOVP, }, } diff --git a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py new file mode 100644 index 00000000000..bf6bb5f95d5 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py @@ -0,0 +1,357 @@ +"""Support for HitachiAirToAirHeatPump.""" +from __future__ import annotations + +from typing import Any + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRESET_NONE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..const import DOMAIN +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +PRESET_HOLIDAY_MODE = "holiday_mode" +FAN_SILENT = "silent" +TEMP_MIN = 16 +TEMP_MAX = 32 +TEMP_AUTO_MIN = 22 +TEMP_AUTO_MAX = 28 +AUTO_PIVOT_TEMPERATURE = 25 +AUTO_TEMPERATURE_CHANGE_MIN = TEMP_AUTO_MIN - AUTO_PIVOT_TEMPERATURE +AUTO_TEMPERATURE_CHANGE_MAX = TEMP_AUTO_MAX - AUTO_PIVOT_TEMPERATURE + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + OverkizCommandParam.AUTOHEATING: HVACMode.AUTO, + OverkizCommandParam.AUTOCOOLING: HVACMode.AUTO, + OverkizCommandParam.ON: HVACMode.HEAT, + OverkizCommandParam.OFF: HVACMode.OFF, + OverkizCommandParam.HEATING: HVACMode.HEAT, + OverkizCommandParam.FAN: HVACMode.FAN_ONLY, + OverkizCommandParam.DEHUMIDIFY: HVACMode.DRY, + OverkizCommandParam.COOLING: HVACMode.COOL, +} + +HVAC_MODES_TO_OVERKIZ: dict[HVACMode, str] = { + HVACMode.AUTO: OverkizCommandParam.AUTO, + HVACMode.HEAT: OverkizCommandParam.HEATING, + HVACMode.OFF: OverkizCommandParam.HEATING, + HVACMode.FAN_ONLY: OverkizCommandParam.FAN, + HVACMode.DRY: OverkizCommandParam.DEHUMIDIFY, + HVACMode.COOL: OverkizCommandParam.COOLING, +} + +OVERKIZ_TO_SWING_MODES: dict[str, str] = { + OverkizCommandParam.BOTH: SWING_BOTH, + OverkizCommandParam.HORIZONTAL: SWING_HORIZONTAL, + OverkizCommandParam.STOP: SWING_OFF, + OverkizCommandParam.VERTICAL: SWING_VERTICAL, +} + +SWING_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_SWING_MODES.items()} + +OVERKIZ_TO_FAN_MODES: dict[str, str] = { + OverkizCommandParam.AUTO: FAN_AUTO, + OverkizCommandParam.HIGH: FAN_HIGH, # fallback, state can be exposed as HIGH, new state = hi + OverkizCommandParam.HI: FAN_HIGH, + OverkizCommandParam.LOW: FAN_LOW, + OverkizCommandParam.LO: FAN_LOW, + OverkizCommandParam.MEDIUM: FAN_MEDIUM, # fallback, state can be exposed as MEDIUM, new state = med + OverkizCommandParam.MED: FAN_MEDIUM, + OverkizCommandParam.SILENT: OverkizCommandParam.SILENT, +} + +FAN_MODES_TO_OVERKIZ: dict[str, str] = { + FAN_AUTO: OverkizCommandParam.AUTO, + FAN_HIGH: OverkizCommandParam.HI, + FAN_LOW: OverkizCommandParam.LO, + FAN_MEDIUM: OverkizCommandParam.MED, + FAN_SILENT: OverkizCommandParam.SILENT, +} + + +class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity): + """Representation of Hitachi Air To Air HeatPump.""" + + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_fan_modes = [*FAN_MODES_TO_OVERKIZ] + _attr_preset_modes = [PRESET_NONE, PRESET_HOLIDAY_MODE] + _attr_swing_modes = [*SWING_MODES_TO_OVERKIZ] + _attr_target_temperature_step = 1.0 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + ) + + if self.device.states.get(OverkizState.OVP_SWING): + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + if self._attr_device_info: + self._attr_device_info["manufacturer"] = "Hitachi" + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if ( + main_op_state := self.device.states[OverkizState.OVP_MAIN_OPERATION] + ) and main_op_state.value_as_str: + if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF: + return HVACMode.OFF + + if ( + mode_change_state := self.device.states[OverkizState.OVP_MODE_CHANGE] + ) and mode_change_state.value_as_str: + # The OVP protocol has 'auto cooling' and 'auto heating' values + # that are equivalent to the HLRRWIFI protocol without spaces + sanitized_value = mode_change_state.value_as_str.replace(" ", "").lower() + return OVERKIZ_TO_HVAC_MODES[sanitized_value] + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.OFF: + await self._global_control(main_operation=OverkizCommandParam.OFF) + else: + await self._global_control( + main_operation=OverkizCommandParam.ON, + hvac_mode=HVAC_MODES_TO_OVERKIZ[hvac_mode], + ) + + @property + def fan_mode(self) -> str | None: + """Return the fan setting.""" + if ( + state := self.device.states[OverkizState.OVP_FAN_SPEED] + ) and state.value_as_str: + return OVERKIZ_TO_FAN_MODES[state.value_as_str] + + return None + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await self._global_control(fan_mode=FAN_MODES_TO_OVERKIZ[fan_mode]) + + @property + def swing_mode(self) -> str | None: + """Return the swing setting.""" + if (state := self.device.states[OverkizState.OVP_SWING]) and state.value_as_str: + return OVERKIZ_TO_SWING_MODES[state.value_as_str] + + return None + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + await self._global_control(swing_mode=SWING_MODES_TO_OVERKIZ[swing_mode]) + + @property + def target_temperature(self) -> int | None: + """Return the target temperature.""" + if ( + temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE] + ) and temperature.value_as_int: + return temperature.value_as_int + + return None + + @property + def current_temperature(self) -> int | None: + """Return current temperature.""" + if ( + state := self.device.states[OverkizState.OVP_ROOM_TEMPERATURE] + ) and state.value_as_int: + return state.value_as_int + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + await self._global_control(target_temperature=int(kwargs[ATTR_TEMPERATURE])) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if ( + state := self.device.states[OverkizState.CORE_HOLIDAYS_MODE] + ) and state.value_as_str: + if state.value_as_str == OverkizCommandParam.ON: + return PRESET_HOLIDAY_MODE + + if state.value_as_str == OverkizCommandParam.OFF: + return PRESET_NONE + + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_HOLIDAY_MODE: + await self.executor.async_execute_command( + OverkizCommand.SET_HOLIDAYS, + OverkizCommandParam.ON, + ) + if preset_mode == PRESET_NONE: + await self.executor.async_execute_command( + OverkizCommand.SET_HOLIDAYS, + OverkizCommandParam.OFF, + ) + + # OVP has this property to control the unit's timer mode + @property + def auto_manu_mode(self) -> str | None: + """Return auto/manu mode.""" + if ( + state := self.device.states[OverkizState.CORE_AUTO_MANU_MODE] + ) and state.value_as_str: + return state.value_as_str + return None + + # OVP has this property to control the target temperature delta in auto mode + @property + def temperature_change(self) -> int | None: + """Return temperature change state.""" + if ( + state := self.device.states[OverkizState.OVP_TEMPERATURE_CHANGE] + ) and state.value_as_int: + return state.value_as_int + + return None + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + if self.hvac_mode == HVACMode.AUTO: + return TEMP_AUTO_MIN + return TEMP_MIN + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + if self.hvac_mode == HVACMode.AUTO: + return TEMP_AUTO_MAX + return TEMP_MAX + + def _control_backfill( + self, value: str | None, state_name: str, fallback_value: str + ) -> str: + """Return a parameter value which will be accepted in a command by Overkiz. + + Overkiz doesn't accept commands with undefined parameters. This function + is guaranteed to return a `str` which is the provided `value` if set, or + the current device state if set, or the provided `fallback_value` otherwise. + """ + if value: + return value + if (state := self.device.states[state_name]) is not None and ( + value := state.value_as_str + ) is not None: + return value + return fallback_value + + async def _global_control( + self, + main_operation: str | None = None, + target_temperature: int | None = None, + fan_mode: str | None = None, + hvac_mode: str | None = None, + swing_mode: str | None = None, + leave_home: str | None = None, + ) -> None: + """Execute globalControl command with all parameters. + + There is no option to only set a single parameter, without passing + all other values. + """ + + main_operation = self._control_backfill( + main_operation, OverkizState.OVP_MAIN_OPERATION, OverkizCommandParam.ON + ) + fan_mode = self._control_backfill( + fan_mode, + OverkizState.OVP_FAN_SPEED, + OverkizCommandParam.AUTO, + ) + hvac_mode = self._control_backfill( + hvac_mode, + OverkizState.OVP_MODE_CHANGE, + OverkizCommandParam.AUTO, + ).lower() # Overkiz returns uppercase states that are not acceptable commands + if hvac_mode.replace(" ", "") in [ + # Overkiz returns compound states like 'auto cooling' or 'autoHeating' + # that are not valid commands and need to be mapped to 'auto' + OverkizCommandParam.AUTOCOOLING, + OverkizCommandParam.AUTOHEATING, + ]: + hvac_mode = OverkizCommandParam.AUTO + + swing_mode = self._control_backfill( + swing_mode, + OverkizState.OVP_SWING, + OverkizCommandParam.STOP, + ) + + # AUTO_MANU parameter is not controlled by HA and is turned "off" when the device is on Holiday mode + auto_manu_mode = self._control_backfill( + None, OverkizState.CORE_AUTO_MANU_MODE, OverkizCommandParam.MANU + ) + if self.preset_mode == PRESET_HOLIDAY_MODE: + auto_manu_mode = OverkizCommandParam.OFF + + # In all the hvac modes except AUTO, the temperature command parameter is the target temperature + temperature_command = None + target_temperature = target_temperature or self.target_temperature + if hvac_mode == OverkizCommandParam.AUTO: + # In hvac mode AUTO, the temperature command parameter is a temperature_change + # which is the delta between a pivot temperature (25) and the target temperature + temperature_change = 0 + + if target_temperature: + temperature_change = target_temperature - AUTO_PIVOT_TEMPERATURE + elif self.temperature_change: + temperature_change = self.temperature_change + + # Keep temperature_change in the API accepted range + temperature_change = min( + max(temperature_change, AUTO_TEMPERATURE_CHANGE_MIN), + AUTO_TEMPERATURE_CHANGE_MAX, + ) + + temperature_command = temperature_change + else: + # In other modes, the temperature command is the target temperature + temperature_command = target_temperature + + command_data = [ + main_operation, # Main Operation + temperature_command, # Temperature Command + fan_mode, # Fan Mode + hvac_mode, # Mode + auto_manu_mode, # Auto Manu Mode + ] + + await self.executor.async_execute_command( + OverkizCommand.GLOBAL_CONTROL, command_data + ) From a9410ded113c681408f99cee1f87cc8881bfdd8f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 29 Feb 2024 18:05:22 +0100 Subject: [PATCH 34/95] Update frontend to 20240228.1 (#111859) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7e24b8d0880..3bbee2eae58 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240228.0"] + "requirements": ["home-assistant-frontend==20240228.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 976c6c514f5..c1f80804b5c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ habluetooth==2.4.1 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240228.0 +home-assistant-frontend==20240228.1 home-assistant-intents==2024.2.28 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index a0c3ea77efa..1257caa2ec9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1074,7 +1074,7 @@ hole==0.8.0 holidays==0.43 # homeassistant.components.frontend -home-assistant-frontend==20240228.0 +home-assistant-frontend==20240228.1 # homeassistant.components.conversation home-assistant-intents==2024.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 692e4d397a8..d93e5dcdd27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -873,7 +873,7 @@ hole==0.8.0 holidays==0.43 # homeassistant.components.frontend -home-assistant-frontend==20240228.0 +home-assistant-frontend==20240228.1 # homeassistant.components.conversation home-assistant-intents==2024.2.28 From 4f50c7217befe5a8f10584aa488d71f29bd736c7 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 29 Feb 2024 20:53:52 -0600 Subject: [PATCH 35/95] Move HassSetPosition to homeassistant domain (#111867) * Move HassSetPosition to homeassistant domain * Add test for unsupported domain with HassSetPosition * Split service intent handler * cleanup --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/cover/intent.py | 18 +----- homeassistant/components/intent/__init__.py | 43 +++++++++++--- homeassistant/components/valve/intent.py | 22 ------- homeassistant/helpers/intent.py | 59 +++++++++++++++---- .../test_default_agent_intents.py | 3 - tests/components/cover/test_intent.py | 3 +- tests/components/intent/test_init.py | 17 ++++++ tests/components/valve/test_intent.py | 3 +- 8 files changed, 105 insertions(+), 63 deletions(-) delete mode 100644 homeassistant/components/valve/intent.py diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py index 633b09f987c..dc8f722c7ed 100644 --- a/homeassistant/components/cover/intent.py +++ b/homeassistant/components/cover/intent.py @@ -1,16 +1,11 @@ """Intents for the cover integration.""" -import voluptuous as vol -from homeassistant.const import ( - SERVICE_CLOSE_COVER, - SERVICE_OPEN_COVER, - SERVICE_SET_COVER_POSITION, -) +from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from . import ATTR_POSITION, DOMAIN +from . import DOMAIN INTENT_OPEN_COVER = "HassOpenCover" INTENT_CLOSE_COVER = "HassCloseCover" @@ -30,12 +25,3 @@ async def async_setup_intents(hass: HomeAssistant) -> None: INTENT_CLOSE_COVER, DOMAIN, SERVICE_CLOSE_COVER, "Closed {}" ), ) - intent.async_register( - hass, - intent.ServiceIntentHandler( - intent.INTENT_SET_POSITION, - DOMAIN, - SERVICE_SET_COVER_POSITION, - extra_slots={ATTR_POSITION: vol.All(vol.Range(min=0, max=100))}, - ), - ) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 3c8e1d57d7c..f307208e537 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -10,9 +10,11 @@ import voluptuous as vol from homeassistant.components import http from homeassistant.components.cover import ( + ATTR_POSITION, DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, ) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.lock import ( @@ -24,6 +26,7 @@ from homeassistant.components.valve import ( DOMAIN as VALVE_DOMAIN, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -75,6 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, NevermindIntentHandler(), ) + intent.async_register(hass, SetPositionIntentHandler()) return True @@ -89,14 +93,16 @@ class IntentPlatformProtocol(Protocol): class OnOffIntentHandler(intent.ServiceIntentHandler): """Intent handler for on/off that also supports covers, valves, locks, etc.""" - async def async_call_service(self, intent_obj: intent.Intent, state: State) -> None: + async def async_call_service( + self, domain: str, service: str, intent_obj: intent.Intent, state: State + ) -> None: """Call service on entity with handling for special cases.""" hass = intent_obj.hass if state.domain == COVER_DOMAIN: # on = open # off = close - if self.service == SERVICE_TURN_ON: + if service == SERVICE_TURN_ON: service_name = SERVICE_OPEN_COVER else: service_name = SERVICE_CLOSE_COVER @@ -117,7 +123,7 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): if state.domain == LOCK_DOMAIN: # on = lock # off = unlock - if self.service == SERVICE_TURN_ON: + if service == SERVICE_TURN_ON: service_name = SERVICE_LOCK else: service_name = SERVICE_UNLOCK @@ -138,7 +144,7 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): if state.domain == VALVE_DOMAIN: # on = opened # off = closed - if self.service == SERVICE_TURN_ON: + if service == SERVICE_TURN_ON: service_name = SERVICE_OPEN_VALVE else: service_name = SERVICE_CLOSE_VALVE @@ -156,13 +162,13 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): ) return - if not hass.services.has_service(state.domain, self.service): + if not hass.services.has_service(state.domain, service): raise intent.IntentHandleError( - f"Service {self.service} does not support entity {state.entity_id}" + f"Service {service} does not support entity {state.entity_id}" ) # Fall back to homeassistant.turn_on/off - await super().async_call_service(intent_obj, state) + await super().async_call_service(domain, service, intent_obj, state) class GetStateIntentHandler(intent.IntentHandler): @@ -296,6 +302,29 @@ class NevermindIntentHandler(intent.IntentHandler): return intent_obj.create_response() +class SetPositionIntentHandler(intent.DynamicServiceIntentHandler): + """Intent handler for setting positions.""" + + def __init__(self) -> None: + """Create set position handler.""" + super().__init__( + intent.INTENT_SET_POSITION, + extra_slots={ATTR_POSITION: vol.All(vol.Range(min=0, max=100))}, + ) + + def get_domain_and_service( + self, intent_obj: intent.Intent, state: State + ) -> tuple[str, str]: + """Get the domain and service name to call.""" + if state.domain == COVER_DOMAIN: + return (COVER_DOMAIN, SERVICE_SET_COVER_POSITION) + + if state.domain == VALVE_DOMAIN: + return (VALVE_DOMAIN, SERVICE_SET_VALVE_POSITION) + + raise intent.IntentHandleError(f"Domain not supported: {state.domain}") + + async def _async_process_intent( hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol ) -> None: diff --git a/homeassistant/components/valve/intent.py b/homeassistant/components/valve/intent.py deleted file mode 100644 index 1b77bdce343..00000000000 --- a/homeassistant/components/valve/intent.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Intents for the valve integration.""" - -import voluptuous as vol - -from homeassistant.const import SERVICE_SET_VALVE_POSITION -from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent - -from . import ATTR_POSITION, DOMAIN - - -async def async_setup_intents(hass: HomeAssistant) -> None: - """Set up the valve intents.""" - intent.async_register( - hass, - intent.ServiceIntentHandler( - intent.INTENT_SET_POSITION, - DOMAIN, - SERVICE_SET_VALVE_POSITION, - extra_slots={ATTR_POSITION: vol.All(vol.Range(min=0, max=100))}, - ), - ) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 2fd745c35fa..82385f0cda8 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -2,6 +2,7 @@ from __future__ import annotations +from abc import abstractmethod import asyncio from collections.abc import Collection, Coroutine, Iterable import dataclasses @@ -385,8 +386,8 @@ class IntentHandler: return f"<{self.__class__.__name__} - {self.intent_type}>" -class ServiceIntentHandler(IntentHandler): - """Service Intent handler registration. +class DynamicServiceIntentHandler(IntentHandler): + """Service Intent handler registration (dynamic). Service specific intent handler that calls a service by name/entity_id. """ @@ -404,15 +405,11 @@ class ServiceIntentHandler(IntentHandler): def __init__( self, intent_type: str, - domain: str, - service: str, speech: str | None = None, extra_slots: dict[str, vol.Schema] | None = None, ) -> None: """Create Service Intent Handler.""" self.intent_type = intent_type - self.domain = domain - self.service = service self.speech = speech self.extra_slots = extra_slots @@ -441,6 +438,13 @@ class ServiceIntentHandler(IntentHandler): extra=vol.ALLOW_EXTRA, ) + @abstractmethod + def get_domain_and_service( + self, intent_obj: Intent, state: State + ) -> tuple[str, str]: + """Get the domain and service name to call.""" + raise NotImplementedError() + async def async_handle(self, intent_obj: Intent) -> IntentResponse: """Handle the hass intent.""" hass = intent_obj.hass @@ -536,7 +540,10 @@ class ServiceIntentHandler(IntentHandler): service_coros: list[Coroutine[Any, Any, None]] = [] for state in states: - service_coros.append(self.async_call_service(intent_obj, state)) + domain, service = self.get_domain_and_service(intent_obj, state) + service_coros.append( + self.async_call_service(domain, service, intent_obj, state) + ) # Handle service calls in parallel, noting failures as they occur. failed_results: list[IntentResponseTarget] = [] @@ -558,7 +565,7 @@ class ServiceIntentHandler(IntentHandler): # If no entities succeeded, raise an error. failed_entity_ids = [target.id for target in failed_results] raise IntentHandleError( - f"Failed to call {self.service} for: {failed_entity_ids}" + f"Failed to call {service} for: {failed_entity_ids}" ) response.async_set_results( @@ -574,7 +581,9 @@ class ServiceIntentHandler(IntentHandler): return response - async def async_call_service(self, intent_obj: Intent, state: State) -> None: + async def async_call_service( + self, domain: str, service: str, intent_obj: Intent, state: State + ) -> None: """Call service on entity.""" hass = intent_obj.hass @@ -587,13 +596,13 @@ class ServiceIntentHandler(IntentHandler): await self._run_then_background( hass.async_create_task( hass.services.async_call( - self.domain, - self.service, + domain, + service, service_data, context=intent_obj.context, blocking=True, ), - f"intent_call_service_{self.domain}_{self.service}", + f"intent_call_service_{domain}_{service}", ) ) @@ -615,6 +624,32 @@ class ServiceIntentHandler(IntentHandler): raise +class ServiceIntentHandler(DynamicServiceIntentHandler): + """Service Intent handler registration. + + Service specific intent handler that calls a service by name/entity_id. + """ + + def __init__( + self, + intent_type: str, + domain: str, + service: str, + speech: str | None = None, + extra_slots: dict[str, vol.Schema] | None = None, + ) -> None: + """Create service handler.""" + super().__init__(intent_type, speech=speech, extra_slots=extra_slots) + self.domain = domain + self.service = service + + def get_domain_and_service( + self, intent_obj: Intent, state: State + ) -> tuple[str, str]: + """Get the domain and service name to call.""" + return (self.domain, self.service) + + class IntentCategory(Enum): """Category of an intent.""" diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index e796a6893a8..edf7e17682e 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -8,7 +8,6 @@ from homeassistant.components.cover import intent as cover_intent from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.media_player import intent as media_player_intent from homeassistant.components.vacuum import intent as vaccum_intent -from homeassistant.components.valve import intent as valve_intent from homeassistant.const import STATE_CLOSED from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import intent @@ -84,8 +83,6 @@ async def test_valve_intents( init_components, ) -> None: """Test open/close/set position for valves.""" - await valve_intent.async_setup_intents(hass) - entity_id = f"{valve.DOMAIN}.main_valve" hass.states.async_set(entity_id, STATE_CLOSED) async_expose_entity(hass, conversation.DOMAIN, entity_id, True) diff --git a/tests/components/cover/test_intent.py b/tests/components/cover/test_intent.py index 7705dc1c5a9..b1dbe786065 100644 --- a/tests/components/cover/test_intent.py +++ b/tests/components/cover/test_intent.py @@ -11,6 +11,7 @@ from homeassistant.components.cover import ( from homeassistant.const import STATE_CLOSED, STATE_OPEN from homeassistant.core import HomeAssistant from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component from tests.common import async_mock_service @@ -60,7 +61,7 @@ async def test_close_cover_intent(hass: HomeAssistant) -> None: async def test_set_cover_position(hass: HomeAssistant) -> None: """Test HassSetPosition intent for covers.""" - await cover_intent.async_setup_intents(hass) + assert await async_setup_component(hass, "intent", {}) entity_id = f"{DOMAIN}.test_cover" hass.states.async_set( diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 4c327a237c7..77a6a368c01 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -432,3 +432,20 @@ async def test_get_state_intent( "domain": {"value": "light"}, }, ) + + +async def test_set_position_intent_unsupported_domain(hass: HomeAssistant) -> None: + """Test that HassSetPosition intent fails with unsupported domain.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + # Can't set position of lights + hass.states.async_set("light.test_light", "off") + + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + "HassSetPosition", + {"name": {"value": "test light"}, "position": {"value": 100}}, + ) diff --git a/tests/components/valve/test_intent.py b/tests/components/valve/test_intent.py index 049bb21c722..a8f4054602b 100644 --- a/tests/components/valve/test_intent.py +++ b/tests/components/valve/test_intent.py @@ -6,7 +6,6 @@ from homeassistant.components.valve import ( SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE, SERVICE_SET_VALVE_POSITION, - intent as valve_intent, ) from homeassistant.const import STATE_CLOSED, STATE_OPEN from homeassistant.core import HomeAssistant @@ -60,7 +59,7 @@ async def test_close_valve_intent(hass: HomeAssistant) -> None: async def test_set_valve_position(hass: HomeAssistant) -> None: """Test HassSetPosition intent for valves.""" - await valve_intent.async_setup_intents(hass) + assert await async_setup_component(hass, "intent", {}) entity_id = f"{DOMAIN}.test_valve" hass.states.async_set( From 7a9e5354ee99511b192664d33a01c56c269d1202 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Feb 2024 09:01:03 -1000 Subject: [PATCH 36/95] Fallback to event loop import on deadlock (#111868) --- homeassistant/loader.py | 32 +++++++- tests/test_loader.py | 162 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 189 insertions(+), 5 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 1bbd22d4070..1ff98ff6ff2 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -852,7 +852,14 @@ class Integration: # Some integrations fail on import because they call functions incorrectly. # So we do it before validating config to catch these errors. if load_executor: - comp = await self.hass.async_add_executor_job(self.get_component) + try: + comp = await self.hass.async_add_executor_job(self.get_component) + except ImportError as ex: + load_executor = False + _LOGGER.debug("Failed to import %s in executor", domain, exc_info=ex) + # If importing in the executor deadlocks because there is a circular + # dependency, we fall back to the event loop. + comp = self.get_component() else: comp = self.get_component() @@ -885,6 +892,9 @@ class Integration: ) except ImportError: raise + except RuntimeError as err: + # _DeadlockError inherits from RuntimeError + raise ImportError(f"RuntimeError importing {self.pkg_path}: {err}") from err except Exception as err: _LOGGER.exception( "Unexpected exception importing component %s", self.pkg_path @@ -913,9 +923,18 @@ class Integration: ) try: if load_executor: - platform = await self.hass.async_add_executor_job( - self._load_platform, platform_name - ) + try: + platform = await self.hass.async_add_executor_job( + self._load_platform, platform_name + ) + except ImportError as ex: + _LOGGER.debug( + "Failed to import %s in executor", domain, exc_info=ex + ) + load_executor = False + # If importing in the executor deadlocks because there is a circular + # dependency, we fall back to the event loop. + platform = self._load_platform(platform_name) else: platform = self._load_platform(platform_name) import_future.set_result(platform) @@ -983,6 +1002,11 @@ class Integration: ] missing_platforms_cache[full_name] = ex raise + except RuntimeError as err: + # _DeadlockError inherits from RuntimeError + raise ImportError( + f"RuntimeError importing {self.pkg_path}.{platform_name}: {err}" + ) from err except Exception as err: _LOGGER.exception( "Unexpected exception importing platform %s.%s", diff --git a/tests/test_loader.py b/tests/test_loader.py index d173e3e8aa6..babe1abcdd2 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,6 +1,8 @@ """Test to verify that we can load components.""" import asyncio -from unittest.mock import Mock, patch +import sys +from typing import Any +from unittest.mock import MagicMock, Mock, patch import pytest @@ -1033,3 +1035,161 @@ async def test_hass_components_use_reported( "Detected that custom integration 'test_integration_frame'" " accesses hass.components.http. This is deprecated" ) in caplog.text + + +async def test_async_get_component_deadlock_fallback( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Verify async_get_component fallback to importing in the event loop on deadlock.""" + executor_import_integration = _get_test_integration( + hass, "executor_import", True, import_executor=True + ) + assert executor_import_integration.import_executor is True + module_mock = MagicMock() + import_attempts = 0 + + def mock_import(module: str, *args: Any, **kwargs: Any) -> Any: + nonlocal import_attempts + if module == "homeassistant.components.executor_import": + import_attempts += 1 + + if import_attempts == 1: + # _DeadlockError inherits from RuntimeError + raise RuntimeError( + "Detected deadlock trying to import homeassistant.components.executor_import" + ) + + return module_mock + + assert "homeassistant.components.executor_import" not in sys.modules + assert "custom_components.executor_import" not in sys.modules + with patch("homeassistant.loader.importlib.import_module", mock_import): + module = await executor_import_integration.async_get_component() + + assert ( + "Detected deadlock trying to import homeassistant.components.executor_import" + in caplog.text + ) + assert "loaded_executor=False" in caplog.text + assert module is module_mock + + +async def test_async_get_component_raises_after_import_failure( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Verify async_get_component raises if we fail to import in both the executor and loop.""" + executor_import_integration = _get_test_integration( + hass, "executor_import", True, import_executor=True + ) + assert executor_import_integration.import_executor is True + module_mock = MagicMock() + import_attempts = 0 + + def mock_import(module: str, *args: Any, **kwargs: Any) -> Any: + nonlocal import_attempts + if module == "homeassistant.components.executor_import": + import_attempts += 1 + + if import_attempts == 1: + # _DeadlockError inherits from RuntimeError + raise RuntimeError( + "Detected deadlock trying to import homeassistant.components.executor_import" + ) + + if import_attempts == 2: + raise ImportError("Failed import homeassistant.components.executor_import") + return module_mock + + assert "homeassistant.components.executor_import" not in sys.modules + assert "custom_components.executor_import" not in sys.modules + with patch( + "homeassistant.loader.importlib.import_module", mock_import + ), pytest.raises(ImportError): + await executor_import_integration.async_get_component() + + assert ( + "Detected deadlock trying to import homeassistant.components.executor_import" + in caplog.text + ) + assert "loaded_executor=False" not in caplog.text + + +async def test_async_get_platform_deadlock_fallback( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Verify async_get_platform fallback to importing in the event loop on deadlock.""" + executor_import_integration = _get_test_integration( + hass, "executor_import", True, import_executor=True + ) + assert executor_import_integration.import_executor is True + module_mock = MagicMock() + import_attempts = 0 + + def mock_import(module: str, *args: Any, **kwargs: Any) -> Any: + nonlocal import_attempts + if module == "homeassistant.components.executor_import.config_flow": + import_attempts += 1 + + if import_attempts == 1: + # _DeadlockError inherits from RuntimeError + raise RuntimeError( + "Detected deadlock trying to import homeassistant.components.executor_import" + ) + + return module_mock + + assert "homeassistant.components.executor_import" not in sys.modules + assert "custom_components.executor_import" not in sys.modules + with patch("homeassistant.loader.importlib.import_module", mock_import): + module = await executor_import_integration.async_get_platform("config_flow") + + assert ( + "Detected deadlock trying to import homeassistant.components.executor_import" + in caplog.text + ) + assert "loaded_executor=False" in caplog.text + assert module is module_mock + + +async def test_async_get_platform_raises_after_import_failure( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Verify async_get_platform raises if we fail to import in both the executor and loop.""" + executor_import_integration = _get_test_integration( + hass, "executor_import", True, import_executor=True + ) + assert executor_import_integration.import_executor is True + module_mock = MagicMock() + import_attempts = 0 + + def mock_import(module: str, *args: Any, **kwargs: Any) -> Any: + nonlocal import_attempts + if module == "homeassistant.components.executor_import.config_flow": + import_attempts += 1 + + if import_attempts == 1: + # _DeadlockError inherits from RuntimeError + raise RuntimeError( + "Detected deadlock trying to import homeassistant.components.executor_import" + ) + + if import_attempts == 2: + # _DeadlockError inherits from RuntimeError + raise ImportError( + "Error trying to import homeassistant.components.executor_import" + ) + + return module_mock + + assert "homeassistant.components.executor_import" not in sys.modules + assert "custom_components.executor_import" not in sys.modules + with patch( + "homeassistant.loader.importlib.import_module", mock_import + ), pytest.raises(ImportError): + await executor_import_integration.async_get_platform("config_flow") + + assert ( + "Detected deadlock trying to import homeassistant.components.executor_import" + in caplog.text + ) + assert "loaded_executor=False" not in caplog.text From 63740d2a6dc94541edd8ca922f08841ff625a20b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 29 Feb 2024 12:09:38 -0600 Subject: [PATCH 37/95] Use correct service name with Wyoming satellite + local wake word detection (#111870) * Use correct service name with satellite + local wake word detection * Don't load platforms for satellite services * Update homeassistant/components/wyoming/data.py Co-authored-by: Paulus Schoutsen * Fix ruff error --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/wyoming/data.py | 33 +++++++++++++----------- tests/components/wyoming/test_data.py | 31 +++++++++++++++++++--- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index adcb472d5e0..e333a740741 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -1,10 +1,11 @@ """Base class for Wyoming providers.""" + from __future__ import annotations import asyncio from wyoming.client import AsyncTcpClient -from wyoming.info import Describe, Info, Satellite +from wyoming.info import Describe, Info from homeassistant.const import Platform @@ -23,14 +24,19 @@ class WyomingService: self.host = host self.port = port self.info = info - platforms = [] + self.platforms = [] + + if (self.info.satellite is not None) and self.info.satellite.installed: + # Don't load platforms for satellite services, such as local wake + # word detection. + return + if any(asr.installed for asr in info.asr): - platforms.append(Platform.STT) + self.platforms.append(Platform.STT) if any(tts.installed for tts in info.tts): - platforms.append(Platform.TTS) + self.platforms.append(Platform.TTS) if any(wake.installed for wake in info.wake): - platforms.append(Platform.WAKE_WORD) - self.platforms = platforms + self.platforms.append(Platform.WAKE_WORD) def has_services(self) -> bool: """Return True if services are installed that Home Assistant can use.""" @@ -43,6 +49,12 @@ class WyomingService: def get_name(self) -> str | None: """Return name of first installed usable service.""" + + # Wyoming satellite + # Must be checked first because satellites may contain wake services, etc. + if (self.info.satellite is not None) and self.info.satellite.installed: + return self.info.satellite.name + # ASR = automated speech recognition (speech-to-text) asr_installed = [asr for asr in self.info.asr if asr.installed] if asr_installed: @@ -58,15 +70,6 @@ class WyomingService: if wake_installed: return wake_installed[0].name - # satellite - satellite_installed: Satellite | None = None - - if (self.info.satellite is not None) and self.info.satellite.installed: - satellite_installed = self.info.satellite - - if satellite_installed: - return satellite_installed.name - return None @classmethod diff --git a/tests/components/wyoming/test_data.py b/tests/components/wyoming/test_data.py index b7de9dbfdc1..282326b2ce0 100644 --- a/tests/components/wyoming/test_data.py +++ b/tests/components/wyoming/test_data.py @@ -1,9 +1,11 @@ """Test tts.""" + from __future__ import annotations from unittest.mock import patch from syrupy.assertion import SnapshotAssertion +from wyoming.info import Info from homeassistant.components.wyoming.data import WyomingService, load_wyoming_info from homeassistant.core import HomeAssistant @@ -27,10 +29,13 @@ async def test_load_info_oserror(hass: HomeAssistant) -> None: """Test loading info and error raising.""" mock_client = MockAsyncTcpClient([STT_INFO.event()]) - with patch( - "homeassistant.components.wyoming.data.AsyncTcpClient", - mock_client, - ), patch.object(mock_client, "read_event", side_effect=OSError("Boom!")): + with ( + patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + mock_client, + ), + patch.object(mock_client, "read_event", side_effect=OSError("Boom!")), + ): info = await load_wyoming_info( "localhost", 1234, @@ -75,3 +80,21 @@ async def test_service_name(hass: HomeAssistant) -> None: service = await WyomingService.create("localhost", 1234) assert service is not None assert service.get_name() == SATELLITE_INFO.satellite.name + + +async def test_satellite_with_wake_word(hass: HomeAssistant) -> None: + """Test that wake word info with satellite doesn't overwrite the service name.""" + # Info for local wake word detection + satellite_info = Info( + satellite=SATELLITE_INFO.satellite, + wake=WAKE_WORD_INFO.wake, + ) + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([satellite_info.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == satellite_info.satellite.name + assert not service.platforms From c9aea5732658d56fe1eef1dd50aca94d8118ab7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Feb 2024 16:04:41 -1000 Subject: [PATCH 38/95] Fix circular imports in core integrations (#111875) * Fix circular imports in core integrations * fix circular import * fix more circular imports * fix more circular imports * fix more circular imports * fix more circular imports * fix more circular imports * fix more circular imports * fix more circular imports * adjust * fix * increase timeout * remove unused logger * keep up to date * make sure its reprod --- homeassistant/components/http/__init__.py | 15 +- homeassistant/components/http/auth.py | 2 +- homeassistant/components/http/ban.py | 5 +- homeassistant/components/http/const.py | 3 +- .../components/http/request_context.py | 5 +- homeassistant/components/http/view.py | 179 +---------------- .../components/websocket_api/connection.py | 2 +- homeassistant/helpers/http.py | 184 ++++++++++++++++++ tests/test_circular_imports.py | 39 ++++ 9 files changed, 242 insertions(+), 192 deletions(-) create mode 100644 homeassistant/helpers/http.py create mode 100644 tests/test_circular_imports.py diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 6bb0c154540..ab228e32a52 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -32,6 +32,11 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import storage import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.http import ( + KEY_AUTHENTICATED, # noqa: F401 + HomeAssistantView, + current_request, +) from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -41,20 +46,14 @@ from homeassistant.util.json import json_loads from .auth import async_setup_auth from .ban import setup_bans -from .const import ( # noqa: F401 - KEY_AUTHENTICATED, - KEY_HASS, - KEY_HASS_REFRESH_TOKEN_ID, - KEY_HASS_USER, -) +from .const import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401 from .cors import setup_cors from .decorators import require_admin # noqa: F401 from .forwarded import async_setup_forwarded from .headers import setup_headers -from .request_context import current_request, setup_request_context +from .request_context import setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource -from .view import HomeAssistantView from .web_runner import HomeAssistantTCPSite DOMAIN: Final = "http" diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 99d38bf582e..640d899924e 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -20,13 +20,13 @@ from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.http import current_request from homeassistant.helpers.json import json_bytes from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.storage import Store from homeassistant.util.network import is_local from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER -from .request_context import current_request _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 62569495ba7..0b720b078b9 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -15,7 +15,6 @@ from aiohttp.web import Application, Request, Response, StreamResponse, middlewa from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol -from homeassistant.components import persistent_notification from homeassistant.config import load_yaml_config_file from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -128,6 +127,10 @@ async def process_wrong_login(request: Request) -> None: _LOGGER.warning(log_msg) + # Circular import with websocket_api + # pylint: disable=import-outside-toplevel + from homeassistant.components import persistent_notification + persistent_notification.async_create( hass, notification_msg, "Login attempt failed", NOTIFICATION_ID_LOGIN ) diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index df27122b64a..090e5234aeb 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,7 +1,8 @@ """HTTP specific constants.""" from typing import Final -KEY_AUTHENTICATED: Final = "ha_authenticated" +from homeassistant.helpers.http import KEY_AUTHENTICATED # noqa: F401 + KEY_HASS: Final = "hass" KEY_HASS_USER: Final = "hass_user" KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" diff --git a/homeassistant/components/http/request_context.py b/homeassistant/components/http/request_context.py index 6e036b9cdc8..b516b63dc5c 100644 --- a/homeassistant/components/http/request_context.py +++ b/homeassistant/components/http/request_context.py @@ -7,10 +7,7 @@ from contextvars import ContextVar from aiohttp.web import Application, Request, StreamResponse, middleware from homeassistant.core import callback - -current_request: ContextVar[Request | None] = ContextVar( - "current_request", default=None -) +from homeassistant.helpers.http import current_request # noqa: F401 @callback diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 1be3d761a3b..ce02879dbb3 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -1,180 +1,7 @@ """Support for views.""" from __future__ import annotations -import asyncio -from collections.abc import Awaitable, Callable -from http import HTTPStatus -import logging -from typing import Any - -from aiohttp import web -from aiohttp.typedefs import LooseHeaders -from aiohttp.web_exceptions import ( - HTTPBadRequest, - HTTPInternalServerError, - HTTPUnauthorized, +from homeassistant.helpers.http import ( # noqa: F401 + HomeAssistantView, + request_handler_factory, ) -from aiohttp.web_urldispatcher import AbstractRoute -import voluptuous as vol - -from homeassistant import exceptions -from homeassistant.const import CONTENT_TYPE_JSON -from homeassistant.core import Context, HomeAssistant, is_callback -from homeassistant.helpers.json import ( - find_paths_unserializable_data, - json_bytes, - json_dumps, -) -from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data - -from .const import KEY_AUTHENTICATED - -_LOGGER = logging.getLogger(__name__) - - -class HomeAssistantView: - """Base view for all views.""" - - url: str | None = None - extra_urls: list[str] = [] - # Views inheriting from this class can override this - requires_auth = True - cors_allowed = False - - @staticmethod - def context(request: web.Request) -> Context: - """Generate a context from a request.""" - if (user := request.get("hass_user")) is None: - return Context() - - return Context(user_id=user.id) - - @staticmethod - def json( - result: Any, - status_code: HTTPStatus | int = HTTPStatus.OK, - headers: LooseHeaders | None = None, - ) -> web.Response: - """Return a JSON response.""" - try: - msg = json_bytes(result) - except JSON_ENCODE_EXCEPTIONS as err: - _LOGGER.error( - "Unable to serialize to JSON. Bad data found at %s", - format_unserializable_data( - find_paths_unserializable_data(result, dump=json_dumps) - ), - ) - raise HTTPInternalServerError from err - response = web.Response( - body=msg, - content_type=CONTENT_TYPE_JSON, - status=int(status_code), - headers=headers, - zlib_executor_size=32768, - ) - response.enable_compression() - return response - - def json_message( - self, - message: str, - status_code: HTTPStatus | int = HTTPStatus.OK, - message_code: str | None = None, - headers: LooseHeaders | None = None, - ) -> web.Response: - """Return a JSON message response.""" - data = {"message": message} - if message_code is not None: - data["code"] = message_code - return self.json(data, status_code, headers=headers) - - def register( - self, hass: HomeAssistant, app: web.Application, router: web.UrlDispatcher - ) -> None: - """Register the view with a router.""" - assert self.url is not None, "No url set for view" - urls = [self.url] + self.extra_urls - routes: list[AbstractRoute] = [] - - for method in ("get", "post", "delete", "put", "patch", "head", "options"): - if not (handler := getattr(self, method, None)): - continue - - handler = request_handler_factory(hass, self, handler) - - for url in urls: - routes.append(router.add_route(method, url, handler)) - - # Use `get` because CORS middleware is not be loaded in emulated_hue - if self.cors_allowed: - allow_cors = app.get("allow_all_cors") - else: - allow_cors = app.get("allow_configured_cors") - - if allow_cors: - for route in routes: - allow_cors(route) - - -def request_handler_factory( - hass: HomeAssistant, view: HomeAssistantView, handler: Callable -) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: - """Wrap the handler classes.""" - is_coroutinefunction = asyncio.iscoroutinefunction(handler) - assert is_coroutinefunction or is_callback( - handler - ), "Handler should be a coroutine or a callback." - - async def handle(request: web.Request) -> web.StreamResponse: - """Handle incoming request.""" - if hass.is_stopping: - return web.Response(status=HTTPStatus.SERVICE_UNAVAILABLE) - - authenticated = request.get(KEY_AUTHENTICATED, False) - - if view.requires_auth and not authenticated: - raise HTTPUnauthorized() - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug( - "Serving %s to %s (auth: %s)", - request.path, - request.remote, - authenticated, - ) - - try: - if is_coroutinefunction: - result = await handler(request, **request.match_info) - else: - result = handler(request, **request.match_info) - except vol.Invalid as err: - raise HTTPBadRequest() from err - except exceptions.ServiceNotFound as err: - raise HTTPInternalServerError() from err - except exceptions.Unauthorized as err: - raise HTTPUnauthorized() from err - - if isinstance(result, web.StreamResponse): - # The method handler returned a ready-made Response, how nice of it - return result - - status_code = HTTPStatus.OK - if isinstance(result, tuple): - result, status_code = result - - if isinstance(result, bytes): - return web.Response(body=result, status=status_code) - - if isinstance(result, str): - return web.Response(text=result, status=status_code) - - if result is None: - return web.Response(body=b"", status=status_code) - - raise TypeError( - f"Result should be None, string, bytes or StreamResponse. Got: {result}" - ) - - return handle diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 280ff41c56e..aa7bcefadae 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -9,9 +9,9 @@ from aiohttp import web import voluptuous as vol from homeassistant.auth.models import RefreshToken, User -from homeassistant.components.http import current_request from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.helpers.http import current_request from homeassistant.util.json import JsonValueType from . import const, messages diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py new file mode 100644 index 00000000000..63ff173a3a0 --- /dev/null +++ b/homeassistant/helpers/http.py @@ -0,0 +1,184 @@ +"""Helper to track the current http request.""" +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from contextvars import ContextVar +from http import HTTPStatus +import logging +from typing import Any, Final + +from aiohttp import web +from aiohttp.typedefs import LooseHeaders +from aiohttp.web import Request +from aiohttp.web_exceptions import ( + HTTPBadRequest, + HTTPInternalServerError, + HTTPUnauthorized, +) +from aiohttp.web_urldispatcher import AbstractRoute +import voluptuous as vol + +from homeassistant import exceptions +from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant.core import Context, HomeAssistant, is_callback +from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data + +from .json import find_paths_unserializable_data, json_bytes, json_dumps + +_LOGGER = logging.getLogger(__name__) + + +KEY_AUTHENTICATED: Final = "ha_authenticated" + +current_request: ContextVar[Request | None] = ContextVar( + "current_request", default=None +) + + +def request_handler_factory( + hass: HomeAssistant, view: HomeAssistantView, handler: Callable +) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: + """Wrap the handler classes.""" + is_coroutinefunction = asyncio.iscoroutinefunction(handler) + assert is_coroutinefunction or is_callback( + handler + ), "Handler should be a coroutine or a callback." + + async def handle(request: web.Request) -> web.StreamResponse: + """Handle incoming request.""" + if hass.is_stopping: + return web.Response(status=HTTPStatus.SERVICE_UNAVAILABLE) + + authenticated = request.get(KEY_AUTHENTICATED, False) + + if view.requires_auth and not authenticated: + raise HTTPUnauthorized() + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Serving %s to %s (auth: %s)", + request.path, + request.remote, + authenticated, + ) + + try: + if is_coroutinefunction: + result = await handler(request, **request.match_info) + else: + result = handler(request, **request.match_info) + except vol.Invalid as err: + raise HTTPBadRequest() from err + except exceptions.ServiceNotFound as err: + raise HTTPInternalServerError() from err + except exceptions.Unauthorized as err: + raise HTTPUnauthorized() from err + + if isinstance(result, web.StreamResponse): + # The method handler returned a ready-made Response, how nice of it + return result + + status_code = HTTPStatus.OK + if isinstance(result, tuple): + result, status_code = result + + if isinstance(result, bytes): + return web.Response(body=result, status=status_code) + + if isinstance(result, str): + return web.Response(text=result, status=status_code) + + if result is None: + return web.Response(body=b"", status=status_code) + + raise TypeError( + f"Result should be None, string, bytes or StreamResponse. Got: {result}" + ) + + return handle + + +class HomeAssistantView: + """Base view for all views.""" + + url: str | None = None + extra_urls: list[str] = [] + # Views inheriting from this class can override this + requires_auth = True + cors_allowed = False + + @staticmethod + def context(request: web.Request) -> Context: + """Generate a context from a request.""" + if (user := request.get("hass_user")) is None: + return Context() + + return Context(user_id=user.id) + + @staticmethod + def json( + result: Any, + status_code: HTTPStatus | int = HTTPStatus.OK, + headers: LooseHeaders | None = None, + ) -> web.Response: + """Return a JSON response.""" + try: + msg = json_bytes(result) + except JSON_ENCODE_EXCEPTIONS as err: + _LOGGER.error( + "Unable to serialize to JSON. Bad data found at %s", + format_unserializable_data( + find_paths_unserializable_data(result, dump=json_dumps) + ), + ) + raise HTTPInternalServerError from err + response = web.Response( + body=msg, + content_type=CONTENT_TYPE_JSON, + status=int(status_code), + headers=headers, + zlib_executor_size=32768, + ) + response.enable_compression() + return response + + def json_message( + self, + message: str, + status_code: HTTPStatus | int = HTTPStatus.OK, + message_code: str | None = None, + headers: LooseHeaders | None = None, + ) -> web.Response: + """Return a JSON message response.""" + data = {"message": message} + if message_code is not None: + data["code"] = message_code + return self.json(data, status_code, headers=headers) + + def register( + self, hass: HomeAssistant, app: web.Application, router: web.UrlDispatcher + ) -> None: + """Register the view with a router.""" + assert self.url is not None, "No url set for view" + urls = [self.url] + self.extra_urls + routes: list[AbstractRoute] = [] + + for method in ("get", "post", "delete", "put", "patch", "head", "options"): + if not (handler := getattr(self, method, None)): + continue + + handler = request_handler_factory(hass, self, handler) + + for url in urls: + routes.append(router.add_route(method, url, handler)) + + # Use `get` because CORS middleware is not be loaded in emulated_hue + if self.cors_allowed: + allow_cors = app.get("allow_all_cors") + else: + allow_cors = app.get("allow_configured_cors") + + if allow_cors: + for route in routes: + allow_cors(route) diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py new file mode 100644 index 00000000000..1c5157b74e1 --- /dev/null +++ b/tests/test_circular_imports.py @@ -0,0 +1,39 @@ +"""Test to check for circular imports in core components.""" +import asyncio +import sys + +import pytest + +from homeassistant.bootstrap import ( + CORE_INTEGRATIONS, + DEBUGGER_INTEGRATIONS, + DEFAULT_INTEGRATIONS, + FRONTEND_INTEGRATIONS, + LOGGING_INTEGRATIONS, + RECORDER_INTEGRATIONS, + STAGE_1_INTEGRATIONS, +) + + +@pytest.mark.timeout(30) # cloud can take > 9s +@pytest.mark.parametrize( + "component", + sorted( + { + *DEBUGGER_INTEGRATIONS, + *CORE_INTEGRATIONS, + *LOGGING_INTEGRATIONS, + *FRONTEND_INTEGRATIONS, + *RECORDER_INTEGRATIONS, + *STAGE_1_INTEGRATIONS, + *DEFAULT_INTEGRATIONS, + } + ), +) +async def test_circular_imports(component: str) -> None: + """Check that components can be imported without circular imports.""" + process = await asyncio.create_subprocess_exec( + sys.executable, "-c", f"import homeassistant.components.{component}" + ) + await process.communicate() + assert process.returncode == 0 From f711411d181d95f7d60202f8ad9ab753cb34137c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 29 Feb 2024 22:16:27 +0100 Subject: [PATCH 39/95] Add missing unit of measurement for tolerance option in proximity (#111876) --- homeassistant/components/proximity/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py index f3306bebf39..1a549d22f81 100644 --- a/homeassistant/components/proximity/config_flow.py +++ b/homeassistant/components/proximity/config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOM from homeassistant.components.person import DOMAIN as PERSON_DOMAIN from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow -from homeassistant.const import CONF_ZONE +from homeassistant.const import CONF_ZONE, UnitOfLength from homeassistant.core import State, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.selector import ( @@ -50,7 +50,9 @@ def _base_schema(user_input: dict[str, Any]) -> vol.Schema: CONF_TOLERANCE, default=user_input.get(CONF_TOLERANCE, DEFAULT_TOLERANCE), ): NumberSelector( - NumberSelectorConfig(min=1, max=100, step=1), + NumberSelectorConfig( + min=1, max=100, step=1, unit_of_measurement=UnitOfLength.METERS + ), ), } From c04e68b9b2591a06e767d4f379ea40d6de32f1e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 29 Feb 2024 22:41:33 +0100 Subject: [PATCH 40/95] Update aioairzone to v0.7.5 (#111879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 6987b3213c1..59b8645d26c 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.7.4"] + "requirements": ["aioairzone==0.7.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1257caa2ec9..1e017a317b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -191,7 +191,7 @@ aioairq==0.3.2 aioairzone-cloud==0.3.8 # homeassistant.components.airzone -aioairzone==0.7.4 +aioairzone==0.7.5 # homeassistant.components.ambient_station aioambient==2024.01.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d93e5dcdd27..7517e025c91 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.3.2 aioairzone-cloud==0.3.8 # homeassistant.components.airzone -aioairzone==0.7.4 +aioairzone==0.7.5 # homeassistant.components.ambient_station aioambient==2024.01.0 From f89fddb92bd9a2aedf8ce88be72e03d3ddf6a6d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Feb 2024 12:49:28 -1000 Subject: [PATCH 41/95] Bump habluetooth to 2.4.2 (#111885) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 08e9b8bda2c..b8158a06f7e 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.3.0", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", - "habluetooth==2.4.1" + "habluetooth==2.4.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c1f80804b5c..e8efc513fb0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==2.4.1 +habluetooth==2.4.2 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1e017a317b0..2b0d3ec2240 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1031,7 +1031,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.4.1 +habluetooth==2.4.2 # homeassistant.components.cloud hass-nabucasa==0.78.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7517e025c91..0b1ca167f1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -842,7 +842,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.4.1 +habluetooth==2.4.2 # homeassistant.components.cloud hass-nabucasa==0.78.0 From 88d2772fa9cbc1b1ae802b0c7e86760cefcc39bb Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 29 Feb 2024 22:43:47 +0100 Subject: [PATCH 42/95] Deconz fix gradient color mode (#111890) * Fix deconz gradient colormode * Fix gradient light not reporting color mode in deCONZ --- homeassistant/components/deconz/light.py | 1 + tests/components/deconz/test_light.py | 111 +++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 27038a07ac3..086db2058c9 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -63,6 +63,7 @@ FLASH_TO_DECONZ = {FLASH_SHORT: LightAlert.SHORT, FLASH_LONG: LightAlert.LONG} DECONZ_TO_COLOR_MODE = { LightColorMode.CT: ColorMode.COLOR_TEMP, + LightColorMode.GRADIENT: ColorMode.XY, LightColorMode.HS: ColorMode.HS, LightColorMode.XY: ColorMode.XY, } diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index d1d5b983956..63c544ff189 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -308,6 +308,117 @@ async def test_no_lights_or_groups( }, }, ), + ( # Gradient light + { + "capabilities": { + "alerts": [ + "none", + "select", + "lselect", + "blink", + "breathe", + "okay", + "channelchange", + "finish", + "stop", + ], + "bri": {"min_dim_level": 0.01}, + "color": { + "ct": {"computes_xy": True, "max": 500, "min": 153}, + "effects": [ + "none", + "colorloop", + "candle", + "fireplace", + "prism", + "sunrise", + ], + "gamut_type": "C", + "gradient": { + "max_segments": 9, + "pixel_count": 16, + "pixel_length": 1250, + "styles": ["linear", "mirrored"], + }, + "modes": ["ct", "effect", "gradient", "hs", "xy"], + "xy": { + "blue": [0.1532, 0.0475], + "green": [0.17, 0.7], + "red": [0.6915, 0.3083], + }, + }, + }, + "colorcapabilities": 31, + "config": { + "bri": { + "couple_ct": False, + "execute_if_off": True, + "startup": "previous", + }, + "color": { + "ct": {"startup": "previous"}, + "execute_if_off": True, + "gradient": {"reversed": False}, + "xy": {"startup": "previous"}, + }, + "groups": ["36", "39", "45", "46", "47", "51", "57", "59"], + "on": {"startup": "previous"}, + }, + "ctmax": 500, + "ctmin": 153, + "etag": "077fb97dd6145f10a3c190f0a1ade499", + "hascolor": True, + "lastannounced": None, + "lastseen": "2024-02-29T18:36Z", + "manufacturername": "Signify Netherlands B.V.", + "modelid": "LCX004", + "name": "Gradient light", + "productid": "Philips-LCX004-1-GALSECLv1", + "productname": "Hue gradient lightstrip", + "state": { + "alert": "none", + "bri": 184, + "colormode": "gradient", + "ct": 396, + "effect": "none", + "gradient": { + "color_adjustment": 0, + "offset": 0, + "offset_adjustment": 0, + "points": [ + [0.2728, 0.6226], + [0.163, 0.4262], + [0.1563, 0.1699], + [0.1551, 0.1147], + [0.1534, 0.0579], + ], + "segments": 5, + "style": "linear", + }, + "hue": 20566, + "on": True, + "reachable": True, + "sat": 254, + "xy": [0.2727, 0.6226], + }, + "swconfigid": "F03CAF4D", + "swversion": "1.104.2", + "type": "Extended color light", + "uniqueid": "00:17:88:01:0b:0c:0d:0e-0f", + }, + { + "entity_id": "light.gradient_light", + "state": STATE_ON, + "attributes": { + ATTR_SUPPORTED_COLOR_MODES: [ + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.XY, + ], + ATTR_COLOR_MODE: ColorMode.XY, + }, + }, + ), ], ) async def test_lights( From c9227d3c3da947cdee18879489188ecd1ca6de04 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 1 Mar 2024 03:05:43 +0100 Subject: [PATCH 43/95] Fix unsupported device type in deCONZ integration (#111892) --- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index af1824e441c..ef2f4a73c1b 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==114"], + "requirements": ["pydeconz==115"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index 2b0d3ec2240..ac518aa0299 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1758,7 +1758,7 @@ pydaikin==2.11.1 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==114 +pydeconz==115 # homeassistant.components.delijn pydelijn==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b1ca167f1c..adf9b994467 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1366,7 +1366,7 @@ pycsspeechtts==1.0.8 pydaikin==2.11.1 # homeassistant.components.deconz -pydeconz==114 +pydeconz==115 # homeassistant.components.dexcom pydexcom==0.2.3 From 3896afbb693ebec38e6af918a597c2d502405139 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Feb 2024 16:02:13 -1000 Subject: [PATCH 44/95] Limit executor imports to a single thread (#111898) * Limit executor imports to a single thread * test for import executor * test for import executor * test for import executor * fixes * better fix --- homeassistant/core.py | 15 +++++++++++++++ homeassistant/loader.py | 4 ++-- tests/components/zwave_js/test_update.py | 4 ++-- tests/test_core.py | 16 ++++++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 47a21f3325b..0f038149d63 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -95,6 +95,7 @@ from .util.async_ import ( run_callback_threadsafe, shutdown_run_callback_threadsafe, ) +from .util.executor import InterruptibleThreadPoolExecutor from .util.json import JsonObjectType from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager @@ -394,6 +395,9 @@ class HomeAssistant: self.timeout: TimeoutManager = TimeoutManager() self._stop_future: concurrent.futures.Future[None] | None = None self._shutdown_jobs: list[HassJobWithArgs] = [] + self.import_executor = InterruptibleThreadPoolExecutor( + max_workers=1, thread_name_prefix="ImportExecutor" + ) @cached_property def is_running(self) -> bool: @@ -678,6 +682,16 @@ class HomeAssistant: return task + @callback + def async_add_import_executor_job( + self, target: Callable[..., _T], *args: Any + ) -> asyncio.Future[_T]: + """Add an import executor job from within the event loop.""" + task = self.loop.run_in_executor(self.import_executor, target, *args) + self._tasks.add(task) + task.add_done_callback(self._tasks.remove) + return task + @overload @callback def async_run_hass_job( @@ -992,6 +1006,7 @@ class HomeAssistant: self._async_log_running_tasks("close") self.set_state(CoreState.stopped) + self.import_executor.shutdown() if self._stopped is not None: self._stopped.set() diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 1ff98ff6ff2..6c736bf8c4d 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -853,7 +853,7 @@ class Integration: # So we do it before validating config to catch these errors. if load_executor: try: - comp = await self.hass.async_add_executor_job(self.get_component) + comp = await self.hass.async_add_import_executor_job(self.get_component) except ImportError as ex: load_executor = False _LOGGER.debug("Failed to import %s in executor", domain, exc_info=ex) @@ -924,7 +924,7 @@ class Integration: try: if load_executor: try: - platform = await self.hass.async_add_executor_job( + platform = await self.hass.async_add_import_executor_job( self._load_platform, platform_name ) except ImportError as ex: diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index ed42363ca41..1774254a3c5 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -310,7 +310,7 @@ async def test_update_entity_ha_not_running( hass_ws_client: WebSocketGenerator, ) -> None: """Test update occurs only after HA is running.""" - await hass.async_stop() + hass.set_state(CoreState.not_running) client.async_send_command.return_value = {"updates": []} @@ -632,7 +632,7 @@ async def test_update_entity_delay( """Test update occurs on a delay after HA starts.""" client.async_send_command.reset_mock() client.async_send_command.return_value = {"updates": []} - await hass.async_stop() + hass.set_state(CoreState.not_running) entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) diff --git a/tests/test_core.py b/tests/test_core.py index 3a2c34c5e1c..987f228bea8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2869,3 +2869,19 @@ def test_one_time_listener_repr(hass: HomeAssistant) -> None: assert "OneTimeListener" in repr_str assert "test_core" in repr_str assert "_listener" in repr_str + + +async def test_async_add_import_executor_job(hass: HomeAssistant) -> None: + """Test async_add_import_executor_job works and is limited to one thread.""" + evt = threading.Event() + loop = asyncio.get_running_loop() + + def executor_func() -> None: + evt.set() + return evt + + future = hass.async_add_import_executor_job(executor_func) + await loop.run_in_executor(None, evt.wait) + assert await future is evt + + assert hass.import_executor._max_workers == 1 From 8ddec745edee8d74ccde1b78cda8f19e2171b465 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 1 Mar 2024 03:05:33 +0100 Subject: [PATCH 45/95] Change `hass.components` removal version in log to 2024.9 (#111903) --- homeassistant/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6c736bf8c4d..02696d6beb5 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1277,7 +1277,7 @@ class Components: report( ( f"accesses hass.components.{comp_name}." - " This is deprecated and will stop working in Home Assistant 2024.6, it" + " This is deprecated and will stop working in Home Assistant 2024.9, it" f" should be updated to import functions used from {comp_name} directly" ), error_if_core=False, From 04bf68e6611854131b13290a6f7fa8b997c8a28f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Feb 2024 22:00:35 -0500 Subject: [PATCH 46/95] Bump version to 2024.3.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 859a85ee1b4..93ca3d6948e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 1ac98da33b5..6d8f344a668 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.3.0b2" +version = "2024.3.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 838a4e4f7bccb7f88c9d3677de51585eee61ea50 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 1 Mar 2024 23:29:47 +0100 Subject: [PATCH 47/95] Bump pyOverkiz to 1.13.8 (#111930) Bump pyoverkiz to 1.13.8 --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index e7e5b87e099..db24a299f2a 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -19,7 +19,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.13.7"], + "requirements": ["pyoverkiz==1.13.8"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index ac518aa0299..ee69416ae00 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2036,7 +2036,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.7 +pyoverkiz==1.13.8 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index adf9b994467..2b85857714f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1578,7 +1578,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.7 +pyoverkiz==1.13.8 # homeassistant.components.openweathermap pyowm==3.2.0 From 005493bb5a7a9717ef3b006d3dc8a9174e07b841 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 1 Mar 2024 19:38:08 +0100 Subject: [PATCH 48/95] Update frontend to 20240301.0 (#111961) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 3bbee2eae58..d975daad508 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240228.1"] + "requirements": ["home-assistant-frontend==20240301.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e8efc513fb0..c80da6b5aad 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ habluetooth==2.4.2 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240228.1 +home-assistant-frontend==20240301.0 home-assistant-intents==2024.2.28 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ee69416ae00..73d129cce83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1074,7 +1074,7 @@ hole==0.8.0 holidays==0.43 # homeassistant.components.frontend -home-assistant-frontend==20240228.1 +home-assistant-frontend==20240301.0 # homeassistant.components.conversation home-assistant-intents==2024.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b85857714f..4f6e77b3aa6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -873,7 +873,7 @@ hole==0.8.0 holidays==0.43 # homeassistant.components.frontend -home-assistant-frontend==20240228.1 +home-assistant-frontend==20240301.0 # homeassistant.components.conversation home-assistant-intents==2024.2.28 From 435bb50d290040fb05db5756b041e647c0830af1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 1 Mar 2024 16:18:58 -0500 Subject: [PATCH 49/95] Update reporting for media_source.async_resolve_media (#111969) * Update reporting for media_source.async_resolve_media * Don't raise on core * Fix tests --- .../components/media_source/__init__.py | 5 ++++- tests/components/jellyfin/test_media_source.py | 14 +++++++++++--- tests/components/media_source/test_init.py | 18 +++++++----------- tests/components/reolink/test_media_source.py | 6 ++++-- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 62cf7815613..fdb7fa5f1f2 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -148,7 +148,10 @@ async def async_resolve_media( raise Unresolvable("Media Source not loaded") if target_media_player is UNDEFINED: - report("calls media_source.async_resolve_media without passing an entity_id") + report( + "calls media_source.async_resolve_media without passing an entity_id", + {DOMAIN}, + ) target_media_player = None try: diff --git a/tests/components/jellyfin/test_media_source.py b/tests/components/jellyfin/test_media_source.py index 5f8871e6242..e87b3c15b0b 100644 --- a/tests/components/jellyfin/test_media_source.py +++ b/tests/components/jellyfin/test_media_source.py @@ -40,7 +40,9 @@ async def test_resolve( mock_api.get_item.side_effect = None mock_api.get_item.return_value = load_json_fixture("track.json") - play_media = await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/TRACK-UUID") + play_media = await async_resolve_media( + hass, f"{URI_SCHEME}{DOMAIN}/TRACK-UUID", "media_player.jellyfin_device" + ) assert play_media.mime_type == "audio/flac" assert play_media.url == snapshot @@ -49,7 +51,9 @@ async def test_resolve( mock_api.get_item.side_effect = None mock_api.get_item.return_value = load_json_fixture("movie.json") - play_media = await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/MOVIE-UUID") + play_media = await async_resolve_media( + hass, f"{URI_SCHEME}{DOMAIN}/MOVIE-UUID", "media_player.jellyfin_device" + ) assert play_media.mime_type == "video/mp4" assert play_media.url == snapshot @@ -59,7 +63,11 @@ async def test_resolve( mock_api.get_item.return_value = load_json_fixture("unsupported-item.json") with pytest.raises(BrowseError): - await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/UNSUPPORTED-ITEM-UUID") + await async_resolve_media( + hass, + f"{URI_SCHEME}{DOMAIN}/UNSUPPORTED-ITEM-UUID", + "media_player.jellyfin_device", + ) async def test_root( diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index 4e512608abf..eecfe6cde6e 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -121,17 +121,13 @@ async def test_async_resolve_media_no_entity( assert await async_setup_component(hass, media_source.DOMAIN, {}) await hass.async_block_till_done() - media = await media_source.async_resolve_media( - hass, - media_source.generate_media_source_id(media_source.DOMAIN, "local/test.mp3"), - ) - assert isinstance(media, media_source.models.PlayMedia) - assert media.url == "/media/local/test.mp3" - assert media.mime_type == "audio/mpeg" - assert ( - "calls media_source.async_resolve_media without passing an entity_id" - in caplog.text - ) + with pytest.raises(RuntimeError): + await media_source.async_resolve_media( + hass, + media_source.generate_media_source_id( + media_source.DOMAIN, "local/test.mp3" + ), + ) async def test_async_unresolve_media(hass: HomeAssistant) -> None: diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index ddb66463419..c7abc5b8e0e 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -81,7 +81,9 @@ async def test_resolve( f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" ) - play_media = await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/{file_id}") + play_media = await async_resolve_media( + hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None + ) assert play_media.mime_type == TEST_MIME_TYPE @@ -245,7 +247,7 @@ async def test_browsing_errors( with pytest.raises(Unresolvable): await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/UNKNOWN") with pytest.raises(Unresolvable): - await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/UNKNOWN") + await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/UNKNOWN", None) async def test_browsing_not_loaded( From 2c38b5ee7b47b69a6ac81ea2385c1a62c07999dd Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 1 Mar 2024 18:13:09 -0500 Subject: [PATCH 50/95] Bump Zigpy to 0.63.3 (#112002) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 3a1df4207ac..225837c66fc 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -27,7 +27,7 @@ "pyserial-asyncio==0.6", "zha-quirks==0.0.112", "zigpy-deconz==0.23.1", - "zigpy==0.63.2", + "zigpy==0.63.3", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/requirements_all.txt b/requirements_all.txt index 73d129cce83..4bff6b5d276 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2950,7 +2950,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.63.2 +zigpy==0.63.3 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f6e77b3aa6..eef8c4d19d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2270,7 +2270,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.63.2 +zigpy==0.63.3 # homeassistant.components.zwave_js zwave-js-server-python==0.55.3 From 92d3dccb947513411ead86dec00d8a35fcbfa3df Mon Sep 17 00:00:00 2001 From: Chris Helming <7746625+chelming@users.noreply.github.com> Date: Fri, 1 Mar 2024 20:00:03 -0500 Subject: [PATCH 51/95] Fix minor language issues in strings.json (#112006) language fix: allow -> allows --- homeassistant/components/random/strings.json | 2 +- homeassistant/components/template/strings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index 164f184ae88..0faad1d8093 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -19,7 +19,7 @@ "title": "Random sensor" }, "user": { - "description": "This helper allow you to create a helper that emits a random value.", + "description": "This helper allows you to create a helper that emits a random value.", "menu_options": { "binary_sensor": "Random binary sensor", "sensor": "Random sensor" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 79cd0289724..6122f4c9db5 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -20,7 +20,7 @@ "title": "Template sensor" }, "user": { - "description": "This helper allow you to create helper entities that define their state using a template.", + "description": "This helper allows you to create helper entities that define their state using a template.", "menu_options": { "binary_sensor": "Template a binary sensor", "sensor": "Template a sensor" From 5017f4a2c71b10e7e3af2d3dcd02a6246b91bea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 2 Mar 2024 19:14:00 +0100 Subject: [PATCH 52/95] Update aioairzone-cloud to v0.4.5 (#112034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/__init__.py | 1 + homeassistant/components/airzone_cloud/config_flow.py | 1 + homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone_cloud/test_coordinator.py | 3 +++ 6 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index 7e787ef4c69..697b80942f2 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -24,6 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options = ConnectionOptions( entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], + True, ) airzone = AirzoneCloudApi(aiohttp_client.async_get_clientsession(hass), options) diff --git a/homeassistant/components/airzone_cloud/config_flow.py b/homeassistant/components/airzone_cloud/config_flow.py index 32274d4e8ef..0d04f78245d 100644 --- a/homeassistant/components/airzone_cloud/config_flow.py +++ b/homeassistant/components/airzone_cloud/config_flow.py @@ -94,6 +94,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ConnectionOptions( user_input[CONF_USERNAME], user_input[CONF_PASSWORD], + False, ), ) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index f8b740dc04d..3b8247d003c 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.8"] + "requirements": ["aioairzone-cloud==0.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4bff6b5d276..6684dd41ad9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -188,7 +188,7 @@ aio-georss-gdacs==0.9 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.8 +aioairzone-cloud==0.4.5 # homeassistant.components.airzone aioairzone==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eef8c4d19d1..8067b2e4806 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aio-georss-gdacs==0.9 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.8 +aioairzone-cloud==0.4.5 # homeassistant.components.airzone aioairzone==0.7.5 diff --git a/tests/components/airzone_cloud/test_coordinator.py b/tests/components/airzone_cloud/test_coordinator.py index 40b6c937ed2..a2307b94335 100644 --- a/tests/components/airzone_cloud/test_coordinator.py +++ b/tests/components/airzone_cloud/test_coordinator.py @@ -46,6 +46,9 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: ) as mock_webserver, patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", return_value=None, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi._update_websockets", + return_value=False, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From df5eb552a042f6cdf539a726bb3e4d86d9a14341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 2 Mar 2024 17:48:27 +0100 Subject: [PATCH 53/95] Use description key instead of name for Tibber RT unique ID (#112035) * Use translation key instead of name for Tibber RT unique ID * migration * use decription.key instead --- homeassistant/components/tibber/sensor.py | 77 ++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index c6e1bdc1895..a2bd8d26f75 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -60,6 +60,35 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) PARALLEL_UPDATES = 0 +RT_SENSORS_UNIQUE_ID_MIGRATION = { + "accumulated_consumption_last_hour": "accumulated consumption current hour", + "accumulated_production_last_hour": "accumulated production current hour", + "current_l1": "current L1", + "current_l2": "current L2", + "current_l3": "current L3", + "estimated_hour_consumption": "Estimated consumption current hour", +} + +RT_SENSORS_UNIQUE_ID_MIGRATION_SIMPLE = { + # simple migration can be done by replacing " " with "_" + "accumulated_consumption", + "accumulated_cost", + "accumulated_production", + "accumulated_reward", + "average_power", + "last_meter_consumption", + "last_meter_production", + "max_power", + "min_power", + "power_factor", + "power_production", + "signal_strength", + "voltage_phase1", + "voltage_phase2", + "voltage_phase3", +} + + RT_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="averagePower", @@ -454,7 +483,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]) self._device_name = f"{self._model} {self._home_name}" self._attr_native_value = initial_state - self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{description.name}" + self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{description.key}" if description.key in ("accumulatedCost", "accumulatedReward"): self._attr_native_unit_of_measurement = tibber_home.currency @@ -523,6 +552,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en self._async_remove_device_updates_handler = self.async_add_listener( self._add_sensors ) + self.entity_registry = async_get_entity_reg(hass) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @callback @@ -530,6 +560,49 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en """Handle Home Assistant stopping.""" self._async_remove_device_updates_handler() + @callback + def _migrate_unique_id(self, sensor_description: SensorEntityDescription) -> None: + """Migrate unique id if needed.""" + home_id = self._tibber_home.home_id + translation_key = sensor_description.translation_key + description_key = sensor_description.key + entity_id: str | None = None + if translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION_SIMPLE: + entity_id = self.entity_registry.async_get_entity_id( + "sensor", + TIBBER_DOMAIN, + f"{home_id}_rt_{translation_key.replace('_', ' ')}", + ) + elif translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION: + entity_id = self.entity_registry.async_get_entity_id( + "sensor", + TIBBER_DOMAIN, + f"{home_id}_rt_{RT_SENSORS_UNIQUE_ID_MIGRATION[translation_key]}", + ) + elif translation_key != description_key: + entity_id = self.entity_registry.async_get_entity_id( + "sensor", + TIBBER_DOMAIN, + f"{home_id}_rt_{translation_key}", + ) + + if entity_id is None: + return + + new_unique_id = f"{home_id}_rt_{description_key}" + + _LOGGER.debug( + "Migrating unique id for %s to %s", + entity_id, + new_unique_id, + ) + try: + self.entity_registry.async_update_entity( + entity_id, new_unique_id=new_unique_id + ) + except ValueError as err: + _LOGGER.error(err) + @callback def _add_sensors(self) -> None: """Add sensor.""" @@ -543,6 +616,8 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en state = live_measurement.get(sensor_description.key) if state is None: continue + + self._migrate_unique_id(sensor_description) entity = TibberSensorRT( self._tibber_home, sensor_description, From 675b7ca7ba95a935753351f65170d2c21cc5336b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 2 Mar 2024 12:52:20 +0100 Subject: [PATCH 54/95] Fix config schema for velux (#112037) --- homeassistant/components/velux/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 9bc9c93e0d1..4c84eb687ad 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -22,8 +22,8 @@ CONFIG_SCHEMA = vol.Schema( } ) }, - extra=vol.ALLOW_EXTRA, - ) + ), + extra=vol.ALLOW_EXTRA, ) From 63192f2291abbc3cda25e5c62a90372261342c10 Mon Sep 17 00:00:00 2001 From: Jeef Date: Sat, 2 Mar 2024 11:15:21 -0700 Subject: [PATCH 55/95] Bump weatherflow4py to v0.1.12 (#112040) Backing lib bump --- homeassistant/components/weatherflow_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 2dd4e9ddcd1..6abbeef02df 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", - "requirements": ["weatherflow4py==0.1.11"] + "requirements": ["weatherflow4py==0.1.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6684dd41ad9..f95249f61f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2836,7 +2836,7 @@ watchdog==2.3.1 waterfurnace==1.1.0 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.1.11 +weatherflow4py==0.1.12 # homeassistant.components.webmin webmin-xmlrpc==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8067b2e4806..942bfeb3b3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2174,7 +2174,7 @@ wallbox==0.6.0 watchdog==2.3.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.1.11 +weatherflow4py==0.1.12 # homeassistant.components.webmin webmin-xmlrpc==0.0.1 From a4353cf39d0d149947e591db419073c0489b7036 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 2 Mar 2024 13:24:12 -0500 Subject: [PATCH 56/95] Bump version to 2024.3.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 93ca3d6948e..e0ae0ea5381 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 6d8f344a668..d2ff46a373e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.3.0b3" +version = "2024.3.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 1bcdba1b4bce6090909d4258ba6f79d0740ec2ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Mar 2024 11:08:22 -1000 Subject: [PATCH 57/95] Import anonymize_data in unifiprotect init to avoid it being imported in the event loop (#112052) Improve anonymize_data in unifiprotect init to avoid it being imported in the event loop --- homeassistant/components/unifiprotect/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 94bd3722cfa..942c533b59e 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -7,6 +7,11 @@ import logging from aiohttp.client_exceptions import ServerDisconnectedError from pyunifiprotect.exceptions import ClientError, NotAuthorized +# Import the test_util.anonymize module from the pyunifiprotect package +# in __init__ to ensure it gets imported in the executor since the +# diagnostics module will not be imported in the executor. +from pyunifiprotect.test_util.anonymize import anonymize_data # noqa: F401 + from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant From de5e626430b8a4e1c4a63d41c6ad43cd994fa151 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Mar 2024 10:38:45 -1000 Subject: [PATCH 58/95] Bump unifi-discovery to 1.1.8 (#112056) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 9c0c6c5767a..eba2b934e05 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -42,7 +42,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.23.3", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.23.3", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index f95249f61f3..ff14eea4d87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2758,7 +2758,7 @@ uasiren==0.0.1 ultraheat-api==0.5.7 # homeassistant.components.unifiprotect -unifi-discovery==1.1.7 +unifi-discovery==1.1.8 # homeassistant.components.unifi_direct unifi_ap==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 942bfeb3b3f..f7ae8a096aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2111,7 +2111,7 @@ uasiren==0.0.1 ultraheat-api==0.5.7 # homeassistant.components.unifiprotect -unifi-discovery==1.1.7 +unifi-discovery==1.1.8 # homeassistant.components.zha universal-silabs-flasher==0.0.18 From 88fb44bbbaa39b9c31c58de4dd94c4abdf9ea204 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 3 Mar 2024 00:11:22 +0200 Subject: [PATCH 59/95] Bump bthome-ble to 3.6.0 (#112060) * Bump bthome-ble to 3.6.0 * Fix discovery info typing --- homeassistant/components/bthome/config_flow.py | 11 ++++++----- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bthome/config_flow.py b/homeassistant/components/bthome/config_flow.py index 41440cb435f..62dc8cfa99f 100644 --- a/homeassistant/components/bthome/config_flow.py +++ b/homeassistant/components/bthome/config_flow.py @@ -1,4 +1,5 @@ """Config flow for BTHome Bluetooth integration.""" + from __future__ import annotations from collections.abc import Mapping @@ -11,7 +12,7 @@ import voluptuous as vol from homeassistant.components import onboarding from homeassistant.components.bluetooth import ( - BluetoothServiceInfo, + BluetoothServiceInfoBleak, async_discovered_service_info, ) from homeassistant.config_entries import ConfigFlow @@ -26,11 +27,11 @@ class Discovery: """A discovered bluetooth device.""" title: str - discovery_info: BluetoothServiceInfo + discovery_info: BluetoothServiceInfoBleak device: DeviceData -def _title(discovery_info: BluetoothServiceInfo, device: DeviceData) -> str: +def _title(discovery_info: BluetoothServiceInfoBleak, device: DeviceData) -> str: return device.title or device.get_device_name() or discovery_info.name @@ -41,12 +42,12 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._discovery_info: BluetoothServiceInfo | None = None + self._discovery_info: BluetoothServiceInfoBleak | None = None self._discovered_device: DeviceData | None = None self._discovered_devices: dict[str, Discovery] = {} async def async_step_bluetooth( - self, discovery_info: BluetoothServiceInfo + self, discovery_info: BluetoothServiceInfoBleak ) -> FlowResult: """Handle the bluetooth discovery step.""" await self.async_set_unique_id(discovery_info.address) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 2a7cf84f16b..a3e974bf71e 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.5.0"] + "requirements": ["bthome-ble==3.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ff14eea4d87..09b6812dd7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -621,7 +621,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.5.0 +bthome-ble==3.6.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7ae8a096aa..210501964a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -529,7 +529,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.5.0 +bthome-ble==3.6.0 # homeassistant.components.buienradar buienradar==1.0.5 From 46ee52f4efff77cab7ebab6f932971d0833b4f59 Mon Sep 17 00:00:00 2001 From: Isak Nyberg <36712644+IsakNyberg@users.noreply.github.com> Date: Sat, 2 Mar 2024 22:50:24 +0100 Subject: [PATCH 60/95] Add device class for permobil record distance sensor (#112062) fix record_distance device_class --- homeassistant/components/permobil/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py index 8a504248f5a..8af6fcf5ab1 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -177,6 +177,7 @@ SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( key="record_distance", translation_key="record_distance", icon="mdi:map-marker-distance", + device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL_INCREASING, ), ) From ad52bf608fe049f2e5c9ab7ea9350e0724b1d7c3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 2 Mar 2024 17:18:34 -0500 Subject: [PATCH 61/95] Only load camera prefs once (#112064) --- homeassistant/components/camera/__init__.py | 1 + homeassistant/components/camera/prefs.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 5ac981e9d93..ff4687dd493 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -391,6 +391,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) prefs = CameraPreferences(hass) + await prefs.async_load() hass.data[DATA_CAMERA_PREFS] = prefs hass.http.register_view(CameraImageView(component)) diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 160f896c86c..7f3f142378a 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -29,6 +29,8 @@ class DynamicStreamSettings: class CameraPreferences: """Handle camera preferences.""" + _preload_prefs: dict[str, dict[str, bool | Orientation]] + def __init__(self, hass: HomeAssistant) -> None: """Initialize camera prefs.""" self._hass = hass @@ -41,6 +43,10 @@ class CameraPreferences: str, DynamicStreamSettings ] = {} + async def async_load(self) -> None: + """Initialize the camera preferences.""" + self._preload_prefs = await self._store.async_load() or {} + async def async_update( self, entity_id: str, @@ -63,9 +69,8 @@ class CameraPreferences: if preload_stream is not UNDEFINED: if dynamic_stream_settings: dynamic_stream_settings.preload_stream = preload_stream - preload_prefs = await self._store.async_load() or {} - preload_prefs[entity_id] = {PREF_PRELOAD_STREAM: preload_stream} - await self._store.async_save(preload_prefs) + self._preload_prefs[entity_id] = {PREF_PRELOAD_STREAM: preload_stream} + await self._store.async_save(self._preload_prefs) if orientation is not UNDEFINED: if (registry := er.async_get(self._hass)).async_get(entity_id): @@ -91,10 +96,10 @@ class CameraPreferences: # Get orientation setting from entity registry reg_entry = er.async_get(self._hass).async_get(entity_id) er_prefs: Mapping = reg_entry.options.get(DOMAIN, {}) if reg_entry else {} - preload_prefs = await self._store.async_load() or {} settings = DynamicStreamSettings( preload_stream=cast( - bool, preload_prefs.get(entity_id, {}).get(PREF_PRELOAD_STREAM, False) + bool, + self._preload_prefs.get(entity_id, {}).get(PREF_PRELOAD_STREAM, False), ), orientation=er_prefs.get(PREF_ORIENTATION, Orientation.NO_TRANSFORM), ) From f16ea54b4f4b848bc5d2c0b8363a902a70043433 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 2 Mar 2024 22:00:50 -0500 Subject: [PATCH 62/95] Bump pydrawise to 2024.3.0 (#112066) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 707df93251d..5181de7d2a4 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.2.0"] + "requirements": ["pydrawise==2024.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 09b6812dd7d..d5ca9106765 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1773,7 +1773,7 @@ pydiscovergy==3.0.0 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2024.2.0 +pydrawise==2024.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 210501964a5..77270423473 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1375,7 +1375,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.0 # homeassistant.components.hydrawise -pydrawise==2024.2.0 +pydrawise==2024.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 12574bca8b1fcf19bd649ebab4804ce98b9e7bdf Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sat, 2 Mar 2024 23:28:27 +0100 Subject: [PATCH 63/95] Fix setup failure due to temporary DNS issue in Minecraft Server (#112068) Change ConfigEntryError to ConfigEntryNotReady on failed init --- homeassistant/components/minecraft_server/__init__.py | 6 ++---- tests/components/minecraft_server/test_init.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 6c854750baa..2cd6c51546a 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er @@ -41,9 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await api.async_initialize() except MinecraftServerAddressError as error: - raise ConfigEntryError( - f"Server address in configuration entry is invalid: {error}" - ) from error + raise ConfigEntryNotReady(f"Initialization failed: {error}") from error # Create coordinator instance. coordinator = MinecraftServerCoordinator(hass, entry.data[CONF_NAME], api) diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 5b0d9509d69..3d554bf1a55 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -153,7 +153,7 @@ async def test_setup_entry_lookup_failure( ) await hass.async_block_till_done() - assert java_mock_config_entry.state == ConfigEntryState.SETUP_ERROR + assert java_mock_config_entry.state == ConfigEntryState.SETUP_RETRY async def test_setup_entry_init_failure( From b8e3bb8eb8c76ffe1d2187e86233e1e4e2e35a99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Mar 2024 16:56:33 -1000 Subject: [PATCH 64/95] Ensure all homekit_controller controllers are imported in advance (#112079) * Ensure all homekit_controllers are imported in advance We want to avoid importing them in the event loop later * Ensure all homekit_controllers are imported in advance We want to avoid importing them in the event loop later --- .../components/homekit_controller/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 1043164c801..e3ff4d47fcf 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -6,6 +6,11 @@ import contextlib import logging import aiohomekit +from aiohomekit.const import ( + BLE_TRANSPORT_SUPPORTED, + COAP_TRANSPORT_SUPPORTED, + IP_TRANSPORT_SUPPORTED, +) from aiohomekit.exceptions import ( AccessoryDisconnectedError, AccessoryNotFoundError, @@ -24,6 +29,15 @@ from .connection import HKDevice from .const import DOMAIN, KNOWN_DEVICES from .utils import async_get_controller +# Ensure all the controllers get imported in the executor +# since they are loaded late. +if BLE_TRANSPORT_SUPPORTED: + from aiohomekit.controller import ble # noqa: F401 +if COAP_TRANSPORT_SUPPORTED: + from aiohomekit.controller import coap # noqa: F401 +if IP_TRANSPORT_SUPPORTED: + from aiohomekit.controller import ip # noqa: F401 + _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) From e23f737fa7de8d32ff10175189e7b75d53c90446 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Mar 2024 16:55:04 -1000 Subject: [PATCH 65/95] Fix bootstrap being fetched three times during unifiprotect startup (#112082) We always fetch it to check if the device is online. Avoid fetching it again for migration by passing it to the migrators --- .../components/unifiprotect/__init__.py | 10 ++++-- .../components/unifiprotect/migrate.py | 35 ++++++++----------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 942c533b59e..c4a6bc88068 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -5,6 +5,7 @@ from datetime import timedelta import logging from aiohttp.client_exceptions import ServerDisconnectedError +from pyunifiprotect.data import Bootstrap from pyunifiprotect.exceptions import ClientError, NotAuthorized # Import the test_util.anonymize module from the pyunifiprotect package @@ -128,7 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - await _async_setup_entry(hass, entry, data_service) + await _async_setup_entry(hass, entry, data_service, bootstrap) except Exception as err: if await nvr_info.get_is_prerelease(): # If they are running a pre-release, its quite common for setup @@ -156,9 +157,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, data_service: ProtectData + hass: HomeAssistant, + entry: ConfigEntry, + data_service: ProtectData, + bootstrap: Bootstrap, ) -> None: - await async_migrate_data(hass, entry, data_service.api) + await async_migrate_data(hass, entry, data_service.api, bootstrap) await data_service.async_setup() if not data_service.last_update_success: diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index db1e82d9914..3a6dde653b4 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -3,47 +3,39 @@ from __future__ import annotations import logging -from aiohttp.client_exceptions import ServerDisconnectedError from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import NVR, Bootstrap, ProtectAdoptableDeviceModel -from pyunifiprotect.exceptions import ClientError from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er _LOGGER = logging.getLogger(__name__) async def async_migrate_data( - hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient + hass: HomeAssistant, + entry: ConfigEntry, + protect: ProtectApiClient, + bootstrap: Bootstrap, ) -> None: """Run all valid UniFi Protect data migrations.""" _LOGGER.debug("Start Migrate: async_migrate_buttons") - await async_migrate_buttons(hass, entry, protect) + await async_migrate_buttons(hass, entry, protect, bootstrap) _LOGGER.debug("Completed Migrate: async_migrate_buttons") _LOGGER.debug("Start Migrate: async_migrate_device_ids") - await async_migrate_device_ids(hass, entry, protect) + await async_migrate_device_ids(hass, entry, protect, bootstrap) _LOGGER.debug("Completed Migrate: async_migrate_device_ids") -async def async_get_bootstrap(protect: ProtectApiClient) -> Bootstrap: - """Get UniFi Protect bootstrap or raise appropriate HA error.""" - - try: - bootstrap = await protect.get_bootstrap() - except (TimeoutError, ClientError, ServerDisconnectedError) as err: - raise ConfigEntryNotReady from err - - return bootstrap - - async def async_migrate_buttons( - hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient + hass: HomeAssistant, + entry: ConfigEntry, + protect: ProtectApiClient, + bootstrap: Bootstrap, ) -> None: """Migrate existing Reboot button unique IDs from {device_id} to {deivce_id}_reboot. @@ -63,7 +55,6 @@ async def async_migrate_buttons( _LOGGER.debug("No button entities need migration") return - bootstrap = await async_get_bootstrap(protect) count = 0 for button in to_migrate: device = bootstrap.get_device_from_id(button.unique_id) @@ -94,7 +85,10 @@ async def async_migrate_buttons( async def async_migrate_device_ids( - hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient + hass: HomeAssistant, + entry: ConfigEntry, + protect: ProtectApiClient, + bootstrap: Bootstrap, ) -> None: """Migrate unique IDs from {device_id}_{name} format to {mac}_{name} format. @@ -119,7 +113,6 @@ async def async_migrate_device_ids( _LOGGER.debug("No entities need migration to MAC address ID") return - bootstrap = await async_get_bootstrap(protect) count = 0 for entity in to_migrate: parts = entity.unique_id.split("_") From ab30d44184282672e508322819f74b84e66962cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Mar 2024 16:53:51 -1000 Subject: [PATCH 66/95] Fix executor being overloaded in caldav (#112084) Migrate to using a single executor job instead of creating one per calendar. If the user had a lot of calendars the executor would get overloaded --- homeassistant/components/caldav/api.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index fa89d6acc38..3b524e29370 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -1,6 +1,5 @@ """Library for working with CalDAV api.""" -import asyncio import caldav @@ -13,20 +12,13 @@ async def async_get_calendars( """Get all calendars that support the specified component.""" def _get_calendars() -> list[caldav.Calendar]: - return client.principal().calendars() - - calendars = await hass.async_add_executor_job(_get_calendars) - components_results = await asyncio.gather( - *[ - hass.async_add_executor_job(calendar.get_supported_components) - for calendar in calendars + return [ + calendar + for calendar in client.principal().calendars() + if component in calendar.get_supported_components() ] - ) - return [ - calendar - for calendar, supported_components in zip(calendars, components_results) - if component in supported_components - ] + + return await hass.async_add_executor_job(_get_calendars) def get_attr_value(obj: caldav.CalendarObjectResource, attribute: str) -> str | None: From 780f6e897462aafcb7d7abb181e8fe9ef5b5e0a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Mar 2024 16:50:35 -1000 Subject: [PATCH 67/95] Avoid expensive inspect calls in config validators (#112085) * Avoid expensive inspect calls in config validators inspect has a performance problem https://github.com/python/cpython/issues/92041 We now avoid calling inspect unless we are going to log * remove unused * reduce * get_integration_logger --- homeassistant/helpers/config_validation.py | 49 +++++-------------- homeassistant/helpers/frame.py | 20 ++++++++ tests/helpers/test_config_validation.py | 41 ++++++++-------- tests/helpers/test_frame.py | 43 ++++++++++++++++ .../test_integration_frame/__init__.py | 7 +++ 5 files changed, 101 insertions(+), 59 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index b9ff5704246..59e4f09d26f 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -10,7 +10,6 @@ from datetime import ( timedelta, ) from enum import Enum, StrEnum -import inspect import logging from numbers import Number import os @@ -103,6 +102,7 @@ import homeassistant.util.dt as dt_util from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper +from .frame import get_integration_logger TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'" @@ -890,24 +890,17 @@ def _deprecated_or_removed( - No warning if neither key nor replacement_key are provided - Adds replacement_key with default value in this case """ - module = inspect.getmodule(inspect.stack(context=0)[2].frame) - if module is not None: - module_name = module.__name__ - else: - # If Python is unable to access the sources files, the call stack frame - # will be missing information, so let's guard. - # https://github.com/home-assistant/core/issues/24982 - module_name = __name__ - if option_removed: - logger_func = logging.getLogger(module_name).error - option_status = "has been removed" - else: - logger_func = logging.getLogger(module_name).warning - option_status = "is deprecated" def validator(config: dict) -> dict: """Check if key is in config and log warning or error.""" if key in config: + if option_removed: + level = logging.ERROR + option_status = "has been removed" + else: + level = logging.WARNING + option_status = "is deprecated" + try: near = ( f"near {config.__config_file__}" # type: ignore[attr-defined] @@ -928,7 +921,7 @@ def _deprecated_or_removed( if raise_if_present: raise vol.Invalid(warning % arguments) - logger_func(warning, *arguments) + get_integration_logger(__name__).log(level, warning, *arguments) value = config[key] if replacement_key or option_removed: config.pop(key) @@ -1112,19 +1105,9 @@ def expand_condition_shorthand(value: Any | None) -> Any: def empty_config_schema(domain: str) -> Callable[[dict], dict]: """Return a config schema which logs if there are configuration parameters.""" - module = inspect.getmodule(inspect.stack(context=0)[2].frame) - if module is not None: - module_name = module.__name__ - else: - # If Python is unable to access the sources files, the call stack frame - # will be missing information, so let's guard. - # https://github.com/home-assistant/core/issues/24982 - module_name = __name__ - logger_func = logging.getLogger(module_name).error - def validator(config: dict) -> dict: if domain in config and config[domain]: - logger_func( + get_integration_logger(__name__).error( ( "The %s integration does not support any configuration parameters, " "got %s. Please remove the configuration parameters from your " @@ -1146,16 +1129,6 @@ def _no_yaml_config_schema( ) -> Callable[[dict], dict]: """Return a config schema which logs if attempted to setup from YAML.""" - module = inspect.getmodule(inspect.stack(context=0)[2].frame) - if module is not None: - module_name = module.__name__ - else: - # If Python is unable to access the sources files, the call stack frame - # will be missing information, so let's guard. - # https://github.com/home-assistant/core/issues/24982 - module_name = __name__ - logger_func = logging.getLogger(module_name).error - def raise_issue() -> None: # pylint: disable-next=import-outside-toplevel from .issue_registry import IssueSeverity, async_create_issue @@ -1176,7 +1149,7 @@ def _no_yaml_config_schema( def validator(config: dict) -> dict: if domain in config: - logger_func( + get_integration_logger(__name__).error( ( "The %s integration does not support YAML setup, please remove it " "from your configuration file" diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 9c17e17a2c6..04f16ebddd0 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -34,6 +34,26 @@ class IntegrationFrame: relative_filename: str +def get_integration_logger(fallback_name: str) -> logging.Logger: + """Return a logger by checking the current integration frame. + + If Python is unable to access the sources files, the call stack frame + will be missing information, so let's guard by requiring a fallback name. + https://github.com/home-assistant/core/issues/24982 + """ + try: + integration_frame = get_integration_frame() + except MissingIntegrationFrame: + return logging.getLogger(fallback_name) + + if integration_frame.custom_integration: + logger_name = f"custom_components.{integration_frame.integration}" + else: + logger_name = f"homeassistant.components.{integration_frame.integration}" + + return logging.getLogger(logger_name) + + def get_integration_frame(exclude_integrations: set | None = None) -> IntegrationFrame: """Return the frame, integration and integration path of the current stack frame.""" found_frame = None diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index c16d7b1ec51..060800be62d 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -2,6 +2,7 @@ from collections import OrderedDict from datetime import date, datetime, timedelta import enum +import logging import os from socket import _GLOBAL_DEFAULT_TIMEOUT from unittest.mock import Mock, patch @@ -986,7 +987,11 @@ def test_deprecated_with_default(caplog: pytest.LogCaptureFixture, schema) -> No deprecated_schema = vol.All(cv.deprecated("mars", default=False), schema) test_data = {"mars": True} - output = deprecated_schema(test_data.copy()) + with patch( + "homeassistant.helpers.config_validation.get_integration_logger", + return_value=logging.getLogger(__name__), + ): + output = deprecated_schema(test_data.copy()) assert len(caplog.records) == 1 assert caplog.records[0].name == __name__ assert ( @@ -1062,21 +1067,19 @@ def test_deprecated_with_replacement_key_and_default( def test_deprecated_cant_find_module() -> None: - """Test if the current module cannot be inspected.""" - with patch("inspect.getmodule", return_value=None): - # This used to raise. - cv.deprecated( - "mars", - replacement_key="jupiter", - default=False, - ) + """Test if the current module cannot be found.""" + # This used to raise. + cv.deprecated( + "mars", + replacement_key="jupiter", + default=False, + ) - with patch("inspect.getmodule", return_value=None): - # This used to raise. - cv.removed( - "mars", - default=False, - ) + # This used to raise. + cv.removed( + "mars", + default=False, + ) def test_deprecated_or_removed_logger_with_config_attributes( @@ -1551,8 +1554,7 @@ def test_empty_schema(caplog: pytest.LogCaptureFixture) -> None: def test_empty_schema_cant_find_module() -> None: """Test if the current module cannot be inspected.""" - with patch("inspect.getmodule", return_value=None): - cv.empty_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) + cv.empty_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) def test_config_entry_only_schema( @@ -1582,10 +1584,7 @@ def test_config_entry_only_schema( def test_config_entry_only_schema_cant_find_module() -> None: """Test if the current module cannot be inspected.""" - with patch("inspect.getmodule", return_value=None): - cv.config_entry_only_config_schema("test_domain")( - {"test_domain": {"foo": "bar"}} - ) + cv.config_entry_only_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) def test_config_entry_only_schema_no_hass( diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 5010c459345..fa495e9dbc9 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -22,6 +22,14 @@ async def test_extract_frame_integration( ) +async def test_get_integration_logger( + caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock +) -> None: + """Test extracting the current frame to get the logger.""" + logger = frame.get_integration_logger(__name__) + assert logger.name == "homeassistant.components.hue" + + async def test_extract_frame_resolve_module( hass: HomeAssistant, enable_custom_integrations ) -> None: @@ -39,6 +47,17 @@ async def test_extract_frame_resolve_module( ) +async def test_get_integration_logger_resolve_module( + hass: HomeAssistant, enable_custom_integrations +) -> None: + """Test getting the logger from integration context.""" + from custom_components.test_integration_frame import call_get_integration_logger + + logger = call_get_integration_logger(__name__) + + assert logger.name == "custom_components.test_integration_frame" + + async def test_extract_frame_integration_with_excluded_integration( caplog: pytest.LogCaptureFixture, ) -> None: @@ -102,6 +121,30 @@ async def test_extract_frame_no_integration(caplog: pytest.LogCaptureFixture) -> frame.get_integration_frame() +async def test_get_integration_logger_no_integration( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test getting fallback logger without integration context.""" + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + logger = frame.get_integration_logger(__name__) + + assert logger.name == __name__ + + @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_prevent_flooding( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock diff --git a/tests/testing_config/custom_components/test_integration_frame/__init__.py b/tests/testing_config/custom_components/test_integration_frame/__init__.py index d342509d52e..31e894dddcd 100644 --- a/tests/testing_config/custom_components/test_integration_frame/__init__.py +++ b/tests/testing_config/custom_components/test_integration_frame/__init__.py @@ -1,8 +1,15 @@ """An integration which calls helpers.frame.get_integration_frame.""" +import logging + from homeassistant.helpers import frame +def call_get_integration_logger(fallback_name: str) -> logging.Logger: + """Call get_integration_logger.""" + return frame.get_integration_logger(fallback_name) + + def call_get_integration_frame() -> frame.IntegrationFrame: """Call get_integration_frame.""" return frame.get_integration_frame() From bb6f8b9d57613bb9314b591c55bb9a11a4855f9a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 2 Mar 2024 22:09:17 -0500 Subject: [PATCH 68/95] Bump version to 2024.3.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e0ae0ea5381..e893e59c649 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index d2ff46a373e..1f8e33dd96e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.3.0b4" +version = "2024.3.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9926296d3591975423ba725d698f280a1f44bef2 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 4 Mar 2024 10:10:59 +0100 Subject: [PATCH 69/95] Handle exception in ViCare integration (#111128) --- homeassistant/components/vicare/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 1b8c1edcb8c..10cc1a15c9e 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -205,7 +205,8 @@ class ViCareClimate(ViCareEntity, ClimateEntity): "heating_curve_shift" ] = self._circuit.getHeatingCurveShift() - self._attributes["vicare_modes"] = self._circuit.getModes() + with suppress(PyViCareNotSupportedFeatureError): + self._attributes["vicare_modes"] = self._circuit.getModes() self._current_action = False # Update the specific device attributes From 193332da7485b82d832a1cc6b57f8caefc507aa8 Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Mon, 4 Mar 2024 15:57:37 +0100 Subject: [PATCH 70/95] Bump bring-api to 0.5.4 (#111654) --- homeassistant/components/bring/coordinator.py | 6 +- homeassistant/components/bring/manifest.json | 2 +- homeassistant/components/bring/todo.py | 103 +++++++++++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 69 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index dbb6905473d..550c589aa4e 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -6,7 +6,7 @@ import logging from bring_api.bring import Bring from bring_api.exceptions import BringParseException, BringRequestException -from bring_api.types import BringItemsResponse, BringList +from bring_api.types import BringList, BringPurchase from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -20,8 +20,8 @@ _LOGGER = logging.getLogger(__name__) class BringData(BringList): """Coordinator data class.""" - purchase_items: list[BringItemsResponse] - recently_items: list[BringItemsResponse] + purchase_items: list[BringPurchase] + recently_items: list[BringPurchase] class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index 604b9d9c750..0e425ec3eee 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["bring-api==0.4.1"] + "requirements": ["bring-api==0.5.4"] } diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 3e4e3137aaa..5d3fc5bbf68 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -2,8 +2,10 @@ from __future__ import annotations from typing import TYPE_CHECKING +import uuid from bring_api.exceptions import BringRequestException +from bring_api.types import BringItem, BringItemOperation from homeassistant.components.todo import ( TodoItem, @@ -76,7 +78,7 @@ class BringTodoListEntity( return [ *( TodoItem( - uid=item["itemId"], + uid=item["uuid"], summary=item["itemId"], description=item["specification"] or "", status=TodoItemStatus.NEEDS_ACTION, @@ -85,7 +87,7 @@ class BringTodoListEntity( ), *( TodoItem( - uid=item["itemId"], + uid=item["uuid"], summary=item["itemId"], description=item["specification"] or "", status=TodoItemStatus.COMPLETED, @@ -103,7 +105,10 @@ class BringTodoListEntity( """Add an item to the To-do list.""" try: await self.coordinator.bring.save_item( - self.bring_list["listUuid"], item.summary, item.description or "" + self.bring_list["listUuid"], + item.summary, + item.description or "", + str(uuid.uuid4()), ) except BringRequestException as e: raise HomeAssistantError("Unable to save todo item for bring") from e @@ -121,60 +126,69 @@ class BringTodoListEntity( - Completed items will move to the "completed" section in home assistant todo list and get moved to the recently list in bring - - Bring items do not have unique identifiers and are using the - name/summery/title. Therefore the name is not to be changed! Should a name - be changed anyway, a new item will be created instead and no update for - this item is performed and on the next cloud pull update, it will get - cleared and replaced seamlessly + - Bring shows some odd behaviour when renaming items. This is because Bring + did not have unique identifiers for items in the past and this is still + a relic from it. Therefore the name is not to be changed! Should a name + be changed anyway, the item will be deleted and a new item will be created + instead and no update for this item is performed and on the next cloud pull + update, it will get cleared and replaced seamlessly. """ bring_list = self.bring_list bring_purchase_item = next( - (i for i in bring_list["purchase_items"] if i["itemId"] == item.uid), + (i for i in bring_list["purchase_items"] if i["uuid"] == item.uid), None, ) bring_recently_item = next( - (i for i in bring_list["recently_items"] if i["itemId"] == item.uid), + (i for i in bring_list["recently_items"] if i["uuid"] == item.uid), None, ) + current_item = bring_purchase_item or bring_recently_item + if TYPE_CHECKING: assert item.uid + assert current_item - if item.status == TodoItemStatus.COMPLETED and bring_purchase_item: - await self.coordinator.bring.complete_item( - bring_list["listUuid"], - item.uid, - ) - - elif item.status == TodoItemStatus.NEEDS_ACTION and bring_recently_item: - await self.coordinator.bring.save_item( - bring_list["listUuid"], - item.uid, - ) - - elif item.summary == item.uid: + if item.summary == current_item["itemId"]: try: - await self.coordinator.bring.update_item( + await self.coordinator.bring.batch_update_list( bring_list["listUuid"], - item.uid, - item.description or "", + BringItem( + itemId=item.summary, + spec=item.description, + uuid=item.uid, + ), + BringItemOperation.ADD + if item.status == TodoItemStatus.NEEDS_ACTION + else BringItemOperation.COMPLETE, ) except BringRequestException as e: raise HomeAssistantError("Unable to update todo item for bring") from e else: try: - await self.coordinator.bring.remove_item( + await self.coordinator.bring.batch_update_list( bring_list["listUuid"], - item.uid, - ) - await self.coordinator.bring.save_tem( - bring_list["listUuid"], - item.summary, - item.description or "", + [ + BringItem( + itemId=current_item["itemId"], + spec=item.description, + uuid=item.uid, + operation=BringItemOperation.REMOVE, + ), + BringItem( + itemId=item.summary, + spec=item.description, + uuid=str(uuid.uuid4()), + operation=BringItemOperation.ADD + if item.status == TodoItemStatus.NEEDS_ACTION + else BringItemOperation.COMPLETE, + ), + ], ) + except BringRequestException as e: raise HomeAssistantError("Unable to replace todo item for bring") from e @@ -182,12 +196,21 @@ class BringTodoListEntity( async def async_delete_todo_items(self, uids: list[str]) -> None: """Delete an item from the To-do list.""" - for uid in uids: - try: - await self.coordinator.bring.remove_item( - self.bring_list["listUuid"], uid - ) - except BringRequestException as e: - raise HomeAssistantError("Unable to delete todo item for bring") from e + + try: + await self.coordinator.bring.batch_update_list( + self.bring_list["listUuid"], + [ + BringItem( + itemId=uid, + spec="", + uuid=uid, + ) + for uid in uids + ], + BringItemOperation.REMOVE, + ) + except BringRequestException as e: + raise HomeAssistantError("Unable to delete todo item for bring") from e await self.coordinator.async_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index d5ca9106765..77ba1e3275a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ boschshcpy==0.2.75 boto3==1.33.13 # homeassistant.components.bring -bring-api==0.4.1 +bring-api==0.5.4 # homeassistant.components.broadlink broadlink==0.18.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77270423473..6fa7d1e64ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -514,7 +514,7 @@ bond-async==0.2.1 boschshcpy==0.2.75 # homeassistant.components.bring -bring-api==0.4.1 +bring-api==0.5.4 # homeassistant.components.broadlink broadlink==0.18.3 From 4863c948249d7afa47665237f067a6f17fd26ad5 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sun, 3 Mar 2024 11:31:23 -0500 Subject: [PATCH 71/95] Bump Zigpy to 0.63.4 (#112117) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 225837c66fc..fc050c9b2d1 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -27,7 +27,7 @@ "pyserial-asyncio==0.6", "zha-quirks==0.0.112", "zigpy-deconz==0.23.1", - "zigpy==0.63.3", + "zigpy==0.63.4", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/requirements_all.txt b/requirements_all.txt index 77ba1e3275a..55ccfac0b8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2950,7 +2950,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.63.3 +zigpy==0.63.4 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6fa7d1e64ec..38719ec5efc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2270,7 +2270,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.63.3 +zigpy==0.63.4 # homeassistant.components.zwave_js zwave-js-server-python==0.55.3 From 2cdc8d5f69a7e34ba71e119ac92694caa5205688 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 3 Mar 2024 18:47:09 +0100 Subject: [PATCH 72/95] Bump reolink-aio to 0.8.9 (#112124) * Update strings.json * Bump reolink-aio to 0.8.9 --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/strings.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 0f2ef19ba87..81d11e2fd0a 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.8.8"] + "requirements": ["reolink-aio==0.8.9"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 92e9a6164f8..fb4d42bb97d 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -367,7 +367,8 @@ "state": { "stayoff": "Stay off", "auto": "Auto", - "alwaysonatnight": "Auto & always on at night" + "alwaysonatnight": "Auto & always on at night", + "alwayson": "Always on" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 55ccfac0b8e..1476f11c863 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2433,7 +2433,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.8 +reolink-aio==0.8.9 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38719ec5efc..5f00f4ae440 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1873,7 +1873,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.8 +reolink-aio==0.8.9 # homeassistant.components.rflink rflink==0.0.66 From 62474967c92e2b1c2dbb0b884af57b7601129686 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 3 Mar 2024 18:29:02 +0100 Subject: [PATCH 73/95] Ignore failing gas stations in Tankerkoening (#112125) --- homeassistant/components/tankerkoenig/coordinator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index c28ebf4aab2..c2d91f20b8a 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -62,8 +62,11 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): station = await self._tankerkoenig.station_details(station_id) except TankerkoenigInvalidKeyError as err: raise ConfigEntryAuthFailed(err) from err - except (TankerkoenigError, TankerkoenigConnectionError) as err: + except TankerkoenigConnectionError as err: raise ConfigEntryNotReady(err) from err + except TankerkoenigError as err: + _LOGGER.error("Error when adding station %s %s", station_id, err) + continue self.stations[station_id] = station From 93ee900cb34f34d16c223978784d20e43d8354d1 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sun, 3 Mar 2024 19:17:02 -0500 Subject: [PATCH 74/95] Fix ZHA groups page (#112140) * Fix ZHA groups page * test --- homeassistant/components/zha/core/group.py | 3 ++ tests/components/zha/test_switch.py | 62 +++++++++++----------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index fc7f1f8758f..a62c00e7106 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -87,6 +87,9 @@ class ZHAGroupMember(LogMixin): entity_info = [] for entity_ref in zha_device_registry.get(self.device.ieee): + # We have device entities now that don't leverage cluster handlers + if not entity_ref.cluster_handlers: + continue entity = entity_registry.async_get(entity_ref.reference_id) handler = list(entity_ref.cluster_handlers.values())[0] diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index cb1d87210a7..9a9fbc2b50e 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -25,6 +25,7 @@ from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component @@ -39,6 +40,8 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.common import MockConfigEntry + ON = 1 OFF = 0 IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" @@ -91,27 +94,6 @@ def zigpy_cover_device(zigpy_device_mock): return zigpy_device_mock(endpoints) -@pytest.fixture -async def coordinator(hass, zigpy_device_mock, zha_device_joined): - """Test ZHA light platform.""" - - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [], - SIG_EP_OUTPUT: [], - SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, - } - }, - ieee="00:15:8d:00:02:32:4f:32", - nwk=0x0000, - node_descriptor=b"\xf8\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", - ) - zha_device = await zha_device_joined(zigpy_device) - zha_device.available = True - return zha_device - - @pytest.fixture async def device_switch_1(hass, zigpy_device_mock, zha_device_joined): """Test ZHA switch platform.""" @@ -300,19 +282,41 @@ async def zigpy_device_tuya(hass, zigpy_device_mock, zha_device_joined): new=0, ) async def test_zha_group_switch_entity( - hass: HomeAssistant, device_switch_1, device_switch_2, coordinator + hass: HomeAssistant, + device_switch_1, + device_switch_2, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test the switch entity for a ZHA group.""" + + # make sure we can still get groups when counter entities exist + entity_id = "sensor.coordinator_manufacturer_coordinator_model_counter_1" + state = hass.states.get(entity_id) + assert state is None + + # Enable the entity. + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "1" + zha_gateway = get_zha_gateway(hass) assert zha_gateway is not None - zha_gateway.coordinator_zha_device = coordinator - coordinator._zha_gateway = zha_gateway device_switch_1._zha_gateway = zha_gateway device_switch_2._zha_gateway = zha_gateway - member_ieee_addresses = [device_switch_1.ieee, device_switch_2.ieee] + member_ieee_addresses = [ + device_switch_1.ieee, + device_switch_2.ieee, + zha_gateway.coordinator_zha_device.ieee, + ] members = [ GroupMember(device_switch_1.ieee, 1), GroupMember(device_switch_2.ieee, 1), + GroupMember(zha_gateway.coordinator_zha_device.ieee, 1), ] # test creating a group with 2 members @@ -320,7 +324,7 @@ async def test_zha_group_switch_entity( await hass.async_block_till_done() assert zha_group is not None - assert len(zha_group.members) == 2 + assert len(zha_group.members) == 3 for member in zha_group.members: assert member.device.ieee in member_ieee_addresses assert member.group == zha_group @@ -333,12 +337,6 @@ async def test_zha_group_switch_entity( dev1_cluster_on_off = device_switch_1.device.endpoints[1].on_off dev2_cluster_on_off = device_switch_2.device.endpoints[1].on_off - await async_enable_traffic(hass, [device_switch_1, device_switch_2], enabled=False) - await async_wait_for_updates(hass) - - # test that the switches were created and that they are off - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - # allow traffic to flow through the gateway and device await async_enable_traffic(hass, [device_switch_1, device_switch_2]) await async_wait_for_updates(hass) From 274ab2328e9417ad033b621fb9c681c5c7bbafd0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 3 Mar 2024 16:54:05 -0800 Subject: [PATCH 75/95] Limit rainbird aiohttp client session to a single connection (#112146) Limit rainbird to a single open http connection --- homeassistant/components/rainbird/__init__.py | 7 +++-- .../components/rainbird/config_flow.py | 7 +++-- .../components/rainbird/coordinator.py | 11 ++++++++ tests/components/rainbird/conftest.py | 28 ++++++++++++++++++- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index f7eab3bc2f2..2a660435e17 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -11,11 +11,10 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from .const import CONF_SERIAL_NUMBER -from .coordinator import RainbirdData +from .coordinator import RainbirdData, async_create_clientsession _LOGGER = logging.getLogger(__name__) @@ -36,9 +35,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) + clientsession = async_create_clientsession() + entry.async_on_unload(clientsession.close) controller = AsyncRainbirdController( AsyncRainbirdClient( - async_get_clientsession(hass), + clientsession, entry.data[CONF_HOST], entry.data[CONF_PASSWORD], ) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index d9cf7b565a7..a4fceceede9 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -20,7 +20,6 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from .const import ( @@ -30,6 +29,7 @@ from .const import ( DOMAIN, TIMEOUT_SECONDS, ) +from .coordinator import async_create_clientsession _LOGGER = logging.getLogger(__name__) @@ -101,9 +101,10 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): Raises a ConfigFlowError on failure. """ + clientsession = async_create_clientsession() controller = AsyncRainbirdController( AsyncRainbirdClient( - async_get_clientsession(self.hass), + clientsession, host, password, ) @@ -124,6 +125,8 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): f"Error connecting to Rain Bird controller: {str(err)}", "cannot_connect", ) from err + finally: + await clientsession.close() async def async_finish( self, diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 9f1ea95b333..22aaf2d11a0 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -9,6 +9,7 @@ from functools import cached_property import logging from typing import TypeVar +import aiohttp from pyrainbird.async_client import ( AsyncRainbirdController, RainbirdApiException, @@ -28,6 +29,9 @@ UPDATE_INTERVAL = datetime.timedelta(minutes=1) # changes, so we refresh it less often. CALENDAR_UPDATE_INTERVAL = datetime.timedelta(minutes=15) +# Rainbird devices can only accept a single request at a time +CONECTION_LIMIT = 1 + _LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") @@ -43,6 +47,13 @@ class RainbirdDeviceState: rain_delay: int +def async_create_clientsession() -> aiohttp.ClientSession: + """Create a rainbird async_create_clientsession with a connection limit.""" + return aiohttp.ClientSession( + connector=aiohttp.TCPConnector(limit=CONECTION_LIMIT), + ) + + class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): """Coordinator for rainbird API calls.""" diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 52b98e5c6b6..53df24264df 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Generator from http import HTTPStatus import json from typing import Any @@ -15,7 +16,7 @@ from homeassistant.components.rainbird.const import ( ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES, ) -from homeassistant.const import Platform +from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -155,6 +156,31 @@ def setup_platforms( yield +@pytest.fixture(autouse=True) +def aioclient_mock(hass: HomeAssistant) -> Generator[AiohttpClientMocker, None, None]: + """Context manager to mock aiohttp client.""" + mocker = AiohttpClientMocker() + + def create_session(): + session = mocker.create_session(hass.loop) + + async def close_session(event): + """Close session.""" + await session.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, close_session) + return session + + with patch( + "homeassistant.components.rainbird.async_create_clientsession", + side_effect=create_session, + ), patch( + "homeassistant.components.rainbird.config_flow.async_create_clientsession", + side_effect=create_session, + ): + yield mocker + + def rainbird_json_response(result: dict[str, str]) -> bytes: """Create a fake API response.""" return encryption.encrypt( From 79b1d6df1b27562a0751a293d48eaaaa27f5be88 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 4 Mar 2024 01:05:28 -0800 Subject: [PATCH 76/95] Add rainbird request debouncer and immediately update entity switch state (#112152) --- homeassistant/components/rainbird/coordinator.py | 8 ++++++++ homeassistant/components/rainbird/switch.py | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 22aaf2d11a0..70365c2f095 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -19,6 +19,7 @@ from pyrainbird.data import ModelAndVersion, Schedule from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -29,6 +30,10 @@ UPDATE_INTERVAL = datetime.timedelta(minutes=1) # changes, so we refresh it less often. CALENDAR_UPDATE_INTERVAL = datetime.timedelta(minutes=15) +# The valves state are not immediately reflected after issuing a command. We add +# small delay to give additional time to reflect the new state. +DEBOUNCER_COOLDOWN = 5 + # Rainbird devices can only accept a single request at a time CONECTION_LIMIT = 1 @@ -71,6 +76,9 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): _LOGGER, name=name, update_interval=UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=DEBOUNCER_COOLDOWN, immediate=False + ), ) self._controller = controller self._unique_id = unique_id diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index da3979a27fd..810a6fbb721 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -103,6 +103,10 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) except RainbirdApiException as err: raise HomeAssistantError("Rain Bird device failure") from err + # The device reflects the old state for a few moments. Update the + # state manually and trigger a refresh after a short debounced delay. + self.coordinator.data.active_zones.add(self._zone) + self.async_write_ha_state() await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs): @@ -115,6 +119,11 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) ) from err except RainbirdApiException as err: raise HomeAssistantError("Rain Bird device failure") from err + + # The device reflects the old state for a few moments. Update the + # state manually and trigger a refresh after a short debounced delay. + self.coordinator.data.active_zones.remove(self._zone) + self.async_write_ha_state() await self.coordinator.async_request_refresh() @property From 44c961720cc40969595e68a414d73fed45f2771c Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 4 Mar 2024 18:09:49 +0100 Subject: [PATCH 77/95] Update frontend to 20240304.0 (#112263) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d975daad508..0606312aaea 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240301.0"] + "requirements": ["home-assistant-frontend==20240304.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c80da6b5aad..36b95272104 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ habluetooth==2.4.2 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240301.0 +home-assistant-frontend==20240304.0 home-assistant-intents==2024.2.28 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1476f11c863..83a1a7760cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1074,7 +1074,7 @@ hole==0.8.0 holidays==0.43 # homeassistant.components.frontend -home-assistant-frontend==20240301.0 +home-assistant-frontend==20240304.0 # homeassistant.components.conversation home-assistant-intents==2024.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f00f4ae440..0fcf4f916f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -873,7 +873,7 @@ hole==0.8.0 holidays==0.43 # homeassistant.components.frontend -home-assistant-frontend==20240301.0 +home-assistant-frontend==20240304.0 # homeassistant.components.conversation home-assistant-intents==2024.2.28 From dedd7a5a41065798bc2a0ca39bc341eea8ecf128 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Mar 2024 13:04:03 -0500 Subject: [PATCH 78/95] Bump version to 2024.3.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e893e59c649..51951543196 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 1f8e33dd96e..09b3cb76e24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.3.0b5" +version = "2024.3.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 0ad56de6fca61100d922c17f8eaee4fb678cc6a6 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Mar 2024 11:55:22 +0100 Subject: [PATCH 79/95] Fix deCONZ light entity might not report a supported color mode (#112116) * Handle case where deCONZ light entity might not report a supported color mode * If in an unknown color mode set ColorMode.UNKNOWN * Fix comment from external discussion --- homeassistant/components/deconz/light.py | 1 + tests/components/deconz/test_light.py | 141 ++++++++++++++++++++++- 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 086db2058c9..d618edc93f8 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -165,6 +165,7 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): """Representation of a deCONZ light.""" TYPE = DOMAIN + _attr_color_mode = ColorMode.UNKNOWN def __init__(self, device: _LightDeviceT, gateway: DeconzGateway) -> None: """Set up light.""" diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 63c544ff189..07e284d65f2 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -1380,10 +1380,147 @@ async def test_verify_group_supported_features( assert len(hass.states.async_all()) == 4 - assert hass.states.get("light.group").state == STATE_ON + group_state = hass.states.get("light.group") + assert group_state.state == STATE_ON + assert group_state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP assert ( - hass.states.get("light.group").attributes[ATTR_SUPPORTED_FEATURES] + group_state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION | LightEntityFeature.FLASH | LightEntityFeature.EFFECT ) + + +async def test_verify_group_color_mode_fallback( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket +) -> None: + """Test that group supported features reflect what included lights support.""" + data = { + "groups": { + "43": { + "action": { + "alert": "none", + "bri": 127, + "colormode": "hs", + "ct": 0, + "effect": "none", + "hue": 0, + "on": True, + "sat": 127, + "scene": "4", + "xy": [0, 0], + }, + "devicemembership": [], + "etag": "4548e982c4cfff942f7af80958abb2a0", + "id": "43", + "lights": ["13"], + "name": "Opbergruimte", + "scenes": [ + { + "id": "1", + "lightcount": 1, + "name": "Scene Normaal deCONZ", + "transitiontime": 10, + }, + { + "id": "2", + "lightcount": 1, + "name": "Scene Fel deCONZ", + "transitiontime": 10, + }, + { + "id": "3", + "lightcount": 1, + "name": "Scene Gedimd deCONZ", + "transitiontime": 10, + }, + { + "id": "4", + "lightcount": 1, + "name": "Scene Uit deCONZ", + "transitiontime": 10, + }, + ], + "state": {"all_on": False, "any_on": False}, + "type": "LightGroup", + }, + }, + "lights": { + "13": { + "capabilities": { + "alerts": [ + "none", + "select", + "lselect", + "blink", + "breathe", + "okay", + "channelchange", + "finish", + "stop", + ], + "bri": {"min_dim_level": 5}, + }, + "config": { + "bri": {"execute_if_off": True, "startup": "previous"}, + "groups": ["43"], + "on": {"startup": "previous"}, + }, + "etag": "ca0ed7763eca37f5e6b24f6d46f8a518", + "hascolor": False, + "lastannounced": None, + "lastseen": "2024-03-02T20:08Z", + "manufacturername": "Signify Netherlands B.V.", + "modelid": "LWA001", + "name": "Opbergruimte Lamp Plafond", + "productid": "Philips-LWA001-1-A19DLv5", + "productname": "Hue white lamp", + "state": { + "alert": "none", + "bri": 76, + "effect": "none", + "on": False, + "reachable": True, + }, + "swconfigid": "87169548", + "swversion": "1.104.2", + "type": "Dimmable light", + "uniqueid": "00:17:88:01:08:11:22:33-01", + }, + }, + } + with patch.dict(DECONZ_WEB_REQUEST, data): + await setup_deconz_integration(hass, aioclient_mock) + + group_state = hass.states.get("light.opbergruimte") + assert group_state.state == STATE_OFF + assert group_state.attributes[ATTR_COLOR_MODE] is None + + await mock_deconz_websocket( + data={ + "e": "changed", + "id": "13", + "r": "lights", + "state": { + "alert": "none", + "bri": 76, + "effect": "none", + "on": True, + "reachable": True, + }, + "t": "event", + "uniqueid": "00:17:88:01:08:11:22:33-01", + } + ) + await mock_deconz_websocket( + data={ + "e": "changed", + "id": "43", + "r": "groups", + "state": {"all_on": True, "any_on": True}, + "t": "event", + } + ) + group_state = hass.states.get("light.opbergruimte") + assert group_state.state == STATE_ON + assert group_state.attributes[ATTR_COLOR_MODE] is ColorMode.UNKNOWN From cc8d44bbd1e2ec300dd5352f7dfc62e7ce8c317d Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 4 Mar 2024 14:28:53 -0500 Subject: [PATCH 80/95] Bump python_roborock to 0.40.0 (#112238) * bump to python_roborock 0.40.0 * manifest went away in merge? --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roborock/snapshots/test_diagnostics.ambr | 4 ++++ tests/components/roborock/test_diagnostics.py | 3 ++- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 946ba77b163..a7a7fe01d23 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==0.39.2", + "python-roborock==0.40.0", "vacuum-map-parser-roborock==0.1.1" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 83a1a7760cc..6121e0863ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2285,7 +2285,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.39.2 +python-roborock==0.40.0 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0fcf4f916f9..8d6f5fd434a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1755,7 +1755,7 @@ python-qbittorrent==0.4.3 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==0.39.2 +python-roborock==0.40.0 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 6bcd2152a95..3d78e5fd638 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -32,6 +32,8 @@ 'coordinators': dict({ '**REDACTED-0**': dict({ 'api': dict({ + 'misc_info': dict({ + }), }), 'roborock_device_info': dict({ 'device': dict({ @@ -309,6 +311,8 @@ }), '**REDACTED-1**': dict({ 'api': dict({ + 'misc_info': dict({ + }), }), 'roborock_device_info': dict({ 'device': dict({ diff --git a/tests/components/roborock/test_diagnostics.py b/tests/components/roborock/test_diagnostics.py index a10cbcf057e..cc02fff3edc 100644 --- a/tests/components/roborock/test_diagnostics.py +++ b/tests/components/roborock/test_diagnostics.py @@ -1,6 +1,7 @@ """Tests for the diagnostics data provided by the Roborock integration.""" from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props from homeassistant.core import HomeAssistant @@ -20,4 +21,4 @@ async def test_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, setup_entry) assert isinstance(result, dict) - assert result == snapshot + assert result == snapshot(exclude=props("Nonce")) From 2e6906c8d4d41535e404e814c067425b812bd780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 4 Mar 2024 18:34:53 +0000 Subject: [PATCH 81/95] Update aioairzone to v0.7.6 (#112264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 59b8645d26c..a14215fea6b 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.7.5"] + "requirements": ["aioairzone==0.7.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6121e0863ca..af200c14321 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -191,7 +191,7 @@ aioairq==0.3.2 aioairzone-cloud==0.4.5 # homeassistant.components.airzone -aioairzone==0.7.5 +aioairzone==0.7.6 # homeassistant.components.ambient_station aioambient==2024.01.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d6f5fd434a..cc10584032b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.3.2 aioairzone-cloud==0.4.5 # homeassistant.components.airzone -aioairzone==0.7.5 +aioairzone==0.7.6 # homeassistant.components.ambient_station aioambient==2024.01.0 From fb789d95ed98df8d9c748055d38fb816cf9705fb Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Tue, 5 Mar 2024 03:19:26 +0100 Subject: [PATCH 82/95] Bump bring-api to 0.5.5 (#112266) Fix KeyError listArticleLanguage --- homeassistant/components/bring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index 0e425ec3eee..d8bfc6c7ebd 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["bring-api==0.5.4"] + "requirements": ["bring-api==0.5.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index af200c14321..efe023b118c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -603,7 +603,7 @@ boschshcpy==0.2.75 boto3==1.33.13 # homeassistant.components.bring -bring-api==0.5.4 +bring-api==0.5.5 # homeassistant.components.broadlink broadlink==0.18.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc10584032b..fe50710f20c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -514,7 +514,7 @@ bond-async==0.2.1 boschshcpy==0.2.75 # homeassistant.components.bring -bring-api==0.5.4 +bring-api==0.5.5 # homeassistant.components.broadlink broadlink==0.18.3 From 3c5b5ca49b2be27068f088f19d58d96e7a27b0d5 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 5 Mar 2024 22:11:04 +0100 Subject: [PATCH 83/95] Allow duplicate modbus addresses on different devices (#112434) --- homeassistant/components/modbus/validators.py | 12 +- tests/components/modbus/test_init.py | 127 ++++++++++++++++++ 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index bdf472e4f76..765ce4d8be3 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -308,7 +308,7 @@ def check_config(config: dict) -> dict: ) -> bool: """Validate entity.""" name = entity[CONF_NAME] - addr = str(entity[CONF_ADDRESS]) + addr = f"{hub_name}{entity[CONF_ADDRESS]}" scan_interval = entity.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) if scan_interval < 5: _LOGGER.warning( @@ -335,11 +335,15 @@ def check_config(config: dict) -> dict: loc_addr: set[str] = {addr} if CONF_TARGET_TEMP in entity: - loc_addr.add(f"{entity[CONF_TARGET_TEMP]}_{inx}") + loc_addr.add(f"{hub_name}{entity[CONF_TARGET_TEMP]}_{inx}") if CONF_HVAC_MODE_REGISTER in entity: - loc_addr.add(f"{entity[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]}_{inx}") + loc_addr.add( + f"{hub_name}{entity[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]}_{inx}" + ) if CONF_FAN_MODE_REGISTER in entity: - loc_addr.add(f"{entity[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]}_{inx}") + loc_addr.add( + f"{hub_name}{entity[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]}_{inx}" + ) dup_addrs = ent_addr.intersection(loc_addr) if len(dup_addrs) > 0: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 5738268a593..4de9a439a01 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -740,6 +740,133 @@ async def test_duplicate_fan_mode_validator(do_config) -> None: assert len(do_config[CONF_FAN_MODE_VALUES]) == 2 +@pytest.mark.parametrize( + ("do_config", "sensor_cnt"), + [ + ( + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_TIMEOUT: 3, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + "1", + CONF_ADDRESS: 119, + CONF_SLAVE: 0, + }, + ], + }, + ], + 2, + ), + ( + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_TIMEOUT: 3, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + "1", + CONF_ADDRESS: 117, + CONF_SLAVE: 1, + }, + ], + }, + ], + 2, + ), + ( + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_TIMEOUT: 3, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + "1", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + }, + ], + 1, + ), + ( + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_TIMEOUT: 3, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + "1", + CONF_ADDRESS: 119, + CONF_SLAVE: 0, + }, + ], + }, + { + CONF_NAME: TEST_MODBUS_NAME + "1", + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_TIMEOUT: 3, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 119, + CONF_SLAVE: 0, + }, + ], + }, + ], + 2, + ), + ], +) +async def test_duplicate_addresses(do_config, sensor_cnt) -> None: + """Test duplicate entity validator.""" + check_config(do_config) + use_inx = len(do_config) - 1 + assert len(do_config[use_inx][CONF_SENSORS]) == sensor_cnt + + @pytest.mark.parametrize( "do_config", [ From b8b654a16046582fa06cee2e3abebae007e6a28c Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Mar 2024 21:13:14 +0100 Subject: [PATCH 84/95] Do not use list comprehension in async_add_entities in Unifi (#112435) Do not use list comprehension in async_add_entities --- homeassistant/components/unifi/hub/hub.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/unifi/hub/hub.py b/homeassistant/components/unifi/hub/hub.py index c9054152abb..0188adf5c3f 100644 --- a/homeassistant/components/unifi/hub/hub.py +++ b/homeassistant/components/unifi/hub/hub.py @@ -196,12 +196,10 @@ class UnifiHub: def async_add_unifi_entities() -> None: """Add UniFi entity.""" async_add_entities( - [ - unifi_platform_entity(obj_id, self, description) - for description in descriptions - for obj_id in description.api_handler_fn(self.api) - if self._async_should_add_entity(description, obj_id) - ] + unifi_platform_entity(obj_id, self, description) + for description in descriptions + for obj_id in description.api_handler_fn(self.api) + if self._async_should_add_entity(description, obj_id) ) async_add_unifi_entities() From 015aeadf883e38a99f9ab937f1d4b9fe03e80a43 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 6 Mar 2024 03:41:20 +0100 Subject: [PATCH 85/95] Fix handling missing parameter by bumping axis library to v50 (#112437) Fix handling missing parameter --- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index bd6faf8b149..5311d18f991 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -26,7 +26,7 @@ "iot_class": "local_push", "loggers": ["axis"], "quality_scale": "platinum", - "requirements": ["axis==49"], + "requirements": ["axis==50"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/requirements_all.txt b/requirements_all.txt index efe023b118c..205b867bb82 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -514,7 +514,7 @@ aurorapy==0.2.7 # avion==0.10 # homeassistant.components.axis -axis==49 +axis==50 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe50710f20c..89a3809dcc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -454,7 +454,7 @@ auroranoaa==0.0.3 aurorapy==0.2.7 # homeassistant.components.axis -axis==49 +axis==50 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From 742710443a2bf864712998d7dbd99252748e6703 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 6 Mar 2024 03:43:58 +0100 Subject: [PATCH 86/95] Bump holidays to 0.44 (#112442) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 234df998035..5f78d961810 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.43", "babel==2.13.1"] + "requirements": ["holidays==0.44", "babel==2.13.1"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 62819f74c2a..96a3b53797c 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.43"] + "requirements": ["holidays==0.44"] } diff --git a/requirements_all.txt b/requirements_all.txt index 205b867bb82..3d93f4766b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1071,7 +1071,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.43 +holidays==0.44 # homeassistant.components.frontend home-assistant-frontend==20240304.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89a3809dcc8..b303b3769f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -870,7 +870,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.43 +holidays==0.44 # homeassistant.components.frontend home-assistant-frontend==20240304.0 From 862bd8ff07ba86da0f0c2b235a9a74c9d41cc775 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 5 Mar 2024 23:35:22 +0100 Subject: [PATCH 87/95] Update xknx to 2.12.2 - Fix thread leak on unsuccessful connections (#112450) Update xknx to 2.12.2 --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 906b072c0be..290b560dad5 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,7 +11,7 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==2.12.1", + "xknx==2.12.2", "xknxproject==3.7.0", "knx-frontend==2024.1.20.105944" ] diff --git a/requirements_all.txt b/requirements_all.txt index 3d93f4766b7..dd6005ccd83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2872,7 +2872,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.25.2 # homeassistant.components.knx -xknx==2.12.1 +xknx==2.12.2 # homeassistant.components.knx xknxproject==3.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b303b3769f8..20925be15c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2207,7 +2207,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.25.2 # homeassistant.components.knx -xknx==2.12.1 +xknx==2.12.2 # homeassistant.components.knx xknxproject==3.7.0 From 061ae756acbbe5aa2837071457292e7e8e722035 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Mar 2024 23:43:11 -0500 Subject: [PATCH 88/95] Bump version to 2024.3.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 51951543196..46fcc65e30d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 09b3cb76e24..1ad1250fa1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.3.0b6" +version = "2024.3.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3b63719fad27c54bac22e8f37ab969bbd1e4e89e Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 6 Mar 2024 11:25:56 +0100 Subject: [PATCH 89/95] Avoid errors when there is no internet connection in Husqvarna Automower (#111101) * Avoid errors when no internet connection * Add error * Create task in HA * change from matter to automower * tests * Update homeassistant/components/husqvarna_automower/coordinator.py Co-authored-by: Martin Hjelmare * address review * Make websocket optional * fix aioautomower version * Fix tests * Use stored websocket * reset reconnect time after sucessful connection * Typo * Remove comment * Add test * Address review --------- Co-authored-by: Martin Hjelmare --- .../husqvarna_automower/__init__.py | 15 +++--- .../husqvarna_automower/coordinator.py | 49 +++++++++++++++---- .../husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../husqvarna_automower/conftest.py | 8 +++ .../husqvarna_automower/test_init.py | 44 ++++++++++++++++- 7 files changed, 101 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 7ed8a6b23e8..20218229385 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -6,7 +6,7 @@ from aioautomower.session import AutomowerSession from aiohttp import ClientError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow @@ -17,7 +17,6 @@ from .coordinator import AutomowerDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) - PLATFORMS: list[Platform] = [Platform.LAWN_MOWER, Platform.SENSOR, Platform.SWITCH] @@ -38,13 +37,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await api_api.async_get_access_token() except ClientError as err: raise ConfigEntryNotReady from err - coordinator = AutomowerDataUpdateCoordinator(hass, automower_api) + coordinator = AutomowerDataUpdateCoordinator(hass, automower_api, entry) await coordinator.async_config_entry_first_refresh() + entry.async_create_background_task( + hass, + coordinator.client_listen(hass, entry, automower_api), + "websocket_task", + ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.shutdown) - ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -52,8 +53,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle unload of an entry.""" - coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - await coordinator.shutdown() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 70d69f90549..2840823415a 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -1,23 +1,28 @@ """Data UpdateCoordinator for the Husqvarna Automower integration.""" +import asyncio from datetime import timedelta import logging -from typing import Any +from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError from aioautomower.model import MowerAttributes +from aioautomower.session import AutomowerSession +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .api import AsyncConfigEntryAuth from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +MAX_WS_RECONNECT_TIME = 600 class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): """Class to manage fetching Husqvarna data.""" - def __init__(self, hass: HomeAssistant, api: AsyncConfigEntryAuth) -> None: + def __init__( + self, hass: HomeAssistant, api: AutomowerSession, entry: ConfigEntry + ) -> None: """Initialize data updater.""" super().__init__( hass, @@ -35,13 +40,39 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib await self.api.connect() self.api.register_data_callback(self.callback) self.ws_connected = True - return await self.api.get_status() - - async def shutdown(self, *_: Any) -> None: - """Close resources.""" - await self.api.close() + try: + return await self.api.get_status() + except ApiException as err: + raise UpdateFailed(err) from err @callback def callback(self, ws_data: dict[str, MowerAttributes]) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" self.async_set_updated_data(ws_data) + + async def client_listen( + self, + hass: HomeAssistant, + entry: ConfigEntry, + automower_client: AutomowerSession, + reconnect_time: int = 2, + ) -> None: + """Listen with the client.""" + try: + await automower_client.auth.websocket_connect() + reconnect_time = 2 + await automower_client.start_listening() + except HusqvarnaWSServerHandshakeError as err: + _LOGGER.debug( + "Failed to connect to websocket. Trying to reconnect: %s", err + ) + + if not hass.is_stopping: + await asyncio.sleep(reconnect_time) + reconnect_time = min(reconnect_time * 2, MAX_WS_RECONNECT_TIME) + await self.client_listen( + hass=hass, + entry=entry, + automower_client=automower_client, + reconnect_time=reconnect_time, + ) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 1eb40bfad33..dc40116f31e 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", "iot_class": "cloud_push", - "requirements": ["aioautomower==2024.2.7"] + "requirements": ["aioautomower==2024.2.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd6005ccd83..95d6c0f5be7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -206,7 +206,7 @@ aioaseko==0.0.2 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.2.7 +aioautomower==2024.2.10 # homeassistant.components.azure_devops aioazuredevops==1.3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20925be15c5..c5f3e921b6f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -185,7 +185,7 @@ aioaseko==0.0.2 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.2.7 +aioautomower==2024.2.10 # homeassistant.components.azure_devops aioazuredevops==1.3.5 diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 89c0133cd0b..3194f1b3188 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -4,6 +4,7 @@ import time from unittest.mock import AsyncMock, patch from aioautomower.utils import mower_list_to_dictionary_dataclass +from aiohttp import ClientWebSocketResponse import pytest from homeassistant.components.application_credentials import ( @@ -82,4 +83,11 @@ def mock_automower_client() -> Generator[AsyncMock, None, None]: client.get_status.return_value = mower_list_to_dictionary_dataclass( load_json_value_fixture("mower.json", DOMAIN) ) + + async def websocket_connect() -> ClientWebSocketResponse: + """Mock listen.""" + return ClientWebSocketResponse + + client.auth = AsyncMock(side_effect=websocket_connect) + yield client diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 14460ad5d21..c11e4ac4cc7 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -1,8 +1,11 @@ """Tests for init module.""" +from datetime import timedelta import http import time from unittest.mock import AsyncMock +from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.husqvarna_automower.const import DOMAIN, OAUTH2_TOKEN @@ -11,7 +14,7 @@ from homeassistant.core import HomeAssistant from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -66,3 +69,42 @@ async def test_expired_token_refresh_failure( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is expected_state + + +async def test_update_failed( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + getattr(mock_automower_client, "get_status").side_effect = ApiException( + "Test error" + ) + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_websocket_not_available( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trying reload the websocket.""" + mock_automower_client.start_listening.side_effect = HusqvarnaWSServerHandshakeError( + "Boom" + ) + await setup_integration(hass, mock_config_entry) + assert "Failed to connect to websocket. Trying to reconnect: Boom" in caplog.text + assert mock_automower_client.auth.websocket_connect.call_count == 1 + assert mock_automower_client.start_listening.call_count == 1 + assert mock_config_entry.state == ConfigEntryState.LOADED + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_automower_client.auth.websocket_connect.call_count == 2 + assert mock_automower_client.start_listening.call_count == 2 + assert mock_config_entry.state == ConfigEntryState.LOADED From 8b2f40390be02e0d2efff81a34ca45adcb6d56d7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Mar 2024 13:56:47 +0100 Subject: [PATCH 90/95] Add custom integration block list (#112481) * Add custom integration block list * Fix typo * Add version condition * Add block reason, simplify blocked versions, add tests * Change logic for OK versions * Add link to custom integration's issue tracker * Add missing file --------- Co-authored-by: Martin Hjelmare --- homeassistant/loader.py | 65 +++++++++++++++++-- tests/test_loader.py | 52 +++++++++++++++ .../test_blocked_version/manifest.json | 4 ++ 3 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 tests/testing_config/custom_components/test_blocked_version/manifest.json diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 02696d6beb5..c790370b3fc 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -52,6 +52,20 @@ _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) _LOGGER = logging.getLogger(__name__) + +@dataclass +class BlockedIntegration: + """Blocked custom integration details.""" + + lowest_good_version: AwesomeVersion | None + reason: str + + +BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { + # Added in 2024.3.0 because of https://github.com/home-assistant/core/issues/112464 + "start_time": BlockedIntegration(None, "breaks Home Assistant") +} + DATA_COMPONENTS = "components" DATA_INTEGRATIONS = "integrations" DATA_MISSING_PLATFORMS = "missing_platforms" @@ -599,6 +613,7 @@ class Integration: return integration _LOGGER.warning(CUSTOM_WARNING, integration.domain) + if integration.version is None: _LOGGER.error( ( @@ -635,6 +650,21 @@ class Integration: integration.version, ) return None + + if blocked := BLOCKED_CUSTOM_INTEGRATIONS.get(integration.domain): + if _version_blocked(integration.version, blocked): + _LOGGER.error( + ( + "Version %s of custom integration '%s' %s and was blocked " + "from loading, please %s" + ), + integration.version, + integration.domain, + blocked.reason, + async_suggest_report_issue(None, integration=integration), + ) + return None + return integration return None @@ -1032,6 +1062,20 @@ class Integration: return f"" +def _version_blocked( + integration_version: AwesomeVersion, + blocked_integration: BlockedIntegration, +) -> bool: + """Return True if the integration version is blocked.""" + if blocked_integration.lowest_good_version is None: + return True + + if integration_version >= blocked_integration.lowest_good_version: + return False + + return True + + def _resolve_integrations_from_root( hass: HomeAssistant, root_module: ModuleType, domains: Iterable[str] ) -> dict[str, Integration]: @@ -1387,6 +1431,7 @@ def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: def async_get_issue_tracker( hass: HomeAssistant | None, *, + integration: Integration | None = None, integration_domain: str | None = None, module: str | None = None, ) -> str | None: @@ -1394,19 +1439,23 @@ def async_get_issue_tracker( issue_tracker = ( "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) - if not integration_domain and not module: + if not integration and not integration_domain and not module: # If we know nothing about the entity, suggest opening an issue on HA core return issue_tracker - if hass and integration_domain: + if not integration and (hass and integration_domain): with suppress(IntegrationNotLoaded): integration = async_get_loaded_integration(hass, integration_domain) - if not integration.is_built_in: - return integration.issue_tracker + + if integration and not integration.is_built_in: + return integration.issue_tracker if module and "custom_components" in module: return None + if integration: + integration_domain = integration.domain + if integration_domain: issue_tracker += f"+label%3A%22integration%3A+{integration_domain}%22" return issue_tracker @@ -1416,15 +1465,21 @@ def async_get_issue_tracker( def async_suggest_report_issue( hass: HomeAssistant | None, *, + integration: Integration | None = None, integration_domain: str | None = None, module: str | None = None, ) -> str: """Generate a blurb asking the user to file a bug report.""" issue_tracker = async_get_issue_tracker( - hass, integration_domain=integration_domain, module=module + hass, + integration=integration, + integration_domain=integration_domain, + module=module, ) if not issue_tracker: + if integration: + integration_domain = integration.domain if not integration_domain: return "report it to the custom integration author" return ( diff --git a/tests/test_loader.py b/tests/test_loader.py index babe1abcdd2..4555dc767a9 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -4,6 +4,7 @@ import sys from typing import Any from unittest.mock import MagicMock, Mock, patch +from awesomeversion import AwesomeVersion import pytest from homeassistant import loader @@ -163,6 +164,57 @@ async def test_custom_integration_version_not_valid( ) in caplog.text +@pytest.mark.parametrize( + "blocked_versions", + [ + loader.BlockedIntegration(None, "breaks Home Assistant"), + loader.BlockedIntegration(AwesomeVersion("2.0.0"), "breaks Home Assistant"), + ], +) +async def test_custom_integration_version_blocked( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, + blocked_versions, +) -> None: + """Test that we log a warning when custom integrations have a blocked version.""" + with patch.dict( + loader.BLOCKED_CUSTOM_INTEGRATIONS, {"test_blocked_version": blocked_versions} + ): + with pytest.raises(loader.IntegrationNotFound): + await loader.async_get_integration(hass, "test_blocked_version") + + assert ( + "Version 1.0.0 of custom integration 'test_blocked_version' breaks" + " Home Assistant and was blocked from loading, please report it to the" + " author of the 'test_blocked_version' custom integration" + ) in caplog.text + + +@pytest.mark.parametrize( + "blocked_versions", + [ + loader.BlockedIntegration(AwesomeVersion("0.9.9"), "breaks Home Assistant"), + loader.BlockedIntegration(AwesomeVersion("1.0.0"), "breaks Home Assistant"), + ], +) +async def test_custom_integration_version_not_blocked( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, + blocked_versions, +) -> None: + """Test that we log a warning when custom integrations have a blocked version.""" + with patch.dict( + loader.BLOCKED_CUSTOM_INTEGRATIONS, {"test_blocked_version": blocked_versions} + ): + await loader.async_get_integration(hass, "test_blocked_version") + + assert ( + "Version 1.0.0 of custom integration 'test_blocked_version'" + ) not in caplog.text + + async def test_get_integration(hass: HomeAssistant) -> None: """Test resolving integration.""" with pytest.raises(loader.IntegrationNotLoaded): diff --git a/tests/testing_config/custom_components/test_blocked_version/manifest.json b/tests/testing_config/custom_components/test_blocked_version/manifest.json new file mode 100644 index 00000000000..8359c4fe510 --- /dev/null +++ b/tests/testing_config/custom_components/test_blocked_version/manifest.json @@ -0,0 +1,4 @@ +{ + "domain": "test_blocked_version", + "version": "1.0.0" +} From 080fe4cf5fc3a00fbfc8e16baf4afae5b44637f8 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 6 Mar 2024 12:16:29 +0100 Subject: [PATCH 91/95] Update frontend to 20240306.0 (#112492) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 0606312aaea..cea376fa8ff 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240304.0"] + "requirements": ["home-assistant-frontend==20240306.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 36b95272104..03a08a442a8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ habluetooth==2.4.2 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240304.0 +home-assistant-frontend==20240306.0 home-assistant-intents==2024.2.28 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 95d6c0f5be7..1dc7531c648 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1074,7 +1074,7 @@ hole==0.8.0 holidays==0.44 # homeassistant.components.frontend -home-assistant-frontend==20240304.0 +home-assistant-frontend==20240306.0 # homeassistant.components.conversation home-assistant-intents==2024.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c5f3e921b6f..fa7e75b2473 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -873,7 +873,7 @@ hole==0.8.0 holidays==0.44 # homeassistant.components.frontend -home-assistant-frontend==20240304.0 +home-assistant-frontend==20240306.0 # homeassistant.components.conversation home-assistant-intents==2024.2.28 From 5294b492fc085e2b09877f3c56152561c116cc44 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 6 Mar 2024 14:24:53 +0100 Subject: [PATCH 92/95] Bump pytedee_async to 0.2.15 (#112495) --- homeassistant/components/tedee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 1776e3b7ab2..a3e29e1b40f 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", - "requirements": ["pytedee-async==0.2.13"] + "requirements": ["pytedee-async==0.2.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1dc7531c648..62d5b75af2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2182,7 +2182,7 @@ pyswitchbee==1.8.0 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.13 +pytedee-async==0.2.15 # homeassistant.components.tfiac pytfiac==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa7e75b2473..552e9074daf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1697,7 +1697,7 @@ pyswitchbee==1.8.0 pytautulli==23.1.1 # homeassistant.components.tedee -pytedee-async==0.2.13 +pytedee-async==0.2.15 # homeassistant.components.motionmount python-MotionMount==0.3.1 From b480b68e3e016abbf11669a847e6ae0020bb8f50 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Mar 2024 14:56:50 +0100 Subject: [PATCH 93/95] Allow start_time >= 1.1.7 (#112500) --- homeassistant/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index c790370b3fc..8afbf3c5124 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -63,7 +63,7 @@ class BlockedIntegration: BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { # Added in 2024.3.0 because of https://github.com/home-assistant/core/issues/112464 - "start_time": BlockedIntegration(None, "breaks Home Assistant") + "start_time": BlockedIntegration(AwesomeVersion("1.1.7"), "breaks Home Assistant") } DATA_COMPONENTS = "components" From 1b649899098c710362694eb6adcf9b8c91f730d5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Mar 2024 15:03:47 +0100 Subject: [PATCH 94/95] Bump version to 2024.3.0b8 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 46fcc65e30d..8b65977f802 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 1ad1250fa1a..f903636ba7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.3.0b7" +version = "2024.3.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From efe9938b33e2ab0b37dbfb487659ad053f26c222 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Mar 2024 18:37:11 +0100 Subject: [PATCH 95/95] Bump version to 2024.3.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8b65977f802..78085695b0e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 3 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index f903636ba7a..ba2360adc2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.3.0b8" +version = "2024.3.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"