初始化config文件

This commit is contained in:
2026-08-24 21:55:33 +08:00
commit 80a05d172e
2983 changed files with 1221334 additions and 0 deletions
@@ -0,0 +1,454 @@
"""Init for Midea LAN.
integration load process:
1. component setup: `async_setup`
1.1 use `hass.services.async_register` to register service
2. config entry setup: `async_setup_entry`
2.1 forward the Config Entry to the platform `async_forward_entry_setups`
2.2 register listener `update_listener`
3. unloading a config entry: `async_unload_entry`
"""
import logging
from typing import Any, cast
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.device_registry as dr
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_CUSTOMIZE,
CONF_DEVICE_ID,
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PORT,
CONF_PROTOCOL,
CONF_TOKEN,
CONF_TYPE,
MAJOR_VERSION,
MINOR_VERSION,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from midealan.device import DeviceType, MideaDevice, ProtocolVersion
from midealan.devices import device_selector
from midealan.discover import discover
from .const import (
ALL_PLATFORM,
CONF_ACCOUNT,
CONF_KEY,
CONF_MAC,
CONF_MODEL,
CONF_REFRESH_INTERVAL,
CONF_SN,
CONF_SUBTYPE,
DEVICES,
DOMAIN,
EXTRA_SWITCH,
)
from .midea_devices import MIDEA_DEVICES
_LOGGER = logging.getLogger(__name__)
def _close_device(device: MideaDevice) -> None:
"""Close a Midea device connection without failing unload/setup cleanup."""
try:
device.close()
except (OSError, ConnectionError, AttributeError) as e:
_LOGGER.warning("Failed to close Midea socket cleanly: %s", e)
def _device_store(hass: HomeAssistant) -> dict[int, MideaDevice]:
"""Return the integration's loaded device map.
Returns
-------
Device id to Midea device mapping.
"""
return cast(
"dict[int, MideaDevice]",
hass.data.setdefault(DOMAIN, {}).setdefault(DEVICES, {}),
)
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Option flow signal update.
Registered in `async_setup_entry` via
`config_entry.async_on_unload(config_entry.add_update_listener(...))`, so it
is attached when the entry loads and detached at unload. Reload the entry so
the changed options (customize JSON, IP override, refresh interval, and the
extra sensor/switch selection) are re-applied through the normal setup path.
Reloading avoids the previous fire-and-forget re-setup, which awaited the
platform unload but then re-forwarded setup via an untracked
`async_create_task` — swallowing any setup error and racing the customize/
ip/refresh application that followed.
"""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # ruff:ignore[unused-function-argument]
"""Set up midea_lan component when load this integration.
Returns
-------
True if entry is configured.
"""
hass.data.setdefault(DOMAIN, {})
attributes = []
for device_entities in MIDEA_DEVICES.values():
for attribute_name, attribute in cast(
"dict",
device_entities["entities"],
).items():
attribute_key = (
attribute_name
if isinstance(attribute_name, str)
else attribute_name.value
)
if (
attribute.get("type") in EXTRA_SWITCH
and attribute_key not in attributes
):
attributes.append(attribute_key)
def service_set_attribute(service: Any) -> None: # ruff:ignore[any-type]
"""Set service attribute func."""
device_id: int = service.data["device_id"]
attr = service.data["attribute"]
value = service.data["value"]
dev: MideaDevice = hass.data[DOMAIN][DEVICES].get(device_id)
if dev:
if attr == "fan_speed" and value == "auto":
value = 102
item = None
if dev_ := MIDEA_DEVICES.get(dev.device_type):
item = cast("dict", dev_["entities"]).get(attr)
if (
item
and (item.get("type") in EXTRA_SWITCH)
or (
dev.device_type == DeviceType.AC
and attr == "fan_speed"
and value in range(103)
)
):
dev.set_attribute(attr=attr, value=value)
else:
_LOGGER.error(
"Appliance [%s] has no attribute %s or value is invalid",
device_id,
attr,
)
def service_send_command(service: Any) -> None: # ruff:ignore[any-type]
"""Send command to service func."""
device_id = service.data.get("device_id")
cmd_type = service.data.get("cmd_type")
cmd_body = service.data.get("cmd_body")
try:
cmd_body = bytearray.fromhex(cmd_body)
except ValueError:
_LOGGER.exception(
"Appliance [%s] invalid cmd_body, a hexadecimal string required",
device_id,
)
return
dev = hass.data[DOMAIN][DEVICES].get(device_id)
if dev:
dev.send_command(cmd_type, cmd_body)
# register service func calls:
# `service_set_attribute`, service.yaml key: `set_attribute`
hass.services.async_register(
DOMAIN,
"set_attribute",
service_set_attribute,
schema=vol.Schema(
{
vol.Required("device_id"): vol.Coerce(int),
vol.Required("attribute"): vol.In(attributes),
vol.Required("value"): vol.Any(int, cv.boolean, str),
},
),
)
# register service func calls:
# `service_send_command`, service.yaml key: `send_command`
hass.services.async_register(
DOMAIN,
"send_command",
service_send_command,
schema=vol.Schema(
{
vol.Required("device_id"): vol.Coerce(int),
vol.Required("cmd_type"): vol.In([2, 3]),
vol.Required("cmd_body"): str,
},
),
)
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up platform for current integration.
Returns
-------
True if entry is configured.
"""
device_type = config_entry.data.get(CONF_TYPE)
if device_type == CONF_ACCOUNT:
return True
name = config_entry.data.get(CONF_NAME)
device_id: int = config_entry.data[CONF_DEVICE_ID]
if name is None:
name = f"{device_id}"
if device_type is None:
device_type = 0xAC
token: str = config_entry.data.get(CONF_TOKEN) or ""
key: str = config_entry.data.get(CONF_KEY) or ""
ip_address = config_entry.options.get(CONF_IP_ADDRESS, None)
if ip_address is None:
ip_address = config_entry.data.get(CONF_IP_ADDRESS)
refresh_interval = config_entry.options.get(CONF_REFRESH_INTERVAL)
port: int = config_entry.data[CONF_PORT]
model: str = config_entry.data[CONF_MODEL]
subtype = config_entry.data.get(CONF_SUBTYPE, 0)
protocol: ProtocolVersion = ProtocolVersion(config_entry.data[CONF_PROTOCOL])
customize: str = config_entry.options.get(CONF_CUSTOMIZE, "")
mac: str | None = config_entry.data.get(CONF_MAC)
serial_number: str | None = config_entry.data.get(CONF_SN)
if protocol == ProtocolVersion.V3 and (key == "" or token == ""):
_LOGGER.error("For V3 devices, the key and the token is required")
return False
# device_selector in `midealan/devices/__init__.py`
# hass core version >= 2024.3
if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 3):
device = await hass.async_add_import_executor_job(
device_selector,
name,
device_id,
device_type,
ip_address,
port,
token,
key,
protocol,
model,
subtype,
customize,
mac,
serial_number,
)
# hass core version < 2024.3
else:
device = device_selector(
name=name,
device_id=device_id,
device_type=device_type,
ip_address=ip_address,
port=port,
token=token,
key=key,
device_protocol=protocol,
model=model,
subtype=subtype,
customize=customize,
mac=mac,
serial_number=serial_number,
)
if device:
if refresh_interval is not None:
device.set_refresh_interval(refresh_interval)
device.open()
_device_store(hass)[device_id] = device
try:
# Forward the setup of an entry to all platforms
await hass.config_entries.async_forward_entry_setups(
config_entry,
ALL_PLATFORM,
)
except Exception:
_device_store(hass).pop(device_id, None)
_close_device(device)
raise
# Listener `update_listener` is
# attached when the entry is loaded
# and detached when it's unloaded
config_entry.async_on_unload(config_entry.add_update_listener(update_listener))
return True
return False
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Clean up entities, unsubscribe event listener and close all connections.
Returns
-------
True if entry is unloaded.
"""
device_type = config_entry.data.get(CONF_TYPE)
if device_type == CONF_ACCOUNT:
return True
# Unload the platforms first; only tear the device down if that succeeded,
# and report the real result so a failed platform unload isn't masked.
# bool() keeps mypy happy: async_unload_platforms is typed to return Any.
unload_ok = bool(
await hass.config_entries.async_unload_platforms(
config_entry,
ALL_PLATFORM,
),
)
if unload_ok:
device_id = config_entry.data.get(CONF_DEVICE_ID)
if device_id is not None:
dm = _device_store(hass).pop(device_id, None)
if dm is not None:
_close_device(dm)
return unload_ok
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry.
Returns
-------
True if entry is migrated.
"""
# 1 -> 2: convert device identifiers from int to str
if config_entry.version == 1:
_LOGGER.debug("Migrating configuration from version 1")
if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 3):
hass.config_entries.async_update_entry(config_entry, version=2)
else:
config_entry.version = 2
hass.config_entries.async_update_entry(config_entry)
# Migrate device.
await _async_migrate_device_identifiers(hass, config_entry)
_LOGGER.debug("Migration to configuration version 2 successful")
# 2.1 -> 2.2: backfill mac address and serial number for existing devices
entry_version_2 = 2
if config_entry.version == entry_version_2 and config_entry.minor_version == 1:
_LOGGER.debug("Migrating configuration from version 2.1")
if await _async_backfill_mac_and_sn(hass, config_entry):
if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 3):
hass.config_entries.async_update_entry(config_entry, minor_version=2)
else:
config_entry.minor_version = 2
hass.config_entries.async_update_entry(config_entry)
_LOGGER.debug("Migration to configuration version 2.2 successful")
else:
_LOGGER.debug(
"Device %s did not respond to discovery;"
" mac/serial number migration will be retried on next start",
config_entry.data.get(CONF_DEVICE_ID),
)
return True
async def _async_backfill_mac_and_sn(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> bool:
"""Best-effort discovery to backfill mac address and serial number.
Returns
-------
True if the entry already had the data, has no IP/device id to look up,
or was successfully updated. False if the device did not answer
discovery and the migration should be retried on a later start.
"""
if config_entry.data.get(CONF_TYPE) == CONF_ACCOUNT:
return True
if config_entry.data.get(CONF_MAC) or config_entry.data.get(CONF_SN):
return True
# Honor an IP override set via the options flow (e.g. after a DHCP change),
# mirroring async_setup_entry; the data IP may be stale.
ip_address = config_entry.options.get(CONF_IP_ADDRESS)
if ip_address is None:
ip_address = config_entry.data.get(CONF_IP_ADDRESS)
device_id = config_entry.data.get(CONF_DEVICE_ID)
if ip_address is None or device_id is None:
return True
try:
found_devices = await hass.async_add_executor_job(
lambda: discover(ip_address=ip_address),
)
except Exception:
# Best-effort migration: any discovery failure (socket error, malformed
# reply from a legacy device, parse error) must not break entry setup.
# Retry on the next start instead.
_LOGGER.debug(
"Discovery for device %s failed; mac/serial number migration"
" will be retried on next start",
device_id,
exc_info=True,
)
return False
device = found_devices.get(int(device_id))
if device is None:
return False
hass.config_entries.async_update_entry(
config_entry,
data={
**config_entry.data,
CONF_MAC: device.get(CONF_MAC),
CONF_SN: device.get(CONF_SN),
},
)
return True
async def _async_migrate_device_identifiers(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> None:
"""Migrate the device identifiers to the new format."""
device_registry = dr.async_get(hass)
device_entry_found = False
for device_entry in dr.async_entries_for_config_entry(
device_registry,
config_entry.entry_id,
):
for identifier in device_entry.identifiers:
# Device found in registry. Update identifiers.
new_identifiers = {
(
DOMAIN,
str(identifier[1]),
),
}
_LOGGER.debug(
"Migrating device identifiers from %s to %s",
device_entry.identifiers,
new_identifiers,
)
device_registry.async_update_device(
device_id=device_entry.id,
new_identifiers=new_identifiers,
)
# Device entry found. Leave inner for loop.
device_entry_found = True
break
# Leave outer for loop if device entry is already found.
if device_entry_found:
break
@@ -0,0 +1,56 @@
"""Binary sensor for Midea Lan."""
from typing import cast
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_SENSORS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DEVICES, DOMAIN
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors for device."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_sensors = config_entry.options.get(CONF_SENSORS, [])
binary_sensors = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if config["type"] != Platform.BINARY_SENSOR or entity_key not in extra_sensors:
continue
required_attribute = config.get("required_attribute")
if (
required_attribute is not None
and required_attribute not in device.attributes
):
continue
binary_sensors.append(MideaBinarySensor(device, entity_key))
async_add_entities(binary_sensors)
class MideaBinarySensor(MideaEntity, BinarySensorEntity):
"""Represent a Midea binary sensor."""
@property
def device_class(self) -> BinarySensorDeviceClass | None:
"""Device class of the binary sensor."""
return cast("BinarySensorDeviceClass", self._config.get("device_class"))
@property
def is_on(self) -> bool:
"""Whether the binary sensor is on."""
return cast("bool", self._device.get_attribute(self._entity_key))
@@ -0,0 +1,102 @@
"""Button for Midea Lan."""
from typing import TYPE_CHECKING, Any, cast
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_SWITCHES, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from midealan.device import MideaDevice
from .const import DEVICES, DOMAIN, supports_model
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
if TYPE_CHECKING:
from midealan.devices.e1 import MideaE1Device
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up buttons for device."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_switches = config_entry.options.get(CONF_SWITCHES, [])
buttons = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if (
config["type"] == Platform.BUTTON
and supports_model(device.model, config)
and (config.get("default") or entity_key in extra_switches)
):
buttons.append(MideaButton(device, entity_key))
async_add_entities(buttons)
class MideaButton(MideaEntity, ButtonEntity):
"""Represent a Midea button."""
def __init__(self, device: MideaDevice, entity_key: str) -> None:
"""Initialize Midea button."""
super().__init__(device, entity_key)
self._power_attribute = self._config.get("available_power_attribute")
@property
def available(self) -> bool:
"""Whether the entity is available."""
if not super().available:
return False
if self._power_attribute and not self._device.get_attribute(
self._power_attribute,
):
return False
return self._get_current_mode_code() not in {None, 0}
def press(self) -> None:
"""Press button."""
if self._config.get("set_message") == "e1_start":
self._press_e1_start()
def _press_e1_start(self) -> None:
"""Start E1 dishwasher using the current mode.
Raises
------
ValueError
If the current dishwasher mode is not supported.
"""
mode = self._get_current_mode_code()
if mode is None or mode == 0:
msg = "Unsupported dishwasher mode"
raise ValueError(msg)
cast("MideaE1Device", self._device).start_work()
def _get_current_mode_code(self) -> int | None:
"""Return raw code for the current dishwasher mode.
Returns
-------
Raw mode code when the current mode is known.
"""
mode_name = self._device.get_attribute("mode")
modes = cast("MideaE1Device", self._device).modes
for key, item in modes.items():
if item == mode_name:
return key
return None
@callback
def update_state(self, status: Any) -> None: # ruff:ignore[any-type]
"""Update entity state."""
super().update_state(status)
if self.hass and ("mode" in status or self._power_attribute in status):
self.schedule_update_if_running()
@@ -0,0 +1,947 @@
"""Midea Climate entries."""
import json
import logging
from typing import Any, ClassVar, cast
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
PRESET_AWAY,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
PRESET_NONE,
PRESET_SLEEP,
SWING_BOTH,
SWING_HORIZONTAL,
SWING_OFF,
SWING_ON,
SWING_VERTICAL,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_CUSTOMIZE,
CONF_DEVICE_ID,
CONF_SWITCHES,
MAJOR_VERSION,
MINOR_VERSION,
PRECISION_HALVES,
PRECISION_WHOLE,
Platform,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from midealan.device import DeviceType
from midealan.devices.ac import DeviceAttributes as ACAttributes
from midealan.devices.ac import MideaACDevice
from midealan.devices.c3 import DeviceAttributes as C3Attributes
from midealan.devices.c3 import MideaC3Device
from midealan.devices.cc import DeviceAttributes as CCAttributes
from midealan.devices.cc import MideaCCDevice
from midealan.devices.cf import DeviceAttributes as CFAttributes
from midealan.devices.cf import MideaCFDevice
from midealan.devices.fb import DeviceAttributes as FBAttributes
from midealan.devices.fb import MideaFBDevice
from .const import DEVICES, DOMAIN, FanSpeed
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
_LOGGER = logging.getLogger(__name__)
TEMPERATURE_MAX = 30
TEMPERATURE_MIN = 16
FAN_SILENT = "silent"
FAN_FULL_SPEED = "full"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up climate entries."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_switches = config_entry.options.get(CONF_SWITCHES, [])
devs: list[
MideaACClimate
| MideaCCClimate
| MideaCFClimate
| MideaC3Climate
| MideaFBClimate
] = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if config["type"] == Platform.CLIMATE and (
config.get("default") or entity_key in extra_switches
):
if device.device_type == DeviceType.AC:
# add config_entry args to fix indoor_humidity error bug
devs.append(MideaACClimate(device, entity_key, config_entry))
elif device.device_type == DeviceType.CC:
devs.append(MideaCCClimate(device, entity_key))
elif device.device_type == DeviceType.CF:
devs.append(MideaCFClimate(device, entity_key))
elif device.device_type == DeviceType.C3:
devs.append(MideaC3Climate(device, entity_key, config["zone"]))
elif device.device_type == DeviceType.FB:
devs.append(MideaFBClimate(device, entity_key))
async_add_entities(devs)
type MideaClimateDevice = (
MideaACDevice | MideaCCDevice | MideaCFDevice | MideaC3Device | MideaFBDevice
)
class MideaClimate(MideaEntity, ClimateEntity):
"""Midea Climate Entries Base Class."""
# https://developers.home-assistant.io/blog/2024/01/24/climate-climateentityfeatures-expanded
_enable_turn_on_off_backwards_compatibility: bool = (
False # maybe remove after 2025.1
)
_device: MideaClimateDevice
_attr_max_temp: float = TEMPERATURE_MAX
_attr_min_temp: float = TEMPERATURE_MIN
_attr_target_temperature_high: float | None = TEMPERATURE_MAX
_attr_target_temperature_low: float | None = TEMPERATURE_MIN
_attr_temperature_unit: str = UnitOfTemperature.CELSIUS
def __init__(self, device: MideaClimateDevice, entity_key: str) -> None:
"""Midea Climate entity init."""
super().__init__(device, entity_key)
@property
def supported_features(self) -> ClimateEntityFeature:
"""Midea Climate supported features."""
features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.SWING_MODE
)
if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 2):
features |= ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
return features
@property
def hvac_mode(self) -> HVACMode:
"""Midea Climate hvac mode."""
if self._device.get_attribute("power"):
mode = cast("int", self._device.get_attribute("mode"))
# Guard against an out-of-range/undefined device mode so a malformed
# frame cannot raise IndexError on every state render.
if 0 <= mode < len(self.hvac_modes):
return self.hvac_modes[mode]
return HVACMode.OFF
@property
def target_temperature(self) -> float:
"""Midea Climate target temperature."""
return cast("float", self._device.get_attribute("target_temperature"))
@property
def current_temperature(self) -> float | None:
"""Midea Climate current temperature."""
return cast("float | None", self._device.get_attribute("indoor_temperature"))
@property
def preset_mode(self) -> str:
"""Midea Climate preset mode."""
if self._device.get_attribute("comfort_mode"):
mode = PRESET_COMFORT
elif self._device.get_attribute("eco_mode"):
mode = PRESET_ECO
elif self._device.get_attribute("boost_mode"):
mode = PRESET_BOOST
elif self._device.get_attribute("sleep_mode"):
mode = PRESET_SLEEP
elif self._device.get_attribute("frost_protect"):
mode = PRESET_AWAY
else:
mode = PRESET_NONE
return str(mode)
@property
def extra_state_attributes(self) -> dict:
"""Midea Climate extra state attributes."""
return cast("dict", self._device.attributes)
def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Climate turn on."""
self._device.set_attribute(attr="power", value=True)
def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Climate turn off."""
self._device.set_attribute(attr="power", value=False)
def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type]
"""Midea Climate set temperature."""
if ATTR_TEMPERATURE not in kwargs:
return
temperature = float(int((float(kwargs[ATTR_TEMPERATURE]) * 2) + 0.5)) / 2
hvac_mode = kwargs.get(ATTR_HVAC_MODE)
if hvac_mode == HVACMode.OFF:
self.turn_off()
else:
try:
mode = self.hvac_modes.index(hvac_mode.lower()) if hvac_mode else None
self._device.set_target_temperature(
target_temperature=temperature,
mode=mode,
zone=None,
)
except ValueError:
_LOGGER.exception("Error setting temperature with: %s", kwargs)
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Midea Climate set hvac mode."""
if hvac_mode == HVACMode.OFF:
self.turn_off()
else:
self._device.set_attribute(
attr="mode",
value=self.hvac_modes.index(hvac_mode),
)
def set_preset_mode(self, preset_mode: str) -> None:
"""Midea Climate set preset mode."""
old_mode = self.preset_mode
preset_mode = preset_mode.lower()
if preset_mode == PRESET_AWAY:
self._device.set_attribute(attr="frost_protect", value=True)
elif preset_mode == PRESET_COMFORT:
self._device.set_attribute(attr="comfort_mode", value=True)
elif preset_mode == PRESET_SLEEP:
self._device.set_attribute(attr="sleep_mode", value=True)
elif preset_mode == PRESET_ECO:
self._device.set_attribute(attr="eco_mode", value=True)
elif preset_mode == PRESET_BOOST:
self._device.set_attribute(attr="boost_mode", value=True)
elif old_mode == PRESET_AWAY:
self._device.set_attribute(attr="frost_protect", value=False)
elif old_mode == PRESET_COMFORT:
self._device.set_attribute(attr="comfort_mode", value=False)
elif old_mode == PRESET_SLEEP:
self._device.set_attribute(attr="sleep_mode", value=False)
elif old_mode == PRESET_ECO:
self._device.set_attribute(attr="eco_mode", value=False)
elif old_mode == PRESET_BOOST:
self._device.set_attribute(attr="boost_mode", value=False)
def update_state(self, status: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Climate update state."""
if not self.hass:
_LOGGER.warning(
"Climate update_state skipped for %s [%s]: HASS is None",
self.name,
type(self),
)
return
self.schedule_update_if_running()
class MideaACClimate(MideaClimate):
"""Midea AC Climate Entries."""
_device: MideaACDevice
def __init__(
self,
device: MideaACDevice,
entity_key: str,
config_entry: ConfigEntry,
) -> None:
"""Midea AC Climate entity init."""
super().__init__(device, entity_key)
# fixed positional map: device "mode" int -> HVACMode. DO NOT reorder,
# the device mode value indexes into this list.
self._mode_index = [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.COOL,
HVACMode.DRY,
HVACMode.HEAT,
HVACMode.FAN_ONLY,
]
self._attr_hvac_modes = list(self._mode_index)
# customize overrides parsed once; B5 capabilities read dynamically
# (they arrive after the first refresh, so hvac_modes/supported_features
# are computed lazily — priority: customize > B5 > defaults)
self._customize_swing: bool | None = None
self._customize_hvac_modes: list[HVACMode] | None = None
self._customize_preset_modes: list[str] | None = None
self._parse_capability_customize(config_entry)
self._fan_speeds: dict[str, int] = {
FAN_SILENT: 20,
FAN_LOW: 40,
FAN_MEDIUM: 60,
FAN_HIGH: 80,
FAN_FULL_SPEED: 100,
FAN_AUTO: 102,
}
self._attr_swing_modes: list[str] = [
SWING_OFF,
SWING_VERTICAL,
SWING_HORIZONTAL,
SWING_BOTH,
]
self._attr_preset_modes = [
PRESET_NONE,
PRESET_COMFORT,
PRESET_ECO,
PRESET_BOOST,
PRESET_SLEEP,
PRESET_AWAY,
]
self._attr_fan_modes = list(self._fan_speeds.keys())
# fix error humidity value, disable indoor_humidity
# add config_entry args to fix indoor_humidity error bug
self._indoor_humidity_enabled = (
"sensors" in config_entry.options
and "indoor_humidity" in config_entry.options["sensors"]
)
def _parse_capability_customize(self, config_entry: ConfigEntry) -> None:
"""Parse the swing / hvac_modes customize overrides (highest priority).
``{"swing": false, "hvac_modes": ["off", "cool", "dry", "fan_only"]}``
"""
customize = config_entry.options.get(CONF_CUSTOMIZE, "")
if not customize:
return
try:
params = json.loads(customize)
except (ValueError, TypeError):
return
if not isinstance(params, dict):
return
if "swing" in params:
self._customize_swing = bool(params["swing"])
modes = params.get("hvac_modes")
if isinstance(modes, list):
valid = {mode.value: mode for mode in self._mode_index}
wanted: list[HVACMode] = []
for name in modes:
hvac = valid.get(str(name))
if hvac is not None and hvac not in wanted:
wanted.append(hvac)
if HVACMode.OFF not in wanted:
wanted.insert(0, HVACMode.OFF)
if any(hvac != HVACMode.OFF for hvac in wanted):
self._customize_hvac_modes = wanted
presets = params.get("preset_modes")
if isinstance(presets, list):
valid_presets = {
PRESET_NONE,
PRESET_COMFORT,
PRESET_ECO,
PRESET_BOOST,
PRESET_SLEEP,
PRESET_AWAY,
}
wanted_p: list[str] = []
for name in presets:
preset = str(name)
if preset in valid_presets and preset not in wanted_p:
wanted_p.append(preset)
if PRESET_NONE not in wanted_p:
wanted_p.insert(0, PRESET_NONE)
# honor even an empty / none-only list (user wants no presets)
self._customize_preset_modes = wanted_p
def _capability_swing(self) -> bool:
"""Whether swing is available (customize > B5 capability > default).
Returns:
``True`` when the swing control should be exposed.
"""
if self._customize_swing is not None:
return self._customize_swing
caps = getattr(self._device, "capabilities", {})
if "swing_vertical" in caps or "swing_horizontal" in caps:
return bool(caps.get("swing_vertical") or caps.get("swing_horizontal"))
return True
@property
def hvac_modes(self) -> list[HVACMode]:
"""hvac_modes: customize > B5 capabilities > default full set.
Read dynamically so capabilities decoded after the first refresh are
reflected (they are not yet available when the entity is created).
"""
if self._customize_hvac_modes is not None:
return self._customize_hvac_modes
caps = getattr(self._device, "capabilities", {})
if not caps:
return list(self._mode_index)
modes = [HVACMode.OFF]
if caps.get("auto_mode"):
modes.append(HVACMode.AUTO)
if caps.get("cool_mode"):
modes.append(HVACMode.COOL)
if caps.get("dry_mode"):
modes.append(HVACMode.DRY)
if caps.get("heat_mode"):
modes.append(HVACMode.HEAT)
# fan-only is always available on AC devices
modes.append(HVACMode.FAN_ONLY)
return modes
@property
def preset_modes(self) -> list[str]:
"""preset_modes: customize > B5 capabilities > default full set.
B5 only declares eco and turbo (and heat implies away). comfort and
sleep have no capability flag, so they are kept unless overridden via
the customize ``preset_modes`` option.
"""
all_presets = [
PRESET_NONE,
PRESET_COMFORT,
PRESET_ECO,
PRESET_BOOST,
PRESET_SLEEP,
PRESET_AWAY,
]
if self._customize_preset_modes is not None:
return self._customize_preset_modes
caps = getattr(self._device, "capabilities", {})
if not caps:
return all_presets
keep = {
PRESET_NONE: True,
PRESET_COMFORT: True,
PRESET_ECO: bool(caps.get("eco")),
PRESET_BOOST: bool(caps.get("turbo_cool") or caps.get("turbo_heat")),
PRESET_SLEEP: True,
PRESET_AWAY: bool(caps.get("heat_mode")),
}
return [preset for preset in all_presets if keep[preset]]
@property
def supported_features(self) -> ClimateEntityFeature:
"""Midea AC Climate supported features."""
features = super().supported_features
if not self._capability_swing():
features &= ~ClimateEntityFeature.SWING_MODE
# drop the preset control entirely when no real preset is available
if not any(preset != PRESET_NONE for preset in self.preset_modes):
features &= ~ClimateEntityFeature.PRESET_MODE
return features
@property
def hvac_mode(self) -> HVACMode:
"""Midea AC Climate hvac mode (device mode int -> fixed map)."""
if self._device.get_attribute("power"):
mode = cast("int", self._device.get_attribute("mode"))
# The AC `mode` field is a 3-bit value (0-7) but `_mode_index` only
# maps 0-5; guard so an out-of-spec 6/7 cannot raise IndexError.
if 0 <= mode < len(self._mode_index):
return self._mode_index[mode]
return HVACMode.OFF
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Midea AC Climate set hvac mode (via the fixed map index)."""
if hvac_mode == HVACMode.OFF:
self.turn_off()
else:
self._device.set_attribute(
attr="mode",
value=self._mode_index.index(hvac_mode),
)
def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type]
"""Midea AC Climate set temperature (raw mode via the fixed map).
``hvac_modes`` is a filtered subset here, so the raw protocol mode must
come from the fixed ``_mode_index`` and not from the displayed list.
"""
if ATTR_TEMPERATURE not in kwargs:
return
temperature = float(int((float(kwargs[ATTR_TEMPERATURE]) * 2) + 0.5)) / 2
hvac_mode = kwargs.get(ATTR_HVAC_MODE)
if hvac_mode == HVACMode.OFF:
self.turn_off()
else:
try:
mode = self._mode_index.index(hvac_mode.lower()) if hvac_mode else None
self._device.set_target_temperature(
target_temperature=temperature,
mode=mode,
zone=None,
)
except ValueError:
_LOGGER.exception("Error setting temperature with: %s", kwargs)
@property
def fan_mode(self) -> str:
"""Midea AC Climate fan mode."""
fan_speed = cast("int", self._device.get_attribute(ACAttributes.fan_speed))
if fan_speed > FanSpeed.AUTO:
return str(FAN_AUTO)
if fan_speed > FanSpeed.FULL_SPEED:
return str(FAN_FULL_SPEED)
if fan_speed > FanSpeed.HIGH:
return str(FAN_HIGH)
if fan_speed > FanSpeed.MEDIUM:
return str(FAN_MEDIUM)
if fan_speed > FanSpeed.LOW:
return str(FAN_LOW)
return str(FAN_SILENT)
@property
def fan_modes(self) -> list[str] | None:
"""fan_modes: B5 capabilities > default full set.
Read dynamically so capabilities decoded after the first refresh are
reflected (they are not yet available when the entity is created).
The B5 ``b5_wind_speed`` byte is a single capability profile (1-7), not
a bitmask: value 1 (``fan_custom``) means the fan is stepless/inverter
and accepts every speed, so all discrete modes are valid. Mapping
``fan_custom`` to ``full`` alone collapsed the list to ``["full"]`` on
such units (issue #904); short-circuit to the full set instead.
"""
caps = getattr(self._device, "capabilities", {})
if not caps:
return list(self._fan_speeds.keys())
# stepless/inverter fan: expose every discrete speed, not just "full"
if caps.get("fan_custom"):
return list(self._fan_speeds.keys())
cap_by_fan = {
FAN_SILENT: "fan_silent",
FAN_LOW: "fan_low",
FAN_MEDIUM: "fan_medium",
FAN_HIGH: "fan_high",
FAN_FULL_SPEED: "fan_custom",
FAN_AUTO: "fan_auto",
}
modes = [
name for name in self._fan_speeds if caps.get(cap_by_fan.get(name, ""))
]
return modes or list(self._fan_speeds.keys())
@property
def target_temperature_step(self) -> float:
"""Midea AC Climate target temperature step."""
return float(
PRECISION_WHOLE if self._device.temperature_step == 1 else PRECISION_HALVES,
)
@property
def swing_mode(self) -> str:
"""Midea AC Climate swing mode."""
swing_mode = (
1 if self._device.get_attribute(ACAttributes.swing_vertical) else 0
) + (2 if self._device.get_attribute(ACAttributes.swing_horizontal) else 0)
return self._attr_swing_modes[swing_mode]
@property
def current_humidity(self) -> float | None:
"""Current indoor humidity, or None if unavailable."""
# fix error humidity, disable indoor_humidity in web UI
# https://github.com/wuwentao/midea_ac_lan/pull/641
if not self._indoor_humidity_enabled:
return None
raw = self._device.get_attribute("indoor_humidity")
if isinstance(raw, (int, float)) and raw not in {0, 0xFF}:
return float(raw)
# indoor_humidity is 0 or 255, return None
# https://github.com/wuwentao/midea_ac_lan/pull/614
return None
@property
def outdoor_temperature(self) -> float:
"""Midea AC Climate outdoor temperature."""
return cast(
"float",
self._device.get_attribute(ACAttributes.outdoor_temperature),
)
@property
def min_temp(self) -> float:
"""Midea AC Climate min temperature (from device capability)."""
value = self._device.get_attribute(ACAttributes.min_temperature)
if value is None:
return TEMPERATURE_MIN
return cast("float", value)
@property
def max_temp(self) -> float:
"""Midea AC Climate max temperature (from device capability)."""
value = self._device.get_attribute(ACAttributes.max_temperature)
if value is None:
return TEMPERATURE_MAX
return cast("float", value)
def set_fan_mode(self, fan_mode: str) -> None:
"""Midea AC Climate set fan mode."""
fan_speed = self._fan_speeds.get(fan_mode)
if fan_speed:
self._device.set_attribute(attr=ACAttributes.fan_speed, value=fan_speed)
def set_swing_mode(self, swing_mode: str) -> None:
"""Midea AC Climate set swing mode."""
swing = self._attr_swing_modes.index(swing_mode)
swing_vertical = swing & 1 > 0
swing_horizontal = swing & 2 > 0
self._device.set_swing(
swing_vertical=swing_vertical,
swing_horizontal=swing_horizontal,
)
class MideaCCClimate(MideaClimate):
"""Midea CC Climate Entries Base Class."""
_device: MideaCCDevice
def __init__(self, device: MideaCCDevice, entity_key: str) -> None:
"""Midea CC Climate entity init."""
super().__init__(device, entity_key)
self._attr_hvac_modes = [
HVACMode.OFF,
HVACMode.FAN_ONLY,
HVACMode.DRY,
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.AUTO,
]
self._attr_swing_modes = [SWING_OFF, SWING_ON]
self._attr_preset_modes = [PRESET_NONE, PRESET_SLEEP, PRESET_ECO]
@property
def fan_modes(self) -> list[str] | None:
"""Midea CC Climate fan modes."""
return cast("list", self._device.fan_modes)
@property
def fan_mode(self) -> str:
"""Midea CC Climate fan mode."""
return cast("str", self._device.get_attribute(CCAttributes.fan_speed))
@property
def target_temperature_step(self) -> float:
"""Midea CC Climate target temperature step."""
return cast(
"float",
self._device.get_attribute(CCAttributes.temperature_precision),
)
@property
def swing_mode(self) -> str:
"""Midea CC Climate swing mode."""
return str(
SWING_ON if self._device.get_attribute(CCAttributes.swing) else SWING_OFF,
)
def set_fan_mode(self, fan_mode: str) -> None:
"""Midea CC Climate set fan mode."""
self._device.set_attribute(attr=CCAttributes.fan_speed, value=fan_mode)
def set_swing_mode(self, swing_mode: str) -> None:
"""Midea CC Climate set swing mode."""
self._device.set_attribute(
attr=CCAttributes.swing,
value=swing_mode == SWING_ON,
)
class MideaCFClimate(MideaClimate):
"""Midea CF Climate Entries."""
_device: MideaCFDevice
_attr_target_temperature_step: float | None = PRECISION_WHOLE
def __init__(self, device: MideaCFDevice, entity_key: str) -> None:
"""Midea CF Climate entity init."""
super().__init__(device, entity_key)
self._attr_hvac_modes = [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.COOL,
HVACMode.HEAT,
]
@property
def supported_features(self) -> ClimateEntityFeature:
"""Midea CF Climate supported features."""
features = ClimateEntityFeature.TARGET_TEMPERATURE
if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 2):
features |= ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
return features
@property
def min_temp(self) -> float:
"""Midea CF Climate min temperature."""
return cast("float", self._device.get_attribute(CFAttributes.min_temperature))
@property
def max_temp(self) -> float:
"""Midea CF Climate max temperature."""
return cast("float", self._device.get_attribute(CFAttributes.max_temperature))
@property
def target_temperature_low(self) -> float:
"""Midea CF Climate target temperature."""
return cast("float", self._device.get_attribute(CFAttributes.min_temperature))
@property
def target_temperature_high(self) -> float:
"""Midea CF Climate target temperature high."""
return cast("float", self._device.get_attribute(CFAttributes.max_temperature))
@property
def current_temperature(self) -> float:
"""Midea CF Climate current temperature."""
return cast(
"float",
self._device.get_attribute(CFAttributes.current_temperature),
)
class MideaC3Climate(MideaClimate):
"""Midea C3 Climate Entries."""
_device: MideaC3Device
_powers: ClassVar[list[C3Attributes]] = [
C3Attributes.zone1_power,
C3Attributes.zone2_power,
]
def __init__(self, device: MideaC3Device, entity_key: str, zone: int) -> None:
"""Midea C3 Climate entity init."""
super().__init__(device, entity_key)
self._zone = zone
self._attr_hvac_modes = [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.COOL,
HVACMode.HEAT,
]
self._power_attr = MideaC3Climate._powers[zone]
def _temperature(self, minimum: bool) -> list[str]:
"""Midea C3 Climate temperature.
Returns
-------
List of temperatures
"""
# fmt: off
value = (C3Attributes.temperature_min
if minimum
else C3Attributes.temperature_max)
# fmt: on
return cast("list[str]", self._device.get_attribute(value))
@property
def supported_features(self) -> ClimateEntityFeature:
"""Midea C3 Climate supported features."""
features = ClimateEntityFeature.TARGET_TEMPERATURE
if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 2):
features |= ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
return features
@property
def target_temperature_step(self) -> float:
"""Midea C3 Climate target temperature step."""
zone_temp_type = cast(
"list[str]",
self._device.get_attribute(C3Attributes.zone_temp_type),
)
return float(
PRECISION_WHOLE if zone_temp_type[self._zone] else PRECISION_HALVES,
)
@property
def min_temp(self) -> float:
"""Midea C3 Climate min temperature."""
return cast(
"float",
self._temperature(True)[self._zone],
)
@property
def max_temp(self) -> float:
"""Midea C3 Climate max temperature."""
return cast(
"float",
self._temperature(False)[self._zone],
)
@property
def target_temperature_low(self) -> float:
"""Midea C3 Climate target temperature low."""
return cast(
"float",
self._temperature(True)[self._zone],
)
@property
def target_temperature_high(self) -> float:
"""Midea C3 Climate target temperature high."""
return cast(
"float",
self._temperature(False)[self._zone],
)
def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea C3 Climate turn on."""
self._device.set_attribute(attr=self._power_attr, value=True)
def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea C3 Climate turn off."""
self._device.set_attribute(attr=self._power_attr, value=False)
@property
def hvac_mode(self) -> HVACMode:
"""Midea C3 Climate hvac mode."""
mode = self._device.get_attribute(C3Attributes.mode)
if (
self._device.get_attribute(self._power_attr)
and isinstance(mode, int)
and 0 <= mode < len(self.hvac_modes)
):
return self.hvac_modes[mode]
return HVACMode.OFF
@property
def target_temperature(self) -> float:
"""Midea C3 Climate target temperature."""
target_temperature = cast(
"list[str]",
self._device.get_attribute(C3Attributes.target_temperature),
)
return cast(
"float",
target_temperature[self._zone],
)
@property
def current_temperature(self) -> float | None:
"""Midea C3 Climate current temperature."""
return None
def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type]
"""Midea C3 Climate set temperature."""
if ATTR_TEMPERATURE not in kwargs:
return
temperature = float(int((float(kwargs[ATTR_TEMPERATURE]) * 2) + 0.5)) / 2
hvac_mode = kwargs.get(ATTR_HVAC_MODE)
if hvac_mode == HVACMode.OFF:
self.turn_off()
else:
try:
mode = self.hvac_modes.index(hvac_mode.lower()) if hvac_mode else None
self._device.set_target_temperature(
target_temperature=temperature,
mode=mode,
zone=self._zone,
)
except ValueError:
_LOGGER.exception("Error setting temperature with: %s", kwargs)
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Midea C3 Climate set hvac mode."""
if hvac_mode == HVACMode.OFF:
self.turn_off()
else:
self._device.set_mode(self._zone, self.hvac_modes.index(hvac_mode))
class MideaFBClimate(MideaClimate):
"""Midea FB Climate Entries."""
_device: MideaFBDevice
_attr_max_temp: float = 35
_attr_min_temp: float = 5
_attr_target_temperature_high: float | None = 35
_attr_target_temperature_low: float | None = 5
_attr_target_temperature_step: float | None = PRECISION_WHOLE
def __init__(self, device: MideaFBDevice, entity_key: str) -> None:
"""Midea FB Climate entity init."""
super().__init__(device, entity_key)
self._attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
self._attr_preset_modes: list[str] = self._device.modes
@property
def supported_features(self) -> ClimateEntityFeature:
"""Midea FB Climate supported features."""
features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
)
if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 2):
features |= ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
return features
@property
def preset_mode(self) -> str:
"""Midea FB Climate preset mode."""
return cast("str", self._device.get_attribute(attr=FBAttributes.mode))
@property
def hvac_mode(self) -> HVACMode:
"""Midea FB Climate hvac mode."""
return (
HVACMode.HEAT
if self._device.get_attribute(attr=FBAttributes.power)
else HVACMode.OFF
)
@property
def current_temperature(self) -> float:
"""Midea FB Climate current temperature."""
return cast(
"float",
self._device.get_attribute(FBAttributes.current_temperature),
)
def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type]
"""Midea FB Climate set temperature."""
if ATTR_TEMPERATURE not in kwargs:
return
temperature = float(int((float(kwargs[ATTR_TEMPERATURE]) * 2) + 0.5)) / 2
hvac_mode = kwargs.get(ATTR_HVAC_MODE)
if hvac_mode == HVACMode.OFF:
self.turn_off()
else:
self._device.set_target_temperature(
target_temperature=temperature,
mode=None,
)
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Midea FB Climate set hvac mode."""
if hvac_mode == HVACMode.OFF:
self.turn_off()
else:
self.turn_on()
def set_preset_mode(self, preset_mode: str) -> None:
"""Midea FB Climate set preset mode."""
self._device.set_attribute(attr=FBAttributes.mode, value=preset_mode)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
"""Const for Midea Lan."""
from enum import IntEnum
from typing import Any, cast
from homeassistant.const import Platform
DOMAIN = "midea_ac_lan"
COMPONENT = "component"
DEVICES = "devices"
CONF_KEY = "key"
CONF_MODEL = "model"
CONF_SUBTYPE = "subtype"
CONF_ACCOUNT = "account"
CONF_SERVER = "server"
CONF_REFRESH_INTERVAL = "refresh_interval"
CONF_MAC = "mac"
CONF_SN = "sn"
EXTRA_SENSOR = [Platform.SENSOR, Platform.BINARY_SENSOR]
EXTRA_SWITCH = [
Platform.SWITCH,
Platform.LOCK,
Platform.SELECT,
Platform.NUMBER,
Platform.TIME,
]
EXTRA_CONTROL = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.WATER_HEATER,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.LIGHT,
*EXTRA_SWITCH,
]
ALL_PLATFORM = EXTRA_SENSOR + EXTRA_CONTROL
def supports_model(model: object, config: dict[str, Any]) -> bool:
"""Return if the entity config applies to the device model.
Returns
-------
True if the entity is available for the device model.
"""
models = config.get("models")
return not models or str(model) in cast("list[str]", models)
class FanSpeed(IntEnum):
"""FanSpeed reference values."""
LOW = 20
MEDIUM = 40
HIGH = 60
FULL_SPEED = 80
AUTO = 100
@@ -0,0 +1,32 @@
"""Diagnostics support for Midea AC LAN."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import (
CONF_TOKEN,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import CONF_KEY
TO_REDACT = {CONF_TOKEN, CONF_KEY}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, # ruff:ignore[unused-function-argument]
entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry.
Returns
-------
Dictionary of config
"""
return {"entry": async_redact_data(entry.as_dict(), TO_REDACT)}
@@ -0,0 +1,381 @@
"""Midea Fan entries."""
import logging
from typing import Any, cast
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_SWITCHES,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from midealan.device import DeviceType
from midealan.devices.ac import DeviceAttributes as ACAttributes
from midealan.devices.ac import MideaACDevice
from midealan.devices.b6 import MideaB6Device
from midealan.devices.ce import DeviceAttributes as CEAttributes
from midealan.devices.ce import MideaCEDevice
from midealan.devices.fa import MideaFADevice
from midealan.devices.x40 import DeviceAttributes as X40Attributes
from midealan.devices.x40 import MideaX40Device
from .const import DEVICES, DOMAIN
from .midea_devices import (
FRESH_AIR_EXHAUST,
FRESH_AIR_EXHAUST_MODE,
FRESH_AIR_EXHAUST_POWER,
FRESH_AIR_EXHAUST_SPEED,
MIDEA_DEVICES,
)
from .midea_entity import MideaEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up fan entries."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_switches = config_entry.options.get(CONF_SWITCHES, [])
devs: list[
MideaFAFan | MideaB6Fan | MideaACFreshAirFan | MideaCEFan | MideaX40Fan
] = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
required_attribute = config.get("required_attribute")
if (
required_attribute is not None
and required_attribute not in device.attributes
):
continue
if config["type"] == Platform.FAN and (
config.get("default") or entity_key in extra_switches
):
if device.device_type == DeviceType.FA:
devs.append(MideaFAFan(device, entity_key))
elif device.device_type == DeviceType.B6:
devs.append(MideaB6Fan(device, entity_key))
elif device.device_type == DeviceType.AC:
devs.append(MideaACFreshAirFan(device, entity_key))
elif device.device_type == DeviceType.CE:
devs.append(MideaCEFan(device, entity_key))
elif device.device_type == DeviceType.X40:
devs.append(MideaX40Fan(device, entity_key))
async_add_entities(devs)
# HA version >= 2024,8 support TURN_ON | TURN_OFF,for future changes, ref PR #285
try:
FAN_FEATURE_TURN_ON_OFF = FanEntityFeature["TURN_ON"] | FanEntityFeature["TURN_OFF"]
except KeyError:
FAN_FEATURE_TURN_ON_OFF = FanEntityFeature(0)
class MideaFan(MideaEntity, FanEntity):
"""Midea Fan Entries Base Class."""
_enable_turn_on_off_backwards_compatibility = False # 2024.8~2025.1
@property
def preset_modes(self) -> list[str] | None:
"""Midea Fan preset modes."""
return (
self._device.preset_modes if hasattr(self._device, "preset_modes") else None
)
@property
def is_on(self) -> bool:
"""Midea Fan is on."""
return cast("bool", self._device.get_attribute("power"))
@property
def oscillating(self) -> bool:
"""Midea Fan oscillating."""
return cast("bool", self._device.get_attribute("oscillate"))
@property
def preset_mode(self) -> str | None:
"""Midea Fan preset mode."""
return cast("str", self._device.get_attribute("mode"))
@property
def fan_speed(self) -> int | None:
"""Midea Fan fan speed."""
return cast("int", self._device.get_attribute("fan_speed"))
def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Fan turn off."""
self._device.set_attribute(attr="power", value=False)
def oscillate(self, oscillating: bool) -> None:
"""Midea Fan oscillate."""
self._device.set_attribute(attr="oscillate", value=oscillating)
def set_preset_mode(self, preset_mode: str) -> None:
"""Midea Fan set preset mode."""
# Pass the preset value through unchanged. `preset_modes` already
# returns the device's own strings and HA only ever hands one of them
# back, so the value is guaranteed valid. Do NOT `.capitalize()` it:
# that corrupts multi-word / mixed-case names such as the CE fan's
# "ECO mode" -> "Eco mode", which the device matches case-sensitively
# and silently drops, making the ECO preset unselectable.
self._device.set_attribute(attr="mode", value=preset_mode)
@property
def percentage(self) -> int | None:
"""Midea Fan percentage."""
if not self.fan_speed:
return None
return int(round(self.fan_speed * self.percentage_step)) # ruff:ignore[unnecessary-cast-to-int]
def set_percentage(self, percentage: int) -> None:
"""Midea Fan set percentage."""
fan_speed = round(percentage / self.percentage_step)
self._device.set_attribute(attr="fan_speed", value=fan_speed)
async def async_set_percentage(self, percentage: int) -> None:
"""Midea Fan async set percentage."""
if percentage == 0:
await self.async_turn_off()
else:
await self.hass.async_add_executor_job(self.set_percentage, percentage)
def update_state(self, status: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Fan update state."""
if not self.hass:
_LOGGER.warning(
"Fan update_state skipped for %s [%s]: HASS is None",
self.name,
type(self),
)
return
self.schedule_update_if_running()
class MideaFAFan(MideaFan):
"""Midea FA Fan Entries."""
_device: MideaFADevice
def __init__(self, device: MideaFADevice, entity_key: str) -> None:
"""Midea FA Fan entity init."""
super().__init__(device, entity_key)
self._attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.OSCILLATE
| FanEntityFeature.PRESET_MODE
| FAN_FEATURE_TURN_ON_OFF
)
self._attr_speed_count = self._device.speed_count
def turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any, # ruff:ignore[any-type, unused-method-argument]
) -> None:
"""Midea FA Fan turn on."""
fan_speed = int(percentage / self.percentage_step + 0.5) if percentage else None
self._device.turn_on(fan_speed=fan_speed, mode=preset_mode)
class MideaB6Fan(MideaFan):
"""Midea B6 Fan Entries."""
_device: MideaB6Device
def __init__(self, device: MideaB6Device, entity_key: str) -> None:
"""Midea B6 Fan entity init."""
super().__init__(device, entity_key)
self._attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
| FAN_FEATURE_TURN_ON_OFF
)
self._attr_speed_count = self._device.speed_count
def turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any, # ruff:ignore[any-type, unused-method-argument]
) -> None:
"""Midea B6 Fan turn on."""
fan_speed = int(percentage / self.percentage_step + 0.5) if percentage else None
self._device.turn_on(fan_speed=fan_speed, mode=preset_mode)
class MideaACFreshAirFan(MideaFan):
"""Midea AC Fresh Air Fan Entries."""
_device: MideaACDevice
def __init__(self, device: MideaACDevice, entity_key: str) -> None:
"""Midea AC Fresh Air Fan entity init."""
super().__init__(device, entity_key)
self._is_exhaust = entity_key == FRESH_AIR_EXHAUST
self._attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
| FAN_FEATURE_TURN_ON_OFF
)
self._attr_speed_count = 100
@property
def preset_modes(self) -> list[str] | None:
"""Midea AC Fan preset modes."""
if self._is_exhaust:
return cast(
"list[str]",
getattr(self._device, "fresh_air_exhaust_fan_speeds", []),
)
return cast("list", self._device.fresh_air_fan_speeds)
@property
def is_on(self) -> bool:
"""Midea AC Fan is on."""
attribute = (
FRESH_AIR_EXHAUST_POWER
if self._is_exhaust
else ACAttributes.fresh_air_power
)
return cast("bool", self._device.get_attribute(attribute))
@property
def fan_speed(self) -> int:
"""Midea AC Fan fan speed."""
attribute = (
FRESH_AIR_EXHAUST_SPEED
if self._is_exhaust
else ACAttributes.fresh_air_fan_speed
)
return cast("int", self._device.get_attribute(attribute))
def turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any, # ruff:ignore[any-type, unused-method-argument]
) -> None:
"""Midea AC Fan tun on."""
if preset_mode is not None:
self.set_preset_mode(preset_mode)
return
if percentage is not None:
self.set_percentage(percentage)
return
attribute = (
FRESH_AIR_EXHAUST_POWER
if self._is_exhaust
else ACAttributes.fresh_air_power
)
self._device.set_attribute(attr=attribute, value=True)
def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea AC Fan turn off."""
attribute = (
FRESH_AIR_EXHAUST_POWER
if self._is_exhaust
else ACAttributes.fresh_air_power
)
self._device.set_attribute(attr=attribute, value=False)
def set_percentage(self, percentage: int) -> None:
"""Midea AC Fan set percentage."""
fan_speed = int(percentage / self.percentage_step + 0.5)
attribute = (
FRESH_AIR_EXHAUST_SPEED
if self._is_exhaust
else ACAttributes.fresh_air_fan_speed
)
self._device.set_attribute(
attr=attribute,
value=fan_speed,
)
def set_preset_mode(self, preset_mode: str) -> None:
"""Midea AC Fan set preset mode."""
attribute = (
FRESH_AIR_EXHAUST_MODE if self._is_exhaust else ACAttributes.fresh_air_mode
)
self._device.set_attribute(attr=attribute, value=preset_mode)
@property
def preset_mode(self) -> str | None:
"""Midea AC Fan preset mode."""
attribute = (
FRESH_AIR_EXHAUST_MODE if self._is_exhaust else ACAttributes.fresh_air_mode
)
return cast("str", self._device.get_attribute(attr=attribute))
class MideaCEFan(MideaFan):
"""Midea CE Fan Entries."""
_device: MideaCEDevice
def __init__(self, device: MideaCEDevice, entity_key: str) -> None:
"""Midea CE Fan entity init."""
super().__init__(device, entity_key)
self._attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
| FAN_FEATURE_TURN_ON_OFF
)
self._attr_speed_count = self._device.speed_count
def turn_on(
self,
percentage: int | None = None, # ruff:ignore[unused-method-argument]
preset_mode: str | None = None, # ruff:ignore[unused-method-argument]
**kwargs: Any, # ruff:ignore[any-type, unused-method-argument]
) -> None:
"""Midea CE Fan turn on."""
self._device.set_attribute(attr=CEAttributes.power, value=True)
async def async_set_percentage(self, percentage: int) -> None:
"""Midea CE Fan async set percentage."""
await self.hass.async_add_executor_job(self.set_percentage, percentage)
class MideaX40Fan(MideaFan):
"""Midea X40 Fan Entries."""
_device: MideaX40Device
def __init__(self, device: MideaX40Device, entity_key: str) -> None:
"""Midea X40 Fan entity init."""
super().__init__(device, entity_key)
self._attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.OSCILLATE
| FAN_FEATURE_TURN_ON_OFF
)
self._attr_speed_count = 2
@property
def is_on(self) -> bool:
"""Midea X40 Fan is on."""
return cast("int", self._device.get_attribute(attr=X40Attributes.fan_speed)) > 0
def turn_on(
self,
percentage: int | None = None, # ruff:ignore[unused-method-argument]
preset_mode: str | None = None, # ruff:ignore[unused-method-argument]
**kwargs: Any, # ruff:ignore[any-type, unused-method-argument]
) -> None:
"""Midea X40 Fan turn on."""
self._device.set_attribute(attr=X40Attributes.fan_speed, value=1)
def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea X40 Fan turn off."""
self._device.set_attribute(attr=X40Attributes.fan_speed, value=0)
@@ -0,0 +1,140 @@
"""Midea Humidifier entries."""
import logging
from typing import Any, cast
from homeassistant.components.humidifier import (
HumidifierDeviceClass,
HumidifierEntity,
HumidifierEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_SWITCHES, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from midealan.device import DeviceType
from midealan.devices.a1 import MideaA1Device
from midealan.devices.fd import MideaFDDevice
from .const import DEVICES, DOMAIN
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up humidifier entries."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_switches = config_entry.options.get(CONF_SWITCHES, [])
devs: list[MideaA1Humidifier | MideaFDHumidifier] = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if config["type"] == Platform.HUMIDIFIER and (
config.get("default") or entity_key in extra_switches
):
if device.device_type == DeviceType.A1:
devs.append(MideaA1Humidifier(device, entity_key))
if device.device_type == DeviceType.FD:
devs.append(MideaFDHumidifier(device, entity_key))
async_add_entities(devs)
type MideaHumidifierDevice = MideaFDDevice | MideaA1Device
class MideaHumidifier(MideaEntity, HumidifierEntity):
"""Midea Humidifier Entries Base Class."""
_device: MideaHumidifierDevice
def __init__(self, device: MideaHumidifierDevice, entity_key: str) -> None:
"""Midea Humidifier entity init."""
super().__init__(device, entity_key)
@property
def current_humidity(self) -> float | None:
"""Midea Humidifier current humidity."""
return cast("float", self._device.get_attribute("current_humidity"))
@property
def target_humidity(self) -> float:
"""Midea Humidifier target humidity."""
return cast("float", self._device.get_attribute("target_humidity"))
@property
def mode(self) -> str:
"""Midea Humidifier mode."""
return cast("str", self._device.get_attribute("mode"))
@property
def available_modes(self) -> list[str] | None:
"""Midea Humidifier available modes."""
return cast("list", self._device.modes)
def set_humidity(self, humidity: int) -> None:
"""Midea Humidifier set humidity."""
self._device.set_attribute("target_humidity", humidity)
def set_mode(self, mode: str) -> None:
"""Midea Humidifier set mode."""
self._device.set_attribute("mode", mode)
@property
def is_on(self) -> bool:
"""Midea Humidifier is on."""
return cast("bool", self._device.get_attribute(attr="power"))
def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Humidifier turn on."""
self._device.set_attribute(attr="power", value=True)
def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Humidifier turn off."""
self._device.set_attribute(attr="power", value=False)
def update_state(self, status: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Humidifier update state."""
if not self.hass:
_LOGGER.warning(
"Humidifier update_state skipped for %s [%s]: HASS is None",
self.name,
type(self),
)
return
self.schedule_update_if_running()
class MideaA1Humidifier(MideaHumidifier):
"""Midea A1 Humidifier Entries."""
_device: MideaA1Device
def __init__(self, device: MideaA1Device, entity_key: str) -> None:
"""Midea A1 Humidifier entity init."""
super().__init__(device, entity_key)
self._attr_min_humidity: float = 35
self._attr_max_humidity: float = 85
self._attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER
self._attr_supported_features = HumidifierEntityFeature.MODES
class MideaFDHumidifier(MideaHumidifier):
"""Midea FD Humidifier Entries."""
_device: MideaFDDevice
def __init__(self, device: MideaFDDevice, entity_key: str) -> None:
"""Midea FD Humidifier entity init."""
super().__init__(device, entity_key)
self._attr_min_humidity: float = 35
self._attr_max_humidity: float = 85
self._attr_device_class = HumidifierDeviceClass.HUMIDIFIER
self._attr_supported_features = HumidifierEntityFeature.MODES
@@ -0,0 +1,16 @@
{
"entity": {
"climate": {
"climate_key": {
"state_attributes": {
"fan_mode": {
"state": {
"silent": "mdi:fan-clock",
"full": "mdi:fan-alert"
}
}
}
}
}
}
}
@@ -0,0 +1,198 @@
"""Midea Light entries."""
import logging
from typing import Any, cast
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ColorMode,
LightEntity,
LightEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_SWITCHES, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from midealan.devices.x13 import DeviceAttributes as X13Attributes
from midealan.devices.x13 import Midea13Device
from .const import DEVICES, DOMAIN
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up light entries."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_switches = config_entry.options.get(CONF_SWITCHES, [])
devs = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if config["type"] == Platform.LIGHT and (
config.get("default") or entity_key in extra_switches
):
devs.append(MideaLight(device, entity_key))
async_add_entities(devs)
def _calc_supported_features(device: Midea13Device) -> LightEntityFeature:
supported_features = LightEntityFeature(0)
if device.get_attribute(X13Attributes.effect):
supported_features |= LightEntityFeature.EFFECT
return supported_features
def _calc_supported_color_modes(device: Midea13Device) -> set[ColorMode]:
# https://github.com/home-assistant/core/blob/c34731185164aaf44419977c4086e9a7dd6c0a7f/homeassistant/components/light/__init__.py#L1278
supported = set[ColorMode]()
if device.get_attribute(X13Attributes.color_temperature) is not None:
supported.add(ColorMode.COLOR_TEMP)
if device.get_attribute(X13Attributes.rgb_color) is not None:
supported.add(ColorMode.HS)
if not supported and device.get_attribute(X13Attributes.brightness) is not None:
supported = {ColorMode.BRIGHTNESS}
if not supported:
supported = {ColorMode.ONOFF}
return supported
class MideaLight(MideaEntity, LightEntity):
"""Midea Light Entries."""
_device: Midea13Device
# NOTE: supported_features / supported_color_modes / color_mode are computed
# as properties (not latched in __init__). The device starts its socket
# refresh on a background thread, so at construction time brightness,
# color_temperature and rgb_color are all still None. Latching these in
# __init__ would lock a dimmable/color-temp light to ONOFF forever, because
# update_state never recomputes them. Recomputing per access reflects the
# capabilities once the first status arrives.
@property
def supported_features(self) -> LightEntityFeature:
"""Midea Light supported features."""
return _calc_supported_features(self._device)
@property
def supported_color_modes(self) -> set[ColorMode] | set[str] | None:
"""Midea Light supported color modes."""
return _calc_supported_color_modes(self._device)
@property
def color_mode(self) -> ColorMode | str | None:
"""Midea Light current color mode."""
# Call the concrete helper (returns set[ColorMode]) rather than the
# supported_color_modes property, whose type is widened to
# set[ColorMode] | set[str] | None to match LightEntity's base override.
return self._calc_color_mode(_calc_supported_color_modes(self._device))
def _calc_color_mode(self, supported: set[ColorMode]) -> ColorMode:
"""Midea Light calculate color mode.
Returns
-------
Calculated color mode
"""
# https://github.com/home-assistant/core/blob/c34731185164aaf44419977c4086e9a7dd6c0a7f/homeassistant/components/light/__init__.py#L925
if ColorMode.HS in supported and self.hs_color is not None:
return ColorMode.HS
if ColorMode.COLOR_TEMP in supported and self.color_temp_kelvin is not None:
return ColorMode.COLOR_TEMP
if ColorMode.BRIGHTNESS in supported and self.brightness is not None:
return ColorMode.BRIGHTNESS
if ColorMode.ONOFF in supported:
return ColorMode.ONOFF
return ColorMode.UNKNOWN
@property
def is_on(self) -> bool:
"""Midea Light is on."""
return cast("bool", self._device.get_attribute(X13Attributes.power))
@property
def brightness(self) -> int | None:
"""Midea Light brightness."""
return cast("int", self._device.get_attribute(X13Attributes.brightness))
@property
def rgb_color(self) -> tuple[int, int, int] | None:
"""Midea Light rgb color."""
return cast("tuple", self._device.get_attribute(X13Attributes.rgb_color))
@property
def color_temp(self) -> int | None:
"""Midea Light color temperature."""
if not self.color_temp_kelvin:
return None
return round(1000000 / self.color_temp_kelvin)
# https://developers.home-assistant.io/blog/2024/12/14/kelvin-preferred-color-temperature-unit
@property
def color_temp_kelvin(self) -> int | None:
"""Midea Light color temperature kelvin."""
return cast("int", self._device.get_attribute(X13Attributes.color_temperature))
@property
def min_color_temp_kelvin(self) -> int:
"""Midea Light min color temperature kelvin."""
return self._device.color_temp_range[0]
@property
def max_color_temp_kelvin(self) -> int:
"""Midea Light max color temperature kelvin."""
return self._device.color_temp_range[1]
@property
def effect_list(self) -> list[str] | None:
"""Midea Light effect list."""
return cast("list", self._device.effects)
@property
def effect(self) -> str | None:
"""Midea Light effect."""
return cast("str", self._device.get_attribute(X13Attributes.effect))
def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type]
"""Midea Light turn on."""
if not self.is_on:
self._device.set_attribute(attr=X13Attributes.power, value=True)
for key, value in kwargs.items():
if key == ATTR_BRIGHTNESS:
self._device.set_attribute(attr=X13Attributes.brightness, value=value)
if key == ATTR_COLOR_TEMP_KELVIN:
self._device.set_attribute(
attr=X13Attributes.color_temperature,
value=value,
)
if key == ATTR_EFFECT:
self._device.set_attribute(attr=X13Attributes.effect, value=value)
def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Light turn off."""
self._device.set_attribute(attr=X13Attributes.power, value=False)
def update_state(self, status: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Light update state."""
if not self.hass:
_LOGGER.warning(
"Light update_state skipped for %s [%s]: HASS is None",
self.name,
type(self),
)
return
self.schedule_update_if_running()
@@ -0,0 +1,54 @@
"""Lock entities for Midea Lan."""
from typing import Any, cast
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_SWITCHES, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DEVICES, DOMAIN
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entities for device."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_switches = config_entry.options.get(CONF_SWITCHES, [])
locks = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if config["type"] == Platform.LOCK and entity_key in extra_switches:
dev = MideaLock(device, entity_key)
locks.append(dev)
async_add_entities(locks)
class MideaLock(MideaEntity, LockEntity):
"""Represent a Midea lock entity."""
@property
def is_locked(self) -> bool:
"""Whether the lock is locked."""
return cast("bool", self._device.get_attribute(self._entity_key))
def lock(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Lock the lock."""
self._device.set_attribute(attr=self._entity_key, value=True)
def unlock(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Unlock the lock."""
self._device.set_attribute(attr=self._entity_key, value=False)
def open(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Open the lock."""
self.unlock()
@@ -0,0 +1,23 @@
{
"domain": "midea_ac_lan",
"name": "Midea AC LAN",
"codeowners": [
"@wuwentao",
"@rokam",
"@chemelli74",
"@caibinqing"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/wuwentao/midea_ac_lan#readme",
"integration_type": "device",
"iot_class": "local_push",
"issue_tracker": "https://github.com/wuwentao/midea_ac_lan/issues",
"loggers": [
"midealan"
],
"requirements": [
"midea-lan==2026.8.0"
],
"version": "2026.8.0"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
"""Base entity for Midea Lan."""
import logging
from typing import Any, cast
from homeassistant.const import MAJOR_VERSION, MINOR_VERSION
from homeassistant.core import callback
if (MAJOR_VERSION, MINOR_VERSION) >= (2023, 9):
from homeassistant.helpers.device_registry import DeviceInfo
else:
from homeassistant.helpers.entity import ( # type: ignore[attr-defined]
DeviceInfo,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.helpers.entity import Entity
from midealan.device import MideaDevice
from .const import DOMAIN
from .midea_devices import MIDEA_DEVICES
_LOGGER = logging.getLogger(__name__)
class MideaEntity(Entity):
"""Base Midea entity."""
def __init__(self, device: MideaDevice, entity_key: str) -> None:
"""Initialize Midea base entity."""
self._device = device
self._config = cast(
"dict",
MIDEA_DEVICES[self._device.device_type]["entities"],
)[entity_key]
self._entity_key = entity_key
self._unique_id = f"{DOMAIN}.{self._device.device_id}_{entity_key}"
# Build entity_id with the correct platform domain (sensor.*, switch.*, …)
# instead of the integration domain. Keeps the legacy "<device_id>_<key>"
# object_id so existing entity_ids are unchanged, while fixing the HA
# wrong-domain deprecation (breaks in HA 2027.5.0).
ha_domain = self._config["type"]
self.entity_id = f"{ha_domain}.{self._device.device_id}_{entity_key}"
self._device_name = self._device.name
# HA language setting:
# 1. hass.config.language: Settings / System / General settings
# 2. user language setting in user profile setting
# Entity name translation based on hass.config.language
# add language in /config/configuration.yaml will disable web UI setting
# homeassistant:
# language: zh-Hans # ruff:ignore[commented-out-code]
# Translating the name and attributes of entities:
# https://developers.home-assistant.io/blog/2023/03/27/entity_name_translations/#translating-entity-name
# https://developers.home-assistant.io/docs/internationalization/core
# translation_key: if defined, Home Assistant will try to find a
# translation in translations/<lang>.json.
# If translation exists -> UI shows the translated string.
# If translation not found -> fallback to "name" / device_class / entity_id.
self._attr_translation_key = self._config.get("translation_key")
# has_entity_name: MUST be True in modern HA (old False behavior is deprecated).
self._attr_has_entity_name = True
# Step 1: translation_key is defined
# - If translation is found in the current language:
# -> UI displays the translated string.
if self._attr_translation_key is not None:
# skip set attr_name and use translation_key
pass
# set attr_name to None will only show device name without translaion_key
# Step 2: No translation_key
# but english "name" is explicitly set in config:
# -> UI displays this name directly (highest priority).
elif self._config.get("name") is not None:
self._attr_name = self._config["name"]
# Step 3: No translation_key, no name,
# fallback to device_class default label.
# Example: device_class = temperature -> "Temperature".
elif "device_class" in self._config:
self._attr_name = None # Let HA generate from device_class
# Step 4: Nothing available (no translation_key, no name, no
# device_class). With has_entity_name=True HA already prepends the
# device name, so the entity's own name must be None — the entity then
# represents the device's main feature and shows just the device name.
# (The previous fallback embedded the device name itself, yielding a
# doubled "DeviceName DeviceName", or "DeviceName None" when a null
# "name" key was present.)
else:
self._attr_name = None
@property
def device(self) -> MideaDevice:
"""Underlying Midea device instance."""
return self._device
@property
def device_info(self) -> DeviceInfo:
"""Device registry info for the entity."""
info: DeviceInfo = {
"manufacturer": "Midea",
"model": f"{MIDEA_DEVICES[self._device.device_type]['name']} "
f"{self._device.model}"
f" ({self._device.subtype})",
"identifiers": {(DOMAIN, str(self._device.device_id))},
"name": self._device_name,
}
if self._device.mac:
info["connections"] = {
(CONNECTION_NETWORK_MAC, format_mac(self._device.mac)),
}
if self._device.serial_number:
info["serial_number"] = self._device.serial_number
return info
@property
def unique_id(self) -> str:
"""Unique id of the entity."""
return self._unique_id
@property
def should_poll(self) -> bool:
"""Whether the integration should poll for updates."""
return False
@property
def available(self) -> bool:
"""Whether the entity is available."""
return bool(self._device.available)
@property
def icon(self) -> str:
"""Icon for the entity."""
return cast("str", self._config.get("icon"))
async def async_added_to_hass(self) -> None:
"""Subscribe to device updates once the entity is added to HA.
Registering the callback here (rather than in ``__init__``) ensures an
entity that is constructed but never added to HA never receives updates,
which avoids the recurring "HASS is None" warnings.
"""
await super().async_added_to_hass()
self._device.register_update(self.update_state)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from device updates when the entity is removed from HA."""
await super().async_will_remove_from_hass()
self._device.unregister_update(self.update_state)
@callback
def schedule_update_if_running(self) -> None:
"""Schedule a state write unless HA is shutting down.
Shared by the base ``update_state`` and by the per-device-type
subclasses that override it (climate, fan, light, water_heater,
humidifier), so the shutdown guard lives in exactly one place and the
main control entities are protected too (issues #798 and #809).
The caller is responsible for the ``self.hass is None`` guard.
Raises:
RuntimeError: If ``schedule_update_ha_state()`` fails for a reason
other than the event loop being closed during shutdown.
"""
if self.hass.is_stopping:
_LOGGER.debug(
"MideaEntity update_state for %s [%s]: HASS is stopping",
self.name,
type(self),
)
return
# A device background thread can still deliver an update after the HA
# event loop has been closed during shutdown. Scheduling a state write
# then raises RuntimeError because the loop is already closed (issues
# #798 and #809). The is_stopping guard above covers the common case,
# but a race remains between that check and the schedule call, so also
# guard against the closed loop and swallow the residual RuntimeError.
if self.hass.loop.is_closed():
return
try:
self.schedule_update_ha_state()
except RuntimeError:
# Only swallow the shutdown race; re-raise any unrelated RuntimeError.
if not self.hass.loop.is_closed():
raise
_LOGGER.debug(
"Ignoring update for %s: event loop closed during shutdown",
self.name,
)
@callback
def update_state(self, status: Any) -> None: # ruff:ignore[any-type]
"""Update entity state."""
if not self.hass:
# Defensive guard for the is_stopping access below. Since the update
# callback is now registered in async_added_to_hass (#869), hass is
# always set on the normal path, so this is debug (like is_stopping).
_LOGGER.debug(
"MideaEntity update_state for %s [%s] with status %s: HASS is None",
self.name,
type(self),
status,
)
return
if self._entity_key not in status and "available" not in status:
return
self.schedule_update_if_running()
@@ -0,0 +1,90 @@
"""Number for Midea Lan."""
from typing import Any, cast
from homeassistant.components.number import NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_SWITCHES, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from midealan.device import MideaDevice
from .const import DEVICES, DOMAIN
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up numbers for device."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_switches = config_entry.options.get(CONF_SWITCHES, [])
numbers = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if config["type"] == Platform.NUMBER and entity_key in extra_switches:
dev = MideaNumber(device, entity_key)
numbers.append(dev)
async_add_entities(numbers)
class MideaNumber(MideaEntity, NumberEntity):
"""Represent a Midea number sensor."""
def __init__(self, device: MideaDevice, entity_key: str) -> None:
"""Midea number sensor init."""
super().__init__(device, entity_key)
self._max_value = self._config.get("max")
self._min_value = self._config.get("min")
self._step_value = self._config.get("step")
def _resolve_bound(self, bound: Any) -> float: # ruff:ignore[any-type]
"""Resolve a min/max/step config value to a concrete number.
A numeric literal is used as-is. Otherwise the value is treated as an
attribute name: prefer the device attribute of that name, falling back
to a same-named device property populated by ``set_customize``.
Returns
-------
The resolved bound as a float.
"""
if isinstance(bound, (int, float)):
return cast("float", bound)
# `bound` is an attribute name. Use `is not None` (not truthiness) so a
# legitimate 0 is not treated as "missing", and read the attribute once.
value = self._device.get_attribute(attr=bound)
if value is None:
value = getattr(self._device, bound)
return cast("float", value)
@property
def native_min_value(self) -> float:
"""Minimum value allowed."""
return self._resolve_bound(self._min_value)
@property
def native_max_value(self) -> float:
"""Maximum value allowed."""
return self._resolve_bound(self._max_value)
@property
def native_step(self) -> float:
"""Step value between allowed values."""
return self._resolve_bound(self._step_value)
@property
def native_value(self) -> float:
"""Native value of the entity."""
return cast("float", self._device.get_attribute(self._entity_key))
def set_native_value(self, value: Any) -> None: # ruff:ignore[any-type]
"""Set value."""
self._device.set_attribute(self._entity_key, value)
@@ -0,0 +1,137 @@
"""Select for Midea Lan."""
from typing import TYPE_CHECKING, Any, cast
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_SWITCHES, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from midealan.device import MideaDevice
from .const import DEVICES, DOMAIN, supports_model
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
if TYPE_CHECKING:
from midealan.devices.e1 import MideaE1Device
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up selects for device."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_switches = config_entry.options.get(CONF_SWITCHES, [])
selects = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if (
config["type"] != Platform.SELECT
or not supports_model(device.model, config)
or (not config.get("default") and entity_key not in extra_switches)
):
continue
required_attribute = config.get("required_attribute")
if (
required_attribute is not None
and required_attribute not in device.attributes
):
continue
dev = MideaSelect(device, entity_key)
selects.append(dev)
async_add_entities(selects)
class MideaSelect(MideaEntity, SelectEntity):
"""Represent a Midea select."""
def __init__(self, device: MideaDevice, entity_key: str) -> None:
"""Midea select init."""
super().__init__(device, entity_key)
self._attribute_key = self._config.get("attribute", entity_key)
self._options_name = self._config.get("options")
self._options_dict_name = self._config.get("options_dict")
@property
def options(self) -> list[str]:
"""Available options for the entity."""
if self._options_dict_name:
options = self._get_options_dict()
codes_by_model = self._config.get("options_codes_by_model", {})
codes = codes_by_model.get(str(self._device.model)) or self._config.get(
"options_codes",
)
if codes:
return [options[code] for code in codes if code in options]
return list(options.values())
return cast("list", getattr(self._device, self._options_name))
@property
def current_option(self) -> str | None:
"""Currently selected option."""
option = cast("str | None", self._device.get_attribute(self._attribute_key))
return option if option in self.options else None
@property
def available(self) -> bool:
"""Whether the entity is available."""
if not super().available:
return False
power_attribute = self._config.get("available_power_attribute")
return not power_attribute or bool(self._device.get_attribute(power_attribute))
def select_option(self, option: str) -> None:
"""Select entity option."""
if self._config.get("set_message") == "e1_work_mode":
self._select_e1_work_mode(option)
return
self._device.set_attribute(self._attribute_key, option)
def _get_options_dict(self) -> dict[int, str]:
"""Return option dict from the backing midea-lan device.
Returns
-------
Option labels keyed by the raw mode code.
"""
return cast("dict[int, str]", getattr(self._device, self._options_dict_name))
def _select_e1_work_mode(self, option: str) -> None:
"""Set dishwasher work mode via midea-lan's public E1 API.
Raises
------
ValueError
If the requested option is not a supported work mode.
"""
mode = self._get_dict_key_by_value(self._get_options_dict(), option)
if mode is None:
raise ValueError(f"Unsupported dishwasher mode: {option}")
cast("MideaE1Device", self._device).set_work_mode(mode)
@callback
def update_state(self, status: Any) -> None: # ruff:ignore[any-type]
"""Update entity state."""
super().update_state(status)
power_attribute = self._config.get("available_power_attribute")
if (
power_attribute
and self.hass
and (self._attribute_key in status or power_attribute in status)
):
self.schedule_update_if_running()
@staticmethod
def _get_dict_key_by_value(source: dict[int, str], value: str) -> int | None:
for key, item in source.items():
if item == value:
return key
return None
@@ -0,0 +1,191 @@
"""Sensor for Midea Lan."""
from typing import Any, cast
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_SENSORS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import StateType
from midealan.device import MideaDevice
from .const import DEVICES, DOMAIN, supports_model
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors for device."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_sensors = config_entry.options.get(CONF_SENSORS, [])
sensors = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if (
config["type"] != Platform.SENSOR
or not supports_model(device.model, config)
or (not config.get("default") and entity_key not in extra_sensors)
):
continue
required_attribute = config.get("required_attribute")
if (
required_attribute is not None
and required_attribute not in device.attributes
):
continue
sensor = (
MideaEstimatedUsageSensor(device, entity_key)
if config.get("estimate")
else MideaSensor(device, entity_key)
)
sensors.append(sensor)
async_add_entities(sensors)
class MideaSensor(MideaEntity, SensorEntity):
"""Represent a Midea sensor."""
@property
def native_value(self) -> StateType:
"""Native value of the sensor."""
value = self._device.get_attribute(self._entity_key)
# If an options mapping exists, translate the raw value to its enum key.
options = self._config.get("options")
if options is not None and isinstance(value, int):
# For an enum sensor an unmapped value is not a valid state, so
# report it as unknown (None) instead of leaking the raw int.
return cast("StateType", options.get(value))
return cast("StateType", value)
@property
def device_class(self) -> SensorDeviceClass:
"""Device class of the sensor."""
return cast("SensorDeviceClass", self._config.get("device_class"))
@property
def state_class(self) -> SensorStateClass | None:
"""State class of the sensor."""
return cast("SensorStateClass | None", self._config.get("state_class"))
@property
def options(self) -> list[str] | None:
"""List of possible states for an enum sensor."""
if self.device_class != SensorDeviceClass.ENUM:
return None
options = self._config.get("options")
return list(options.values()) if options else None
@property
def native_unit_of_measurement(self) -> str | None:
"""Unit of measurement for the sensor."""
return cast("str | None", self._config.get("unit"))
@property
def suggested_display_precision(self) -> int | None:
"""Suggested number of decimal digits for display."""
return cast("int | None", self._config.get("suggested_display_precision"))
@property
def capability_attributes(self) -> dict[str, Any] | None:
"""Capability attributes of the sensor."""
if self.options is not None:
return {"options": self.options}
return {"state_class": self.state_class} if self.state_class else {}
class MideaEstimatedUsageSensor(MideaSensor, RestoreEntity):
"""Represent estimated dishwasher usage accumulated per run.
The dishwasher does not report actual energy/water usage, so this sensor
accumulates a fixed per-mode estimate (from the product manual) once each
time a wash run *completes*. Completion is detected by the device
``progress`` attribute reaching ``"Complete"``; a cancelled or errored run
never reaches that state and is therefore not counted.
"""
_COMPLETE_PROGRESS = "Complete"
_RUNNING_STATUS = "Running"
def __init__(self, device: MideaDevice, entity_key: str) -> None:
"""Initialize estimated usage sensor."""
super().__init__(device, entity_key)
self._native_value: float = 0.0
self._last_progress: str | None = None
self._running_mode: str | None = None
async def async_added_to_hass(self) -> None:
"""Register for device updates and restore the accumulated value."""
await super().async_added_to_hass()
if last_state := await self.async_get_last_state():
try:
self._native_value = float(last_state.state)
except (TypeError, ValueError):
self._native_value = 0.0
self._last_progress = cast(
"str | None",
self._device.get_attribute("progress"),
)
if self._device.get_attribute("status") == self._RUNNING_STATUS:
self._running_mode = cast("str | None", self._device.get_attribute("mode"))
@property
def native_value(self) -> StateType:
"""Accumulated estimated usage."""
return round(self._native_value, 3)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Estimate metadata for the sensor."""
estimate = cast("dict[str, Any]", self._config["estimate"])
return {
"estimate_source": "fixed_per_wash_mode",
"known_modes": list(cast("dict[str, float]", estimate["values"]).keys()),
"last_progress": self._last_progress,
"running_mode": self._running_mode,
}
def update_state(self, status: Any) -> None: # ruff:ignore[any-type]
"""Accumulate the estimate once when a dishwasher run completes."""
current_status = self._device.get_attribute("status")
current_progress = cast(
"str | None",
self._device.get_attribute("progress"),
)
current_mode = cast("str | None", self._device.get_attribute("mode"))
# Remember the mode selected while the machine is actually running so we
# can still attribute usage after it stops reporting the mode on finish.
if current_status == self._RUNNING_STATUS:
self._running_mode = current_mode
# Count once, on the edge into the "Complete" progress state. A cancel or
# error transition never reaches "Complete", so it is not counted.
if (
current_progress == self._COMPLETE_PROGRESS
and self._last_progress != self._COMPLETE_PROGRESS
):
mode = self._running_mode or current_mode
values = cast("dict[str, float]", self._config["estimate"]["values"])
if mode in values:
self._native_value += values[mode]
self._running_mode = None
self._last_progress = current_progress
super().update_state(status)
if self.hass and (
"progress" in status or "status" in status or "mode" in status
):
self.schedule_update_if_running()
@@ -0,0 +1,18 @@
---
set_attribute:
fields:
device_id:
example: "1234567890"
attribute:
example: "eco_mode"
value:
example: true
send_command:
fields:
device_id:
example: "1234567890"
cmd_type:
example: 2
cmd_body:
example: "B0FF01370E0000A500"
@@ -0,0 +1,50 @@
"""Switch for Midea Lan."""
from typing import Any, cast
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_SWITCHES, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DEVICES, DOMAIN
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches for device."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_switches = config_entry.options.get(CONF_SWITCHES, [])
switches = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if config["type"] == Platform.SWITCH and entity_key in extra_switches:
dev = MideaSwitch(device, entity_key)
switches.append(dev)
async_add_entities(switches)
class MideaSwitch(MideaEntity, ToggleEntity):
"""Represent a Midea switch."""
@property
def is_on(self) -> bool:
"""Whether the switch is on."""
return cast("bool", self._device.get_attribute(self._entity_key))
def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Turn on switch."""
self._device.set_attribute(attr=self._entity_key, value=True)
def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Turn off switch."""
self._device.set_attribute(attr=self._entity_key, value=False)
@@ -0,0 +1,83 @@
"""Time for Midea Lan."""
import logging
from datetime import time
from typing import cast
from homeassistant.components.time import TimeEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_SWITCHES, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from midealan.device import MideaDevice
from .const import DEVICES, DOMAIN
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up times for device."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_switches = config_entry.options.get(CONF_SWITCHES, [])
times = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if config["type"] == Platform.TIME and entity_key in extra_switches:
dev = MideaTime(device, entity_key)
times.append(dev)
async_add_entities(times)
class MideaTime(MideaEntity, TimeEntity):
"""Represent a Midea time entity."""
def __init__(self, device: MideaDevice, entity_key: str) -> None:
"""Midea time entity init."""
super().__init__(device, entity_key)
# Config can specify hour/min attribute names explicitly:
# "hour_attr": "timing_regeneration_hour"
# "min_attr": "timing_regeneration_min"
# Otherwise derive from entity_key: if it ends with "_hour",
# use it directly and replace "_hour" with "_min" for minute.
self._hour_attr = self._config.get("hour_attr")
self._min_attr = self._config.get("min_attr")
if self._hour_attr is None:
if self._entity_key.endswith("_hour"):
self._hour_attr = self._entity_key
self._min_attr = self._entity_key[:-5] + "_min"
else:
self._hour_attr = f"{self._entity_key}_hour"
self._min_attr = f"{self._entity_key}_min"
@property
def native_value(self) -> time | None:
"""Native value of the entity."""
hour = self._device.get_attribute(self._hour_attr)
minute = self._device.get_attribute(self._min_attr)
if hour is None or minute is None:
return None
try:
return time(hour=cast("int", hour), minute=cast("int", minute))
except (TypeError, ValueError):
_LOGGER.warning(
"Invalid time value for %s: hour=%s, minute=%s",
self._entity_key,
hour,
minute,
)
return None
def set_value(self, value: time) -> None:
"""Set entity value."""
self._device.set_attribute(self._hour_attr, value.hour)
self._device.set_attribute(self._min_attr, value.minute)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,485 @@
"""Midea Water Heater entries."""
import functools as ft
import logging
from typing import Any, ClassVar, cast
from homeassistant.components.water_heater import (
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_DEVICE_ID,
CONF_SWITCHES,
PRECISION_HALVES,
PRECISION_WHOLE,
STATE_OFF,
STATE_ON,
Platform,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from midealan.device import DeviceType
from midealan.devices.c3 import DeviceAttributes as C3Attributes
from midealan.devices.c3 import MideaC3Device
from midealan.devices.cd import DeviceAttributes as CDAttributes
from midealan.devices.cd import MideaCDDevice
from midealan.devices.e2 import DeviceAttributes as E2Attributes
from midealan.devices.e2 import MideaE2Device
from midealan.devices.e3 import MideaE3Device
from midealan.devices.e6 import DeviceAttributes as E6Attributes
from midealan.devices.e6 import MideaE6Device
from .const import DEVICES, DOMAIN
from .midea_devices import MIDEA_DEVICES
from .midea_entity import MideaEntity
_LOGGER = logging.getLogger(__name__)
E2_TEMPERATURE_MAX = 75
E2_TEMPERATURE_MIN = 30
E3_TEMPERATURE_MAX = 65
E3_TEMPERATURE_MIN = 35
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up water heater entries."""
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
extra_switches = config_entry.options.get(CONF_SWITCHES, [])
devs: list[
MideaE2WaterHeater
| MideaE3WaterHeater
| MideaE6WaterHeater
| MideaC3WaterHeater
| MideaCDWaterHeater
] = []
for entity_key, config in cast(
"dict",
MIDEA_DEVICES[device.device_type]["entities"],
).items():
if config["type"] == Platform.WATER_HEATER and (
config.get("default") or entity_key in extra_switches
):
if device.device_type == DeviceType.E2:
devs.append(MideaE2WaterHeater(device, entity_key))
elif device.device_type == DeviceType.E3:
devs.append(MideaE3WaterHeater(device, entity_key))
elif device.device_type == DeviceType.E6:
devs.append(MideaE6WaterHeater(device, entity_key, config["use"]))
elif device.device_type == DeviceType.C3:
devs.append(MideaC3WaterHeater(device, entity_key))
elif device.device_type == DeviceType.CD:
devs.append(MideaCDWaterHeater(device, entity_key))
async_add_entities(devs)
type MideaWaterHeaterDevice = (
MideaE2Device | MideaE3Device | MideaC3Device | MideaE6Device | MideaCDDevice
)
class MideaWaterHeater(MideaEntity, WaterHeaterEntity):
"""Midea Water Heater Entries Base Class."""
_device: MideaWaterHeaterDevice
def __init__(self, device: MideaWaterHeaterDevice, entity_key: str) -> None:
"""Midea Water Heater entity init."""
super().__init__(device, entity_key)
self._operations: list[str] = []
@property
def supported_features(self) -> WaterHeaterEntityFeature:
"""Midea Water Heater supported features."""
return WaterHeaterEntityFeature.TARGET_TEMPERATURE
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Midea Water Heater extra state attributes."""
attrs: dict[str, Any] = self._device.attributes
if hasattr(self._device, "temperature_step"):
attrs["target_temperature_step"] = self._device.temperature_step
return attrs
@property
def min_temp(self) -> float:
"""Midea Water Heater min temperature."""
raise NotImplementedError
@property
def max_temp(self) -> float:
"""Midea Water Heater max temperature."""
raise NotImplementedError
@property
def precision(self) -> float:
"""Midea Water Heater precision."""
return float(PRECISION_WHOLE)
@property
def temperature_unit(self) -> UnitOfTemperature:
"""Midea Water Heater temperature unix."""
return UnitOfTemperature.CELSIUS
@property
def current_operation(self) -> str | None:
"""Midea Water Heater current operation."""
return cast(
"str",
(
self._device.get_attribute("mode")
if self._device.get_attribute("power")
else STATE_OFF
),
)
@property
def current_temperature(self) -> float:
"""Midea Water Heater current temperature."""
return cast("float", self._device.get_attribute("current_temperature"))
@property
def target_temperature(self) -> float:
"""Midea Water Heater target temperature."""
return cast("float", self._device.get_attribute("target_temperature"))
def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type]
"""Midea Water Heater set temperature."""
if ATTR_TEMPERATURE not in kwargs:
return
# input target_temperature should be float
temperature = float(kwargs[ATTR_TEMPERATURE])
self._device.set_attribute("target_temperature", temperature)
def set_operation_mode(self, operation_mode: str) -> None:
"""Midea Water Heater set operation mode."""
self._device.set_attribute(attr="mode", value=operation_mode)
@property
def operation_list(self) -> list[str] | None:
"""Midea Water Heater operation list."""
if not hasattr(self._device, "preset_modes"):
return None
return cast("list", self._device.preset_modes)
def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Water Heater turn on."""
self._device.set_attribute(attr="power", value=True)
def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Water Heater turn off."""
self._device.set_attribute(attr="power", value=False)
async def async_turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type]
"""Midea Water Heater async turn on."""
await self.hass.async_add_executor_job(ft.partial(self.turn_on, **kwargs))
async def async_turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type]
"""Midea Water Heater async off."""
await self.hass.async_add_executor_job(ft.partial(self.turn_off, **kwargs))
def update_state(self, status: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea Water Heater update state."""
if not self.hass:
_LOGGER.warning(
"Water update_state skipped for %s [%s]: HASS is None",
self.name,
type(self),
)
return
self.schedule_update_if_running()
class MideaE2WaterHeater(MideaWaterHeater):
"""Midea E2 Water Heater Entries."""
_device: MideaE2Device
def __init__(self, device: MideaE2Device, entity_key: str) -> None:
"""Midea E2 Water Heater entity init."""
super().__init__(device, entity_key)
@property
def current_operation(self) -> str:
"""Midea E2 Water Heater current operation."""
return str(
STATE_ON if self._device.get_attribute(E2Attributes.power) else STATE_OFF,
)
@property
def min_temp(self) -> float:
"""Midea E2 Water Heater min temperature."""
return E2_TEMPERATURE_MIN
@property
def max_temp(self) -> float:
"""Midea E2 Water Heater max temperature."""
return E2_TEMPERATURE_MAX
@property
def supported_features(self) -> WaterHeaterEntityFeature:
"""Midea E2 Water Heater supported features."""
return (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.ON_OFF
)
class MideaE3WaterHeater(MideaWaterHeater):
"""Midea E3 Water Heater Entries."""
_device: MideaE3Device
def __init__(self, device: MideaE3Device, entity_key: str) -> None:
"""Midea E3 Water Heater entity init."""
super().__init__(device, entity_key)
@property
def supported_features(self) -> WaterHeaterEntityFeature:
"""Midea E3 Water Heater supported features."""
# E3 implements turn_on/turn_off and reports on/off state, so advertise
# ON_OFF to self-document that support (matches E2).
return (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.ON_OFF
)
@property
def min_temp(self) -> float:
"""Midea E3 Water Heater min temperature."""
return E3_TEMPERATURE_MIN
@property
def max_temp(self) -> float:
"""Midea E3 Water Heater max temperature."""
return E3_TEMPERATURE_MAX
@property
def precision(self) -> float:
"""Midea E3 Water Heater precision."""
return float(
PRECISION_HALVES if self._device.precision_halves else PRECISION_WHOLE,
)
@property
def current_operation(self) -> str:
"""Midea E3 Water Heater current operation."""
return str(
STATE_ON if self._device.get_attribute("power") else STATE_OFF,
)
class MideaC3WaterHeater(MideaWaterHeater):
"""Midea C3 Water Heater Entries."""
_device: MideaC3Device
def __init__(self, device: MideaC3Device, entity_key: str) -> None:
"""Midea C3 Water Heater entity init."""
super().__init__(device, entity_key)
@property
def supported_features(self) -> WaterHeaterEntityFeature:
"""Midea C3 Water Heater supported features."""
# C3 implements turn_on/turn_off (dhw_power) and reports on/off state,
# so advertise ON_OFF to self-document that support (matches E2).
return (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.ON_OFF
)
@property
def current_operation(self) -> str:
"""Midea C3 Water Heater current operation."""
return str(
(
STATE_ON
if self._device.get_attribute(C3Attributes.dhw_power)
else STATE_OFF
),
)
@property
def current_temperature(self) -> float:
"""Midea C3 Water Heater current temperature."""
return cast(
"float",
self._device.get_attribute(C3Attributes.tank_actual_temperature),
)
@property
def target_temperature(self) -> float:
"""Midea C3 Water Heater target temperature."""
return cast("float", self._device.get_attribute(C3Attributes.dhw_target_temp))
def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type]
"""Midea C3 Water Heater set temperature."""
if ATTR_TEMPERATURE not in kwargs:
return
temperature = float(kwargs[ATTR_TEMPERATURE])
self._device.set_attribute(C3Attributes.dhw_target_temp, temperature)
@property
def min_temp(self) -> float:
"""Midea C3 Water Heater min temperature."""
return cast("float", self._device.get_attribute(C3Attributes.dhw_temp_min))
@property
def max_temp(self) -> float:
"""Midea C3 Water Heater max temperature."""
return cast("float", self._device.get_attribute(C3Attributes.dhw_temp_max))
def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea C3 Water Heater turn on."""
self._device.set_attribute(attr=C3Attributes.dhw_power, value=True)
def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea C3 Water Heater turn off."""
self._device.set_attribute(attr=C3Attributes.dhw_power, value=False)
class MideaE6WaterHeater(MideaWaterHeater):
"""Midea E6 Water Heater Entries."""
_device: MideaE6Device
_powers: ClassVar[list[E6Attributes]] = [
E6Attributes.heating_power,
E6Attributes.main_power,
]
_current_temperatures: ClassVar[list[E6Attributes]] = [
E6Attributes.heating_leaving_temperature,
E6Attributes.bathing_leaving_temperature,
]
_target_temperatures: ClassVar[list[E6Attributes]] = [
E6Attributes.heating_temperature,
E6Attributes.bathing_temperature,
]
def __init__(self, device: MideaE6Device, entity_key: str, use: int) -> None:
"""Midea E6 Water Heater entity init."""
super().__init__(device, entity_key)
self._use = use
self._power_attr = MideaE6WaterHeater._powers[self._use]
self._current_temperature_attr = MideaE6WaterHeater._current_temperatures[
self._use
]
self._target_temperature_attr = MideaE6WaterHeater._target_temperatures[
self._use
]
@property
def supported_features(self) -> WaterHeaterEntityFeature:
"""Midea E6 Water Heater supported features."""
# E6 implements turn_on/turn_off and reports on/off state, so advertise
# ON_OFF to self-document that support (matches E2).
return (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.ON_OFF
)
@property
def current_operation(self) -> str:
"""Midea E6 Water Heater current operation."""
if self._use == 0: # for heating
return str(
(
STATE_ON
if self._device.get_attribute(E6Attributes.main_power)
and self._device.get_attribute(E6Attributes.heating_power)
else STATE_OFF
),
)
# for bathing
return str(
(
STATE_ON
if self._device.get_attribute(E6Attributes.main_power)
else STATE_OFF
),
)
@property
def current_temperature(self) -> float:
"""Midea E6 Water Heater current temperature."""
return cast("float", self._device.get_attribute(self._current_temperature_attr))
@property
def target_temperature(self) -> float:
"""Midea E6 Water Heater target temperature."""
return cast("float", self._device.get_attribute(self._target_temperature_attr))
def set_temperature(self, **kwargs: Any) -> None: # ruff:ignore[any-type]
"""Midea E6 Water Heater set temperature."""
if ATTR_TEMPERATURE not in kwargs:
return
temperature = float(kwargs[ATTR_TEMPERATURE])
self._device.set_attribute(self._target_temperature_attr, temperature)
@property
def min_temp(self) -> float:
"""Midea E6 Water Heater min temperature."""
min_temperature = cast(
"list[str]",
self._device.get_attribute(E6Attributes.temperature_min),
)
return cast(
"float",
min_temperature[self._use],
)
@property
def max_temp(self) -> float:
"""Midea E6 Water Heater max temperature."""
max_temperature = cast(
"list[str]",
self._device.get_attribute(E6Attributes.temperature_max),
)
return cast(
"float",
max_temperature[self._use],
)
def turn_on(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea E6 Water Heater turn on."""
self._device.set_attribute(attr=self._power_attr, value=True)
def turn_off(self, **kwargs: Any) -> None: # ruff:ignore[any-type, unused-method-argument]
"""Midea E6 Water Heater turn off."""
self._device.set_attribute(attr=self._power_attr, value=False)
class MideaCDWaterHeater(MideaWaterHeater):
"""Midea CD Water Heater Entries."""
_device: MideaCDDevice
def __init__(self, device: MideaCDDevice, entity_key: str) -> None:
"""Midea CD Water Heater entity init."""
super().__init__(device, entity_key)
@property
def supported_features(self) -> WaterHeaterEntityFeature:
"""Midea CD Water Heater supported features."""
return (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
)
@property
def min_temp(self) -> float:
"""Midea CD Water Heater min temperature."""
return cast("float", self._device.get_attribute(CDAttributes.min_temperature))
@property
def max_temp(self) -> float:
"""Midea CD Water Heater max temperature."""
return cast("float", self._device.get_attribute(CDAttributes.max_temperature))