8000 Discovery of Miele temperature sensors by aturri · Pull Request #144585 · home-assistant/core · GitHub
[go: up one dir, main page]

Skip to content

Discovery of Miele temperature sensors #144585

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
8357d2e
fix discovery of temperature sensors
aturri May 9, 2025
b367f14
testing temperature sensors
aturri May 9, 2025
3adacb9
Merge branch 'home-assistant:dev' into feature/fix-discovery-temp-sen…
aturri May 10, 2025
c70c651
use disabled temp constants
aturri May 10, 2025
59d43f9
remove zone from core temp description
aturri May 10, 2025
0003fad
fixup
aturri May 10, 2025
27ce811
Merge branch 'dev' into feature/fix-discovery-temp-sensors
aturri May 14, 2025
716c7cc
post-merge fixup
aturri May 14, 2025
01b2d0a
test snapshots update
aturri May 14, 2025
5b7a8aa
cleanup zone on core temperature
aturri May 14, 2025
554256c
refactor to make temperature sensor appear dynamically as they provid…
aturri May 20, 2025
b9b8971
fix unique id generation
aturri May 20, 2025
2350f85
Merge branch 'home-assistant:dev' into feature/fix-discovery-temp-sen…
aturri May 20, 2025
a580dac
snapshots fixup
aturri May 20, 2025
11adcdb
add tests for checking transitions between states and creation of tem…
aturri May 21, 2025
9e218ee
remove duplicate oven on snapshots
aturri May 21, 2025
607a930
add fridge freezer snapshot
aturri May 21, 2025
fbce94a
conversion of temperature value inside value_fn
aturri May 21, 2025
5957633
improve test coverage
aturri May 21, 2025
ad47cbb
use attribute unique_id instead of property
aturri May 26, 2025
bf302de
clean up dataclass
aturri May 26, 2025
b14de8a
Merge branch 'dev' into feature/fix-discovery-temp-sensors
aturri May 26, 2025
3928355
post merge test fixup
aturri May 26, 2025
4b4eff6
Refactor homeassistant/components/miele/sensor.py
aturri Jul 10, 2025
1e64035
Merge branch 'dev' into feature/fix-discovery-temp-sensors
aturri Jul 10, 2025
9cb2ce0
Refactor sensor test to have inline state transitions checks
aturri Jul 10, 2025
17a2cdc
Refactor tests to avoid using too many json files
aturri Jul 10, 2025
557a8d6
comment test
aturri Jul 10, 2025
fa8b61e
fix test
aturri Jul 10, 2025
c22d22c
Merge branch 'dev' into feature/fix-discovery-temp-sensors
aturri Jul 11, 2025
16a1154
fix tests
aturri Jul 11, 2025
17b3424
avoid reloading integration at each test step
aturri Jul 14, 2025
82a1ead
Group the freezer with the fire time changed
joostlek Jul 15, 2025
785f906
Merge branch 'dev' into feature/fix-discovery-temp-sensors
joostlek Jul 15, 2025
f2c1286
Merge branch 'dev' into feature/fix-discovery-temp-sensors
joostlek Jul 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
10000
Diff view
Diff view
7 changes: 6 additions & 1 deletion homeassistant/components/miele/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]):

_attr_has_entity_name = True

@staticmethod
def get_unique_id(device_id: str, description: EntityDescription) -> str:
"""Generate a unique ID for the entity."""
return f"{device_id}-{description.key}"

