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 asyncio import sleep as asyncio_sleep
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from typing import Any, cast from typing import Any, cast
@ -119,6 +120,9 @@ class HomeConnectCoordinator(
self.__dict__.pop("context_listeners", None) self.__dict__.pop("context_listeners", None)
def remove_listener_and_invalidate_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() remove_listener()
self.__dict__.pop("context_listeners", None) self.__dict__.pop("context_listeners", None)

View File

@ -1,7 +1,10 @@
"""Provides a sensor for Home Connect.""" """Provides a sensor for Home Connect."""
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from functools import partial
import logging import logging
from typing import cast from typing import cast
@ -14,7 +17,7 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume 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.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util, slugify from homeassistant.util import dt as dt_util, slugify
@ -42,7 +45,6 @@ class HomeConnectSensorEntityDescription(
): ):
"""Entity Description class for sensors.""" """Entity Description class for sensors."""
default_value: str | None = None
appliance_types: tuple[str, ...] | None = None appliance_types: tuple[str, ...] | None = None
fetch_unit: bool = False fetch_unit: bool = False
@ -198,7 +200,6 @@ EVENT_SENSORS = (
key=EventKey.BSH_COMMON_EVENT_PROGRAM_ABORTED, key=EventKey.BSH_COMMON_EVENT_PROGRAM_ABORTED,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="program_aborted", translation_key="program_aborted",
appliance_types=("Dishwasher", "CleaningRobot", "CookProcessor"), appliance_types=("Dishwasher", "CleaningRobot", "CookProcessor"),
), ),
@ -206,7 +207,6 @@ EVENT_SENSORS = (
key=EventKey.BSH_COMMON_EVENT_PROGRAM_FINISHED, key=EventKey.BSH_COMMON_EVENT_PROGRAM_FINISHED,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="program_finished", translation_key="program_finished",
appliance_types=( appliance_types=(
"Oven", "Oven",
@ -222,7 +222,6 @@ EVENT_SENSORS = (
key=EventKey.BSH_COMMON_EVENT_ALARM_CLOCK_ELAPSED, key=EventKey.BSH_COMMON_EVENT_ALARM_CLOCK_ELAPSED,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="alarm_clock_elapsed", translation_key="alarm_clock_elapsed",
appliance_types=("Oven", "Cooktop"), appliance_types=("Oven", "Cooktop"),
), ),
@ -230,7 +229,6 @@ EVENT_SENSORS = (
key=EventKey.COOKING_OVEN_EVENT_PREHEAT_FINISHED, key=EventKey.COOKING_OVEN_EVENT_PREHEAT_FINISHED,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="preheat_finished", translation_key="preheat_finished",
appliance_types=("Oven", "Cooktop"), appliance_types=("Oven", "Cooktop"),
), ),
@ -238,7 +236,6 @@ EVENT_SENSORS = (
key=EventKey.COOKING_OVEN_EVENT_REGULAR_PREHEAT_FINISHED, key=EventKey.COOKING_OVEN_EVENT_REGULAR_PREHEAT_FINISHED,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="regular_preheat_finished", translation_key="regular_preheat_finished",
appliance_types=("Oven",), appliance_types=("Oven",),
), ),
@ -246,7 +243,6 @@ EVENT_SENSORS = (
key=EventKey.LAUNDRY_CARE_DRYER_EVENT_DRYING_PROCESS_FINISHED, key=EventKey.LAUNDRY_CARE_DRYER_EVENT_DRYING_PROCESS_FINISHED,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="drying_process_finished", translation_key="drying_process_finished",
appliance_types=("Dryer",), appliance_types=("Dryer",),
), ),
@ -254,7 +250,6 @@ EVENT_SENSORS = (
key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY, key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="salt_nearly_empty", translation_key="salt_nearly_empty",
appliance_types=("Dishwasher",), appliance_types=("Dishwasher",),
), ),
@ -262,7 +257,6 @@ EVENT_SENSORS = (
key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY, key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="rinse_aid_nearly_empty", translation_key="rinse_aid_nearly_empty",
appliance_types=("Dishwasher",), appliance_types=("Dishwasher",),
), ),
@ -270,7 +264,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="bean_container_empty", translation_key="bean_container_empty",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -278,7 +271,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="water_tank_empty", translation_key="water_tank_empty",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -286,7 +278,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="drip_tray_full", translation_key="drip_tray_full",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -294,7 +285,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_KEEP_MILK_TANK_COOL, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_KEEP_MILK_TANK_COOL,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="keep_milk_tank_cool", translation_key="keep_milk_tank_cool",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -302,7 +292,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_20_CUPS, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_20_CUPS,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="descaling_in_20_cups", translation_key="descaling_in_20_cups",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -310,7 +299,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_15_CUPS, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_15_CUPS,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="descaling_in_15_cups", translation_key="descaling_in_15_cups",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -318,7 +306,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_10_CUPS, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_10_CUPS,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="descaling_in_10_cups", translation_key="descaling_in_10_cups",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -326,7 +313,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_5_CUPS, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_5_CUPS,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="descaling_in_5_cups", translation_key="descaling_in_5_cups",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -334,7 +320,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_DESCALED, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_DESCALED,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="device_should_be_descaled", translation_key="device_should_be_descaled",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -342,7 +327,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_OVERDUE, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_OVERDUE,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="device_descaling_overdue", translation_key="device_descaling_overdue",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -350,7 +334,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_BLOCKAGE, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_BLOCKAGE,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="device_descaling_blockage", translation_key="device_descaling_blockage",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -358,7 +341,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CLEANED, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CLEANED,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="device_should_be_cleaned", translation_key="device_should_be_cleaned",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -366,7 +348,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CLEANING_OVERDUE, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CLEANING_OVERDUE,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="device_cleaning_overdue", translation_key="device_cleaning_overdue",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -374,7 +355,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN20CUPS, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN20CUPS,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="calc_n_clean_in20cups", translation_key="calc_n_clean_in20cups",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -382,7 +362,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN15CUPS, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN15CUPS,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="calc_n_clean_in15cups", translation_key="calc_n_clean_in15cups",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -390,7 +369,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN10CUPS, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN10CUPS,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="calc_n_clean_in10cups", translation_key="calc_n_clean_in10cups",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -398,7 +376,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN5CUPS, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN5CUPS,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="calc_n_clean_in5cups", translation_key="calc_n_clean_in5cups",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -406,7 +383,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CALC_N_CLEANED, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CALC_N_CLEANED,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="device_should_be_calc_n_cleaned", translation_key="device_should_be_calc_n_cleaned",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -414,7 +390,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_OVERDUE, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_OVERDUE,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="device_calc_n_clean_overdue", translation_key="device_calc_n_clean_overdue",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -422,7 +397,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_BLOCKAGE, key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_BLOCKAGE,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="device_calc_n_clean_blockage", translation_key="device_calc_n_clean_blockage",
appliance_types=("CoffeeMaker",), appliance_types=("CoffeeMaker",),
), ),
@ -430,7 +404,6 @@ EVENT_SENSORS = (
key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER, key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="freezer_door_alarm", translation_key="freezer_door_alarm",
appliance_types=("FridgeFreezer", "Freezer"), appliance_types=("FridgeFreezer", "Freezer"),
), ),
@ -438,7 +411,6 @@ EVENT_SENSORS = (
key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR, key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="refrigerator_door_alarm", translation_key="refrigerator_door_alarm",
appliance_types=("FridgeFreezer", "Refrigerator"), appliance_types=("FridgeFreezer", "Refrigerator"),
), ),
@ -446,7 +418,6 @@ EVENT_SENSORS = (
key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER, key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="freezer_temperature_alarm", translation_key="freezer_temperature_alarm",
appliance_types=("FridgeFreezer", "Freezer"), appliance_types=("FridgeFreezer", "Freezer"),
), ),
@ -454,7 +425,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_EMPTY_DUST_BOX_AND_CLEAN_FILTER, key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_EMPTY_DUST_BOX_AND_CLEAN_FILTER,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="empty_dust_box_and_clean_filter", translation_key="empty_dust_box_and_clean_filter",
appliance_types=("CleaningRobot",), appliance_types=("CleaningRobot",),
), ),
@ -462,7 +432,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_ROBOT_IS_STUCK, key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_ROBOT_IS_STUCK,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="robot_is_stuck", translation_key="robot_is_stuck",
appliance_types=("CleaningRobot",), appliance_types=("CleaningRobot",),
), ),
@ -470,7 +439,6 @@ EVENT_SENSORS = (
key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_DOCKING_STATION_NOT_FOUND, key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_DOCKING_STATION_NOT_FOUND,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="docking_station_not_found", translation_key="docking_station_not_found",
appliance_types=("CleaningRobot",), appliance_types=("CleaningRobot",),
), ),
@ -478,7 +446,6 @@ EVENT_SENSORS = (
key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR, key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="poor_i_dos_1_fill_level", translation_key="poor_i_dos_1_fill_level",
appliance_types=("Washer", "WasherDryer"), appliance_types=("Washer", "WasherDryer"),
), ),
@ -486,7 +453,6 @@ EVENT_SENSORS = (
key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_2_FILL_LEVEL_POOR, key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_2_FILL_LEVEL_POOR,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="poor_i_dos_2_fill_level", translation_key="poor_i_dos_2_fill_level",
appliance_types=("Washer", "WasherDryer"), appliance_types=("Washer", "WasherDryer"),
), ),
@ -494,7 +460,6 @@ EVENT_SENSORS = (
key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_NEARLY_REACHED, key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_NEARLY_REACHED,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="grease_filter_max_saturation_nearly_reached", translation_key="grease_filter_max_saturation_nearly_reached",
appliance_types=("Hood",), appliance_types=("Hood",),
), ),
@ -502,7 +467,6 @@ EVENT_SENSORS = (
key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_REACHED, key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_REACHED,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS, options=EVENT_OPTIONS,
default_value="off",
translation_key="grease_filter_max_saturation_reached", translation_key="grease_filter_max_saturation_reached",
appliance_types=("Hood",), appliance_types=("Hood",),
), ),
@ -515,12 +479,6 @@ def _get_entities_for_appliance(
) -> list[HomeConnectEntity]: ) -> list[HomeConnectEntity]:
"""Get a list of entities.""" """Get a list of entities."""
return [ 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) HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
for desc in BSH_PROGRAM_SENSORS 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
@ -546,6 +570,32 @@ async def async_setup_entry(
async_add_entities, 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): class HomeConnectSensor(HomeConnectEntity, SensorEntity):
"""Sensor class for Home Connect.""" """Sensor class for Home Connect."""
@ -650,8 +700,5 @@ class HomeConnectEventSensor(HomeConnectSensor):
def update_native_value(self) -> None: def update_native_value(self) -> None:
"""Update the sensor's status.""" """Update the sensor's status."""
event = self.appliance.events.get(cast(EventKey, self.bsh_key)) event = self.appliance.events[cast(EventKey, self.bsh_key)]
if event:
self._update_native_value(event.value) 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 assert config_entry.state == ConfigEntryState.LOADED
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state
event_message = EventMessage( event_message = EventMessage(
appliance.ha_id, appliance.ha_id,
event_type, event_type,
@ -309,6 +309,7 @@ async def test_event_listener(
new_state = hass.states.get(entity_id) new_state = hass.states.get(entity_id)
assert new_state assert new_state
if state is not None:
assert new_state.state != state.state assert new_state.state != state.state
# Following, we are gonna check that the listeners are clean up correctly # Following, we are gonna check that the listeners are clean up correctly

View File

@ -1,6 +1,7 @@
"""Tests for home_connect sensor entities.""" """Tests for home_connect sensor entities."""
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
import logging
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from aiohomeconnect.model import ( from aiohomeconnect.model import (
@ -153,6 +154,29 @@ async def test_paired_depaired_devices_flow(
for entity_entry in entity_entries: for entity_entry in entity_entries:
assert entity_registry.async_get(entity_entry.entity_id) 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) @pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
async def test_connected_devices( async def test_connected_devices(
@ -224,6 +248,28 @@ async def test_sensor_entity_availability(
assert await integration_setup(client) assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED 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: for entity_id in entity_ids:
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state assert state
@ -509,126 +555,54 @@ async def test_remaining_prog_time_edge_cases(
( (
"entity_id", "entity_id",
"event_key", "event_key",
"event_type", "value_expected_state",
"event_value_update",
"expected",
"appliance", "appliance",
), ),
[ [
( (
"sensor.dishwasher_door", "sensor.dishwasher_door",
EventKey.BSH_COMMON_STATUS_DOOR_STATE, EventKey.BSH_COMMON_STATUS_DOOR_STATE,
EventType.STATUS, [
(
BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_LOCKED,
"locked", "locked",
"Dishwasher",
), ),
( (
"sensor.dishwasher_door",
EventKey.BSH_COMMON_STATUS_DOOR_STATE,
EventType.STATUS,
BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_CLOSED,
"closed", "closed",
"Dishwasher",
), ),
( (
"sensor.dishwasher_door",
EventKey.BSH_COMMON_STATUS_DOOR_STATE,
EventType.STATUS,
BSH_DOOR_STATE_OPEN, BSH_DOOR_STATE_OPEN,
"open", "open",
),
],
"Dishwasher", "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"], indirect=["appliance"],
) )
async def test_sensors_states( async def test_sensors_states(
entity_id: str, entity_id: str,
event_key: EventKey, event_key: EventKey,
event_type: EventType, value_expected_state: list[tuple[str, str]],
event_value_update: str,
appliance: HomeAppliance, appliance: HomeAppliance,
expected: str,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]], integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None, setup_credentials: None,
client: MagicMock, client: MagicMock,
) -> None: ) -> None:
"""Tests for appliance alarm sensors.""" """Tests for appliance sensors."""
assert config_entry.state == ConfigEntryState.NOT_LOADED assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client) assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
for value, expected_state in value_expected_state:
await client.add_events( await client.add_events(
[ [
EventMessage( EventMessage(
appliance.ha_id, appliance.ha_id,
event_type, EventType.STATUS,
ArrayOfEvents( ArrayOfEvents(
[ [
Event( Event(
@ -637,7 +611,7 @@ async def test_sensors_states(
timestamp=0, timestamp=0,
level="", level="",
handling="", handling="",
value=event_value_update, value=value,
) )
], ],
), ),
@ -645,7 +619,84 @@ async def test_sensors_states(
] ]
) )
await hass.async_block_till_done() 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( @pytest.mark.parametrize(