初始化config文件
This commit is contained in:
@@ -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))
|
||||
Reference in New Issue
Block a user