def __init__(
self,
coordinator: MieleDataUpdateCoordinator,
Expand All @@ -26,7 +31,7 @@ def __init__(
super().__init__(coordinator)
self._device_id = device_id
self.entity_description = description
self._attr_unique_id = f"{device_id}-{description.key}"
self._attr_unique_id = MieleEntity.get_unique_id(device_id, description)

device = self.device
appliance_type = DEVICE_TYPE_TAGS.get(MieleAppliance(device.device_type))
Expand Down
204 changes: 129 additions & 75 deletions homeassistant/components/miele/sensor.py
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to move the entity_class assignment to the description instead of doing it by logic in _async_add_new_devices()?

match definition.description.key:
    case "state_status":
        entity_class = MieleStatusSensor
    case "state_program_id":
        entity_class = MieleProgramIdSensor
    ...

Copy link
Contributor Author
@aturri aturri May 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes a lot of sense, but it requires moving around items inside sensor.py and it messes up the PR. I already prepared it on a separate branch and there will be a follow-up PR (already drafted: #145578)

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import logging
from typing import Final, cast

from pymiele import MieleDevice
from pymiele import MieleDevice, MieleTemperature

from homeassistant.components.sensor import (
SensorDeviceClass,
Expand All @@ -25,10 +25,13 @@
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType

from .const import (
DISABLED_TEMP_ENTITIES,
DOMAIN,
STATE_PROGRAM_ID,
STATE_PROGRAM_PHASE,
STATE_STATUS_TAGS,
Expand All @@ -45,8 +48,6 @@

_LOGGER = logging.getLogger(__name__)

DISABLED_TEMPERATURE = -32768

DEFAULT_PLATE_COUNT = 4

PLATE_COUNT = {
Expand Down Expand Up @@ -75,12 +76,25 @@
return value_list[0] * 60 + value_list[1] if value_list else None


def _convert_temperature(
value_list: list[MieleTemperature], index: int
) -> float | None:
"""Convert temperature object to readable value."""
if index >= len(value_list):
return None

Check warning on line 84 in homeassistant/components/miele/sensor.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/miele/sensor.py#L84

Added line #L84 was not covered by tests
raw_value = cast(int, value_list[index].temperature) / 100.0
if raw_value in DISABLED_TEMP_ENTITIES:
return None
return raw_value


@dataclass(frozen=True, kw_only=True)
class MieleSensorDescription(SensorEntityDescription):
"""Class describing Miele sensor entities."""

value_fn: Callable[[MieleDevice], StateType]
zone: int = 1
zone: int | None = None
unique_id_fn: Callable[[str, MieleSensorDescription], str] | None = None


@dataclass
Expand Down Expand Up @@ -404,32 +418,20 @@
),
description=MieleSensorDescription(
key="state_temperature_1",
zone=1,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: cast(int, value.state_temperatures[0].temperature)
/ 100.0,
value_fn=lambda value: _convert_temperature(value.state_temperatures, 0),
),
),
MieleSensorDefinition(
types=(
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL,
MieleAppliance.OVEN,
MieleAppliance.OVEN_MICROWAVE,
MieleAppliance.DISH_WARMER,
MieleAppliance.STEAM_OVEN,
MieleAppliance.MICROWAVE,
MieleAppliance.FRIDGE,
MieleAppliance.FREEZER,
MieleAppliance.FRIDGE_FREEZER,
MieleAppliance.STEAM_OVEN_COMBI,
MieleAppliance.WINE_CABINET,
MieleAppliance.WINE_CONDITIONING_UNIT,
MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT,
MieleAppliance.STEAM_OVEN_MICRO,
MieleAppliance.DIALOG_OVEN,
MieleAppliance.WINE_CABINET_FREEZER,
MieleAppliance.STEAM_OVEN_MK2,
),
description=MieleSensorDescription(
key="state_temperature_2",
Expand All @@ -438,7 +440,24 @@
translation_key="temperature_zone_2",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: value.state_temperatures[1].temperature / 100.0, # type: ignore [operator]
value_fn=lambda value: _convert_temperature(value.state_temperatures, 1),
),
),
MieleSensorDefinition(
types=(
MieleAppliance.WINE_CABINET,
MieleAppliance.WINE_CONDITIONING_UNIT,
MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT,
MieleAppliance.WINE_CABINET_FREEZER,
),
description=MieleSensorDescription(
key="state_temperature_3",
zone=3,
device_class=SensorDeviceClass.TEMPERATURE,
translation_key="temperature_zone_3",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: _convert_temperature(value.state_temperatures, 2),
),
),
MieleSensorDefinition(
Expand All @@ -454,11 +473,8 @@
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda value: cast(
int, value.state_core_target_temperature[0].temperature
)
/ 100.0
value_fn=lambda value: _convert_temperature(
value.state_core_target_temperature, 0
),
),
),
Expand All @@ -479,9 +495,8 @@
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda value: cast(int, value.state_target_temperature[0].temperature)
/ 100.0
value_fn=lambda value: _convert_temperature(
value.state_target_temperature, 0
),
),
),
Expand All @@ -497,9 +512,8 @@
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda value: cast(int, value.state_core_temperature[0].temperature)
/ 100.0
value_fn=lambda value: _convert_temperature(
value.state_core_temperature, 0
),
),
),
Expand All @@ -518,6 +532,8 @@
device_class=SensorDeviceClass.ENUM,
options=sorted(PlatePowerStep.keys()),
value_fn=lambda value: None,
unique_id_fn=lambda device_id,
description: f"{device_id}-{description.key}-{description.zone}",
),
)
for i in range(1, 7)
Expand Down Expand Up @@ -559,51 +575,88 @@
) -> None:
"""Set up the sensor platform."""
coordinator = config_entry.runtime_data
added_devices: set[str] = set()
added_devices: set[str] = set() # device_id
added_entities: set[str] = set() # unique_id

