From 09f6246d1b7693393332535866e02fb203fb689d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 29 Mar 2025 12:53:34 +0100 Subject: [PATCH] Dynamically add Home Connect event sensors (#141198) * Dynamically add Home Connect event sensors to HA * Add and remove listeners on paired and depaired events * Apply suggestion Co-authored-by: Martin Hjelmare * Update test * Adjust English --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/coordinator.py | 8 +- .../components/home_connect/sensor.py | 149 ++++++---- .../home_connect/test_coordinator.py | 5 +- tests/components/home_connect/test_sensor.py | 273 +++++++++++------- 4 files changed, 269 insertions(+), 166 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 079db6b148e..5e24ed25abd 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations from asyncio import sleep as asyncio_sleep from collections import defaultdict from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass import logging from typing import Any, cast @@ -119,8 +120,11 @@ class HomeConnectCoordinator( self.__dict__.pop("context_listeners", None) def remove_listener_and_invalidate_context_listeners() -> None: - remove_listener() - self.__dict__.pop("context_listeners", None) + # There are cases where the remove_listener will be called + # although it has been already removed somewhere else + with suppress(KeyError): + remove_listener() + self.__dict__.pop("context_listeners", None) return remove_listener_and_invalidate_context_listeners diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index f3c73c8a5ff..0f0161971a2 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,7 +1,10 @@ """Provides a sensor for Home Connect.""" +from collections import defaultdict +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta +from functools import partial import logging from typing import cast @@ -14,7 +17,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify @@ -42,7 +45,6 @@ class HomeConnectSensorEntityDescription( ): """Entity Description class for sensors.""" - default_value: str | None = None appliance_types: tuple[str, ...] | None = None fetch_unit: bool = False @@ -198,7 +200,6 @@ EVENT_SENSORS = ( key=EventKey.BSH_COMMON_EVENT_PROGRAM_ABORTED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="program_aborted", appliance_types=("Dishwasher", "CleaningRobot", "CookProcessor"), ), @@ -206,7 +207,6 @@ EVENT_SENSORS = ( key=EventKey.BSH_COMMON_EVENT_PROGRAM_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="program_finished", appliance_types=( "Oven", @@ -222,7 +222,6 @@ EVENT_SENSORS = ( key=EventKey.BSH_COMMON_EVENT_ALARM_CLOCK_ELAPSED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="alarm_clock_elapsed", appliance_types=("Oven", "Cooktop"), ), @@ -230,7 +229,6 @@ EVENT_SENSORS = ( key=EventKey.COOKING_OVEN_EVENT_PREHEAT_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="preheat_finished", appliance_types=("Oven", "Cooktop"), ), @@ -238,7 +236,6 @@ EVENT_SENSORS = ( key=EventKey.COOKING_OVEN_EVENT_REGULAR_PREHEAT_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="regular_preheat_finished", appliance_types=("Oven",), ), @@ -246,7 +243,6 @@ EVENT_SENSORS = ( key=EventKey.LAUNDRY_CARE_DRYER_EVENT_DRYING_PROCESS_FINISHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="drying_process_finished", appliance_types=("Dryer",), ), @@ -254,7 +250,6 @@ EVENT_SENSORS = ( key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="salt_nearly_empty", appliance_types=("Dishwasher",), ), @@ -262,7 +257,6 @@ EVENT_SENSORS = ( key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="rinse_aid_nearly_empty", appliance_types=("Dishwasher",), ), @@ -270,7 +264,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="bean_container_empty", appliance_types=("CoffeeMaker",), ), @@ -278,7 +271,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="water_tank_empty", appliance_types=("CoffeeMaker",), ), @@ -286,7 +278,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="drip_tray_full", appliance_types=("CoffeeMaker",), ), @@ -294,7 +285,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_KEEP_MILK_TANK_COOL, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="keep_milk_tank_cool", appliance_types=("CoffeeMaker",), ), @@ -302,7 +292,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_20_CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="descaling_in_20_cups", appliance_types=("CoffeeMaker",), ), @@ -310,7 +299,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_15_CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="descaling_in_15_cups", appliance_types=("CoffeeMaker",), ), @@ -318,7 +306,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_10_CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="descaling_in_10_cups", appliance_types=("CoffeeMaker",), ), @@ -326,7 +313,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_5_CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="descaling_in_5_cups", appliance_types=("CoffeeMaker",), ), @@ -334,7 +320,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_DESCALED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_should_be_descaled", appliance_types=("CoffeeMaker",), ), @@ -342,7 +327,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_OVERDUE, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_descaling_overdue", appliance_types=("CoffeeMaker",), ), @@ -350,7 +334,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_BLOCKAGE, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_descaling_blockage", appliance_types=("CoffeeMaker",), ), @@ -358,7 +341,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CLEANED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_should_be_cleaned", appliance_types=("CoffeeMaker",), ), @@ -366,7 +348,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CLEANING_OVERDUE, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_cleaning_overdue", appliance_types=("CoffeeMaker",), ), @@ -374,7 +355,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN20CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="calc_n_clean_in20cups", appliance_types=("CoffeeMaker",), ), @@ -382,7 +362,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN15CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="calc_n_clean_in15cups", appliance_types=("CoffeeMaker",), ), @@ -390,7 +369,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN10CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="calc_n_clean_in10cups", appliance_types=("CoffeeMaker",), ), @@ -398,7 +376,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN5CUPS, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="calc_n_clean_in5cups", appliance_types=("CoffeeMaker",), ), @@ -406,7 +383,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CALC_N_CLEANED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_should_be_calc_n_cleaned", appliance_types=("CoffeeMaker",), ), @@ -414,7 +390,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_OVERDUE, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_calc_n_clean_overdue", appliance_types=("CoffeeMaker",), ), @@ -422,7 +397,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_BLOCKAGE, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="device_calc_n_clean_blockage", appliance_types=("CoffeeMaker",), ), @@ -430,7 +404,6 @@ EVENT_SENSORS = ( key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="freezer_door_alarm", appliance_types=("FridgeFreezer", "Freezer"), ), @@ -438,7 +411,6 @@ EVENT_SENSORS = ( key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="refrigerator_door_alarm", appliance_types=("FridgeFreezer", "Refrigerator"), ), @@ -446,7 +418,6 @@ EVENT_SENSORS = ( key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="freezer_temperature_alarm", appliance_types=("FridgeFreezer", "Freezer"), ), @@ -454,7 +425,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_EMPTY_DUST_BOX_AND_CLEAN_FILTER, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="empty_dust_box_and_clean_filter", appliance_types=("CleaningRobot",), ), @@ -462,7 +432,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_ROBOT_IS_STUCK, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="robot_is_stuck", appliance_types=("CleaningRobot",), ), @@ -470,7 +439,6 @@ EVENT_SENSORS = ( key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_DOCKING_STATION_NOT_FOUND, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="docking_station_not_found", appliance_types=("CleaningRobot",), ), @@ -478,7 +446,6 @@ EVENT_SENSORS = ( key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="poor_i_dos_1_fill_level", appliance_types=("Washer", "WasherDryer"), ), @@ -486,7 +453,6 @@ EVENT_SENSORS = ( key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_2_FILL_LEVEL_POOR, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="poor_i_dos_2_fill_level", appliance_types=("Washer", "WasherDryer"), ), @@ -494,7 +460,6 @@ EVENT_SENSORS = ( key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_NEARLY_REACHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="grease_filter_max_saturation_nearly_reached", appliance_types=("Hood",), ), @@ -502,7 +467,6 @@ EVENT_SENSORS = ( key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_REACHED, device_class=SensorDeviceClass.ENUM, options=EVENT_OPTIONS, - default_value="off", translation_key="grease_filter_max_saturation_reached", appliance_types=("Hood",), ), @@ -515,12 +479,6 @@ def _get_entities_for_appliance( ) -> list[HomeConnectEntity]: """Get a list of entities.""" return [ - *[ - HomeConnectEventSensor(entry.runtime_data, appliance, description) - for description in EVENT_SENSORS - if description.appliance_types - and appliance.info.type in description.appliance_types - ], *[ HomeConnectProgramSensor(entry.runtime_data, appliance, desc) for desc in BSH_PROGRAM_SENSORS @@ -534,6 +492,72 @@ def _get_entities_for_appliance( ] +def _add_event_sensor_entity( + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + appliance: HomeConnectApplianceData, + description: HomeConnectSensorEntityDescription, + remove_event_sensor_listener_list: list[Callable[[], None]], +) -> None: + """Add an event sensor entity.""" + if ( + (appliance_data := entry.runtime_data.data.get(appliance.info.ha_id)) is None + ) or description.key not in appliance_data.events: + return + + for remove_listener in remove_event_sensor_listener_list: + remove_listener() + async_add_entities( + [ + HomeConnectEventSensor(entry.runtime_data, appliance, description), + ] + ) + + +def _add_event_sensor_listeners( + entry: HomeConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]], +) -> None: + for appliance in entry.runtime_data.data.values(): + if appliance.info.ha_id in remove_event_sensor_listener_dict: + continue + for event_sensor_description in EVENT_SENSORS: + if appliance.info.type not in cast( + tuple[str, ...], event_sensor_description.appliance_types + ): + continue + # We use a list as a kind of lazy initializer, as we can use the + # remove_listener while we are initializing it. + remove_event_sensor_listener_list = remove_event_sensor_listener_dict[ + appliance.info.ha_id + ] + remove_listener = entry.runtime_data.async_add_listener( + partial( + _add_event_sensor_entity, + entry, + async_add_entities, + appliance, + event_sensor_description, + remove_event_sensor_listener_list, + ), + (appliance.info.ha_id, event_sensor_description.key), + ) + remove_event_sensor_listener_list.append(remove_listener) + entry.async_on_unload(remove_listener) + + +def _remove_event_sensor_listeners_on_depaired( + entry: HomeConnectConfigEntry, + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]], +) -> None: + registered_listeners_ha_id = set(remove_event_sensor_listener_dict) + actual_appliances = set(entry.runtime_data.data) + for appliance_ha_id in registered_listeners_ha_id - actual_appliances: + for listener in remove_event_sensor_listener_dict.pop(appliance_ha_id): + listener() + + async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, @@ -546,6 +570,32 @@ async def async_setup_entry( async_add_entities, ) + remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]] = defaultdict( + list + ) + + entry.async_on_unload( + entry.runtime_data.async_add_special_listener( + partial( + _add_event_sensor_listeners, + entry, + async_add_entities, + remove_event_sensor_listener_dict, + ), + (EventKey.BSH_COMMON_APPLIANCE_PAIRED,), + ) + ) + entry.async_on_unload( + entry.runtime_data.async_add_special_listener( + partial( + _remove_event_sensor_listeners_on_depaired, + entry, + remove_event_sensor_listener_dict, + ), + (EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,), + ) + ) + class HomeConnectSensor(HomeConnectEntity, SensorEntity): """Sensor class for Home Connect.""" @@ -650,8 +700,5 @@ class HomeConnectEventSensor(HomeConnectSensor): def update_native_value(self) -> None: """Update the sensor's status.""" - event = self.appliance.events.get(cast(EventKey, self.bsh_key)) - if event: - self._update_native_value(event.value) - elif not self._attr_native_value: - self._attr_native_value = self.entity_description.default_value + event = self.appliance.events[cast(EventKey, self.bsh_key)] + self._update_native_value(event.value) diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 050758a6568..e6a3390b284 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -287,7 +287,7 @@ async def test_event_listener( assert config_entry.state == ConfigEntryState.LOADED state = hass.states.get(entity_id) - assert state + event_message = EventMessage( appliance.ha_id, event_type, @@ -309,7 +309,8 @@ async def test_event_listener( new_state = hass.states.get(entity_id) assert new_state - assert new_state.state != state.state + if state is not None: + assert new_state.state != state.state # Following, we are gonna check that the listeners are clean up correctly new_entity_id = entity_id + "_new" diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index f30723af7fa..e2f3761dcd9 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -1,6 +1,7 @@ """Tests for home_connect sensor entities.""" from collections.abc import Awaitable, Callable +import logging from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -153,6 +154,29 @@ async def test_paired_depaired_devices_flow( for entity_entry in entity_entries: assert entity_registry.async_get(entity_entry.entity_id) + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR, + raw_key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR.value, + timestamp=0, + level="", + handling="", + value=BSH_EVENT_PRESENT_STATE_PRESENT, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state("sensor.washer_poor_i_dos_1_fill_level", "present") + @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( @@ -224,6 +248,28 @@ async def test_sensor_entity_availability( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, + raw_key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY.value, + timestamp=0, + level="", + handling="", + value=BSH_EVENT_PRESENT_STATE_OFF, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + for entity_id in entity_ids: state = hass.states.get(entity_id) assert state @@ -509,143 +555,148 @@ async def test_remaining_prog_time_edge_cases( ( "entity_id", "event_key", - "event_type", - "event_value_update", - "expected", + "value_expected_state", "appliance", ), [ ( "sensor.dishwasher_door", EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_LOCKED, - "locked", + [ + ( + BSH_DOOR_STATE_LOCKED, + "locked", + ), + ( + BSH_DOOR_STATE_CLOSED, + "closed", + ), + ( + BSH_DOOR_STATE_OPEN, + "open", + ), + ], "Dishwasher", ), - ( - "sensor.dishwasher_door", - EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_CLOSED, - "closed", - "Dishwasher", - ), - ( - "sensor.dishwasher_door", - EventKey.BSH_COMMON_STATUS_DOOR_STATE, - EventType.STATUS, - BSH_DOOR_STATE_OPEN, - "open", - "Dishwasher", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", - EventType.EVENT, - "", - "off", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_OFF, - "off", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_PRESENT, - "present", - "FridgeFreezer", - ), - ( - "sensor.fridgefreezer_freezer_door_alarm", - EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_CONFIRMED, - "confirmed", - "FridgeFreezer", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventType.EVENT, - "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", - "", - "off", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_OFF, - "off", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_PRESENT, - "present", - "CoffeeMaker", - ), - ( - "sensor.coffeemaker_bean_container_empty", - EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, - EventType.EVENT, - BSH_EVENT_PRESENT_STATE_CONFIRMED, - "confirmed", - "CoffeeMaker", - ), ], indirect=["appliance"], ) async def test_sensors_states( entity_id: str, event_key: EventKey, - event_type: EventType, - event_value_update: str, + value_expected_state: list[tuple[str, str]], appliance: HomeAppliance, - expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, ) -> None: - """Tests for appliance alarm sensors.""" + """Tests for appliance sensors.""" assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - await client.add_events( - [ - EventMessage( - appliance.ha_id, - event_type, - ArrayOfEvents( - [ - Event( - key=event_key, - raw_key=str(event_key), - timestamp=0, - level="", - handling="", - value=event_value_update, - ) - ], + for value, expected_state in value_expected_state: + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), ), - ), - ] - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, expected) + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected_state) + + +@pytest.mark.parametrize( + ( + "entity_id", + "event_key", + "appliance", + ), + [ + ( + "sensor.fridgefreezer_freezer_door_alarm", + EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, + "FridgeFreezer", + ), + ( + "sensor.coffeemaker_bean_container_empty", + EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, + "CoffeeMaker", + ), + ], + indirect=["appliance"], +) +async def test_event_sensors_states( + entity_id: str, + event_key: EventKey, + appliance: HomeAppliance, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Tests for appliance event sensors.""" + caplog.set_level(logging.ERROR) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert not hass.states.get(entity_id) + + for value, expected_state in ( + (BSH_EVENT_PRESENT_STATE_OFF, "off"), + (BSH_EVENT_PRESENT_STATE_PRESENT, "present"), + (BSH_EVENT_PRESENT_STATE_CONFIRMED, "confirmed"), + ): + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.EVENT, + ArrayOfEvents( + [ + Event( + key=event_key, + raw_key=str(event_key), + timestamp=0, + level="", + handling="", + value=value, + ) + ], + ), + ), + ] + ) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected_state) + + # Verify that the integration doesn't attempt to add the event sensors more than once + # If that happens, the EntityPlatform logs an error with the entity's unique ID. + assert "exists" not in caplog.text + assert entity_id not in caplog.text + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.unique_id not in caplog.text @pytest.mark.parametrize(