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 <marhje52@gmail.com>

* Update test

* Adjust English

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Diego Rodríguez Royo 2025-03-29 12:53:34 +01:00 committed by GitHub
parent 96ff389fd1
commit 09f6246d1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 269 additions and 166 deletions

View File

@ -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,6 +120,9 @@ class HomeConnectCoordinator(
self.__dict__.pop("context_listeners", None)
def remove_listener_and_invalidate_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)

View File

@ -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:
event = self.appliance.events[cast(EventKey, self.bsh_key)]
self._update_native_value(event.value)
elif not self._attr_native_value:
self._attr_native_value = self.entity_description.default_value

View File

@ -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,6 +309,7 @@ async def test_event_listener(
new_state = hass.states.get(entity_id)
assert new_state
if state is not None:
assert new_state.state != state.state
# Following, we are gonna check that the listeners are clean up correctly

View File

@ -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,126 +555,54 @@ 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",
"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
for value, expected_state in value_expected_state:
await client.add_events(
[
EventMessage(
appliance.ha_id,
event_type,
EventType.STATUS,
ArrayOfEvents(
[
Event(
@ -637,7 +611,7 @@ async def test_sensors_states(
timestamp=0,
level="",
handling="",
value=event_value_update,
value=value,
)
],
),
@ -645,7 +619,84 @@ async def test_sensors_states(
]
)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, expected)
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(