def _get_entity_class(definition: MieleSensorDefinition) -> type[MieleSensor]:
"""Get the entity class for the sensor."""
return {
"state_status": MieleStatusSensor,
"state_program_id": MieleProgramIdSensor,
"state_program_phase": MielePhaseSensor,
"state_plate_step": MielePlateSensor,
}.get(definition.description.key, MieleSensor)

def _is_entity_registered(unique_id: str) -> bool:
"""Check if the entity is already registered."""
entity_registry = er.async_get(hass)
return any(
entry.platform == DOMAIN and entry.unique_id == unique_id
for entry in entity_registry.entities.values()
)

def _is_sensor_enabled(
definition: MieleSensorDefinition,
device: MieleDevice,
unique_id: str,
) -> bool:
"""Check if the sensor is enabled."""
if (
definition.description.device_class == SensorDeviceClass.TEMPERATURE
and definition.description.value_fn(device) is None
and definition.description.zone != 1
):
# all appliances supporting temperature have at least zone 1, for other zones
# don't create entity if API signals that datapoint is disabled, unless the sensor
# already appeared in the past (= it provided a valid value)
return _is_entity_registered(unique_id)
if (
definition.description.key == "state_plate_step"
and definition.description.zone is not None
and definition.description.zone > _get_plate_count(device.tech_type)
):
# don't create plate entity if not expected by the appliance tech type
return False
return True

def _async_add_new_devices() -> None:
nonlocal added_devices
def _async_add_devices() -> None:
nonlocal added_devices, added_entities
entities: list = []
entity_class: type[MieleSensor]
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices

