Add Home Connect sensors for fridge door states and alarms (#125490)

* New sensors for Fridge door states and alarms

* Move 2 option entities to binary_sensor, tests

* Change state translations

* Fix stale docstring
This commit is contained in:
Robert Contreras 2024-09-13 10:31:35 -07:00 committed by GitHub
parent ba7ca84899
commit 85aa32338e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 478 additions and 6 deletions

View file

@ -1,14 +1,21 @@
"""Provides a binary sensor for Home Connect."""
from dataclasses import dataclass, field
import logging
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITIES
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api import HomeConnectDevice
from .const import (
ATTR_DEVICE,
ATTR_VALUE,
BSH_DOOR_STATE,
BSH_DOOR_STATE_CLOSED,
@ -17,12 +24,47 @@ from .const import (
BSH_REMOTE_CONTROL_ACTIVATION_STATE,
BSH_REMOTE_START_ALLOWANCE_STATE,
DOMAIN,
REFRIGERATION_STATUS_DOOR_CHILLER,
REFRIGERATION_STATUS_DOOR_CLOSED,
REFRIGERATION_STATUS_DOOR_FREEZER,
REFRIGERATION_STATUS_DOOR_OPEN,
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
)
from .entity import HomeConnectEntity
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Entity Description class for binary sensors."""
state_key: str | None
device_class: BinarySensorDeviceClass | None = BinarySensorDeviceClass.DOOR
boolean_map: dict[str, bool] = field(
default_factory=lambda: {
REFRIGERATION_STATUS_DOOR_CLOSED: False,
REFRIGERATION_STATUS_DOOR_OPEN: True,
}
)
BINARY_SENSORS: tuple[HomeConnectBinarySensorEntityDescription, ...] = (
HomeConnectBinarySensorEntityDescription(
key="Chiller Door",
state_key=REFRIGERATION_STATUS_DOOR_CHILLER,
),
HomeConnectBinarySensorEntityDescription(
key="Freezer Door",
state_key=REFRIGERATION_STATUS_DOOR_FREEZER,
),
HomeConnectBinarySensorEntityDescription(
key="Refrigerator Door",
state_key=REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@ -36,6 +78,15 @@ async def async_setup_entry(
for device_dict in hc_api.devices:
entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("binary_sensor", [])
entities += [HomeConnectBinarySensor(**d) for d in entity_dicts]
device: HomeConnectDevice = device_dict[ATTR_DEVICE]
# Auto-discover entities
entities.extend(
HomeConnectFridgeDoorBinarySensor(
device=device, entity_description=description
)
for description in BINARY_SENSORS
if description.state_key in device.appliance.status
)
return entities
async_add_entities(await hass.async_add_executor_job(get_entities), True)
@ -93,3 +144,37 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
def device_class(self):
"""Return the device class."""
return self._device_class
class HomeConnectFridgeDoorBinarySensor(HomeConnectEntity, BinarySensorEntity):
"""Binary sensor for Home Connect Fridge Doors."""
entity_description: HomeConnectBinarySensorEntityDescription
def __init__(
self,
device: HomeConnectDevice,
entity_description: HomeConnectBinarySensorEntityDescription,
) -> None:
"""Initialize the entity."""
self.entity_description = entity_description
super().__init__(device, entity_description.key)
async def async_update(self) -> None:
"""Update the binary sensor's status."""
_LOGGER.debug(
"Updating: %s, cur state: %s",
self._attr_unique_id,
self.state,
)
self._attr_is_on = self.entity_description.boolean_map.get(
self.device.appliance.status.get(self.entity_description.state_key, {}).get(
ATTR_VALUE
)
)
self._attr_available = self._attr_is_on is not None
_LOGGER.debug(
"Updated: %s, new state: %s",
self._attr_unique_id,
self.state,
)

View file

@ -14,6 +14,9 @@ BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive"
BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed"
BSH_CHILD_LOCK_STATE = "BSH.Common.Setting.ChildLock"
BSH_EVENT_PRESENT_STATE_PRESENT = "BSH.Common.EnumType.EventPresentState.Present"
BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confirmed"
BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off"
BSH_OPERATION_STATE = "BSH.Common.Status.OperationState"
BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run"
@ -23,6 +26,11 @@ BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished"
COOKING_LIGHTING = "Cooking.Common.Setting.Lighting"
COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness"
COFFEE_EVENT_BEAN_CONTAINER_EMPTY = (
"ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty"
)
COFFEE_EVENT_WATER_TANK_EMPTY = "ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty"
COFFEE_EVENT_DRIP_TRAY_FULL = "ConsumerProducts.CoffeeMaker.Event.DripTrayFull"
REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer"
REFRIGERATION_SUPERMODEREFRIGERATOR = (
@ -30,6 +38,24 @@ REFRIGERATION_SUPERMODEREFRIGERATOR = (
)
REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled"
REFRIGERATION_STATUS_DOOR_CHILLER = "Refrigeration.Common.Status.Door.ChillerCommon"
REFRIGERATION_STATUS_DOOR_FREEZER = "Refrigeration.Common.Status.Door.Freezer"
REFRIGERATION_STATUS_DOOR_REFRIGERATOR = "Refrigeration.Common.Status.Door.Refrigerator"
REFRIGERATION_STATUS_DOOR_CLOSED = "Refrigeration.Common.EnumType.Door.States.Closed"
REFRIGERATION_STATUS_DOOR_OPEN = "Refrigeration.Common.EnumType.Door.States.Open"
REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR = (
"Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator"
)
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER = (
"Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer"
)
REFRIGERATION_EVENT_TEMP_ALARM_FREEZER = (
"Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer"
)
BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled"
BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness"
BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor"

View file

@ -23,6 +23,50 @@
}
},
"entity": {
"sensor": {
"alarm_sensor_fridge": {
"default": "mdi:fridge",
"state": {
"confirmed": "mdi:fridge-alert-outline",
"present": "mdi:fridge-alert"
}
},
"alarm_sensor_freezer": {
"default": "mdi:snowflake",
"state": {
"confirmed": "mdi:snowflake-check",
"present": "mdi:snowflake-alert"
}
},
"alarm_sensor_temp": {
"default": "mdi:thermometer",
"state": {
"confirmed": "mdi:thermometer-check",
"present": "mdi:thermometer-alert"
}
},
"alarm_sensor_coffee_bean_container": {
"default": "mdi:coffee-maker",
"state": {
"confirmed": "mdi:coffee-maker-check",
"present": "mdi:coffee-maker-outline"
}
},
"alarm_sensor_coffee_water_tank": {
"default": "mdi:water",
"state": {
"confirmed": "mdi:water-check",
"present": "mdi:water-alert"
}
},
"alarm_sensor_coffee_drip_tray": {
"default": "mdi:tray",
"state": {
"confirmed": "mdi:tray-full",
"present": "mdi:tray-alert"
}
}
},
"switch": {
"refrigeration_dispenser": {
"default": "mdi:snowflake",

View file

@ -1,29 +1,95 @@
"""Provides a sensor for Home Connect."""
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import logging
from typing import cast
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITIES
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util
from .api import ConfigEntryAuth, HomeConnectDevice
from .const import (
ATTR_DEVICE,
ATTR_VALUE,
BSH_EVENT_PRESENT_STATE_OFF,
BSH_OPERATION_STATE,
BSH_OPERATION_STATE_FINISHED,
BSH_OPERATION_STATE_PAUSE,
BSH_OPERATION_STATE_RUN,
COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
COFFEE_EVENT_DRIP_TRAY_FULL,
COFFEE_EVENT_WATER_TANK_EMPTY,
DOMAIN,
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR,
REFRIGERATION_EVENT_TEMP_ALARM_FREEZER,
)
from .entity import HomeConnectEntity
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class HomeConnectSensorEntityDescription(SensorEntityDescription):
"""Entity Description class for sensors."""
device_class: SensorDeviceClass | None = SensorDeviceClass.ENUM
options: list[str] | None = field(
default_factory=lambda: ["confirmed", "off", "present"]
)
state_key: str
appliance_types: tuple[str, ...]
SENSORS: tuple[HomeConnectSensorEntityDescription, ...] = (
HomeConnectSensorEntityDescription(
key="Door Alarm Freezer",
translation_key="alarm_sensor_freezer",
state_key=REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
appliance_types=("FridgeFreezer", "Freezer"),
),
HomeConnectSensorEntityDescription(
key="Door Alarm Refrigerator",
translation_key="alarm_sensor_fridge",
state_key=REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR,
appliance_types=("FridgeFreezer", "Refrigerator"),
),
HomeConnectSensorEntityDescription(
key="Temperature Alarm Freezer",
translation_key="alarm_sensor_temp",
state_key=REFRIGERATION_EVENT_TEMP_ALARM_FREEZER,
appliance_types=("FridgeFreezer", "Freezer"),
),
HomeConnectSensorEntityDescription(
key="Bean Container Empty",
translation_key="alarm_sensor_coffee_bean_container",
state_key=COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
appliance_types=("CoffeeMaker",),
),
HomeConnectSensorEntityDescription(
key="Water Tank Empty",
translation_key="alarm_sensor_coffee_water_tank",
state_key=COFFEE_EVENT_WATER_TANK_EMPTY,
appliance_types=("CoffeeMaker",),
),
HomeConnectSensorEntityDescription(
key="Drip Tray Full",
translation_key="alarm_sensor_coffee_drip_tray",
state_key=COFFEE_EVENT_DRIP_TRAY_FULL,
appliance_types=("CoffeeMaker",),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@ -34,10 +100,20 @@ async def async_setup_entry(
def get_entities():
"""Get a list of entities."""
entities = []
hc_api = hass.data[DOMAIN][config_entry.entry_id]
hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id]
for device_dict in hc_api.devices:
entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("sensor", [])
entities += [HomeConnectSensor(**d) for d in entity_dicts]
device: HomeConnectDevice = device_dict[ATTR_DEVICE]
# Auto-discover entities
entities.extend(
HomeConnectAlarmSensor(
device,
entity_description=description,
)
for description in SENSORS
if device.appliance.type in description.appliance_types
)
return entities
async_add_entities(await hass.async_add_executor_job(get_entities), True)
@ -101,3 +177,37 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
-1
]
_LOGGER.debug("Updated, new state: %s", self._attr_native_value)
class HomeConnectAlarmSensor(HomeConnectEntity, SensorEntity):
"""Sensor entity setup using SensorEntityDescription."""
entity_description: HomeConnectSensorEntityDescription
def __init__(
self,
device: HomeConnectDevice,
entity_description: HomeConnectSensorEntityDescription,
) -> None:
"""Initialize the entity."""
self.entity_description = entity_description
super().__init__(device, self.entity_description.key)
@property
def available(self) -> bool:
"""Return true if the sensor is available."""
return self._attr_native_value is not None
async def async_update(self) -> None:
"""Update the sensor's status."""
self._attr_native_value = (
self.device.appliance.status.get(self.entity_description.state_key, {})
.get(ATTR_VALUE, BSH_EVENT_PRESENT_STATE_OFF)
.rsplit(".", maxsplit=1)[-1]
.lower()
)
_LOGGER.debug(
"Updated: %s, new state: %s",
self._attr_unique_id,
self._attr_native_value,
)

View file

@ -1,4 +1,8 @@
{
"common": {
"confirmed": "Confirmed",
"present": "Present"
},
"config": {
"step": {
"pick_implementation": {
@ -129,5 +133,45 @@
"value": { "name": "Value", "description": "Value of the setting." }
}
}
},
"entity": {
"sensor": {
"alarm_sensor_fridge": {
"state": {
"confirmed": "[%key:component::home_connect::common::confirmed%]",
"present": "[%key:component::home_connect::common::present%]"
}
},
"alarm_sensor_freezer": {
"state": {
"confirmed": "[%key:component::home_connect::common::confirmed%]",
"present": "[%key:component::home_connect::common::present%]"
}
},
"alarm_sensor_temp": {
"state": {
"confirmed": "[%key:component::home_connect::common::confirmed%]",
"present": "[%key:component::home_connect::common::present%]"
}
},
"alarm_sensor_coffee_bean_container": {
"state": {
"confirmed": "[%key:component::home_connect::common::confirmed%]",
"present": "[%key:component::home_connect::common::present%]"
}
},
"alarm_sensor_coffee_water_tank": {
"state": {
"confirmed": "[%key:component::home_connect::common::confirmed%]",
"present": "[%key:component::home_connect::common::present%]"
}
},
"alarm_sensor_coffee_drip_tray": {
"state": {
"confirmed": "[%key:component::home_connect::common::confirmed%]",
"present": "[%key:component::home_connect::common::present%]"
}
}
}
}
}

View file

@ -10,6 +10,10 @@
{
"key": "BSH.Common.Status.DoorState",
"value": "BSH.Common.EnumType.DoorState.Closed"
},
{
"key": "Refrigeration.Common.Status.Door.Refrigerator",
"value": "BSH.Common.EnumType.DoorState.Open"
}
]
}

View file

@ -3,6 +3,7 @@
from collections.abc import Awaitable, Callable
from unittest.mock import MagicMock, Mock
from homeconnect.api import HomeConnectAPI
import pytest
from homeassistant.components.home_connect.const import (
@ -10,13 +11,16 @@ from homeassistant.components.home_connect.const import (
BSH_DOOR_STATE_CLOSED,
BSH_DOOR_STATE_LOCKED,
BSH_DOOR_STATE_OPEN,
REFRIGERATION_STATUS_DOOR_CLOSED,
REFRIGERATION_STATUS_DOOR_OPEN,
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_component import async_update_entity
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture
@ -70,3 +74,59 @@ async def test_binary_sensors_door_states(
await async_update_entity(hass, entity_id)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, expected)
@pytest.mark.parametrize(
("entity_id", "status_key", "event_value_update", "expected", "appliance"),
[
(
"binary_sensor.fridgefreezer_refrigerator_door",
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
REFRIGERATION_STATUS_DOOR_CLOSED,
STATE_OFF,
"FridgeFreezer",
),
(
"binary_sensor.fridgefreezer_refrigerator_door",
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
REFRIGERATION_STATUS_DOOR_OPEN,
STATE_ON,
"FridgeFreezer",
),
(
"binary_sensor.fridgefreezer_refrigerator_door",
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
"",
STATE_UNAVAILABLE,
"FridgeFreezer",
),
],
indirect=["appliance"],
)
@pytest.mark.usefixtures("bypass_throttle")
async def test_bianry_sensors_fridge_door_states(
entity_id: str,
status_key: str,
event_value_update: str,
appliance: Mock,
expected: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
) -> None:
"""Tests for Home Connect Fridge appliance door states."""
appliance.status.update(
HomeConnectAPI.json2dict(
load_json_object_fixture("home_connect/status.json")["data"]["status"]
)
)
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update({status_key: {"value": event_value_update}})
await async_update_entity(hass, entity_id)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, expected)

View file

@ -4,14 +4,22 @@ from collections.abc import Awaitable, Callable
from unittest.mock import MagicMock, Mock
from freezegun.api import FrozenDateTimeFactory
from homeconnect.api import HomeConnectAPI
import pytest
from homeassistant.components.home_connect.const import (
BSH_EVENT_PRESENT_STATE_CONFIRMED,
BSH_EVENT_PRESENT_STATE_OFF,
BSH_EVENT_PRESENT_STATE_PRESENT,
COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_component import async_update_entity
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, load_json_object_fixture
TEST_HC_APP = "Dishwasher"
@ -207,3 +215,94 @@ async def test_remaining_prog_time_edge_cases(
await hass.async_block_till_done()
freezer.tick()
assert hass.states.is_state(entity_id, expected_state)
@pytest.mark.parametrize(
("entity_id", "status_key", "event_value_update", "expected", "appliance"),
[
(
"sensor.fridgefreezer_door_alarm_freezer",
"EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF",
"",
"off",
"FridgeFreezer",
),
(
"sensor.fridgefreezer_door_alarm_freezer",
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
BSH_EVENT_PRESENT_STATE_OFF,
"off",
"FridgeFreezer",
),
(
"sensor.fridgefreezer_door_alarm_freezer",
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
BSH_EVENT_PRESENT_STATE_PRESENT,
"present",
"FridgeFreezer",
),
(
"sensor.fridgefreezer_door_alarm_freezer",
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
BSH_EVENT_PRESENT_STATE_CONFIRMED,
"confirmed",
"FridgeFreezer",
),
(
"sensor.coffeemaker_bean_container_empty",
"EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF",
"",
"off",
"CoffeeMaker",
),
(
"sensor.coffeemaker_bean_container_empty",
COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
BSH_EVENT_PRESENT_STATE_OFF,
"off",
"CoffeeMaker",
),
(
"sensor.coffeemaker_bean_container_empty",
COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
BSH_EVENT_PRESENT_STATE_PRESENT,
"present",
"CoffeeMaker",
),
(
"sensor.coffeemaker_bean_container_empty",
COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
BSH_EVENT_PRESENT_STATE_CONFIRMED,
"confirmed",
"CoffeeMaker",
),
],
indirect=["appliance"],
)
@pytest.mark.usefixtures("bypass_throttle")
async def test_sensors_states(
entity_id: str,
status_key: str,
event_value_update: str,
appliance: Mock,
expected: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
) -> None:
"""Tests for Appliance alarm sensors."""
appliance.status.update(
HomeConnectAPI.json2dict(
load_json_object_fixture("home_connect/status.json")["data"]["status"]
)
)
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED
appliance.status.update({status_key: {"value": event_value_update}})
await async_update_entity(hass, entity_id)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, expected)