diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 99228bcc48a5..59a9e211f06b 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -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" }, diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 89765622e909..ac09a9fc70d6 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -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.""" diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index c94b365f2fff..eb16d935380c 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -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" }, diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 1223dab940ed..45d9a9f4493b 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_finish', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.oven_finish', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.oven_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_finish', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.washing_machine_finish', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.washing_machine_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_finish', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.oven_finish', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.oven_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_finish', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.washing_machine_finish', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.washing_machine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.washing_machine_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_finish', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.robot_vacuum_cleaner_finish', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.robot_vacuum_cleaner_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index 642e69e4f1f3..d6b5106eccbf 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -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"