for device_id, device in coordinator.data.devices.items():
for definition in SENSOR_TYPES:
if (
device_id in new_devices_set
and device.device_type in definition.types
):
match definition.description.key:
case "state_status":
entity_class = MieleStatusSensor
case "state_program_id":
entity_class = MieleProgramIdSensor
case "state_program_phase":
entity_class = MielePhaseSensor
case "state_plate_step":
entity_class = MielePlateSensor
case _:
entity_class = MieleSensor
if (
definition.description.device_class
== SensorDeviceClass.TEMPERATURE
and definition.description.value_fn(device)
== DISABLED_TEMPERATURE / 100
) or (
definition.description.key == "state_plate_step"
and definition.description.zone
> _get_plate_count(device.tech_type)
):
# Don't create entity if API signals that datapoint is disabled
continue
entities.append(
entity_class(coordinator, device_id, definition.description)
# device is not supported, skip
if device.device_type not in definition.types:
continue

entity_class = _get_entity_class(definition)
unique_id = (
definition.description.unique_id_fn(
device_id, definition.description
Comment on lines +622 to +638
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay so one thing is, we should only check this on creation, not every time we get an update, do we keep track of all data we already kept track off?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should only check this on creation, not every time we get an update

But in this way, I would recognize new temperature sensors only at creation. We need that the temperature sensor is created when it provides a valid value for the first time because of API limitations.

do we keep track of all data we already kept track off?

Sorry, I don't get the point here :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But you mentioned you don't know what values are available at boot right? But do we make sure we don't check every datapoint at every update?

Copy link
Contributor Author
@aturri aturri May 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the API is always reporting something, valid or not valid value. Due to how the Miele API works, temperature sensors are not exposed at initial device discovery and only start reporting values later. So we need to re-evaluate their presence when the data becomes available.

The first time a temperature becomes valid, we need to create the sensor and keep it afterwards, reporting "unknown" as the API gets back to the invalid state (appliance is off or idle for example).
For this reason, at every update we need to check whether the sensor is enabled by looking at the value currently reported by API or in the registry.
This doesn't seem different than the previous implementation or I'm missing something.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm adding some details to be more clear.

The check for entity_registry is only performed:

  • for newly discovered devices, or
  • for newly available sensors that previously returned a disabled/null value, only for device types that potentially support zones 2 / 3 / core temp (so we will skip the check on washing machines, because it doesn't have different temperatures and it is stated into the sensor definition)

To avoid checking all datapoints every time, we track both:

  • added_devices: only process new devices once
  • added_entities: so we don't try to add the same sensor multiple times

This ensures we don’t process already-added sensors again, and we skip entirely the devices already handled.

Open to suggestions on improving performance :) What about an explicit check for value_fn(device) transitioning from invalid → valid to tighten this further?

)
if definition.description.unique_id_fn is not None
else MieleEntity.get_unique_id(device_id, definition.description)
)

# entity was already added, skip
if device_id not in new_devices_set and unique_id in added_entities:
continue

# sensors is not enabled, skip
if not _is_sensor_enabled(definition, device, unique_id):
continue

added_entities.add(unique_id)
entities.append(
entity_class(coordinator, device_id, definition.description)
)
async_add_entities(entities)

config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_devices))
_async_add_devices()


APPLIANCE_ICONS = {
Expand Down Expand Up @@ -641,6 +694,17 @@

entity_description: MieleSensorDescription

def __init__(
self,
coordinator: MieleDataUpdateCoordinator,
device_id: str,
description: MieleSensorDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, device_id, description)
if description.unique_id_fn is not None:
self._attr_unique_id = description.unique_id_fn(device_id, description)

@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
Expand All @@ -652,16 +716,6 @@

entity_description: MieleSensorDescription

def __init__(
self,
coordinator: MieleDataUpdateCoordinator,
device_id: str,
description: MieleSensorDescription,
) -> None:
"""Initialize the plate sensor."""
super().__init__(coordinator, device_id, description)
self._attr_unique_id = f"{device_id}-{description.key}-{description.zone}"

@property
def native_value(self) -> StateType:
"""Return the state of the plate sensor."""
Expand All @@ -672,7 +726,7 @@
cast(
int,
self.device.state_plate_step[
self.entity_description.zone - 1
cast(int, self.entity_description.zone) - 1
].value_raw,
)
).name
Expand Down
15 changes: 15 additions & 0 deletions tests/components/miele/fixtures/4_actions.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,20 @@
"colors": [],
"modes": [],
"runOnTime": []
},
"DummyAppliance_12": {
"processAction": [],
"light": [2],
"ambientLight": [],
"startTime": [],
"ventilationStep": [],
"programId": [],
"targetTemperature": [],
"deviceName": true,
"powerOn": false,
"powerOff": true,
"colors": [],
"modes": [],
"runOnTime": []
}
}
Loading
0