Miele time sensors 3/3 - Add absolute time sensors (#146055)

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Andrea Turri
2025-11-06 17:09:19 +01:00
committed by GitHub
parent 57e7bc81d4
commit 2ddf55a60d
5 changed files with 716 additions and 19 deletions
@@ -41,6 +41,9 @@
"energy_forecast": {
"default": "mdi:lightning-bolt-outline"
},
"finish": {
"default": "mdi:clock-end"
},
"plate": {
"default": "mdi:circle-outline",
"state": {
@@ -83,6 +86,9 @@
"spin_speed": {
"default": "mdi:sync"
},
"start": {
"default": "mdi:clock-start"
},
"start_time": {
"default": "mdi:clock-start"
},
+132 -5
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import Any, Final, cast
@@ -29,6 +30,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from .const import (
COFFEE_SYSTEM_PROFILE,
@@ -102,12 +104,47 @@ def _get_coffee_profile(value: MieleDevice) -> str | None:
return None
def _convert_start_timestamp(
elapsed_time_list: list[int], start_time_list: list[int]
) -> datetime | None:
"""Convert raw values representing time into start timestamp."""
now = dt_util.utcnow()
elapsed_duration = _convert_duration(elapsed_time_list)
delayed_start_duration = _convert_duration(start_time_list)
if (elapsed_duration is None or elapsed_duration == 0) and (
delayed_start_duration is None or delayed_start_duration == 0
):
return None
if elapsed_duration is not None and elapsed_duration > 0:
duration = -elapsed_duration
elif delayed_start_duration is not None and delayed_start_duration > 0:
duration = delayed_start_duration
delta = timedelta(minutes=duration)
return (now + delta).replace(second=0, microsecond=0)
def _convert_finish_timestamp(
remaining_time_list: list[int], start_time_list: list[int]
) -> datetime | None:
"""Convert raw values representing time into finish timestamp."""
now = dt_util.utcnow()
program_duration = _convert_duration(remaining_time_list)
delayed_start_duration = _convert_duration(start_time_list)
if program_duration is None or program_duration == 0:
return None
duration = program_duration + (
delayed_start_duration if delayed_start_duration is not None else 0
)
delta = timedelta(minutes=duration)
return (now + delta).replace(second=0, microsecond=0)
@dataclass(frozen=True, kw_only=True)
class MieleSensorDescription(SensorEntityDescription):
"""Class describing Miele sensor entities."""
value_fn: Callable[[MieleDevice], StateType]
end_value_fn: Callable[[StateType], StateType] | None = None
value_fn: Callable[[MieleDevice], StateType | datetime]
end_value_fn: Callable[[StateType | datetime], StateType | datetime] | None = None
extra_attributes: dict[str, Callable[[MieleDevice], StateType]] | None = None
zone: int | None = None
unique_id_fn: Callable[[str, MieleSensorDescription], str] | None = None
@@ -428,6 +465,60 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
suggested_unit_of_measurement=UnitOfTime.HOURS,
),
),
MieleSensorDefinition(
types=(
MieleAppliance.WASHING_MACHINE,
MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL,
MieleAppliance.TUMBLE_DRYER,
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL,
MieleAppliance.DISHWASHER,
MieleAppliance.OVEN,
MieleAppliance.OVEN_MICROWAVE,
MieleAppliance.STEAM_OVEN,
MieleAppliance.MICROWAVE,
MieleAppliance.ROBOT_VACUUM_CLEANER,
MieleAppliance.WASHER_DRYER,
MieleAppliance.STEAM_OVEN_COMBI,
MieleAppliance.STEAM_OVEN_MICRO,
MieleAppliance.DIALOG_OVEN,
MieleAppliance.STEAM_OVEN_MK2,
),
description=MieleSensorDescription(
key="state_finish_timestamp",
translation_key="finish",
value_fn=lambda value: _convert_finish_timestamp(
value.state_remaining_time, value.state_start_time
),
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
MieleSensorDefinition(
types=(
MieleAppliance.WASHING_MACHINE,
MieleAppliance.TUMBLE_DRYER,
MieleAppliance.DISHWASHER,
MieleAppliance.OVEN,
MieleAppliance.OVEN_MICROWAVE,
MieleAppliance.STEAM_OVEN,
MieleAppliance.MICROWAVE,
MieleAppliance.WASHER_DRYER,
MieleAppliance.STEAM_OVEN_COMBI,
MieleAppliance.STEAM_OVEN_MICRO,
MieleAppliance.DIALOG_OVEN,
MieleAppliance.ROBOT_VACUUM_CLEANER,
MieleAppliance.STEAM_OVEN_MK2,
),
description=MieleSensorDescription(
key="state_start_timestamp",
translation_key="start",
value_fn=lambda value: _convert_start_timestamp(
value.state_elapsed_time, value.state_start_time
),
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
MieleSensorDefinition(
types=(
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL,
@@ -620,6 +711,8 @@ async def async_setup_entry(
"state_elapsed_time": MieleTimeSensor,
"state_remaining_time": MieleTimeSensor,
"state_start_time": MieleTimeSensor,
"state_start_timestamp": MieleAbsoluteTimeSensor,
"state_finish_timestamp": MieleAbsoluteTimeSensor,
"current_energy_consumption": MieleConsumptionSensor,
"current_water_consumption": MieleConsumptionSensor,
}.get(definition.description.key, MieleSensor)
@@ -743,7 +836,7 @@ class MieleSensor(MieleEntity, SensorEntity):
self._attr_unique_id = description.unique_id_fn(device_id, description)
@property
def native_value(self) -> StateType:
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.device)
@@ -761,7 +854,7 @@ class MieleSensor(MieleEntity, SensorEntity):
class MieleRestorableSensor(MieleSensor, RestoreSensor):
"""Representation of a Sensor whose internal state can be restored."""
_attr_native_value: StateType
_attr_native_value: StateType | datetime
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
@@ -773,7 +866,7 @@ class MieleRestorableSensor(MieleSensor, RestoreSensor):
self._attr_native_value = last_data.native_value # type: ignore[assignment]
@property
def native_value(self) -> StateType:
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor.
It is necessary to override `native_value` to fall back to the default
@@ -934,6 +1027,40 @@ class MieleTimeSensor(MieleRestorableSensor):
self._attr_native_value = current_value
class MieleAbsoluteTimeSensor(MieleRestorableSensor):
"""Representation of absolute time sensors handling precision correctness."""
_previous_value: StateType | datetime = None
def _update_native_value(self) -> None:
"""Update the last value of the sensor."""
current_value = self.entity_description.value_fn(self.device)
current_status = StateStatus(self.device.state_status)
# The API reports with minute precision, to avoid changing
# the value too often, we keep the cached value if it differs
# less than 90s from the new value
if (
isinstance(self._previous_value, datetime)
and isinstance(current_value, datetime)
and (
self._previous_value - timedelta(seconds=90)
< current_value
< self._previous_value + timedelta(seconds=90)
)
) or current_status == StateStatus.PROGRAM_ENDED:
return
# force unknown when appliance is not working (some devices are keeping last value until a new cycle starts)
if current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE):
self._attr_native_value = None
# otherwise, cache value and return it
else:
self._attr_native_value = current_value
self._previous_value = current_value
class MieleConsumptionSensor(MieleRestorableSensor):
"""Representation of consumption sensors keeping state from cache."""
@@ -216,6 +216,9 @@
"energy_forecast": {
"name": "Energy forecast"
},
"finish": {
"name": "Finish"
},
"plate": {
"name": "Plate {plate_no}",
"state": {
@@ -1015,6 +1018,9 @@
"spin_speed": {
"name": "Spin speed"
},
"start": {
"name": "Start"
},
"start_time": {
"name": "Start in"
},
@@ -2873,6 +2873,55 @@
'state': 'unknown',
})
# ---
# name: test_sensor_states[platforms0][sensor.oven_finish-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.oven_finish',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Finish',
'platform': 'miele',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'finish',
'unique_id': 'DummyAppliance_12-state_finish_timestamp',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states[platforms0][sensor.oven_finish-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Oven Finish',
}),
'context': <ANY>,
'entity_id': 'sensor.oven_finish',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_states[platforms0][sensor.oven_program-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -3422,6 +3471,55 @@
'state': 'unknown',
})
# ---
# name: test_sensor_states[platforms0][sensor.oven_start-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.oven_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Start',
'platform': 'miele',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'start',
'unique_id': 'DummyAppliance_12-state_start_timestamp',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states[platforms0][sensor.oven_start-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Oven Start',
}),
'context': <ANY>,
'entity_id': 'sensor.oven_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_states[platforms0][sensor.oven_start_in-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -3986,6 +4084,55 @@
'state': '10.0',
})
# ---
# name: test_sensor_states[platforms0][sensor.washing_machine_finish-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.washing_machine_finish',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Finish',
'platform': 'miele',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'finish',
'unique_id': 'Dummy_Appliance_3-state_finish_timestamp',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states[platforms0][sensor.washing_machine_finish-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Washing machine Finish',
}),
'context': <ANY>,
'entity_id': 'sensor.washing_machine_finish',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_states[platforms0][sensor.washing_machine_program-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -4366,6 +4513,55 @@
'state': 'unknown',
})
# ---
# name: test_sensor_states[platforms0][sensor.washing_machine_start-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.washing_machine_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Start',
'platform': 'miele',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'start',
'unique_id': 'Dummy_Appliance_3-state_start_timestamp',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states[platforms0][sensor.washing_machine_start-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Washing machine Start',
}),
'context': <ANY>,
'entity_id': 'sensor.washing_machine_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_states[platforms0][sensor.washing_machine_start_in-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -5021,6 +5217,55 @@
'state': '0',
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.oven_finish-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.oven_finish',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Finish',
'platform': 'miele',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'finish',
'unique_id': 'DummyAppliance_12-state_finish_timestamp',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.oven_finish-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Oven Finish',
}),
'context': <ANY>,
'entity_id': 'sensor.oven_finish',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-05-31T12:35:00+00:00',
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.oven_program-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -5570,6 +5815,55 @@
'state': '5',
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.oven_start-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.oven_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Start',
'platform': 'miele',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'start',
'unique_id': 'DummyAppliance_12-state_start_timestamp',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.oven_start-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Oven Start',
}),
'context': <ANY>,
'entity_id': 'sensor.oven_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.oven_start_in-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -6134,6 +6428,55 @@
'state': '10.0',
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_finish-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.washing_machine_finish',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Finish',
'platform': 'miele',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'finish',
'unique_id': 'Dummy_Appliance_3-state_finish_timestamp',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_finish-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Washing machine Finish',
}),
'context': <ANY>,
'entity_id': 'sensor.washing_machine_finish',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_program-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -6514,6 +6857,55 @@
'state': 'unknown',
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_start-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.washing_machine_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Start',
'platform': 'miele',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'start',
'unique_id': 'Dummy_Appliance_3-state_start_timestamp',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_start-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Washing machine Start',
}),
'context': <ANY>,
'entity_id': 'sensor.washing_machine_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_start_in-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -6925,6 +7317,55 @@
'state': 'unknown',
})
# ---
# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_finish-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.robot_vacuum_cleaner_finish',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Finish',
'platform': 'miele',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'finish',
'unique_id': 'Dummy_Vacuum_1-state_finish_timestamp',
'unit_of_measurement': None,
})
# ---
# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_finish-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Robot vacuum cleaner Finish',
}),
'context': <ANY>,
'entity_id': 'sensor.robot_vacuum_cleaner_finish',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -7106,3 +7547,52 @@
'state': 'unknown',
})
# ---
# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_start-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.robot_vacuum_cleaner_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Start',
'platform': 'miele',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'start',
'unique_id': 'Dummy_Vacuum_1-state_start_timestamp',
'unit_of_measurement': None,
})
# ---
# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_start-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Robot vacuum cleaner Start',
}),
'context': <ANY>,
'entity_id': 'sensor.robot_vacuum_cleaner_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
+82 -14
View File
@@ -1,6 +1,6 @@
"""Tests for miele sensor module."""
from datetime import timedelta
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
@@ -23,6 +23,7 @@ from tests.common import (
)
@pytest.mark.freeze_time("2025-05-31 12:30:00+00:00")
@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_states(
@@ -37,6 +38,7 @@ async def test_sensor_states(
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
@pytest.mark.freeze_time("2025-05-31 12:30:00+00:00")
@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_states_api_push(
@@ -302,6 +304,7 @@ async def test_laundry_wash_scenario(
"""Parametrized test for verifying time sensors for wahsing machine devices when API glitches at program end."""
step = 0
freezer.move_to("2025-05-31T12:00:00+00:00")
# Initial state when the washing machine is off
check_sensor_state(hass, "sensor.washing_machine", "off", step)
@@ -317,6 +320,8 @@ async def test_laundry_wash_scenario(
check_sensor_state(hass, "sensor.washing_machine_remaining_time", "unknown", step)
# OFF -> elapsed forced to unknown (some devices continue reporting last value of last cycle)
check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "unknown", step)
check_sensor_state(hass, "sensor.washing_machine_start", "unknown", step)
check_sensor_state(hass, "sensor.washing_machine_finish", "unknown", step)
# consumption sensors have to report "unknown" when the device is not working
check_sensor_state(
hass, "sensor.washing_machine_energy_consumption", "unknown", step
@@ -357,7 +362,7 @@ async def test_laundry_wash_scenario(
},
}
freezer.tick(timedelta(seconds=130))
freezer.move_to("2025-05-31T12:30:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
@@ -376,8 +381,12 @@ async def test_laundry_wash_scenario(
"unit": "l",
},
}
device_fixture["DummyWasher"]["state"]["elapsedTime"][0] = 0
device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 14
device_fixture["DummyWasher"]["state"]["remainingTime"][0] = 1
device_fixture["DummyWasher"]["state"]["remainingTime"][1] = 43
freezer.tick(timedelta(seconds=130))
freezer.move_to("2025-05-31T12:32:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
@@ -389,8 +398,14 @@ async def test_laundry_wash_scenario(
check_sensor_state(hass, "sensor.washing_machine_target_temperature", "30.0", step)
check_sensor_state(hass, "sensor.washing_machine_spin_speed", "1200", step)
# IN_USE -> elapsed, remaining time from API (normal case)
check_sensor_state(hass, "sensor.washing_machine_remaining_time", "105", step)
check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "12", step)
check_sensor_state(hass, "sensor.washing_machine_remaining_time", "103", step)
check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "14", step)
check_sensor_state(
hass, "sensor.washing_machine_start", "2025-05-31T12:18:00+00:00", step
)
check_sensor_state(
hass, "sensor.washing_machine_finish", "2025-05-31T14:15:00+00:00", step
)
check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.0", step)
check_sensor_state(hass, "sensor.washing_machine_water_consumption", "0", step)
@@ -406,7 +421,7 @@ async def test_laundry_wash_scenario(
},
}
freezer.tick(timedelta(seconds=130))
freezer.move_to("2025-05-31T12:34:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
@@ -426,7 +441,7 @@ async def test_laundry_wash_scenario(
device_fixture["DummyWasher"]["state"]["elapsedTime"][0] = 1
device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 49
freezer.tick(timedelta(seconds=130))
freezer.move_to("2025-05-31T14:07:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
step += 1
@@ -439,6 +454,12 @@ async def test_laundry_wash_scenario(
# RINSE HOLD -> elapsed, remaining time from API (normal case)
check_sensor_state(hass, "sensor.washing_machine_remaining_time", "8", step)
check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "109", step)
check_sensor_state(
hass, "sensor.washing_machine_start", "2025-05-31T12:18:00+00:00", step
)
check_sensor_state(
hass, "sensor.washing_machine_finish", "2025-05-31T14:15:00+00:00", step
)
# Simulate program ended
device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 7
@@ -453,7 +474,7 @@ async def test_laundry_wash_scenario(
device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 0
device_fixture["DummyWasher"]["state"]["ecoFeedback"] = None
freezer.tick(timedelta(seconds=130))
freezer.move_to("2025-05-31T14:30:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
step += 1
@@ -469,6 +490,12 @@ async def test_laundry_wash_scenario(
check_sensor_state(hass, "sensor.washing_machine_remaining_time", "0", step)
# PROGRAM_ENDED -> elapsed time kept from last program (some devices immediately go to 0)
check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "109", step)
check_sensor_state(
hass, "sensor.washing_machine_start", "2025-05-31T12:18:00+00:00", step
)
check_sensor_state(
hass, "sensor.washing_machine_finish", "2025-05-31T14:15:00+00:00", step
)
# consumption values now are reporting last known value, API might start reporting null object
check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.1", step)
check_sensor_state(hass, "sensor.washing_machine_water_consumption", "7", step)
@@ -489,7 +516,7 @@ async def test_laundry_wash_scenario(
device_fixture["DummyWasher"]["state"]["elapsedTime"][0] = 0
device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 0
freezer.tick(timedelta(seconds=130))
freezer.move_to("2025-05-31T14:32:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
step += 1
@@ -504,6 +531,10 @@ async def test_laundry_wash_scenario(
# PROGRAMMED -> elapsed, remaining time from API (normal case)
check_sensor_state(hass, "sensor.washing_machine_remaining_time", "119", step)
check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "0", step)
check_sensor_state(hass, "sensor.washing_machine_start", "unknown", step)
check_sensor_state(
hass, "sensor.washing_machine_finish", "2025-05-31T16:31:00+00:00", step
)
@pytest.mark.parametrize("load_device_file", ["laundry.json"])
@@ -519,6 +550,7 @@ async def test_laundry_dry_scenario(
"""Parametrized test for verifying time sensors for tumble dryer devices when API reports time value from last cycle, when device is off."""
step = 0
freezer.move_to("2025-05-31T12:00:00+00:00")
# Initial state when the washing machine is off
check_sensor_state(hass, "sensor.tumble_dryer", "off", step)
@@ -528,6 +560,8 @@ async def test_laundry_dry_scenario(
# OFF -> elapsed, remaining forced to unknown (some devices continue reporting last value of last cycle)
check_sensor_state(hass, "sensor.tumble_dryer_remaining_time", "unknown", step)
check_sensor_state(hass, "sensor.tumble_dryer_elapsed_time", "unknown", step)
check_sensor_state(hass, "sensor.tumble_dryer_start", "unknown", step)
check_sensor_state(hass, "sensor.tumble_dryer_finish", "unknown", step)
# Simulate program started
device_fixture["DummyDryer"]["state"]["status"]["value_raw"] = 5
@@ -545,7 +579,7 @@ async def test_laundry_dry_scenario(
device_fixture["DummyDryer"]["state"]["dryingStep"]["value_raw"] = 2
device_fixture["DummyDryer"]["state"]["dryingStep"]["value_localized"] = "Normal"
freezer.tick(timedelta(seconds=130))
freezer.move_to("2025-05-31T12:30:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
step += 1
@@ -557,6 +591,12 @@ async def test_laundry_dry_scenario(
# IN_USE -> elapsed, remaining time from API (normal case)
check_sensor_state(hass, "sensor.tumble_dryer_remaining_time", "49", step)
check_sensor_state(hass, "sensor.tumble_dryer_elapsed_time", "20", step)
check_sensor_state(
hass, "sensor.tumble_dryer_start", "2025-05-31T12:10:00+00:00", step
)
check_sensor_state(
hass, "sensor.tumble_dryer_finish", "2025-05-31T13:19:00+00:00", step
)
# Simulate program end
device_fixture["DummyDryer"]["state"]["status"]["value_raw"] = 7
@@ -570,7 +610,7 @@ async def test_laundry_dry_scenario(
device_fixture["DummyDryer"]["state"]["elapsedTime"][0] = 1
device_fixture["DummyDryer"]["state"]["elapsedTime"][1] = 18
freezer.tick(timedelta(seconds=130))
freezer.move_to("2025-05-31T14:30:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
step += 1
@@ -583,9 +623,18 @@ async def test_laundry_dry_scenario(
check_sensor_state(hass, "sensor.tumble_dryer_remaining_time", "0", step)
# PROGRAM_ENDED -> elapsed time kept from last program (some devices immediately go to 0)
check_sensor_state(hass, "sensor.tumble_dryer_elapsed_time", "20", step)
check_sensor_state(
hass, "sensor.tumble_dryer_start", "2025-05-31T12:10:00+00:00", step
)
check_sensor_state(
hass, "sensor.tumble_dryer_finish", "2025-05-31T13:19:00+00:00", step
)
@pytest.mark.parametrize("restore_state", ["45", STATE_UNKNOWN, STATE_UNAVAILABLE])
@pytest.mark.parametrize(
"restore_state_abs", ["2025-05-31T13:19:00+00:00", STATE_UNKNOWN, STATE_UNAVAILABLE]
)
@pytest.mark.parametrize("load_device_file", ["laundry.json"])
@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)])
async def test_elapsed_time_sensor_restored(
@@ -596,10 +645,12 @@ async def test_elapsed_time_sensor_restored(
device_fixture: MieleDevices,
freezer: FrozenDateTimeFactory,
restore_state,
restore_state_abs,
) -> None:
"""Test that elapsed time returns the restored value when program ended."""
entity_id = "sensor.washing_machine_elapsed_time"
entity_id_abs = "sensor.washing_machine_finish"
# Simulate program started
device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 5
@@ -623,11 +674,12 @@ async def test_elapsed_time_sensor_restored(
device_fixture["DummyWasher"]["state"]["spinningSpeed"]["value_raw"] = 1200
device_fixture["DummyWasher"]["state"]["spinningSpeed"]["value_localized"] = "1200"
freezer.tick(timedelta(seconds=130))
freezer.move_to(datetime(2025, 5, 31, 12, 30, tzinfo=UTC))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "12"
assert hass.states.get(entity_id_abs).state == "2025-05-31T14:15:00+00:00"
# Simulate program ended
device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 7
@@ -641,7 +693,7 @@ async def test_elapsed_time_sensor_restored(
device_fixture["DummyWasher"]["state"]["elapsedTime"][0] = 0
device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 0
freezer.tick(timedelta(seconds=130))
freezer.move_to(datetime(2025, 5, 31, 14, 20, tzinfo=UTC))
async_fire_time_changed(hass)
await hass.async_block_till_done()
@@ -651,6 +703,7 @@ async def test_elapsed_time_sensor_restored(
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "unavailable"
assert hass.states.get(entity_id_abs).state == "unavailable"
# simulate restore with state different from native value
mock_restore_cache_with_extra_data(
@@ -669,9 +722,19 @@ async def test_elapsed_time_sensor_restored(
"native_unit_of_measurement": "min",
},
),
(
State(
entity_id_abs,
restore_state_abs,
{"device_class": "timestamp"},
),
{
"native_value": datetime(2025, 5, 31, 14, 15, tzinfo=UTC),
"native_unit_of_measurement": None,
},
),
],
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
@@ -679,3 +742,8 @@ async def test_elapsed_time_sensor_restored(
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "12"
# check that absolute time is the one restored and not the value reported by API
state = hass.states.get(entity_id_abs)
assert state is not None
assert state.state == "2025-05-31T14:15:00+00:00"