From 2aa09bc9d40237ac2a20e4b54e26b71ea09f94b3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 23 Aug 2026 22:42:01 +0200 Subject: [PATCH] Add number platform to Midea (#179247) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/midea/__init__.py | 1 + .../components/midea/device_catalog.py | 3 + homeassistant/components/midea/number.py | 157 ++++++ homeassistant/components/midea/strings.json | 26 + .../midea/snapshots/test_number.ambr | 479 ++++++++++++++++++ tests/components/midea/test_number.py | 306 +++++++++++ 6 files changed, 972 insertions(+) create mode 100644 homeassistant/components/midea/number.py create mode 100644 tests/components/midea/snapshots/test_number.ambr create mode 100644 tests/components/midea/test_number.py diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index 8667bc3fcfaa..989840a13936 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -23,6 +23,7 @@ from .entity import MideaConfigEntry _PLATFORMS: list[Platform] = [ Platform.CLIMATE, Platform.HUMIDIFIER, + Platform.NUMBER, Platform.SELECT, ] diff --git a/homeassistant/components/midea/device_catalog.py b/homeassistant/components/midea/device_catalog.py index 140b259bbc58..2e65f056eda5 100644 --- a/homeassistant/components/midea/device_catalog.py +++ b/homeassistant/components/midea/device_catalog.py @@ -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", diff --git a/homeassistant/components/midea/number.py b/homeassistant/components/midea/number.py new file mode 100644 index 000000000000..9104b7b27206 --- /dev/null +++ b/homeassistant/components/midea/number.py @@ -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) + ) diff --git a/homeassistant/components/midea/strings.json b/homeassistant/components/midea/strings.json index b5c855de31cf..7b21e3732957 100644 --- a/homeassistant/components/midea/strings.json +++ b/homeassistant/components/midea/strings.json @@ -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", diff --git a/tests/components/midea/snapshots/test_number.ambr b/tests/components/midea/snapshots/test_number.ambr new file mode 100644 index 000000000000..c63916ab5be6 --- /dev/null +++ b/tests/components/midea/snapshots/test_number.ambr @@ -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({ + : 3, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + '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({ + : 'Bedroom AC Dry level', + : 3, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.bedroom_ac_dry_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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({ + : 5, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + '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({ + : 'Bedroom AC Seat temperature level', + : 5, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.bedroom_ac_seat_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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({ + : 5, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + '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({ + : 'Bedroom AC Water temperature level', + : 5, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.bedroom_ac_water_temperature_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_number_state_snapshot[cd][number.bedroom_ac_vacation_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 360, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Vacation days', + 'options': dict({ + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_number_state_snapshot[cd][number.bedroom_ac_vacation_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Bedroom AC Vacation days', + : 360, + : 1, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.bedroom_ac_vacation_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', + }) +# --- +# name: test_number_state_snapshot[ed][number.bedroom_ac_flushing_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 99, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Flushing days', + 'options': dict({ + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_number_state_snapshot[ed][number.bedroom_ac_flushing_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'duration', + : 'Bedroom AC Flushing days', + : 99, + : 0, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.bedroom_ac_flushing_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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({ + : 2550, + : 0, + : , + : 50, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Leak water protection value', + 'options': dict({ + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_number_state_snapshot[ed][number.bedroom_ac_leak_water_protection_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'volume', + : 'Bedroom AC Leak water protection value', + : 2550, + : 0, + : , + : 50, + : , + }), + 'context': , + 'entity_id': 'number.bedroom_ac_leak_water_protection_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '500.0', + }) +# --- +# name: test_number_state_snapshot[ed][number.bedroom_ac_water_hardness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 65535, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + '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({ + : 'Bedroom AC Water hardness', + : 65535, + : 0, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.bedroom_ac_water_hardness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- +# name: test_number_state_snapshot[fb][number.bedroom_ac_heating_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10, + : 1, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + '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({ + : 'Bedroom AC Heating level', + : 10, + : 1, + : , + : 1, + }), + 'context': , + 'entity_id': 'number.bedroom_ac_heating_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- diff --git a/tests/components/midea/test_number.py b/tests/components/midea/test_number.py new file mode 100644 index 000000000000..d93fe47db769 --- /dev/null +++ b/tests/components/midea/test_number.py @@ -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, + )