Add number platform to Midea (#179247)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Simone Chemelli
2026-08-23 22:42:01 +02:00
committed by GitHub
co-authored by Joost Lekkerkerker
parent c830734535
commit 2aa09bc9d4
6 changed files with 972 additions and 0 deletions
@@ -23,6 +23,7 @@ from .entity import MideaConfigEntry
_PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.HUMIDIFIER,
Platform.NUMBER,
Platform.SELECT,
]
@@ -8,6 +8,9 @@ MIDEA_DEVICE_NAMES: dict[DeviceType, str] = {
DeviceType.CC: "MDV Wi-Fi Controller",
DeviceType.CF: "Heat Pump",
DeviceType.FB: "Electric Heater",
DeviceType.C2: "Toilet",
DeviceType.CD: "Heat Pump Water Heater",
DeviceType.ED: "Water Drinking Appliance",
DeviceType.X40: "Integrated Ceiling Fan",
DeviceType.A1: "Dehumidifier",
DeviceType.FA: "Fan",
+157
View File
@@ -0,0 +1,157 @@
"""Number for Midea."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast, override
from midealocal.const import DeviceType
from midealocal.device import MideaDevice
from midealocal.devices.c2 import MideaC2Device
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MideaConfigEntry, MideaEntity, midea_api_call
PARALLEL_UPDATES = 0
@dataclass(kw_only=True, frozen=True)
class MideaNumberEntityDescription(NumberEntityDescription):
"""Description for a Midea number entity."""
models: list[DeviceType]
max_value_fn: Callable[[MideaDevice], float | None] | None = None
NUMBERS: list[MideaNumberEntityDescription] = [
MideaNumberEntityDescription(
key="dry_level",
translation_key="dry_level",
models=[DeviceType.C2],
native_min_value=0,
max_value_fn=lambda device: cast(MideaC2Device, device).max_dry_level,
native_step=1,
),
MideaNumberEntityDescription(
key="water_temp_level",
translation_key="water_temp_level",
models=[DeviceType.C2],
native_min_value=0,
max_value_fn=lambda device: cast(MideaC2Device, device).max_water_temp_level,
native_step=1,
),
MideaNumberEntityDescription(
key="seat_temp_level",
translation_key="seat_temp_level",
models=[DeviceType.C2],
native_min_value=0,
max_value_fn=lambda device: cast(MideaC2Device, device).max_seat_temp_level,
native_step=1,
),
MideaNumberEntityDescription(
key="vacation_days",
translation_key="vacation_days",
models=[DeviceType.CD],
device_class=NumberDeviceClass.DURATION,
native_min_value=1,
native_max_value=360,
native_step=1,
native_unit_of_measurement=UnitOfTime.DAYS,
),
MideaNumberEntityDescription(
key="water_hardness",
translation_key="water_hardness",
models=[DeviceType.ED],
native_min_value=0,
native_max_value=65535,
native_step=1,
),
MideaNumberEntityDescription(
key="flushing_days",
translation_key="flushing_days",
models=[DeviceType.ED],
device_class=NumberDeviceClass.DURATION,
native_min_value=0,
native_max_value=99,
native_step=1,
native_unit_of_measurement=UnitOfTime.DAYS,
),
MideaNumberEntityDescription(
key="leak_water_protection_value",
translation_key="leak_water_protection_value",
models=[DeviceType.ED],
device_class=NumberDeviceClass.VOLUME,
native_min_value=0,
native_max_value=2550,
native_step=50,
native_unit_of_measurement=UnitOfVolume.LITERS,
),
MideaNumberEntityDescription(
key="heating_level",
translation_key="heating_level",
models=[DeviceType.FB],
native_min_value=1,
native_max_value=10,
native_step=1,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MideaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up numbers for device."""
device = config_entry.runtime_data
async_add_entities(
MideaNumber(device, description)
for description in NUMBERS
if device.device_type in description.models
# None means the model doesn't support this attribute at all,
# unlike select.py's key-presence check.
and device.attributes.get(description.key) is not None
)
class MideaNumber(MideaEntity, NumberEntity):
"""Represent a Midea number."""
entity_description: MideaNumberEntityDescription
@property
@override
def native_max_value(self) -> float:
"""Return the maximum value, reading it off the device if dynamic."""
if self.entity_description.max_value_fn is not None:
value = self.entity_description.max_value_fn(self._device)
if value is not None:
return value
return super().native_max_value
@property
@override
def native_value(self) -> float | None:
"""Return the current value."""
value = self._device.get_attribute(self.entity_description.key)
if not isinstance(value, (int, float)):
return None
return float(value)
@override
def set_native_value(self, value: float) -> None:
"""Set the value."""
step = self.step
value = round(value / step) * step
with midea_api_call():
self._device.set_attribute(
attr=self.entity_description.key, value=round(value)
)
@@ -106,6 +106,32 @@
"name": "Zone 2 thermostat"
}
},
"number": {
"dry_level": {
"name": "Dry level"
},
"flushing_days": {
"name": "Flushing days"
},
"heating_level": {
"name": "Heating level"
},
"leak_water_protection_value": {
"name": "Leak water protection value"
},
"seat_temp_level": {
"name": "Seat temperature level"
},
"vacation_days": {
"name": "Vacation days"
},
"water_hardness": {
"name": "Water hardness"
},
"water_temp_level": {
"name": "Water temperature level"
}
},
"select": {
"detect_mode": {
"name": "Detect mode",
@@ -0,0 +1,479 @@
# serializer version: 1
# name: test_number_state_snapshot[c2][number.bedroom_ac_dry_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 3,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.bedroom_ac_dry_level',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Dry level',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Dry level',
'platform': 'midea',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'dry_level',
'unique_id': '12345678_dry_level',
'unit_of_measurement': None,
})
# ---
# name: test_number_state_snapshot[c2][number.bedroom_ac_dry_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Bedroom AC Dry level',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 3,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'context': <ANY>,
'entity_id': 'number.bedroom_ac_dry_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.0',
})
# ---
# name: test_number_state_snapshot[c2][number.bedroom_ac_seat_temperature_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 5,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.bedroom_ac_seat_temperature_level',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Seat temperature level',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Seat temperature level',
'platform': 'midea',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'seat_temp_level',
'unique_id': '12345678_seat_temp_level',
'unit_of_measurement': None,
})
# ---
# name: test_number_state_snapshot[c2][number.bedroom_ac_seat_temperature_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Bedroom AC Seat temperature level',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 5,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'context': <ANY>,
'entity_id': 'number.bedroom_ac_seat_temperature_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '3.0',
})
# ---
# name: test_number_state_snapshot[c2][number.bedroom_ac_water_temperature_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 5,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.bedroom_ac_water_temperature_level',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Water temperature level',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Water temperature level',
'platform': 'midea',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'water_temp_level',
'unique_id': '12345678_water_temp_level',
'unit_of_measurement': None,
})
# ---
# name: test_number_state_snapshot[c2][number.bedroom_ac_water_temperature_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Bedroom AC Water temperature level',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 5,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'context': <ANY>,
'entity_id': 'number.bedroom_ac_water_temperature_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2.0',
})
# ---
# name: test_number_state_snapshot[cd][number.bedroom_ac_vacation_days-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 360,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 1,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.bedroom_ac_vacation_days',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Vacation days',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Vacation days',
'platform': 'midea',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'vacation_days',
'unique_id': '12345678_vacation_days',
'unit_of_measurement': <UnitOfTime.DAYS: 'd'>,
})
# ---
# name: test_number_state_snapshot[cd][number.bedroom_ac_vacation_days-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Bedroom AC Vacation days',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 360,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 1,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.DAYS: 'd'>,
}),
'context': <ANY>,
'entity_id': 'number.bedroom_ac_vacation_days',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '7.0',
})
# ---
# name: test_number_state_snapshot[ed][number.bedroom_ac_flushing_days-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 99,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.bedroom_ac_flushing_days',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Flushing days',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Flushing days',
'platform': 'midea',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'flushing_days',
'unique_id': '12345678_flushing_days',
'unit_of_measurement': <UnitOfTime.DAYS: 'd'>,
})
# ---
# name: test_number_state_snapshot[ed][number.bedroom_ac_flushing_days-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'duration',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Bedroom AC Flushing days',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 99,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTime.DAYS: 'd'>,
}),
'context': <ANY>,
'entity_id': 'number.bedroom_ac_flushing_days',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '14.0',
})
# ---
# name: test_number_state_snapshot[ed][number.bedroom_ac_leak_water_protection_value-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 2550,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 50,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.bedroom_ac_leak_water_protection_value',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Leak water protection value',
'options': dict({
}),
'original_device_class': <NumberDeviceClass.VOLUME: 'volume'>,
'original_icon': None,
'original_name': 'Leak water protection value',
'platform': 'midea',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'leak_water_protection_value',
'unique_id': '12345678_leak_water_protection_value',
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
})
# ---
# name: test_number_state_snapshot[ed][number.bedroom_ac_leak_water_protection_value-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'volume',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Bedroom AC Leak water protection value',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 2550,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 50,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfVolume.LITERS: 'L'>,
}),
'context': <ANY>,
'entity_id': 'number.bedroom_ac_leak_water_protection_value',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '500.0',
})
# ---
# name: test_number_state_snapshot[ed][number.bedroom_ac_water_hardness-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 65535,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.bedroom_ac_water_hardness',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Water hardness',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Water hardness',
'platform': 'midea',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'water_hardness',
'unique_id': '12345678_water_hardness',
'unit_of_measurement': None,
})
# ---
# name: test_number_state_snapshot[ed][number.bedroom_ac_water_hardness-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Bedroom AC Water hardness',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 65535,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'context': <ANY>,
'entity_id': 'number.bedroom_ac_water_hardness',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '120.0',
})
# ---
# name: test_number_state_snapshot[fb][number.bedroom_ac_heating_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 10,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 1,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.bedroom_ac_heating_level',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Heating level',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Heating level',
'platform': 'midea',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'heating_level',
'unique_id': '12345678_heating_level',
'unit_of_measurement': None,
})
# ---
# name: test_number_state_snapshot[fb][number.bedroom_ac_heating_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Bedroom AC Heating level',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 10,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 1,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'context': <ANY>,
'entity_id': 'number.bedroom_ac_heating_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.0',
})
# ---
+306
View File
@@ -0,0 +1,306 @@
"""Tests for midea number.py."""
from collections.abc import Callable
from unittest.mock import patch
from midealocal.const import DeviceType
from midealocal.devices.ac import DeviceAttributes as ACAttributes
from midealocal.devices.c2 import DeviceAttributes as C2Attributes
from midealocal.devices.cd import DeviceAttributes as CDAttributes
from midealocal.devices.ed import DeviceAttributes as EDAttributes
from midealocal.devices.fb import DeviceAttributes as FBAttributes
from midealocal.exceptions import SocketException
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.number import (
ATTR_MAX,
ATTR_MIN,
ATTR_STEP,
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import DummyDevice, entity_entries
from .const import TEST_DEVICE_ID
from tests.common import MockConfigEntry, snapshot_platform
def _c2_device() -> DummyDevice:
device = DummyDevice(
DeviceType.C2,
attributes={
C2Attributes.dry_level: 1,
C2Attributes.water_temp_level: 2,
C2Attributes.seat_temp_level: 3,
},
)
device.max_dry_level = 3
device.max_water_temp_level = 5
device.max_seat_temp_level = 5
return device
def _cd_device() -> DummyDevice:
return DummyDevice(
DeviceType.CD,
attributes={CDAttributes.vacation_days: 7},
)
def _ed_device() -> DummyDevice:
return DummyDevice(
DeviceType.ED,
attributes={
EDAttributes.water_hardness: 120,
EDAttributes.flushing_days: 14,
EDAttributes.leak_water_protection_value: 500,
},
)
def _fb_device() -> DummyDevice:
return DummyDevice(
DeviceType.FB,
attributes={FBAttributes.heating_level: 5},
)
async def _assert_service_call(
hass: HomeAssistant,
entity_id: str,
value: float,
expected_calls: list[tuple],
device: DummyDevice,
) -> None:
"""Call number.set_value and assert the fake device recorded the right call."""
device.calls.clear()
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value},
blocking=True,
)
assert device.calls == expected_calls
@pytest.mark.parametrize(
"device",
[
pytest.param(_c2_device(), id="c2"),
pytest.param(_cd_device(), id="cd"),
pytest.param(_ed_device(), id="ed"),
pytest.param(_fb_device(), id="fb"),
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_number_state_snapshot(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
device: DummyDevice,
) -> None:
"""Test async_setup_entry creates the right number entities per device type."""
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, config_entry, device)
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
async def test_c2_number_dynamic_max_and_services(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
) -> None:
"""Test C2 number entities read their max from a device property and can be set."""
device = _c2_device()
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, config_entry, device)
entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_dry_level"]
assert (state := hass.states.get(entity_entry.entity_id)) is not None
assert float(state.state) == 1
assert state.attributes[ATTR_MIN] == 0
assert state.attributes[ATTR_MAX] == 3
assert state.attributes[ATTR_STEP] == 1
await _assert_service_call(
hass,
entity_entry.entity_id,
2,
[("set_attribute", "dry_level", 2)],
device,
)
water_entry = entity_entries(hass, config_entry)[
f"{TEST_DEVICE_ID}_water_temp_level"
]
assert (water_state := hass.states.get(water_entry.entity_id)) is not None
assert water_state.attributes[ATTR_MAX] == 5
seat_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_seat_temp_level"]
assert (seat_state := hass.states.get(seat_entry.entity_id)) is not None
assert seat_state.attributes[ATTR_MAX] == 5
async def test_cd_number_static_range(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
) -> None:
"""Test CD's vacation_days uses a static min/max/step range."""
device = _cd_device()
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, config_entry, device)
entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_vacation_days"]
assert (state := hass.states.get(entity_entry.entity_id)) is not None
assert float(state.state) == 7
assert state.attributes[ATTR_MIN] == 1
assert state.attributes[ATTR_MAX] == 360
await _assert_service_call(
hass,
entity_entry.entity_id,
30,
[("set_attribute", "vacation_days", 30)],
device,
)
async def test_ed_number_entities(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
) -> None:
"""Test ED exposes water_hardness, flushing_days and leak_water_protection_value."""
device = _ed_device()
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, config_entry, device)
entities = entity_entries(hass, config_entry)
assert f"{TEST_DEVICE_ID}_water_hardness" in entities
assert f"{TEST_DEVICE_ID}_flushing_days" in entities
assert f"{TEST_DEVICE_ID}_leak_water_protection_value" in entities
leak_entry = entities[f"{TEST_DEVICE_ID}_leak_water_protection_value"]
await _assert_service_call(
hass,
leak_entry.entity_id,
550,
[("set_attribute", "leak_water_protection_value", 550)],
device,
)
async def test_fb_heating_level(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
) -> None:
"""Test FB's heating_level number entity."""
device = _fb_device()
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, config_entry, device)
entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_heating_level"]
assert (state := hass.states.get(entity_entry.entity_id)) is not None
assert float(state.state) == 5
await _assert_service_call(
hass,
entity_entry.entity_id,
8,
[("set_attribute", "heating_level", 8)],
device,
)
async def test_number_unknown_when_attribute_not_numeric(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
) -> None:
"""Test native_value gracefully reports unknown if a later update clears it."""
device = _fb_device()
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, config_entry, device)
entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_heating_level"]
assert (state := hass.states.get(entity_entry.entity_id))
assert float(state.state) == 5
device.attributes[FBAttributes.heating_level] = None
device.notify_update({FBAttributes.heating_level: None})
await hass.async_block_till_done()
assert (state := hass.states.get(entity_entry.entity_id))
assert state.state == "unknown"
async def test_number_not_created_when_attribute_missing(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
) -> None:
"""Test no number entity is created when the device does not report the attribute."""
device = DummyDevice(DeviceType.FB, attributes={})
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, config_entry, device)
assert entity_entries(hass, config_entry) == {}
async def test_number_not_created_for_other_device_type(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
) -> None:
"""Test no number entity is created for a device type without one (e.g. AC's fan_speed)."""
device = DummyDevice(
DeviceType.AC,
attributes={
ACAttributes.power: True,
ACAttributes.mode: 1,
ACAttributes.target_temperature: 22.0,
ACAttributes.indoor_temperature: 21.0,
ACAttributes.fan_speed: 60,
},
)
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, config_entry, device)
assert entity_entries(hass, config_entry) == {}
async def test_number_set_value_raises_on_device_communication_error(
hass: HomeAssistant,
mock_config_entry: Callable[[DummyDevice], MockConfigEntry],
) -> None:
"""Test a device communication failure surfaces as a HomeAssistantError."""
device = _fb_device()
config_entry = mock_config_entry(device)
with patch("homeassistant.components.midea._PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, config_entry, device)
entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_heating_level"]
with (
patch.object(device, "set_attribute", side_effect=SocketException("offline")),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: entity_entry.entity_id, ATTR_VALUE: 3},
blocking=True,
)