Initialize docker stack repo

This commit is contained in:
g4st3r
2026-03-07 17:28:59 +00:00
commit 319d4eeb3e
458 changed files with 67215 additions and 0 deletions

View File

@@ -0,0 +1,285 @@
"""
The custom component for local network access to Midea appliances
"""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_API_VERSION,
CONF_BROADCAST_ADDRESS,
CONF_DEVICES,
CONF_DISCOVERY,
CONF_EXCLUDE,
CONF_ID,
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_TTL,
CONF_TYPE,
CONF_UNIQUE_ID,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import async_get
from midea_beautiful.cloud import MideaCloud
from midea_beautiful.exceptions import MideaError
from midea_beautiful.lan import LanDevice
from midea_beautiful.midea import SUPPORTED_APPS, DEFAULT_APP_ID, DEFAULT_APPKEY
from custom_components.midea_dehumidifier_lan.const import (
CONF_MOBILE_APP,
CONF_TOKEN_KEY,
CONF_USE_CLOUD_OBSOLETE,
DEFAULT_APP,
DEFAULT_TTL,
DISCOVERY_CLOUD,
DISCOVERY_IGNORE,
DISCOVERY_LAN,
DISCOVERY_WAIT,
DOMAIN,
LOCAL_BROADCAST,
NAME,
CURRENT_CONFIG_VERSION,
OBSOLETE_CONF_APPID,
OBSOLETE_CONF_APPKEY,
PLATFORMS,
UNKNOWN_IP,
)
from custom_components.midea_dehumidifier_lan.hub import Hub
from custom_components.midea_dehumidifier_lan.util import MideaClient, address_ok
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up platform from a ConfigEntry."""
hass.data.setdefault(DOMAIN, {})
if (hub := hass.data[DOMAIN].get(config_entry.entry_id)) is None:
hub = Hub(hass, config_entry)
hass.data[DOMAIN][config_entry.entry_id] = hub
await hub.async_setup()
await _async_migrate_names(hass, config_entry)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
async def _async_migrate_names(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
entity_registry = async_get(hass)
conf = config_entry.data
if devices := conf.get(CONF_DEVICES):
old_entites = [
entry
for _, entry in entity_registry.entities.items()
if entry.platform == DOMAIN
]
for reg_entry in old_entites:
for device in devices:
old_suffix = f"_{device[CONF_ID]}"
new_suffix = f"_{device[CONF_UNIQUE_ID]}"
if reg_entry.unique_id.endswith(old_suffix):
prefix = reg_entry.unique_id[: -len(old_suffix)]
old_unique_id = reg_entry.unique_id
new_unique_id = f"{prefix}{new_suffix}"
try:
entity_registry.async_update_entity(
reg_entry.entity_id,
new_unique_id=new_unique_id,
)
_LOGGER.warning(
"Changed unique id of %s from %s to %s",
reg_entry.entity_id,
old_unique_id,
new_unique_id,
)
except ValueError as ex:
_LOGGER.error(
"Unable to change unique id of %s: %s",
reg_entry.entity_id,
ex,
)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hub: Hub = hass.data[DOMAIN].pop(entry.entry_id)
await hub.async_unload()
return unload_ok
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old config entry to new version."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version < CURRENT_CONFIG_VERSION:
old_conf = config_entry.data
old_broadcast = old_conf.get(CONF_BROADCAST_ADDRESS, [])
if not old_broadcast:
old_broadcast = [LOCAL_BROADCAST]
new_conf = {
CONF_MOBILE_APP: old_conf.get(CONF_MOBILE_APP),
CONF_BROADCAST_ADDRESS: old_broadcast,
CONF_USERNAME: old_conf.get(CONF_USERNAME),
CONF_PASSWORD: old_conf.get(CONF_PASSWORD),
}
if not old_conf.get(OBSOLETE_CONF_APPID) or not old_conf.get(
OBSOLETE_CONF_APPKEY
):
new_conf[CONF_MOBILE_APP] = DEFAULT_APP
else:
appkey = old_conf.get(OBSOLETE_CONF_APPKEY, DEFAULT_APPKEY)
if appkey:
for appname, appconf in SUPPORTED_APPS.items():
if appconf["appkey"] == appkey:
new_conf[CONF_MOBILE_APP] = appname
break
else:
appid = old_conf.get(OBSOLETE_CONF_APPID, DEFAULT_APP_ID)
for appname, appconf in SUPPORTED_APPS.items():
if appconf["appid"] == appid:
new_conf[CONF_MOBILE_APP] = appname
break
new_devices = []
new_conf[CONF_DEVICES] = new_devices
id_resolver = _ApplianceIdResolver(hass)
old: dict[str, Any]
for old in config_entry.data[CONF_DEVICES]:
new = {
CONF_API_VERSION: old.get(CONF_API_VERSION),
CONF_DISCOVERY: old.get(CONF_DISCOVERY),
CONF_ID: old.get(CONF_ID),
CONF_IP_ADDRESS: old.get(CONF_IP_ADDRESS, UNKNOWN_IP),
CONF_NAME: old.get(CONF_NAME),
CONF_TOKEN: old.get(CONF_TOKEN),
CONF_TOKEN_KEY: old.get(CONF_TOKEN_KEY),
CONF_TYPE: old.get(CONF_TYPE),
CONF_UNIQUE_ID: old.get(CONF_UNIQUE_ID),
CONF_TTL: old.get(CONF_TTL, DEFAULT_TTL),
}
discovery_mode = new.get(CONF_DISCOVERY)
if discovery_mode not in [
DISCOVERY_WAIT,
DISCOVERY_LAN,
DISCOVERY_IGNORE,
DISCOVERY_CLOUD,
]:
if old.get(CONF_USE_CLOUD_OBSOLETE):
new[CONF_DISCOVERY] = DISCOVERY_CLOUD
elif old.get(CONF_EXCLUDE):
new[CONF_DISCOVERY] = DISCOVERY_IGNORE
elif not address_ok(old.get(CONF_IP_ADDRESS)):
new[CONF_DISCOVERY] = DISCOVERY_WAIT
else:
new[CONF_DISCOVERY] = DISCOVERY_LAN
await id_resolver.async_get_unique_id_if_missing(new_conf, new)
new_devices.append(new)
config_entry.version = CURRENT_CONFIG_VERSION
_LOGGER.debug(
"Migrating configuration from %s to %s", config_entry.data, new_conf
)
if hass.config_entries.async_update_entry(
config_entry, data=new_conf, title=NAME
):
_LOGGER.info("Configuration migrated to version %s", config_entry.version)
else:
_LOGGER.debug(
"Configuration didn't change during migration to version %s",
config_entry.version,
)
return id_resolver.success
return True
# pylint: disable=too-few-public-methods
class _ApplianceIdResolver:
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self.client = MideaClient(hass)
self.cloud: MideaCloud | None = None
self.descriptors: list[dict] | None = None
self.success = True
async def _start(self, conf: dict[str, Any]) -> None:
try:
self.cloud = await self.client.async_connect_to_cloud(conf)
self.descriptors = await self.client.async_list_appliances(self.cloud)
except MideaError as ex:
_LOGGER.error(
"Unable to get list of appliances during configuration migration %s.",
ex,
exc_info=True,
)
async def async_get_unique_id_if_missing(
self,
conf: dict[str, Any],
device_conf: dict[str, Any],
) -> None:
"""If there is no unique_id assigned, try to find serial number"""
if device_conf[CONF_UNIQUE_ID] is None:
if device_conf[CONF_DISCOVERY] == DISCOVERY_LAN:
appliance = await self._get_appliance_state(device_conf)
device_conf[CONF_UNIQUE_ID] = appliance and appliance.serial_number
if device_conf[CONF_UNIQUE_ID] is None:
if self.cloud is None:
await self._start(conf)
self._find_unique_id_in_appliance_list(device_conf)
if device_conf[CONF_UNIQUE_ID] is None:
_LOGGER.error(
"Unable to find serial number for appliance %s."
"Please re-install %s integration.",
device_conf[CONF_NAME],
NAME,
)
self.success = False
def _find_unique_id_in_appliance_list(self, device_conf) -> None:
if self.descriptors is not None:
for app in self.descriptors:
if app["id"] == device_conf[CONF_ID]:
if app["sn"] and app["sn"] != "Unknown":
device_conf[CONF_UNIQUE_ID] = app["sn"]
else:
_LOGGER.warning("Unable to get serial number for %s", app)
break
async def _get_appliance_state(
self,
device_conf: dict[str, Any],
cloud: MideaCloud = None,
use_cloud: bool = False,
) -> LanDevice | None:
try:
return await self.hass.async_add_executor_job(
self.client.appliance_state,
device_conf[CONF_IP_ADDRESS],
device_conf[CONF_TOKEN],
device_conf[CONF_TOKEN_KEY],
cloud,
use_cloud,
device_conf[CONF_ID],
)
except MideaError as ex:
_LOGGER.error(
"Unable to poll appliance during configuration migration %s.",
ex,
exc_info=True,
)
return None

View File

@@ -0,0 +1,294 @@
"""Update coordinator for Midea devices"""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
import logging
from time import monotonic
from typing import Any, cast, final
from homeassistant.const import CONF_DISCOVERY, CONF_TOKEN, CONF_TTL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.util import slugify
from midea_beautiful.appliance import AirConditionerAppliance, DehumidifierAppliance
from midea_beautiful.cloud import MideaCloud
from midea_beautiful.exceptions import MideaError
from midea_beautiful.lan import LanDevice
from custom_components.midea_dehumidifier_lan.const import (
APPLIANCE_REFRESH_COOLDOWN,
APPLIANCE_REFRESH_INTERVAL,
CONF_TOKEN_KEY,
DEFAULT_TTL,
DISCOVERY_CLOUD,
DISCOVERY_IGNORE,
DOMAIN,
ENTITY_DISABLED_BY_DEFAULT,
ENTITY_ENABLED_BY_DEFAULT,
UNIQUE_DEHUMIDIFIER_PREFIX,
)
from custom_components.midea_dehumidifier_lan.util import (
AbstractHub,
ApplianceCoordinator,
RedactedConf,
)
_LOGGER = logging.getLogger(__name__)
# pylint: disable=too-many-instance-attributes
class ApplianceUpdateCoordinator(DataUpdateCoordinator, ApplianceCoordinator):
"""Single class to retrieve data from an appliance"""
def __init__( # pylint: disable=too-many-arguments
self,
hass: HomeAssistant,
hub: AbstractHub,
appliance: LanDevice,
device: dict[str, Any],
available: bool,
):
super().__init__(
hass,
_LOGGER,
name=appliance.name,
update_method=self._async_appliance_refresh,
update_interval=timedelta(seconds=APPLIANCE_REFRESH_INTERVAL),
request_refresh_debouncer=Debouncer(
hass,
_LOGGER,
cooldown=APPLIANCE_REFRESH_COOLDOWN,
immediate=True,
function=self.async_refresh,
),
)
self.hub = hub
self.appliance = appliance
self.updating = {}
self.wait_for_update = False
self.device = device
self.discovery_mode = device.get(CONF_DISCOVERY, DISCOVERY_IGNORE)
self.use_cloud: bool = self.discovery_mode == DISCOVERY_CLOUD
self.available = available
# TTL is in minutes
self.time_to_leave = 60 * int(device.get(CONF_TTL, DEFAULT_TTL))
self.has_failure = False
self.first_failure_time: float = 0
def _cloud(self) -> MideaCloud | None:
if self.use_cloud:
if not self.hub.cloud:
raise UpdateFailed(
f"Midea cloud API was not initialized, {self.appliance}"
f" configuration={RedactedConf(self.hub.config)}"
)
return self.hub.cloud
return None
async def _async_appliance_refresh(self) -> LanDevice:
"""Called to refresh appliance state"""
if not self.available:
await self._async_try_to_detect()
if self.wait_for_update:
return self.appliance
try:
if self.updating:
await self._async_do_update()
await self.hass.async_add_executor_job(
self.appliance.refresh, self._cloud()
)
self.has_failure = False
except MideaError as ex:
if not self.has_failure:
self.has_failure = True
self.first_failure_time = monotonic()
if (monotonic() - self.first_failure_time) >= self.time_to_leave:
raise UpdateFailed(str(ex)) from ex
_LOGGER.warning(
"Error fetching %s data: %s, will be trying again.", self.name, ex
)
finally:
self.wait_for_update = False
return self.appliance
async def _async_do_update(self):
self.wait_for_update = True
_LOGGER.debug("Updating attributes for %s: %s", self.appliance, self.updating)
for attr in self.updating:
setattr(self.appliance.state, attr, self.updating[attr])
self.updating.clear()
await self.hass.async_add_executor_job(self.appliance.apply, self._cloud())
async def _async_try_to_detect(self):
_LOGGER.debug("Trying to find appliance %s", self.appliance)
need_token, appliance = await self.hub.async_discover_device(self.device)
if not appliance:
raise UpdateFailed(self.hub.errors.get(str(self.appliance.serial_number)))
if need_token:
self.device[CONF_TOKEN] = appliance.token
self.device[CONF_TOKEN_KEY] = appliance.key
self.appliance = appliance
await self.hub.async_update_config()
self.available = True
async def async_apply(self, args: dict) -> None:
"""Applies changes to device"""
for key, value in args.items():
self.updating[key] = value
await self.async_request_refresh()
class ApplianceEntity(CoordinatorEntity):
"""Represents an appliance that gets data from a coordinator"""
_unique_id_prefx = UNIQUE_DEHUMIDIFIER_PREFIX
_name_suffix = ""
_capability_attr = ""
_add_extra_attrs = False
_was_online_registered = False
def __init__(self, coordinator: ApplianceUpdateCoordinator) -> None:
self.coordinator = coordinator
self.appliance = coordinator.appliance
self._set_enabled_for_capability()
super().__init__(coordinator)
self._attr_unique_id = f"{self.unique_id_prefix}{self.appliance.serial_number}"
self._attr_name = str(self.appliance.name or self.unique_id) + self.name_suffix
if self._add_extra_attrs:
self._attr_extra_state_attributes = {
"last_error_code": 0,
"last_error_time": datetime.now(),
}
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
# Override parent, we will handle state
self.async_on_remove(self.coordinator.async_add_listener(self._updated_data))
if self.coordinator.available:
self.on_online(True)
self._was_online_registered = True
@callback
def _updated_data(self) -> None:
"""Called when data has been updated by coordinator"""
self.appliance = self.coordinator.appliance
self._attr_available = self.appliance.online
if not self.coordinator.available:
self.on_online(False)
elif not self._was_online_registered:
self.on_online(True)
if self.appliance.online:
self.on_update()
self.async_write_ha_state()
def _set_enabled_for_capability(self) -> None:
capability = self._capability_attr
if not capability:
return
if capability == ENTITY_ENABLED_BY_DEFAULT:
enabled = True
elif capability == ENTITY_DISABLED_BY_DEFAULT:
enabled = False
elif capabilities := self.appliance.state.capabilities:
enabled = capabilities.get(capability, False)
elif hasattr(self, "_attr_entity_registry_enabled_default"):
return
else:
enabled = False
self._attr_entity_registry_enabled_default = enabled
def on_update(self) -> None:
"""Allows additional processing after the coordinator updates data"""
if self._add_extra_attrs:
state = self.appliance.state
_error_code = state.error_code
self._attr_extra_state_attributes |= {
"capabilities": str(state.capabilities),
"capabilities_data": state.capabilities_data.hex(),
"error_code": _error_code,
"last_data": state.latest_data.hex(),
}
if _error_code:
self._attr_extra_state_attributes |= {
"last_error_code": _error_code,
"last_error_time": datetime.now(),
}
def on_online(self, update: bool) -> None:
"""To be called when appliance comes online for the first time"""
if update:
self.on_update()
self.async_write_ha_state()
@final
def dehumidifier(self) -> DehumidifierAppliance:
"""Returns state as dehumidifier"""
return cast(DehumidifierAppliance, self.appliance.state)
@final
def airconditioner(self) -> AirConditionerAppliance:
"""Returns state as air conditioner"""
return cast(AirConditionerAppliance, self.appliance.state)
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.available:
return False
return super().available
@property
def name_suffix(self) -> str:
"""Suffix to append to entity name"""
return self._name_suffix
@property
def unique_id_prefix(self) -> str:
"""Prefix for entity id"""
strip = self.name_suffix.strip()
if len(strip) == 0:
return self._unique_id_prefx
slug = slugify(strip)
return f"{self._unique_id_prefx}{slug}_"
@property
def device_info(self) -> DeviceInfo:
identifier = str(self.appliance.serial_number or self.appliance.serial_number)
mac = self.appliance.mac
return DeviceInfo(
identifiers={(DOMAIN, str(identifier))},
name=self.appliance.name,
manufacturer="Midea",
model=str(self.appliance.model),
sw_version=self.appliance.firmware_version,
)
def apply(self, *args, **kwargs) -> None:
"""Applies changes to device"""
if len(args) % 2 != 0:
raise ValueError(f"Expecting attribute/value pairs, had {len(args)} items")
aargs = {}
for i in range(0, len(args), 2):
aargs[args[i]] = args[i + 1]
for key, value in kwargs.items():
aargs[key] = value
asyncio.run_coroutine_threadsafe(
self.coordinator.async_apply(aargs), self.hass.loop
).result()

View File

@@ -0,0 +1,402 @@
"""The custom component for local network access to Midea appliances"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
import ipaddress
from itertools import chain, cycle
import logging
from typing import Any, Iterator, cast
from homeassistant.core import CALLBACK_TYPE
from homeassistant.components.network import async_get_ipv4_broadcast_addresses
from homeassistant.const import (
CONF_API_VERSION,
CONF_BROADCAST_ADDRESS,
CONF_DEVICES,
CONF_DISCOVERY,
CONF_ID,
CONF_IP_ADDRESS,
CONF_NAME,
CONF_SCAN_INTERVAL,
CONF_TOKEN,
CONF_TYPE,
CONF_UNIQUE_ID,
)
from homeassistant.helpers.event import async_track_time_interval
from midea_beautiful.lan import LanDevice
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
ApplianceUpdateCoordinator,
)
from custom_components.midea_dehumidifier_lan.const import (
CONF_TOKEN_KEY,
DEFAULT_DISCOVERY_MODE,
DEFAULT_SCAN_INTERVAL,
DISCOVERY_BATCH_SIZE,
DISCOVERY_IGNORE,
DISCOVERY_LAN,
DISCOVERY_MODE_EXPLANATION,
DISCOVERY_WAIT,
LOCAL_BROADCAST,
NAME,
UNKNOWN_IP,
)
from custom_components.midea_dehumidifier_lan.util import (
AbstractHub,
RedactedConf,
address_ok,
supported_appliance,
)
_LOGGER = logging.getLogger(__name__)
def empty_address_iterator():
"""No addresses to iterate"""
yield from ()
def _add_if_discoverable(conf_addresses: list[str], device: dict[str, Any]):
if device.get(CONF_DISCOVERY) != DISCOVERY_LAN:
if address_ok(device[CONF_IP_ADDRESS]):
conf_addresses.append(device[CONF_IP_ADDRESS])
@dataclass
class _ChangedDevice:
device: LanDevice
coordinator: ApplianceUpdateCoordinator
class ApplianceDiscoveryHelper: # pylint: disable=too-many-instance-attributes
"""Utility class to discover Midea appliances on local network"""
def __init__(
self,
hub: AbstractHub,
) -> None:
self.hass = hub.hass
self.hub = hub
self.new_devices: list[LanDevice] = []
self.changed_devices: list[_ChangedDevice] = []
self.broadcast_addresses: list[str] = []
self.address_iterator: Iterator[list[str]] = empty_address_iterator()
self.notifed_addresses: set[str] = set()
self.remove_discovery: CALLBACK_TYPE | None = None
self.conf_addresses: list[str] = []
def _admit_new(self) -> bool:
"""Admits new devices into configurations"""
need_reload = False
added_devices: list[dict[str, Any]] = []
dev_confs = self.hub.config[CONF_DEVICES]
for new in self.new_devices:
for known in dev_confs:
if self._admitted_known_device(known, new):
need_reload = True
break
else:
added_devices.append(self._admit_not_known_device(new))
need_reload = True
if added_devices:
dev_confs += added_devices
return need_reload
def _admit_not_known_device(self, new: LanDevice) -> dict[str, Any]:
name = f"{new.model} {new.mac[-4] if new.mac else new.serial_number}"
new_device = {
CONF_DISCOVERY: DISCOVERY_IGNORE,
CONF_API_VERSION: new.version,
CONF_ID: new.appliance_id,
CONF_IP_ADDRESS: new.address,
CONF_NAME: name,
CONF_TOKEN_KEY: new.key,
CONF_TOKEN: new.token,
CONF_TYPE: new.type,
CONF_UNIQUE_ID: new.serial_number,
}
_LOGGER.debug("Found unknown device %s at %s.", name, new.address)
msg = (
f"Found previously unknown device {name} found on {new.address}."
f" [Check it out.](/config/integrations)"
)
self.hass.components.persistent_notification.async_create(
title=NAME,
message=msg,
notification_id=f"midea_unknown_{new.serial_number}",
)
return new_device
def _admitted_known_device(self, known: dict[str, Any], new: LanDevice) -> bool:
need_reload = False
if known[CONF_UNIQUE_ID] == new.serial_number:
if known[CONF_DISCOVERY] == DISCOVERY_WAIT:
update = {
CONF_DISCOVERY: DISCOVERY_LAN,
CONF_API_VERSION: new.version,
CONF_ID: new.appliance_id,
CONF_IP_ADDRESS: new.address,
CONF_TOKEN_KEY: new.key,
CONF_TOKEN: new.token,
CONF_TYPE: new.type,
CONF_UNIQUE_ID: new.serial_number,
}
_LOGGER.debug(
"Updating discovered device %s, previous conf %s, conf update %s",
new,
known,
update,
)
msg = (
"Device %(name)s,"
" which was waiting to be discovered,"
" was found on address %(address)s."
" It will now be activated."
) % {
"name": known[CONF_NAME],
"address": new.address,
}
self.hass.components.persistent_notification.async_create(
title=NAME,
message=msg,
notification_id=f"midea_wait_discovery_{new.serial_number}",
)
known |= update
need_reload = True
elif new.address and known[CONF_DISCOVERY] != DISCOVERY_LAN:
self._possible_lan_notification(new, known, new.address)
return need_reload
def _possible_lan_notification(
self, device: LanDevice, known: dict[str, Any], address: str
):
if address not in self.notifed_addresses:
_LOGGER.warning(
"Device %s in mode %s found on address %s. "
" It can be configured for local network access.",
known[CONF_NAME],
known[CONF_DISCOVERY],
address,
)
self.notifed_addresses.add(address)
discovery_label = DISCOVERY_MODE_EXPLANATION.get(
known[CONF_DISCOVERY], known[CONF_DISCOVERY]
)
msg = (
"Device %(name)s,"
" which is %(discovery_label)s,"
" was found on address %(address)s."
" It can be configured for local network access."
" [Check it out.](/config/integrations)"
) % {
"name": known[CONF_NAME],
"discovery_label": discovery_label,
"address": address,
}
self.hass.components.persistent_notification.async_create(
title=NAME,
message=msg,
notification_id=f"midea_non_lan_discovery_{device.serial_number}",
)
def _address_generator(self, batch_size: int = DISCOVERY_BATCH_SIZE):
"""Generator for one batch of ip addresses to scan"""
net_addrs = []
addr_count = 0
for addr in self.conf_addresses:
# If local broadcast address we don't need to expand it
if addr == LOCAL_BROADCAST:
continue
# Get network corresponding to address
net = ipaddress.IPv4Network(addr)
# If network references a block:
if net.num_addresses > 1:
_LOGGER.debug("Block %s with %d addresses", net, net.num_addresses)
# collect all hosts from the block
net_addrs.append(net.hosts())
addr_count += net.num_addresses
# If we do have addresses to scan
if net_addrs:
# we will iterate over all of available addresses in batches
# having batch_size items
all_addrs = chain(*net_addrs)
for _ in range(0, addr_count, batch_size):
yield list(
# We use filter to remove empty addresses
filter(
None,
map(
(lambda _: (x := next(all_addrs)) and str(x)),
range(batch_size),
),
)
)
async def _async_run_discovery(self, devices: list[LanDevice]) -> None:
"""Trigger config flows for discovered devices."""
dev_confs: list[dict[str, Any]] = self.hub.config[CONF_DEVICES]
for dev_conf in dev_confs:
dev_conf.setdefault(CONF_DISCOVERY, DEFAULT_DISCOVERY_MODE)
dev_conf.setdefault(CONF_IP_ADDRESS, UNKNOWN_IP)
self._iterate_devices(devices)
need_reload = self._admit_new()
devices_changed = self._merge_with_configuration()
if devices_changed or need_reload:
_LOGGER.debug("Config entry needs to be updated")
self.hass.config_entries.async_update_entry(
entry=self.hub.config_entry,
data=self.hub.config,
)
if need_reload:
_LOGGER.debug("Config entry needs to be reloaded")
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.hub.config_entry.entry_id)
)
def _iterate_devices(self, devices: list[LanDevice]):
self.new_devices.clear()
self.changed_devices.clear()
for device in devices:
if not device.address:
continue
coordinator = next(
(
cast(ApplianceUpdateCoordinator, coord)
for coord in self.hub.coordinators
if coord.appliance.serial_number == device.serial_number
),
None,
)
if coordinator:
# If address changed, we need to handle it
if device.address and device.address != coordinator.appliance.address:
_LOGGER.debug(
"Device %s changed address to %s",
coordinator.name,
device.address,
)
self.changed_devices.append(_ChangedDevice(device, coordinator))
elif supported_appliance(self.hub.config, device):
_LOGGER.debug("Discovered new device %s", device)
self.new_devices.append(device)
def _merge_with_configuration(self: ApplianceDiscoveryHelper) -> bool:
"""Merges list of changed devices with existing config entry configuration"""
dev_confs: list[dict[str, Any]] = self.hub.config[CONF_DEVICES]
updated_conf = False
for changed in self.changed_devices:
for known in dev_confs:
coordinator = changed.coordinator
device = changed.device
if known[CONF_UNIQUE_ID] == coordinator.appliance.serial_number:
coordinator.appliance.address = device.address
known[CONF_IP_ADDRESS] = device.address
updated_conf = True
if device.address and known[CONF_DISCOVERY] != DISCOVERY_LAN:
self._possible_lan_notification(
coordinator.appliance,
known,
device.address,
)
break
return updated_conf
def _setup(self) -> None:
"""Initializes address iterator.
Address iterator allows iterating over adresses to broadcast to.
It will iterate over all addresses in specified ranges.
"""
self.notifed_addresses.clear()
self.conf_addresses.clear()
has_discoverable = False
device: dict[str, Any]
for device in self.hub.config[CONF_DEVICES]:
if _add_if_discoverable(self.conf_addresses, device):
has_discoverable = True
for coordinator in self.hub.coordinators:
if not coordinator.available:
if _add_if_discoverable(self.conf_addresses, coordinator.device):
has_discoverable = True
self.conf_addresses += [
item
for item in self.hub.config.get(CONF_BROADCAST_ADDRESS, []) or []
if item and item != LOCAL_BROADCAST
]
self.broadcast_addresses = [LOCAL_BROADCAST]
for addr in self.conf_addresses:
net = ipaddress.IPv4Network(addr)
self.broadcast_addresses.append(str(net.broadcast_address))
if has_discoverable and self.conf_addresses:
_LOGGER.debug("Discovery via configured addresses %s", self.conf_addresses)
self.address_iterator = cycle(self._address_generator())
else:
self.address_iterator = empty_address_iterator()
def start(self) -> None:
"""Starts periodic disovery of devices"""
self.stop()
try:
self._setup()
scan_interval = self.hub.config.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
)
if scan_interval:
_LOGGER.debug(
"Starting periodic discovery with interval %s minute(s),"
" broadcast %s, configured %s",
scan_interval,
self.broadcast_addresses,
self.conf_addresses,
)
self.remove_discovery = async_track_time_interval(
self.hass, self._async_discover, timedelta(minutes=scan_interval)
)
except Exception as ex:
_LOGGER.error(
"Unable to setup up periodic discovery."
" Please remove integration and then reinstall it to check if problem"
" can be fixed."
" Cause: %s"
" Configuration: %s",
ex,
RedactedConf(self.hub.config),
)
self.stop()
raise ex
def stop(self) -> None:
"""Stops periodic disovery of devices"""
if self.remove_discovery:
_LOGGER.debug("Stopping periodic discovery")
self.remove_discovery()
self.remove_discovery = None
async def _async_discover(self, _: datetime) -> None:
"""Discover Midea appliances on configured network interfaces."""
addresses = list(address for address in self.broadcast_addresses)
if new_addresses := next(self.address_iterator, None):
addresses += new_addresses
if not addresses:
iface_broadcast = await async_get_ipv4_broadcast_addresses(self.hass)
addresses += [str(address) for address in iface_broadcast]
_LOGGER.debug("Initiated discovery via %s", addresses)
result = self.hub.client.find_appliances(None, addresses, retries=1, timeout=1)
if result:
await self._async_run_discovery(result)

View File

@@ -0,0 +1,118 @@
"""Adds binary sensors for appliances."""
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from midea_beautiful.midea import ERROR_CODE_BUCKET_FULL, ERROR_CODE_BUCKET_REMOVED
from custom_components.midea_dehumidifier_lan.const import (
DOMAIN,
UNIQUE_DEHUMIDIFIER_PREFIX,
)
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
ApplianceEntity,
ApplianceUpdateCoordinator,
)
from custom_components.midea_dehumidifier_lan.hub import Hub
from custom_components.midea_dehumidifier_lan.util import is_enabled_by_capabilities
def _is_enabled(coordinator: ApplianceUpdateCoordinator, capability: str) -> bool:
return is_enabled_by_capabilities(
coordinator.appliance.state.capabilities, capability
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Sets up appliance binary sensors"""
hub: Hub = hass.data[DOMAIN][config_entry.entry_id]
# Dehumidifier sensors
async_add_entities(
TankFullSensor(c) for c in hub.coordinators if c.is_dehumidifier()
)
# Add tank removed sensor if pump is supported
async_add_entities(
TankRemovedSensor(c)
for c in hub.coordinators
if c.is_dehumidifier() and _is_enabled(c, "pump")
)
async_add_entities(
FilterReplacementSensor(c)
for c in hub.coordinators
if c.is_dehumidifier() and _is_enabled(c, "filter")
)
async_add_entities(
DefrostingSensor(c) for c in hub.coordinators if c.is_dehumidifier()
)
class TankFullSensor(ApplianceEntity, BinarySensorEntity):
"""
Describes full tank binary sensors (indicated as problem as it prevents
dehumidifier from operating)
"""
_attr_device_class = BinarySensorDeviceClass.PROBLEM
_name_suffix = " Tank Full"
def on_update(self) -> None:
self._attr_is_on = (
self.dehumidifier().tank_full
or self.dehumidifier().error_code == ERROR_CODE_BUCKET_FULL
)
class TankRemovedSensor(ApplianceEntity, BinarySensorEntity):
"""
Shows that tank has been removed binary sensors (indicated as problem as it prevents
dehumidifier from operating)
"""
_attr_device_class = BinarySensorDeviceClass.PROBLEM
_name_suffix = " Tank Removed"
_capability_attr = "pump"
def on_update(self) -> None:
self._attr_is_on = self.dehumidifier().error_code == ERROR_CODE_BUCKET_REMOVED
class FilterReplacementSensor(ApplianceEntity, BinarySensorEntity):
"""
Describes filter replacement binary sensors (indicated as problem)
"""
_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_entity_registry_enabled_default = False
_name_suffix = " Replace Filter"
_capability_attr = "filter"
@property
def unique_id_prefix(self) -> str:
"""Prefix for entity id"""
return f"{UNIQUE_DEHUMIDIFIER_PREFIX}filter_"
def on_update(self) -> None:
self._attr_is_on = self.dehumidifier().filter_indicator
class DefrostingSensor(ApplianceEntity, BinarySensorEntity):
"""
Describes defrosting mode binary sensors (indicated as cold)
"""
_attr_device_class = BinarySensorDeviceClass.COLD
_attr_entity_registry_enabled_default = False
_name_suffix = " Defrosting"
def on_update(self) -> None:
self._attr_is_on = self.dehumidifier().defrosting

View File

@@ -0,0 +1,254 @@
"""Adds climate entity for each air conditioner appliance."""
import logging
from typing import Final
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
ATTR_FAN_MODE,
ATTR_HVAC_MODE,
ATTR_SWING_MODE,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
PRESET_BOOST,
PRESET_ECO,
PRESET_NONE,
PRESET_SLEEP,
PRESET_AWAY,
PRESET_COMFORT,
ClimateEntityFeature,
SWING_BOTH,
SWING_HORIZONTAL,
SWING_OFF,
SWING_VERTICAL,
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
ApplianceEntity,
)
from custom_components.midea_dehumidifier_lan.const import (
ATTR_RUNNING,
DOMAIN,
MAX_TARGET_TEMPERATURE,
MIN_TARGET_TEMPERATURE,
)
from custom_components.midea_dehumidifier_lan.hub import Hub
_LOGGER = logging.getLogger(__name__)
FAN_SILENT = "Silent"
FAN_FULL = "Full"
HVAC_MODES: Final = [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.DRY,
HVACMode.FAN_ONLY,
]
FAN_MODES: Final = [
FAN_SILENT,
FAN_LOW,
FAN_MEDIUM,
FAN_HIGH,
FAN_FULL,
FAN_AUTO,
]
SWING_MODES: Final = [SWING_OFF, SWING_HORIZONTAL, SWING_VERTICAL, SWING_BOTH]
PRESET_MODES: Final = [PRESET_NONE, PRESET_ECO, PRESET_BOOST, PRESET_SLEEP, PRESET_AWAY, PRESET_COMFORT]
_FAN_SPEEDS = {
FAN_AUTO: 102,
FAN_FULL: 100,
FAN_HIGH: 80,
FAN_MEDIUM: 60,
FAN_LOW: 40,
FAN_SILENT: 20,
}
_MODES_TO_MIDEA = {
HVACMode.AUTO: 1,
HVACMode.COOL: 2,
HVACMode.DRY: 3,
HVACMode.HEAT: 4,
HVACMode.FAN_ONLY: 5,
}
_MIDEA_TO_MODES = {
1: HVACMode.AUTO,
2: HVACMode.COOL,
3: HVACMode.DRY,
4: HVACMode.HEAT,
5: HVACMode.FAN_ONLY,
}
_HVAC_ACTIONS = {
HVACMode.OFF: HVACAction.OFF,
HVACMode.AUTO: None,
HVACMode.COOL: HVACAction.COOLING,
HVACMode.DRY: HVACAction.DRYING,
HVACMode.HEAT: HVACAction.HEATING,
HVACMode.FAN_ONLY: HVACAction.FAN,
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Sets up air conditioner entites"""
hub: Hub = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
AirConditionerEntity(c) for c in hub.coordinators if c.is_climate()
)
class AirConditionerEntity(ApplianceEntity, ClimateEntity):
"""Climate entity for Midea air conditioner"""
_attr_hvac_modes = HVAC_MODES
_attr_fan_modes = FAN_MODES
_attr_preset_modes = PRESET_MODES
_attr_swing_modes = SWING_MODES
_attr_max_temp = MAX_TARGET_TEMPERATURE
_attr_min_temp = MIN_TARGET_TEMPERATURE
_attr_precision = PRECISION_HALVES
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
_name_suffix = ""
_add_extra_attrs = True
def on_update(self) -> None:
aircon = self.airconditioner()
self._attr_current_temperature = aircon.indoor_temperature
self._attr_target_temperature = aircon.target_temperature
self._attr_fan_mode = self._fan_mode()
self._attr_preset_mode = self._preset_mode()
self._attr_swing_mode = self._swing_mode()
self._attr_hvac_mode = self._hvac_mode()
self._attr_hvac_action = _HVAC_ACTIONS.get(self._attr_hvac_mode)
super().on_update()
def _fan_mode(self) -> str:
fan_speed = self.airconditioner().fan_speed
for mode, mode_speed in _FAN_SPEEDS.items():
if fan_speed <= mode_speed:
return mode
return FAN_AUTO
def _preset_mode(self) -> str:
if self.airconditioner().turbo:
return PRESET_BOOST
if self.airconditioner().eco_mode:
return PRESET_ECO
if self.airconditioner().comfort_sleep:
return PRESET_SLEEP
if self.airconditioner().frost_protect:
return PRESET_AWAY
if self.airconditioner().comfort_mode:
return PRESET_COMFORT
return PRESET_NONE
def _swing_mode(self) -> str:
if self.airconditioner().vertical_swing:
if self.airconditioner().horizontal_swing:
return SWING_BOTH
return SWING_VERTICAL
if self.airconditioner().horizontal_swing:
return SWING_HORIZONTAL
return SWING_OFF
def _hvac_mode(self) -> str:
if not self.airconditioner().running:
return HVACMode.OFF
curr_mode = self.airconditioner().mode
mode = _MIDEA_TO_MODES.get(curr_mode)
if mode is None:
mode = HVACMode.AUTO
_LOGGER.warning("Unknown mode %d, reporting %s", curr_mode, mode)
return mode
def turn_on(self, **kwargs) -> None: # pylint: disable=unused-argument
"""Turn the entity on."""
self.apply(ATTR_RUNNING, True)
def turn_off(self, **kwargs) -> None: # pylint: disable=unused-argument
"""Turn the entity off."""
self.apply(ATTR_RUNNING, False)
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.OFF:
self.turn_off()
return
midea_mode = _MODES_TO_MIDEA.get(hvac_mode)
if midea_mode is None:
_LOGGER.warning("Unsupported climate mode %s", hvac_mode)
return
# Make sure we are running
if not self.airconditioner().running:
self.turn_on()
self.apply("mode", midea_mode)
def set_temperature(self, **kwargs) -> None:
"""Set new target temperature."""
if kwargs.get(ATTR_TEMPERATURE):
self.apply("target_temperature", kwargs.get(ATTR_TEMPERATURE))
if kwargs.get(ATTR_HVAC_MODE):
self.set_hvac_mode(kwargs.get(ATTR_HVAC_MODE))
if kwargs.get(ATTR_SWING_MODE):
self.set_swing_mode(kwargs.get(ATTR_SWING_MODE))
if kwargs.get(ATTR_FAN_MODE):
self.set_fan_mode(kwargs.get(ATTR_FAN_MODE))
def set_swing_mode(self, swing_mode: str) -> None:
if swing_mode == SWING_VERTICAL:
self.apply(vertical_swing=True, horizontal_swing=False)
elif swing_mode == SWING_HORIZONTAL:
self.apply(vertical_swing=False, horizontal_swing=True)
elif swing_mode == SWING_BOTH:
self.apply(vertical_swing=True, horizontal_swing=True)
else:
self.apply(vertical_swing=False, horizontal_swing=False)
def set_fan_mode(self, fan_mode: str) -> None:
self.apply(fan_speed=_FAN_SPEEDS.get(fan_mode, 20))
def set_preset_mode(self, preset_mode: str) -> None:
if preset_mode == PRESET_BOOST:
self.apply(turbo=True, eco_mode=False, comfort_sleep=False, frost_protect=False, comfort_mode=False)
elif preset_mode == PRESET_ECO:
self.apply(turbo=False, eco_mode=True, comfort_sleep=False, frost_protect=False, comfort_mode=False)
elif preset_mode == PRESET_SLEEP:
self.apply(turbo=False, eco_mode=False, comfort_sleep=True, frost_protect=False, comfort_mode=False)
elif preset_mode == PRESET_AWAY:
self.apply(turbo=False, eco_mode=False, comfort_sleep=False, frost_protect=True, comfort_mode=False)
elif preset_mode == PRESET_SLEEP:
self.apply(turbo=False, eco_mode=False, comfort_sleep=False, frost_protect=False, comfort_mode=True)
else:
self.apply(turbo=False, eco_mode=False, comfort_sleep=False, frost_protect=False, comfort_mode=False)

View File

@@ -0,0 +1,727 @@
"""Config flow for Midea Air Appliance (Local) integration."""
from __future__ import annotations
from ipaddress import IPv4Address, IPv4Network
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.const import (
ATTR_ID,
ATTR_NAME,
CONF_API_VERSION,
CONF_BROADCAST_ADDRESS,
CONF_DEVICES,
CONF_DISCOVERY,
CONF_ID,
CONF_INCLUDE,
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_TOKEN,
CONF_TTL,
CONF_TYPE,
CONF_UNIQUE_ID,
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowHandler, FlowResult
from homeassistant.helpers import config_validation as cv
import voluptuous as vol
from midea_beautiful.cloud import MideaCloud
from midea_beautiful.exceptions import (
AuthenticationError,
CloudAuthenticationError,
CloudError,
MideaError,
MideaNetworkError,
ProtocolError,
RetryLaterError,
)
from midea_beautiful.lan import LanDevice
from midea_beautiful.midea import (
APPLIANCE_TYPE_DEHUMIDIFIER,
SUPPORTED_APPS,
)
from custom_components.midea_dehumidifier_lan import Hub
from custom_components.midea_dehumidifier_lan.const import (
NAME,
CURRENT_CONFIG_VERSION,
SUPPORTED_APPLIANCES,
CONF_ADVANCED_SETTINGS,
CONF_DEBUG,
CONF_MOBILE_APP,
CONF_TOKEN_KEY,
DEFAULT_APP,
DEFAULT_DISCOVERY_MODE,
DEFAULT_PASSWORD,
DEFAULT_SCAN_INTERVAL,
DEFAULT_TTL,
DEFAULT_USERNAME,
DISCOVERY_CLOUD,
DISCOVERY_IGNORE,
DISCOVERY_LAN,
DISCOVERY_MODE_LABELS,
DISCOVERY_WAIT,
DOMAIN,
LOCAL_BROADCAST,
UNKNOWN_IP,
)
from custom_components.midea_dehumidifier_lan.util import (
MideaClient,
RedactedConf,
address_ok,
supported_appliance,
)
_LOGGER = logging.getLogger(__name__)
def _appliance_schema( # pylint: disable=too-many-arguments
name: str,
address: str = UNKNOWN_IP,
ttl: int = DEFAULT_TTL,
token: str = "",
token_key: str = "",
discovery_mode=DISCOVERY_WAIT,
) -> vol.Schema:
return vol.Schema(
{
vol.Optional(CONF_DISCOVERY, default=str(discovery_mode)): vol.In(
DISCOVERY_MODE_LABELS
),
vol.Optional(
CONF_IP_ADDRESS,
default=address or UNKNOWN_IP,
): cv.string,
vol.Required(CONF_NAME, default=name): cv.string,
vol.Required(
CONF_TTL,
msg="Test",
default=ttl,
description={"suffix": "minutes"},
): cv.positive_int,
vol.Optional(CONF_TOKEN, default=token or ""): cv.string,
vol.Optional(CONF_TOKEN_KEY, default=token_key or ""): cv.string,
}
)
# pylint: disable=too-many-arguments
def _advanced_settings_schema(
username: str = "",
password: str = "",
app: str = DEFAULT_APP,
broadcast_address: str = "",
appliances: list[str] = None,
debug: bool = False,
) -> vol.Schema:
appliances = appliances or [APPLIANCE_TYPE_DEHUMIDIFIER]
return vol.Schema(
{
vol.Required(CONF_USERNAME, default=username): cv.string,
vol.Required(CONF_PASSWORD, default=password): cv.string,
vol.Optional(CONF_MOBILE_APP, default=app): vol.In(SUPPORTED_APPS.keys()),
vol.Optional(CONF_BROADCAST_ADDRESS, default=broadcast_address): cv.string,
vol.Required(
CONF_SCAN_INTERVAL,
msg="Test",
default=DEFAULT_SCAN_INTERVAL,
description={"suffix": "minutes"},
): cv.positive_int,
vol.Required(CONF_INCLUDE, default=appliances): vol.All(
cv.multi_select(SUPPORTED_APPLIANCES),
vol.Length(min=1, msg="Must select at least one appliance category"),
),
vol.Required(CONF_DEBUG, default=debug): bool,
}
)
def _reauth_schema(
username: str,
password: str,
) -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_USERNAME, default=username): cv.string,
vol.Required(CONF_PASSWORD, default=password): cv.string,
}
)
def _user_schema(username: str, password: str, app: str) -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_USERNAME, default=username): cv.string,
vol.Required(CONF_PASSWORD, default=password): cv.string,
vol.Optional(CONF_MOBILE_APP, default=app): vol.In(SUPPORTED_APPS.keys()),
vol.Required(CONF_ADVANCED_SETTINGS, default=False): bool,
}
)
# pylint: disable=too-many-instance-attributes
class _MideaFlow(FlowHandler):
"""Base class for Midea data flows"""
def __init__(self) -> None:
super().__init__()
self.appliance_idx = -1
self.appliances: list[LanDevice] = []
self._client: MideaClient | None = None
self.cloud: MideaCloud | None = None # type: ignore
self.conf = {}
self.config_entry: ConfigEntry | None = None
self.devices_conf: list[dict[str, Any]] = []
self.discovered_appliances: list[LanDevice | None] = []
self.error_cause: str = ""
self.errors: dict[str, Any] = {}
self.indexes_to_process = []
@property
def client(self) -> MideaClient:
"""Returns instance of MideaClient."""
if not self._client:
self._client = MideaClient(self.hass)
return self._client
def _process_exception(self: _MideaFlow, ex: Exception) -> None:
if isinstance(ex, _FlowException):
_LOGGER.warning(
"Caught flow exception during appliance step %s", ex, exc_info=True
)
self.error_cause = str(ex.cause)
self.errors["base"] = ex.message
elif isinstance(ex, CloudAuthenticationError):
self.error_cause = f"{ex.error_code} - {ex.message}"
self.errors["base"] = "invalid_auth"
elif isinstance(ex, CloudError):
self.error_cause = f"{ex.error_code} - {ex.message}"
self.errors["base"] = "midea_client"
elif isinstance(ex, RetryLaterError):
self.error_cause = f"{ex.error_code} - {ex.message}"
self.errors["base"] = "retry_later"
elif isinstance(ex, MideaError):
self.error_cause = f"{ex.message}"
self.errors["base"] = "midea_client"
else:
raise ex
def _connect_to_cloud(self: _MideaFlow, extra_conf: dict[str, Any] = None) -> None:
"""Validates that cloud credentials are valid"""
cfg = self.conf | (extra_conf or {})
try:
self.cloud = self.client.connect_to_cloud(cfg)
except MideaError as ex:
raise _FlowException("no_cloud", str(ex)) from ex
def _validate_appliance(
self: _MideaFlow, appliance: LanDevice, device_conf: dict
) -> LanDevice | None:
"""
Validates that appliance configuration is correct and matches physical
device
"""
discovery_mode = device_conf.get(CONF_DISCOVERY, DEFAULT_DISCOVERY_MODE)
if discovery_mode == DISCOVERY_IGNORE:
_LOGGER.debug("Ignoring appliance %s", appliance)
return None
if discovery_mode == DISCOVERY_WAIT:
_LOGGER.debug(
"Attempt to discover appliance %s will be made later",
appliance,
)
return None
try:
if discovery_mode == DISCOVERY_CLOUD:
discovered = self.client.appliance_state(
appliance_id=appliance.appliance_id,
cloud=self.cloud,
use_cloud=True,
)
else: # DISCOVERY_LAN
ip_address = appliance.address
if not address_ok(ip_address):
raise _FlowException("invalid_ip_address", ip_address)
try:
IPv4Address(ip_address)
except Exception as ex:
_LOGGER.debug("Invalid appliance address %s: %s", ip_address, ex)
raise _FlowException("invalid_ip_address", ip_address) from ex
discovered = self.client.appliance_state(
address=ip_address, cloud=self.cloud
)
except ProtocolError as ex:
raise _FlowException("connection_error", str(ex)) from ex
except AuthenticationError as ex:
raise _FlowException("invalid_auth", str(ex)) from ex
except MideaNetworkError as ex:
raise _FlowException("cannot_connect", str(ex)) from ex
except MideaError as ex:
raise _FlowException("not_discovered", str(ex)) from ex
if discovered is None:
raise _FlowException("not_discovered", appliance.address)
return discovered
async def _async_add_entry(self: _MideaFlow) -> FlowResult:
supported_devices_conf = []
for i, appliance in enumerate(self.appliances):
if not supported_appliance(self.conf, appliance):
continue
device_conf = self.devices_conf[i]
if device_conf.get(CONF_DISCOVERY) != DISCOVERY_IGNORE:
device_conf |= {
CONF_API_VERSION: appliance.version,
CONF_ID: appliance.appliance_id,
CONF_IP_ADDRESS: (
appliance.address or device_conf[CONF_IP_ADDRESS] or UNKNOWN_IP
),
CONF_NAME: appliance.name,
CONF_TOKEN_KEY: appliance.key,
CONF_TOKEN: appliance.token,
CONF_TYPE: appliance.type,
CONF_UNIQUE_ID: appliance.serial_number,
}
suggested_discovery = (
DISCOVERY_LAN
if address_ok(device_conf[CONF_IP_ADDRESS])
else DISCOVERY_WAIT
)
device_conf.get(CONF_DISCOVERY, suggested_discovery)
supported_devices_conf.append(device_conf)
self.devices_conf = supported_devices_conf
self.conf[CONF_DEVICES] = self.devices_conf
# Remove not used elements
self.conf.pop(CONF_ADVANCED_SETTINGS, None)
if self.config_entry:
_LOGGER.debug("Updating configuration data %s", RedactedConf(self.conf))
self.hass.config_entries.async_update_entry(
entry=self.config_entry, data=self.conf
)
# Reload the config entry otherwise devices will remain unavailable
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.config_entry.entry_id)
)
if not self.devices_conf:
_LOGGER.debug("No configured appliances %s", RedactedConf(self.conf))
return self.async_abort(reason="no_configured_devices")
_LOGGER.debug("Creating configuration data %s", RedactedConf(self.conf))
return self.async_create_entry(title=NAME, data=self.conf)
async def _async_step_appliance( # pylint: disable=too-many-locals
self: _MideaFlow,
step_id: str,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Manage an appliances"""
self.errors.clear()
self.error_cause = ""
appliance = self.appliances[self.appliance_idx]
device_conf = self.devices_conf[self.appliance_idx]
discovery_mode = device_conf.get(CONF_DISCOVERY, DEFAULT_DISCOVERY_MODE)
ttl = device_conf.get(CONF_TTL, DEFAULT_TTL)
ip_address = appliance.address or UNKNOWN_IP
if user_input is not None:
try:
ip_address = user_input.get(
CONF_IP_ADDRESS, device_conf.get(CONF_IP_ADDRESS, UNKNOWN_IP)
)
self._check_ip_address_unique(ip_address)
discovery_mode = user_input.get(CONF_DISCOVERY, discovery_mode)
if discovery_mode not in [
DISCOVERY_WAIT,
DISCOVERY_LAN,
DISCOVERY_IGNORE,
DISCOVERY_CLOUD,
]:
discovery_mode = (
DISCOVERY_LAN if address_ok(ip_address) else DISCOVERY_CLOUD
)
device_conf[CONF_DISCOVERY] = discovery_mode
device_conf[CONF_TTL] = user_input.get(CONF_TTL, ttl)
appliance.address = ip_address
appliance.name = user_input.get(CONF_NAME, appliance.name)
appliance.token = user_input.get(CONF_TOKEN, "")
appliance.key = user_input.get(CONF_TOKEN_KEY, "")
if not self.cloud:
await self.hass.async_add_executor_job(self._connect_to_cloud)
discovered = await self.hass.async_add_executor_job(
self._validate_appliance,
appliance,
device_conf,
)
self.discovered_appliances[self.appliance_idx] = discovered
if not self.indexes_to_process:
self._update_appliances_after_flow()
return await self._async_add_entry()
self.appliance_idx = self.indexes_to_process.pop(0)
appliance = self.appliances[self.appliance_idx]
device_conf = self.devices_conf[self.appliance_idx]
ip_address = appliance.address or UNKNOWN_IP
user_input = None
discovery_mode = DEFAULT_DISCOVERY_MODE
ttl = DEFAULT_TTL
except Exception as ex: # pylint: disable=broad-except
self._process_exception(ex)
name = appliance.name
extra = {
"index": str(self.appliance_idx + 1),
"count": str(len(self.appliances)),
"serial_number": appliance.serial_number,
}
placeholders = self._placeholders(appliance, extra)
schema_arg = {
"name": name,
"address": device_conf.get(CONF_IP_ADDRESS, ip_address),
"token": device_conf.get(CONF_TOKEN, appliance.token),
"token_key": device_conf.get(CONF_TOKEN_KEY, appliance.key),
"ttl": device_conf.get(CONF_TTL, ttl),
"discovery_mode": device_conf.get(CONF_DISCOVERY, discovery_mode),
}
schema = _appliance_schema(**schema_arg)
return self.async_show_form(
step_id=step_id,
data_schema=schema,
description_placeholders=placeholders,
errors=self.errors,
last_step=len(self.indexes_to_process) == 0,
)
def _check_ip_address_unique(self, ip_address) -> None:
if address_ok(ip_address):
for i in range(self.appliance_idx):
if (
self.devices_conf[i].get(CONF_IP_ADDRESS) == ip_address
or ip_address == self.appliances[i].address
):
raise _FlowException(
"duplicate_ip_provided", self.appliances[i].name
)
def _update_appliances_after_flow(self) -> None:
for i, discovered in enumerate(self.discovered_appliances):
if discovered:
old_address = self.appliances[i].address
self.appliances[i].update(discovered)
if not discovered.address:
self.appliances[i].address = old_address
def _placeholders(
self: _MideaFlow, appliance: LanDevice = None, extra: dict[str, str] = None
) -> dict[str, str]:
extra = extra or {}
placeholders = {
"cause": self.error_cause or "",
**extra,
}
if appliance:
placeholders[ATTR_ID] = (
appliance.serial_number or f"{appliance.appliance_id} (Missing S/N)"
)
placeholders[ATTR_NAME] = appliance.name
return placeholders
def _get_broadcast_addresses(user_input: dict[str, Any]) -> list[str]:
address_entry = str(user_input.get(CONF_BROADCAST_ADDRESS, ""))
addresses = [LOCAL_BROADCAST]
specified_addresses = [
addr.strip() for addr in address_entry.split(",") if addr.strip()
]
for addr in specified_addresses:
_LOGGER.debug("Trying IPv4 %s", addr)
try:
IPv4Network(addr)
addresses.append(addr)
except ValueError as ex:
raise _FlowException("invalid_ip_range", str(ex)) from ex
except Exception as ex:
_LOGGER.debug("Invalid IP address %s", addr, exc_info=True)
raise _FlowException("invalid_ip_range", addr) from ex
return addresses
class _FlowException(Exception):
def __init__(self, message, cause: str = None) -> None:
super().__init__()
self.message = message
self.cause = cause
# pylint: disable=too-many-instance-attributes
class MideaConfigFlow(ConfigFlow, _MideaFlow, domain=DOMAIN):
"""Configuration flow for Midea dehumidifiers on local network uses
discovery based on Midea cloud, so it first requires credentials for it.
If some appliances are registered in the cloud, but not discovered, configuration
flow will prompt for additional information.
"""
VERSION = CURRENT_CONFIG_VERSION
def __init__(self) -> None:
super().__init__()
self.discovered_appliances: list[LanDevice | None] = []
self.appliances: list[LanDevice] = []
self.config_entry: ConfigEntry | None = None
self.advanced_settings = False
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlow:
"""Define the config flow to handle options."""
return MideaOptionsFlow(config_entry)
def _connect_and_discover(self: MideaConfigFlow) -> None:
"""Validates that cloud credentials are valid and discovers local appliances"""
self._connect_to_cloud()
conf_addresses = self.conf.get(CONF_BROADCAST_ADDRESS, [])
if isinstance(conf_addresses, str):
conf_addresses = [conf_addresses]
addresses = [
str(IPv4Network(addr).broadcast_address) for addr in conf_addresses
]
self.appliances.clear()
self.appliances += self.client.find_appliances(self.cloud, addresses)
self.devices_conf = [{} for _ in self.appliances]
async def _validate_discovery_phase(
self, user_input: dict[str, Any] | None
) -> FlowResult:
assert user_input is not None
self.conf[CONF_USERNAME] = user_input[CONF_USERNAME]
self.conf[CONF_PASSWORD] = user_input[CONF_PASSWORD]
if self.advanced_settings:
assert self.conf is not None
self.conf[CONF_MOBILE_APP] = user_input.get(CONF_MOBILE_APP, DEFAULT_APP)
self.conf[CONF_INCLUDE] = user_input[CONF_INCLUDE]
self.conf[CONF_SCAN_INTERVAL] = user_input[CONF_SCAN_INTERVAL]
self.conf[CONF_DEBUG] = user_input[CONF_DEBUG]
self.conf[CONF_BROADCAST_ADDRESS] = _get_broadcast_addresses(user_input)
else:
self.conf[CONF_MOBILE_APP] = user_input.get(CONF_MOBILE_APP, DEFAULT_APP)
if user_input.get(CONF_ADVANCED_SETTINGS):
return await self.async_step_advanced_settings()
self.conf[CONF_BROADCAST_ADDRESS] = []
self.conf[CONF_INCLUDE] = [APPLIANCE_TYPE_DEHUMIDIFIER]
self.conf[CONF_SCAN_INTERVAL] = DEFAULT_SCAN_INTERVAL
if self.conf.get(CONF_DEBUG, False):
await self.client.async_debug_mode(True)
await self.hass.async_add_executor_job(self._connect_and_discover)
self.indexes_to_process = [
index
for index, appliance in enumerate(self.appliances)
if supported_appliance(self.conf, appliance)
and not address_ok(appliance.address)
]
if self.indexes_to_process:
self.appliance_idx = self.indexes_to_process.pop(0)
self.discovered_appliances = [None] * len(self.devices_conf)
return await self.async_step_unreachable_appliance()
return await self._async_add_entry()
async def _do_validate(self, user_input: dict[str, Any]) -> FlowResult | None:
try:
return await self._validate_discovery_phase(user_input)
except Exception as ex: # pylint: disable=broad-except
self._process_exception(ex)
return None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
self.advanced_settings = False
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
self.errors.clear()
self.error_cause = ""
username = DEFAULT_USERNAME
password = DEFAULT_PASSWORD
app = DEFAULT_APP
if user_input is not None:
username = user_input.get(CONF_USERNAME, username)
password = user_input.get(CONF_PASSWORD, password)
app = user_input.get(CONF_MOBILE_APP, app)
res = await self._do_validate(user_input)
if res:
return res
return self.async_show_form(
step_id="user",
data_schema=_user_schema(username=username, password=password, app=app),
description_placeholders=self._placeholders(),
errors=self.errors,
)
async def async_step_advanced_settings(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Step for managing advanced settings"""
self.errors = {}
self.error_cause = ""
self.advanced_settings = True
if user_input is not None:
if res := await self._do_validate(user_input):
return res
else:
user_input = {}
username = user_input.get(
CONF_USERNAME, self.conf.get(CONF_USERNAME, DEFAULT_USERNAME)
)
password = user_input.get(
CONF_PASSWORD, self.conf.get(CONF_PASSWORD, DEFAULT_PASSWORD)
)
app = user_input.get(CONF_MOBILE_APP, DEFAULT_APP)
broadcast_addresses = user_input.get(
CONF_BROADCAST_ADDRESS, ",".join(self.conf.get(CONF_BROADCAST_ADDRESS, []))
)
return self.async_show_form(
step_id="advanced_settings",
data_schema=_advanced_settings_schema(
username=username,
password=password,
app=app,
broadcast_address=broadcast_addresses,
),
description_placeholders=self._placeholders(),
errors=self.errors,
)
async def async_step_unreachable_appliance(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the appliances that were not discovered automatically on LAN."""
return await self._async_step_appliance(
step_id="unreachable_appliance",
user_input=user_input,
)
async def _async_add_entry(self) -> FlowResult:
assert self.conf is not None
self.config_entry = await self.async_set_unique_id(self.conf[CONF_USERNAME])
return await super()._async_add_entry()
async def async_step_reauth(self, config) -> FlowResult:
"""Handle reauthorization request from Abode."""
self.conf = {**config}
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle reauthorization flow."""
self.errors.clear()
password = ""
username = self.conf.get(CONF_USERNAME, "")
app = self.conf.get(CONF_MOBILE_APP, DEFAULT_APP)
if user_input is not None:
extra_conf = {
CONF_USERNAME: user_input.get(CONF_USERNAME, ""),
CONF_PASSWORD: user_input.get(CONF_PASSWORD, ""),
CONF_MOBILE_APP: user_input.get(CONF_MOBILE_APP, app),
}
try:
await self.hass.async_add_executor_job(
self._connect_to_cloud, extra_conf
)
except Exception as ex: # pylint: disable=broad-except
self._process_exception(ex)
else:
self.conf[CONF_USERNAME] = username
self.conf[CONF_PASSWORD] = password
self.conf[CONF_MOBILE_APP] = app
return await self._async_add_entry()
return self.async_show_form(
step_id="reauth_confirm",
data_schema=_reauth_schema(
username=username,
password=password,
),
description_placeholders=self._placeholders(),
errors=self.errors,
)
class MideaOptionsFlow(OptionsFlow, _MideaFlow):
"""Handle Midea options flow."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize Midea options flow."""
super().__init__()
self.config_entry = config_entry
self.conf = {**config_entry.data}
self.devices_conf = self.conf.get(CONF_DEVICES, [])
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Starts options flow"""
self._build_appliance_list()
return await self.async_step_appliance(user_input)
async def async_step_appliance(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Options for an appliance"""
return await self._async_step_appliance(
step_id="appliance",
user_input=user_input,
)
def _build_appliance_list(self) -> None:
assert self.config_entry
hub: Hub = self.hass.data[DOMAIN][self.config_entry.entry_id]
self.appliances.clear()
self.devices_conf = self.conf[CONF_DEVICES]
for device in self.devices_conf:
for coord in hub.coordinators:
if device[CONF_UNIQUE_ID] == coord.appliance.serial_number:
self.appliances.append(coord.appliance)
break
else:
appliance = LanDevice(
appliance_id=device[CONF_ID],
serial_number=device[CONF_UNIQUE_ID],
appliance_type=device[CONF_TYPE],
)
appliance.name = device[CONF_NAME]
appliance.address = device.get(CONF_IP_ADDRESS, UNKNOWN_IP)
self.appliances.append(appliance)
self.indexes_to_process = list(range(len(self.appliances)))
self.appliance_idx = self.indexes_to_process.pop(0)
self.discovered_appliances = [None] * len(self.devices_conf)

View File

@@ -0,0 +1,111 @@
"""Constants for Midea Air Appliance custom component"""
from __future__ import annotations
from typing import Final
from homeassistant.const import Platform
from midea_beautiful.midea import (
APPLIANCE_TYPE_AIRCON,
APPLIANCE_TYPE_DEHUMIDIFIER,
DEFAULT_APP as DEFAULT_APP_FROM_LIB,
)
__version__ = "0.9.6"
# Base component constants
NAME: Final = "Midea Air Appliance (LAN)"
UNIQUE_ID_PRE_PREFIX: Final = "midea_"
UNIQUE_DEHUMIDIFIER_PREFIX: Final = "midea_dehumidifier_"
UNIQUE_CLIMATE_PREFIX: Final = "midea_climate_"
DOMAIN: Final = f"{UNIQUE_DEHUMIDIFIER_PREFIX}lan"
# pylint: disable=line-too-long
ISSUE_URL: Final = "https://github.com/nbogojevic/homeassistant-midea-air-appliances-lan/issues/new/choose" # noqa: E501
CONF_ADVANCED_SETTINGS: Final = "advanced_settings"
OBSOLETE_CONF_APPID: Final = "appid"
OBSOLETE_CONF_APPKEY: Final = "appkey"
CONF_DEBUG: Final = "debug"
CONF_MOBILE_APP: Final = "mobile_app"
CONF_TOKEN_KEY: Final = "token_key"
CONF_USE_CLOUD_OBSOLETE: Final = "use_cloud"
MAX_TARGET_HUMIDITY: Final = 85
MIN_TARGET_HUMIDITY: Final = 35
MAX_TARGET_TEMPERATURE: Final = 32
MIN_TARGET_TEMPERATURE: Final = 16
CURRENT_CONFIG_VERSION: Final = 3
# Wait half a second between successive refresh calls
APPLIANCE_REFRESH_COOLDOWN: Final = 0.5
APPLIANCE_REFRESH_INTERVAL: Final = 60
DEFAULT_SCAN_INTERVAL: Final = 15
MIN_SCAN_INTERVAL: Final = 2
ATTR_FAN_SPEED: Final = "fan_speed"
ATTR_RUNNING: Final = "running"
PLATFORMS: Final = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.SENSOR,
Platform.SWITCH,
]
ENTITY_DISABLED_BY_DEFAULT: Final = ":disabled:"
ENTITY_ENABLED_BY_DEFAULT: Final = ":enabled:"
_ALWAYS_CREATE: Final = [ENTITY_DISABLED_BY_DEFAULT, ENTITY_ENABLED_BY_DEFAULT]
UNKNOWN_IP: Final = "0.0.0.0"
LOCAL_BROADCAST: Final = "255.255.255.255"
# What to do with configured appliance
DISCOVERY_IGNORE = "IGNORE"
DISCOVERY_LAN = "LAN"
DISCOVERY_CLOUD = "CLOUD"
DISCOVERY_WAIT = "WAIT"
DEFAULT_DISCOVERY_MODE = DISCOVERY_LAN
DISCOVERY_BATCH_SIZE: Final = 64
DEFAULT_APP: Final = DEFAULT_APP_FROM_LIB
DEFAULT_USERNAME: Final = ""
DEFAULT_PASSWORD: Final = ""
STARTUP_MESSAGE: Final = f"""
-------------------------------------------------------------------
{NAME}
Version: {__version__}
This is a custom integration!
If you have any issues with this you need to open an issue here:
{ISSUE_URL}
-------------------------------------------------------------------
"""
DISCOVERY_MODE_LABELS = {
DISCOVERY_IGNORE: "Exclude appliance",
DISCOVERY_LAN: "Provide appliance's IPv4 address",
DISCOVERY_WAIT: "Wait for appliance to come online",
DISCOVERY_CLOUD: "Use cloud API to poll appliance",
}
DISCOVERY_MODE_EXPLANATION = {
DISCOVERY_IGNORE: "excluded from polling",
DISCOVERY_LAN: "assigned local network address",
DISCOVERY_WAIT: "waiting to be disovered",
DISCOVERY_CLOUD: "polled using cloud",
}
SUPPORTED_APPLIANCES = {
APPLIANCE_TYPE_AIRCON: "Air conditioner (BETA)",
APPLIANCE_TYPE_DEHUMIDIFIER: "Dehumidifier",
}
# Default period of failed updates before appliance is declared unavailable
# 5 minutes
DEFAULT_TTL: Final = 5

View File

@@ -0,0 +1,143 @@
"""Adds fan entity for each dehumidifer appliance."""
import logging
from typing import Any, Final
from homeassistant.components.fan import (
FanEntityFeature,
FanEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from custom_components.midea_dehumidifier_lan.const import ATTR_FAN_SPEED, DOMAIN
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
ApplianceEntity,
ApplianceUpdateCoordinator,
)
from custom_components.midea_dehumidifier_lan.hub import Hub
_LOGGER = logging.getLogger(__name__)
MODE_NONE: Final = "None"
MODE_AUTO: Final = "Auto"
MODE_LOW: Final = "Low"
MODE_MEDIUM: Final = "Medium"
MODE_HIGH: Final = "High"
PRESET_MODES_7: Final = [MODE_LOW, MODE_MEDIUM, MODE_HIGH]
PRESET_MODES_3: Final = [MODE_LOW, MODE_HIGH]
PRESET_MODES_2: Final = [MODE_AUTO]
_FAN_SPEEDS = {2: PRESET_MODES_2, 3: PRESET_MODES_3, 7: PRESET_MODES_7}
_ON_SPEED = {2: MODE_AUTO, 3: MODE_HIGH, 7: MODE_HIGH}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Sets up fan entity for dehumidifer"""
hub: Hub = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
DehumidiferFan(c) for c in hub.coordinators if c.is_dehumidifier()
)
# pylint: disable=too-many-ancestors
class DehumidiferFan(ApplianceEntity, FanEntity):
"""Entity for managing dehumidifer fan"""
_attr_supported_features = (
FanEntityFeature.PRESET_MODE |
FanEntityFeature.TURN_OFF |
FanEntityFeature.TURN_ON
)
_attr_preset_modes = PRESET_MODES_7
_attr_speed_count = len(PRESET_MODES_7)
_name_suffix = " Fan"
_on_speed = MODE_MEDIUM
def __init__(self, coordinator: ApplianceUpdateCoordinator) -> None:
super().__init__(coordinator)
self._fan_speeds = {
MODE_NONE: 0,
MODE_LOW: 40,
MODE_MEDIUM: 60,
MODE_HIGH: 80,
MODE_AUTO: 101,
}
@property
def is_on(self):
# Override parent logic
return self._attr_is_on
def on_online(self, update: bool) -> None:
supports = self.dehumidifier().capabilities
fan_capability = supports.get("fan_speed", 0)
self._attr_preset_modes = _FAN_SPEEDS.get(fan_capability, PRESET_MODES_7)
self._on_speed = _ON_SPEED.get(fan_capability, MODE_HIGH)
self._attr_speed_count = len(self._attr_preset_modes)
return super().on_online(update)
def on_update(self) -> None:
fan_speed = self.dehumidifier().fan_speed
self._attr_percentage = fan_speed
self._attr_is_on = fan_speed > self._fan_speeds[MODE_LOW]
for mode, mode_speed in self._fan_speeds.items():
if fan_speed <= mode_speed:
self._attr_preset_mode = mode
break
else:
self._attr_preset_mode = MODE_NONE
def set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
speed = self._fan_speeds.get(preset_mode, None)
_LOGGER.debug("Setting speed to %s", speed)
if speed is not None:
self.apply(ATTR_FAN_SPEED, speed)
else:
_LOGGER.warning("Unsupported fan mode %s", preset_mode)
def set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
_LOGGER.debug("Setting percentage to %s", percentage)
self.apply(ATTR_FAN_SPEED, percentage)
def turn_on(
self,
speed: str = None,
percentage: int = None,
preset_mode: str = None,
**kwargs,
) -> None:
"""Turns fan to medium speed."""
updated = False
if preset_mode is not None:
self.set_preset_mode(preset_mode)
updated = True
if percentage is not None:
self.set_percentage(percentage)
updated = True
if speed is not None:
self.set_speed(speed)
updated = True
# _LOGGER.debug("turn_on percentage=%s was_updated=%s", self._attr_percentage, updated)
if (
not updated
and (self._attr_percentage or 0) < self._fan_speeds[self._on_speed]
):
self.set_preset_mode(self._on_speed)
def turn_off(self, **kwargs: Any) -> None:
"""Turns fan to silent speed."""
self.set_preset_mode(MODE_LOW)

View File

@@ -0,0 +1,303 @@
"""The custom component for local network access to Midea appliances
"""
from __future__ import annotations
import logging
from typing import Any, Tuple
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_API_VERSION,
CONF_DEVICES,
CONF_DISCOVERY,
CONF_ID,
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_TYPE,
CONF_UNIQUE_ID,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from midea_beautiful.exceptions import AuthenticationError
from midea_beautiful.lan import LanDevice
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
ApplianceUpdateCoordinator,
)
from custom_components.midea_dehumidifier_lan.appliance_discovery import (
ApplianceDiscoveryHelper,
)
from custom_components.midea_dehumidifier_lan.const import (
CONF_TOKEN_KEY,
DISCOVERY_CLOUD,
DISCOVERY_IGNORE,
DISCOVERY_LAN,
DISCOVERY_WAIT,
NAME,
UNKNOWN_IP,
)
from custom_components.midea_dehumidifier_lan.util import (
AbstractHub,
RedactedConf,
address_ok,
)
_LOGGER = logging.getLogger(__name__)
def _assure_valid_device_configuration(
conf: dict[str, Any], device: dict[str, Any]
) -> bool:
"""Checks device configuration.
If configuration is correct returns ``True``.
If it is not complete, updates it and returns ``False``.
For example, if discovery mode is not set-up corectly it will try to deduce
correct setting."""
discovery_mode = device.get(CONF_DISCOVERY)
if discovery_mode in [
DISCOVERY_IGNORE,
DISCOVERY_WAIT,
DISCOVERY_LAN,
DISCOVERY_CLOUD,
]:
return True
ip_address = device.get(CONF_IP_ADDRESS)
token = device.get(CONF_TOKEN)
key = device.get(CONF_TOKEN_KEY)
if address_ok(ip_address):
device[CONF_DISCOVERY] = DISCOVERY_LAN if token and key else DISCOVERY_WAIT
elif token and key:
device[CONF_DISCOVERY] = DISCOVERY_WAIT
else:
username = conf.get(CONF_USERNAME)
password = conf.get(CONF_PASSWORD)
device[CONF_DISCOVERY] = (
DISCOVERY_CLOUD if username and password else DISCOVERY_IGNORE
)
_LOGGER.warning(
"Updated discovery mode for device %s.",
RedactedConf(device),
)
return False
def _get_placeholder_appliance(device: dict[str, Any]) -> LanDevice:
appliance = LanDevice(
appliance_id=device[CONF_ID],
serial_number=device[CONF_UNIQUE_ID],
appliance_type=device[CONF_TYPE],
token=device.get(CONF_TOKEN),
key=device.get(CONF_TOKEN_KEY) or "",
address=device.get(CONF_IP_ADDRESS, UNKNOWN_IP),
version=device.get(CONF_API_VERSION, 3),
)
appliance.name = device[CONF_NAME]
return appliance
class Hub(AbstractHub): # pylint: disable=too-many-instance-attributes
"""Central class for interacting with appliances"""
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
super().__init__(hass, config_entry)
self.discovery = ApplianceDiscoveryHelper(self)
self.coordinators: list[ApplianceUpdateCoordinator] = []
self.updated_conf = False
async def async_unload(self) -> None:
"""Stops discovery and coordinators"""
_LOGGER.debug("Unloading hub")
self.discovery.stop()
for coordinator in self.coordinators:
# Stop coordinators
coordinator.update_interval = None
async def async_update_config(self) -> None:
"""Updates config entry from Hub's data"""
self.hass.config_entries.async_update_entry(self.config_entry, data=self.config)
async def async_setup(self) -> None:
"""Sets up appliances and creates an update coordinator for
each one
"""
self.discovery.stop()
self.config = {**self.config_entry.data}
devices = [{**device} for device in self.config.get(CONF_DEVICES, [])]
self.config[CONF_DEVICES] = devices
self.errors = {}
self.updated_conf = False
devices = []
for device in self.config[CONF_DEVICES]:
if not _assure_valid_device_configuration(self.config, device):
self.updated_conf = True
coordinator = await self._process_appliance(device)
if coordinator and coordinator.available:
await coordinator.async_config_entry_first_refresh()
devices.append(device)
if self.updated_conf:
await self.async_update_config()
self.discovery.start()
self._notify_setup_errors()
def _notify_setup_errors(self):
if self.errors:
if not self.coordinators:
raise ConfigEntryNotReady(str(self.errors))
for unique_id, error in self.errors.items():
_LOGGER.warning("Device may be offline or unreachable, trying again later. %s", error)
async def _process_appliance(
self, device: dict[str, Any]
) -> ApplianceUpdateCoordinator | None:
discovery_mode = device.get(CONF_DISCOVERY)
# We are waiting for appliance to come online
if discovery_mode == DISCOVERY_IGNORE:
_LOGGER.debug("Ignored appliance for discovery %s", device)
return None
if discovery_mode == DISCOVERY_WAIT:
_LOGGER.debug("Waiting for appliance discovery %s", device)
return None
need_token, appliance = await self.async_discover_device(
device, initial_discovery=True
)
return self._create_coordinator(appliance, device, need_token)
async def async_discover_device(
self, device: dict[str, Any], initial_discovery=False
) -> Tuple[bool, LanDevice | None]:
"""Finds device on local network or cloud"""
discovery_mode = device.get(CONF_DISCOVERY)
use_cloud = discovery_mode == DISCOVERY_CLOUD
need_cloud = use_cloud
lan_mode = discovery_mode == DISCOVERY_LAN
version = device.get(CONF_API_VERSION, 3)
need_token = (
discovery_mode == DISCOVERY_LAN
and version >= 3
and (not device.get(CONF_TOKEN) or not device.get(CONF_TOKEN_KEY))
)
if need_token:
_LOGGER.debug(
"Appliance %s %s has no token,"
" trying to obtain it from Midea cloud API",
device.get(CONF_NAME),
device.get(CONF_UNIQUE_ID),
)
need_cloud = True
if not await self._async_get_cloud_if_needed(device, need_cloud, need_token):
return need_token, None
ip_address = device[CONF_IP_ADDRESS] if lan_mode else None
if not ip_address and not use_cloud:
_LOGGER.error(
"Missing ip_address and cloud discovery is not used for %s."
"Will fall-back to cloud discovery, full configuration is %s",
device.get(CONF_UNIQUE_ID),
RedactedConf(self.config),
)
use_cloud = True
appliance = None
try:
appliance = await self.hass.async_add_executor_job(
self.client.appliance_state,
device[CONF_IP_ADDRESS] if lan_mode else None,
device.get(CONF_TOKEN),
device.get(CONF_TOKEN_KEY),
self.cloud,
use_cloud,
device[CONF_ID],
)
except Exception as ex: # pylint: disable=broad-except
self.errors[
device[CONF_UNIQUE_ID]
] = f"Unable to get state of device {device[CONF_NAME]}: {ex}"
if initial_discovery:
_LOGGER.error(
"Error '%s' while setting up appliance %s,"
" full configuration %s",
ex,
device.get(CONF_UNIQUE_ID),
RedactedConf(self.config),
exc_info=True,
)
else:
_LOGGER.debug(
"Error '%s' while setting up appliance %s",
ex,
RedactedConf(device),
)
return need_token, appliance
async def _async_get_cloud_if_needed(
self, device: dict[str, Any], need_cloud: bool, need_token: bool
) -> bool:
if need_cloud and self.cloud is None:
self._validate_auth_config_complete(device, need_token)
try:
self.cloud = await self.client.async_connect_to_cloud(self.config)
except AuthenticationError as ex:
raise ConfigEntryAuthFailed(
f"Unable to login to Midea cloud {ex}"
) from ex
except Exception as ex: # pylint: disable=broad-except
self.errors[device[CONF_UNIQUE_ID]] = str(ex)
return False
return True
def _validate_auth_config_complete(self, device, need_token):
if not self.config.get(CONF_USERNAME) or not self.config.get(CONF_PASSWORD):
if not device:
cause = ""
elif need_token:
cause = f" because {device.get(CONF_NAME)} is missing token,"
else:
cause = f" because {device.get(CONF_NAME)} uses cloud polling,"
raise ConfigEntryAuthFailed(
f"Integration needs to connect to Midea cloud,"
f"{cause}"
f" but username or password are not configured."
)
def _create_coordinator(
self, appliance: LanDevice | None, device: dict[str, Any], need_token: bool
) -> ApplianceUpdateCoordinator:
available = appliance is not None
if not available:
appliance = _get_placeholder_appliance(device)
appliance.name = device[CONF_NAME]
self._fix_version_if_missing(appliance, device)
self._update_token(appliance, device, need_token)
coordinator = ApplianceUpdateCoordinator(
self.hass, self, appliance, device, available=available
)
_LOGGER.debug("Created coordinator for %s", RedactedConf(device))
self.coordinators.append(coordinator)
return coordinator
def _update_token(
self, appliance: LanDevice, device: dict[str, Any], need_token: bool
) -> None:
if need_token and appliance.token and appliance.key:
device[CONF_TOKEN] = appliance.token
device[CONF_TOKEN_KEY] = appliance.key
self.updated_conf = True
_LOGGER.debug("Updating token for %s", appliance)
def _fix_version_if_missing(
self, appliance: LanDevice, device: dict[str, Any]
) -> None:
if not device.get(CONF_API_VERSION):
device[CONF_API_VERSION] = appliance.version
self.updated_conf = True
_LOGGER.debug("Updating version for %s", appliance)

View File

@@ -0,0 +1,125 @@
"""Adds dehumidifer entity for each dehumidifer appliance."""
import logging
from typing import Final
from homeassistant.components.humidifier import HumidifierDeviceClass, HumidifierEntity
from homeassistant.components.humidifier.const import HumidifierEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from custom_components.midea_dehumidifier_lan.const import (
ATTR_RUNNING,
DOMAIN,
MAX_TARGET_HUMIDITY,
MIN_TARGET_HUMIDITY,
)
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
ApplianceEntity,
ApplianceUpdateCoordinator,
)
from custom_components.midea_dehumidifier_lan.hub import Hub
_LOGGER = logging.getLogger(__name__)
MODE_SET: Final = "Set"
MODE_DRY: Final = "Dry"
MODE_SMART: Final = "Smart"
MODE_CONTINOUS: Final = "Continuous"
MODE_PURIFIER: Final = "Purifier"
MODE_ANTIMOULD: Final = "Antimould"
MODE_FAN: Final = "Fan"
ENTITY_ID_FORMAT = DOMAIN + ".{}"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Sets up dehumidifier entites"""
hub: Hub = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
DehumidifierEntity(c) for c in hub.coordinators if c.is_dehumidifier()
)
_MODES = [
(1, MODE_SET),
(2, MODE_CONTINOUS),
(3, MODE_SMART),
(4, MODE_DRY),
(6, MODE_PURIFIER),
(7, MODE_ANTIMOULD),
]
_MODES_FROM_CAPABILITY = {
1: [MODE_PURIFIER],
2: [MODE_ANTIMOULD],
3: [MODE_PURIFIER, MODE_ANTIMOULD],
4: [MODE_FAN],
}
# pylint: disable=too-many-ancestors,too-many-instance-attributes
class DehumidifierEntity(ApplianceEntity, HumidifierEntity):
"""(de)Humidifer entity for Midea dehumidifier"""
_attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER
_attr_max_humidity = MAX_TARGET_HUMIDITY
_attr_min_humidity = MIN_TARGET_HUMIDITY
_attr_supported_features = HumidifierEntityFeature.MODES
_name_suffix = ""
_add_extra_attrs = True
def __init__(self, coordinator: ApplianceUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_mode = None
self._attr_available_modes = [MODE_SET]
def on_online(self, update: bool) -> None:
capabilities = self.coordinator.appliance.state.capabilities
self._attr_available_modes = [MODE_SET]
if capabilities.get("auto"):
self._attr_available_modes.append(MODE_SMART)
self._attr_available_modes.append(MODE_CONTINOUS)
if capabilities.get("dry_clothes"):
self._attr_available_modes.append(MODE_DRY)
more_modes = capabilities.get("mode", 0)
self._attr_available_modes += _MODES_FROM_CAPABILITY.get(more_modes, [])
super().on_online(update)
def on_update(self) -> None:
dehumi = self.dehumidifier()
self._attr_mode = next((i[1] for i in _MODES if i[0] == dehumi.mode), MODE_SET)
self._attr_target_humidity = dehumi.target_humidity
self._attr_current_humidity = dehumi.current_humidity # add new attribute current_humidity
self._attr_is_on = dehumi.running
super().on_update()
def turn_on(self, **kwargs) -> None: # pylint: disable=unused-argument
"""Turn the entity on."""
self.apply(ATTR_RUNNING, True)
def turn_off(self, **kwargs) -> None: # pylint: disable=unused-argument
"""Turn the entity off."""
self.apply(ATTR_RUNNING, False)
def set_mode(self, mode) -> None:
"""Set new target preset mode."""
midea_mode = next((i[0] for i in _MODES if i[1] == mode), None)
if midea_mode is None:
_LOGGER.debug("Unsupported dehumidifer mode %s", mode)
midea_mode = 1
self.apply("mode", midea_mode)
def set_humidity(self, humidity) -> None:
"""Set new target humidity."""
self.apply("target_humidity", humidity)

View File

@@ -0,0 +1,19 @@
{
"domain": "midea_dehumidifier_lan",
"name": "Midea Air Appliances (LAN)",
"after_dependencies": [
"network",
"logger"
],
"codeowners": [
"@nbogojevic"
],
"config_flow": true,
"documentation": "https://github.com/nbogojevic/homeassistant-midea-air-appliances-lan/blob/main/README.md",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/nbogojevic/homeassistant-midea-air-appliances-lan/issues",
"requirements": [
"midea-beautiful-air==0.10.5"
],
"version": "0.9.6"
}

View File

@@ -0,0 +1,101 @@
"""Adds sensors for each appliance."""
from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
SensorDeviceClass
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
ApplianceEntity,
)
from custom_components.midea_dehumidifier_lan.const import DOMAIN, UNIQUE_CLIMATE_PREFIX
from custom_components.midea_dehumidifier_lan.hub import Hub
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Sets up current environment humidity and temperature sensors"""
hub: Hub = hass.data[DOMAIN][config_entry.entry_id]
# Dehumidifier sensors
async_add_entities(
CurrentHumiditySensor(c) for c in hub.coordinators if c.is_dehumidifier()
)
async_add_entities(
CurrentTemperatureSensor(c) for c in hub.coordinators if c.is_dehumidifier()
)
async_add_entities(
TankLevelSensor(c)
for c in hub.coordinators
if c.is_dehumidifier() and c.dehumidifier().capabilities.get("water_level")
)
# Climate sensors
async_add_entities(
OutsideTemperatureSensor(c) for c in hub.coordinators if c.is_climate()
)
class CurrentHumiditySensor(ApplianceEntity, SensorEntity):
"""Crrent environment humidity sensor"""
_attr_device_class = SensorDeviceClass.HUMIDITY
_attr_native_unit_of_measurement = PERCENTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
_name_suffix = " Humidity"
def on_update(self) -> None:
self._attr_native_value = self.dehumidifier().current_humidity
class CurrentTemperatureSensor(ApplianceEntity, SensorEntity):
"""Current environment temperature sensor"""
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_state_class = SensorStateClass.MEASUREMENT
_name_suffix = " Temperature"
def on_update(self) -> None:
self._attr_native_value = self.dehumidifier().current_temperature
class TankLevelSensor(ApplianceEntity, SensorEntity):
"""Current tank water level sensor"""
_attr_native_unit_of_measurement = PERCENTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
_name_suffix = " Water Level"
def on_online(self, update: bool) -> None:
self._attr_entity_registry_enabled_default = (
self.dehumidifier().capabilities.get("water_level", False)
)
return super().on_online(update)
def on_update(self) -> None:
self._attr_native_value = self.dehumidifier().tank_level
class OutsideTemperatureSensor(ApplianceEntity, SensorEntity):
"""Current outside temperature sensor"""
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_state_class = SensorStateClass.MEASUREMENT
_unique_id_prefx = UNIQUE_CLIMATE_PREFIX
_name_suffix = " Outdoor Temperature"
def on_update(self) -> None:
self._attr_native_value = self.airconditioner().outdoor_temperature

View File

@@ -0,0 +1,182 @@
"""Support for different Midea appliances switches"""
from dataclasses import dataclass
from typing import Final
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from custom_components.midea_dehumidifier_lan.hub import (
Hub,
)
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
ApplianceEntity,
ApplianceUpdateCoordinator,
)
from custom_components.midea_dehumidifier_lan.const import (
ENTITY_DISABLED_BY_DEFAULT,
DOMAIN,
UNIQUE_CLIMATE_PREFIX,
UNIQUE_DEHUMIDIFIER_PREFIX,
)
from custom_components.midea_dehumidifier_lan.util import is_enabled_by_capabilities
@dataclass
class _MideaSwitchDescriptor:
attr: str
name: str
icon: str
capability: str
prefix: str
ION_MODE_SWITCH: Final = _MideaSwitchDescriptor(
attr="ion_mode",
name="Ion Mode",
icon="mdi:air-purifier",
capability="ion",
prefix=UNIQUE_DEHUMIDIFIER_PREFIX,
)
PUMP_SWITCH: Final = _MideaSwitchDescriptor(
attr="pump",
name="Pump",
icon="mdi:pump",
capability="pump",
prefix=UNIQUE_DEHUMIDIFIER_PREFIX,
)
PUMP_SWITCH_ENABLED: Final = _MideaSwitchDescriptor(
attr="pump_enabled",
name="Pump Enabled",
icon="mdi:electric-switch",
capability="pump",
prefix=UNIQUE_DEHUMIDIFIER_PREFIX,
)
DEHUMIDIFIER_BEEP_SWITCH: Final = _MideaSwitchDescriptor(
attr="beep_prompt",
name="Beep",
icon="mdi:bell-check",
capability=ENTITY_DISABLED_BY_DEFAULT,
prefix=UNIQUE_DEHUMIDIFIER_PREFIX,
)
DEHUMIDIFER_SWITCHES: Final = [
DEHUMIDIFIER_BEEP_SWITCH,
ION_MODE_SWITCH,
PUMP_SWITCH,
PUMP_SWITCH_ENABLED,
]
# Climate
CLIMATE_BEEP_SWITCH: Final = _MideaSwitchDescriptor(
attr="beep_prompt",
name="Beep",
icon="mdi:bell-check",
capability=ENTITY_DISABLED_BY_DEFAULT,
prefix=UNIQUE_CLIMATE_PREFIX,
)
FAHRENHEIT_SWITCH: Final = _MideaSwitchDescriptor(
attr="fahrenheit",
name="Fahrenheit",
icon="mdi:temperature-fahrenheit",
capability="fahrenheit",
prefix=UNIQUE_CLIMATE_PREFIX,
)
DRYER_SWITCH: Final = _MideaSwitchDescriptor(
attr="dryer",
name="Dry Mode",
icon="mdi:water-opacity",
capability="_DISABLED_BY_DEFAULT",
prefix=UNIQUE_CLIMATE_PREFIX,
)
PURIFIER_SWITCH: Final = _MideaSwitchDescriptor(
attr="purifier",
name="Purifier",
icon="mdi:air-purifier",
capability="anion",
prefix=UNIQUE_CLIMATE_PREFIX,
)
TURBO_FAN_SWITCH: Final = _MideaSwitchDescriptor(
attr="turbo_fan",
name="Turbo Fan",
icon="mdi:fan-alert",
capability="strong_fan",
prefix=UNIQUE_CLIMATE_PREFIX,
)
# SCREEN_SWITCH: Final = _MideaSwitchDescriptor(
# attr="show_screen",
# name="Show Screen",
# icon="mdi:clock-digital",
# capability="screen_display",
# prefix=UNIQUE_CLIMATE_PREFIX,
# )
CLIMATE_SWITCHES: Final = [
CLIMATE_BEEP_SWITCH,
DRYER_SWITCH,
FAHRENHEIT_SWITCH,
# SCREEN_SWITCH,
PURIFIER_SWITCH,
TURBO_FAN_SWITCH,
]
def _is_enabled(
coordinator: ApplianceUpdateCoordinator, switch: _MideaSwitchDescriptor
) -> bool:
return is_enabled_by_capabilities(
coordinator.appliance.state.capabilities, switch.capability
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Sets up appliance switches"""
hub: Hub = hass.data[DOMAIN][config_entry.entry_id]
switches = []
# Dehumidifier sensors
for switch in DEHUMIDIFER_SWITCHES:
for coord in hub.coordinators:
if coord.is_dehumidifier() and _is_enabled(coord, switch):
switches.append(MideaSwitch(coord, switch))
# Air conditioner entities
for switch in CLIMATE_SWITCHES:
for coord in hub.coordinators:
if coord.is_climate() and _is_enabled(coord, switch):
switches.append(MideaSwitch(coord, switch))
async_add_entities(switches)
# pylint: disable=too-many-ancestors
class MideaSwitch(ApplianceEntity, SwitchEntity):
"""Generic attr based switch"""
def __init__(
self,
coordinator: ApplianceUpdateCoordinator,
descriptor: _MideaSwitchDescriptor,
) -> None:
self._switch_descriptor = descriptor
self._capability_attr = descriptor.capability
self._unique_id_prefix = descriptor.prefix
self._name_suffix = " " + descriptor.name.strip()
super().__init__(coordinator)
self._attr_icon = descriptor.icon
self._attribute_name = descriptor.attr
def on_update(self) -> None:
self._attr_is_on = getattr(self.appliance.state, self._attribute_name, None)
def turn_on(self, **kwargs) -> None:
"""Turn the entity on."""
self.apply(self._attribute_name, True)
def turn_off(self, **kwargs) -> None:
"""Turn the entity off."""
self.apply(self._attribute_name, False)

View File

@@ -0,0 +1,96 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "Account",
"password": "Password",
"mobile_app": "Mobile app",
"advanced_settings": "Advanced settings"
},
"description": "Please enter username and password that you use to connect with the selected Midea mobile application.",
"title": "Sign-in with Midea app account"
},
"advanced_settings": {
"data": {
"username": "Account",
"password": "Password",
"appkey": "Mobile app key",
"appid": "Mobile app id",
"broadcast_address": "Appliance address or network range",
"include": "Discover following appliance categories:",
"scan_interval": "Network scan interval",
"debug": "Advanced debug mode"
},
"description": "You can specify network address (e.g. 192.0.2.2) or range (e.g. 192.0.2.4/24) to search for specific appliance(s) if regular discovery doesn't work.",
"title": "Advanced settings"
},
"unreachable_appliance": {
"data": {
"discovery": "Choose action (discovery mode)",
"ip_address": "IPv4 address",
"name": "Device name",
"token": "Token",
"token_key": "Token key",
"ttl": "Minutes before unavailable"
},
"description": "We were unable to discover an appliance called {name} with the serial number {id}.\n\nPlease choose if you want to exclude appliance, search for it later, use cloud API to poll it, or provide its IPv4 address if you know it.\n\nYou can also provide token and key if you have them. If not, we will try to obtain them from Midea cloud.",
"title": "Unable to discover appliance"
},
"reauth_confirm": {
"data": {
"username": "Account",
"password": "Password",
"appkey": "Mobile app key",
"appid": "Mobile app id",
"broadcast_address": "Appliance or network broadcast address"
},
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Midea Air Appliance (LAN) integration needs to re-authenticate your account"
}
},
"error": {
"cannot_connect": "Unable to connect to appliance ({cause}).",
"connection_error": "Connection error ({cause}).",
"duplicate_ip_provided": "Same IPv4 address was used for {cause}. Please specify different addresses for different appliances.",
"invalid_auth": "Invalid username or password ({cause})",
"invalid_ip_address": "Invalid IPv4 address ({cause}), please enter a valid IPv4 address (e.g. 192.0.2.2)",
"invalid_ip_range": "Invalid IPv4 address or range ({cause}), please enter a valid IPv4 address or range (e.g. 192.0.2.2 or 192.0.2.0/24)",
"no_cloud": "Unable to connect to Midea cloud API ({cause}).",
"not_discovered": "Unable to find appliance at specified IPv4 address.",
"midea_client": "An error in communication with Midea API has occurred. See log for more information.",
"unknown": "An unknown error has occurred. See log for more information."
},
"abort": {
"single_instance_allowed": "Already defined a Midea app account. Only a single account is supported for Midea Air Appliances (LAN).",
"reauth_successful": "Re-authentication was successful",
"no_configured_devices": "There are no devices to configure"
}
},
"options": {
"step": {
"appliance": {
"description": "Appliance {index} of {count}",
"data": {
"discovery": "Choose action (discovery mode)",
"ip_address": "IPv4 address",
"name": "Device name",
"token": "Token",
"token_key": "Token key",
"ttl": "Minutes before unavailable"
}
}
},
"error": {
"cannot_connect": "Unable to connect to appliance ({cause}).",
"connection_error": "Connection error ({cause}).",
"duplicate_ip_provided": "Same IPv4 address was used for {cause}. Please specify different address for different appliances.",
"invalid_auth": "Invalid username or password ({cause})",
"invalid_ip_address": "Invalid IPv4 address ({cause}), please enter a valid IPv4 address (e.g. 192.0.2.2)",
"invalid_ip_range": "Invalid IPv4 address or range ({cause}), please enter a valid IPv4 address or range (e.g. 192.0.2.2 or 192.0.2.0/24)",
"no_cloud": "Unable to connect to Midea cloud API ({cause}).",
"not_discovered": "Unable to find appliance at specified IPv4 address.",
"unknown": "An unknown error has occurred. See log for more information."
}
}
}

View File

@@ -0,0 +1,96 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "Account",
"password": "Password",
"mobile_app": "App mobile",
"advanced_settings": "Impostazioni avanzate"
},
"description": "Inserisci il nome utente e la password che usi per connetterti con l'applicazione Midea selezionata.",
"title": "Accedi con l'account dell'app Midea"
},
"advanced_settings": {
"data": {
"username": "Account",
"password": "Password",
"appkey": "Chiave dell'app mobile",
"appid": "ID dell'app mobile",
"broadcast_address": "Indirizzo dell'elettrodomestico o della rete",
"include": "Scopri le seguenti categorie di elettrodomestici:",
"scan_interval": "Intervallo di scansione della rete",
"debug": "Modalità di debug avanzata"
},
"description": "Puoi specificare l'indirizzo di rete (ad esempio 192.0.2.2) o il range (ad esempio 192.0.2.4/24) per cercare un elettrodomestico specifico se la ricerca regolare non funziona.",
"title": "Impostazioni avanzate"
},
"unreachable_appliance": {
"data": {
"discovery": "Scegli l'azione (modalità di ricerca)",
"ip_address": "Indirizzo IPv4",
"name": "Nome del dispositivo",
"token": "Token",
"token_key": "Chiave del token",
"ttl": "Minuti prima che diventi non disponibile"
},
"description": "Non siamo riusciti a scoprire un elettrodomestico chiamato {name} con il numero di serie {id}.\n\nScegli se escludere l'elettrodomestico, cercarlo successivamente, usare l'API cloud per interrogarlo o fornire il suo indirizzo IPv4 se lo conosci.\n\nPuoi anche fornire il token e la chiave se li hai. In caso contrario, proveremo a ottenerli dal cloud Midea.",
"title": "Impossibile scoprire l'elettrodomestico"
},
"reauth_confirm": {
"data": {
"username": "Account",
"password": "Password",
"appkey": "Chiave dell'app mobile",
"appid": "ID dell'app mobile",
"broadcast_address": "Indirizzo dell'elettrodomestico o della rete di broadcast"
},
"title": "[%key:common::config_flow::title::reauth%]",
"description": "L'integrazione Midea Air Appliance (LAN) ha bisogno di riautenticare il tuo account"
}
},
"error": {
"cannot_connect": "Impossibile connettersi all'elettrodomestico ({cause}).",
"connection_error": "Errore di connessione ({cause}).",
"duplicate_ip_provided": "Lo stesso indirizzo IPv4 è stato usato per {cause}. Specifica indirizzi diversi per elettrodomestici diversi.",
"invalid_auth": "Nome utente o password non validi ({cause})",
"invalid_ip_address": "Indirizzo IPv4 non valido ({cause}), inserisci un indirizzo IPv4 valido (ad esempio 192.0.2.2)",
"invalid_ip_range": "Indirizzo IPv4 o intervallo non valido ({cause}), inserisci un indirizzo IPv4 o intervallo valido (ad esempio 192.0.2.2 o 192.0.2.0/24)",
"no_cloud": "Impossibile connettersi all'API cloud di Midea ({cause}).",
"not_discovered": "Impossibile trovare l'elettrodomestico all'indirizzo IPv4 specificato.",
"midea_client": "Si è verificato un errore nella comunicazione con l'API di Midea. Consulta il registro per ulteriori informazioni.",
"unknown": "Si è verificato un errore sconosciuto. Consulta il registro per ulteriori informazioni."
},
"abort": {
"single_instance_allowed": "Hai già configurato un account Midea. È supportato un solo account per gli elettrodomestici Midea Air (LAN).",
"reauth_successful": "Rautenticazione riuscita",
"no_configured_devices": "Non ci sono dispositivi da configurare"
}
},
"options": {
"step": {
"appliance": {
"description": "Elettrodomestico {index} di {count}",
"data": {
"discovery": "Scegli azione (modalità di scoperta)",
"ip_address": "Indirizzo IPv4",
"name": "Nome dispositivo",
"token": "Token",
"token_key": "Chiave token",
"ttl": "Minuti prima di non disponibile"
}
}
},
"error": {
"cannot_connect": "Impossibile connettersi all'elettrodomestico ({cause}).",
"connection_error": "Errore di connessione ({cause}).",
"duplicate_ip_provided": "Lo stesso indirizzo IPv4 è stato usato per {cause}. Specifica indirizzi diversi per elettrodomestici diversi.",
"invalid_auth": "Nome utente o password non validi ({cause})",
"invalid_ip_address": "Indirizzo IPv4 non valido ({cause}), inserisci un indirizzo IPv4 valido (ad esempio 192.0.2.2)",
"invalid_ip_range": "Indirizzo IPv4 o intervallo non valido ({cause}), inserisci un indirizzo IPv4 o intervallo valido (ad esempio 192.0.2.2 o 192.0.2.0/24)",
"no_cloud": "Impossibile connettersi all'API cloud di Midea ({cause}).",
"not_discovered": "Impossibile trovare l'elettrodomestico all'indirizzo IPv4 specificato.",
"unknown": "Si è verificato un errore sconosciuto. Consulta il registro per ulteriori informazioni."
}
}
}

View File

@@ -0,0 +1,96 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "Conta",
"password": "Password",
"mobile_app": "Aplicação Móvel",
"advanced_settings": "Definições Avançadas"
},
"description": "Por favor, introduza o nome de utilizador e a palavra-passe que utiliza para conectar-se à aplicação móvel Midea selecionada.",
"title": "Iniciar sessão com a conta da aplicação Midea"
},
"advanced_settings": {
"data": {
"username": "Conta",
"password": "Password",
"appkey": "Key da Aplicação Móvel",
"appid": "Id da Aplicação Móvel",
"broadcast_address": "Endereço do AC / Desumificador ou gama de rede",
"include": "Desbra as seguintes categorias de AC / Desumificador:",
"scan_interval": "Intervalo de scan da rede",
"debug": "Modo de depuraçao avançado"
},
"description": "Pode especificar o endereço de rede (por exemplo, 192.0.2.2) ou gama (por exemplo, 192.0.2.4/24) para procurar AC / Desumificador(es) específico(s) se a descoberta regular não funcionar.",
"title": "Definições Avançadas"
},
"unreachable_appliance": {
"data": {
"discovery": "Escolha a ação (modo discovery)",
"ip_address": "Endereço IPv4",
"name": "Nome do dispositivo",
"token": "Token",
"token_key": "Token key",
"ttl": "Minutos antes de ficar indisponível"
},
"description": "Não conseguimos descobrir um AC / Desumificador chamado {nome} com o número de série {id}.\n\nPor favor, escolha se deseja excluir o AC / Desumificador, procurá-lo mais tarde, usar a API da cloud para verificá-lo ou fornecer o seu endereço IPv4.\n\nPode também fornecer o token e a key se os tiver. Caso contrário, tentaremos obtê-los a partir da cloud da Midea.",
"title": "Unable to discover appliance"
},
"reauth_confirm": {
"data": {
"username": "Conta",
"password": "Password",
"appkey": "Key da aplicação móvel",
"appid": "ID da aplicação móvel",
"broadcast_address": "AC / Desumificador ou IP de broadcast da rede"
},
"title": "[%key:common::config_flow::title::reauth%]",
"description": "A integração do AC / Desumificador da Midea (LAN) precisa de reautenticar a sua conta"
}
},
"error": {
"cannot_connect": "Não é possível conectar ao dispositivo ({cause}).",
"connection_error": "Erro de conexão ({cause}).",
"duplicate_ip_provided": "O mesmo endereço IPv4 foi usado para {cause}. Por favor, especifique endereços IP diferentes para diferentes dispositivos.",
"invalid_auth": "Conta ou password inválido ({cause})",
"invalid_ip_address": "Endereço IPv4 inválido ({cause}), por favor insira um endereço IPv4 válido (por exemplo, 192.0.2.2)",
"invalid_ip_range": "Endereço ou gama IPv4 inválida ({cause}), por favor insira um endereço ou gama IPv4 válidos (por exemplo, 192.0.2.2 ou 192.0.2.0/24)",
"no_cloud": "Não é possível conectar à API da cloud Midea ({cause}).",
"not_discovered": "Não foi possível encontrar o dispositivo no endereço IPv4 especificado.",
"midea_client": "Ocorreu um erro na comunicação com a API da Midea. Consulte o log para mais informações.",
"unknown": "Ocorreu um erro desconhecido. Consulte o log para mais informações."
},
"abort": {
"single_instance_allowed": "Já está definida uma conta Midea. Apenas uma única conta é suportada para os seus dispositivos Midea (LAN).",
"reauth_successful": "A reautenticação foi bem-sucedida",
"no_configured_devices": "Não há dispositivos para configurar"
}
},
"options": {
"step": {
"appliance": {
"description": "Dispositivo {index} de {count}",
"data": {
"discovery": "Escolha a acção (Modo discovery)",
"ip_address": "Endereço IPv4",
"name": "Nome do Dispositivo",
"token": "Token",
"token_key": "Token key",
"ttl": "Minutos antes de ficar indisponível"
}
}
},
"error": {
"cannot_connect": "Não é possível conectar ao dispositivo ({cause}).",
"connection_error": "Erro de conexão ({cause}).",
"duplicate_ip_provided": "O mesmo endereço IPv4 foi usado para {cause}. Por favor, especifique endereços IP diferentes para diferentes dispositivos.",
"invalid_auth": "Conta ou password inválido ({cause})",
"invalid_ip_address": "Endereço IPv4 inválido ({cause}), por favor insira um endereço IPv4 válido (por exemplo, 192.0.2.2)",
"invalid_ip_range": "Endereço ou gama IPv4 inválida ({cause}), por favor insira um endereço ou gama IPv4 válidos (por exemplo, 192.0.2.2 ou 192.0.2.0/24)",
"no_cloud": "Não é possível conectar à API da cloud Midea ({cause}).",
"not_discovered": "Não foi possível encontrar o dispositivo no endereço IPv4 especificado.",
"unknown": "Ocorreu um erro desconhecido. Consulte o log para mais informações."
}
}
}

View File

@@ -0,0 +1,96 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "Účet",
"password": "Heslo",
"mobile_app": "Mobile app",
"advanced_settings": "Pokročilé nastavenia"
},
"description": "Zadajte používateľské meno a heslo, ktoré používate na pripojenie k vybranej mobilnej aplikácii Midea.",
"title": "Prihláste sa pomocou účtu aplikácie Midea"
},
"advanced_settings": {
"data": {
"username": "Účet",
"password": "Heslo",
"appkey": "Mobile app kľúč",
"appid": "Mobile app id",
"broadcast_address": "Adresa spotrebiča alebo rozsah siete",
"include": "Objavte nasledujúce kategórie spotrebičov:",
"scan_interval": "Interval skenovania siete",
"debug": "Pokročilý režim ladenia"
},
"description": "Ak bežné zisťovanie nefunguje, môžete zadať sieťovú adresu (napr. 192.0.2.2) alebo rozsah (napr. 192.0.2.4/24) a vyhľadať konkrétne zariadenia.",
"title": "Pokročilé nastavenia"
},
"unreachable_appliance": {
"data": {
"discovery": "Vyberte akciu (režim objavovania)",
"ip_address": "IPv4 addresa",
"name": "Názov zariadenia",
"token": "Token",
"token_key": "Token kľúč",
"ttl": "Minúty predtým nedostupné"
},
"description": "Nepodarilo sa nám nájsť zariadenie s názvom {name} so sériovým číslom {id}.\n\nVyberte, či chcete zariadenie vylúčiť, vyhľadať ho neskôr, použiť cloudové rozhranie API na prieskum alebo poskytnúť jeho adresu IPv4, ak ju poznáte.\n\nMôžete tiež poskytnúť token a kľúč, ak ich máte. Ak nie, pokúsime sa ich získať z cloudu Midea.",
"title": "Zariadenie sa nepodarilo nájsť"
},
"reauth_confirm": {
"data": {
"username": "Účet",
"password": "Heslo",
"appkey": "Mobile app kľúč",
"appid": "Mobile app id",
"broadcast_address": "Adresa zariadenia alebo sieťového vysielania"
},
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Integrácia zariadenia Midea Air Appliance (LAN) musí znova overiť váš účet"
}
},
"error": {
"cannot_connect": "Nedá sa pripojiť k zariadeniu ({cause}).",
"connection_error": "Chyba spojenia({cause}).",
"duplicate_ip_provided": "Bola použitá rovnaká adresa IPv4 {cause}. Uveďte rôzne adresy pre rôzne spotrebiče.",
"invalid_auth": "nesprávne užívateľské meno alebo heslo ({cause})",
"invalid_ip_address": "Neplatná adresa IPv4 ({cause}), zadajte platnú adresu IPv4 (napr. 192.0.2.2)",
"invalid_ip_range": "Neplatná adresa alebo rozsah IPv4 ({cause}), zadajte platnú adresu IPv4 alebo rozsah (napr. 192.0.2.2 alebo 192.0.2.0/24)",
"no_cloud": "Nedá sa pripojiť ku cloudovému API Midea ({cause}).",
"not_discovered": "Nemožno nájsť zariadenie na zadanej adrese IPv4.",
"midea_client": "Vyskytla sa chyba v komunikácii s Midea API. Viac informácií nájdete v denníku.",
"unknown": "Vyskytla sa neznáma chyba. Viac informácií nájdete v denníku."
},
"abort": {
"single_instance_allowed": "Už definovaný účet aplikácie Midea. Pre zariadenia Midea Air Appliance (LAN) je podporovaný iba jeden účet.",
"reauth_successful": "Opätovné overenie bolo úspešné",
"no_configured_devices": "Neexistujú žiadne zariadenia na konfiguráciu"
}
},
"options": {
"step": {
"appliance": {
"description": "Spotrebič {index} z {count}",
"data": {
"discovery": "Vyberte akciu (režim objavovania)",
"ip_address": "IPv4 addresa",
"name": "Názov zariadenia",
"token": "Token",
"token_key": "Token kľúč",
"ttl": "Minúty predtým nedostupné"
}
}
},
"error": {
"cannot_connect": "Nedá sa pripojiť k zariadeniu ({cause}).",
"connection_error": "Chyba spojenia ({cause}).",
"duplicate_ip_provided": "Bola použitá rovnaká adresa IPv4 {cause}. Zadajte inú adresu pre rôzne spotrebiče.",
"invalid_auth": "Nesprávne užívateľské meno alebo heslo ({cause})",
"invalid_ip_address": "Neplatná IPv4 adresa ({cause}), zadajte platnú adresu IPv4 (napr. 192.0.2.2)",
"invalid_ip_range": "Neplatná IPv4 adresa alebo rozsah ({cause}), zadajte platnú adresu IPv4 alebo rozsah (napr. 192.0.2.2 alebo 192.0.2.0/24)",
"no_cloud": "Nedá sa pripojiť ku cloudovému API Midea ({cause}).",
"not_discovered": "Nemožno nájsť zariadenie na zadanej adrese IPv4.",
"unknown": "Vyskytla sa neznáma chyba. Viac informácií nájdete v denníku."
}
}
}

View File

@@ -0,0 +1,244 @@
"""Utilities for Midea Air Appliances integration"""
from __future__ import annotations
from abc import ABC, abstractmethod
from copy import deepcopy
from typing import Any, Tuple, cast, final
import homeassistant.components.logger as hass_logger
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICES,
CONF_ID,
CONF_INCLUDE,
CONF_PASSWORD,
CONF_TOKEN,
CONF_UNIQUE_ID,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
import midea_beautiful as midea_beautiful_api
from midea_beautiful.appliance import AirConditionerAppliance, DehumidifierAppliance
from midea_beautiful.cloud import MideaCloud
from midea_beautiful.lan import LanDevice
from midea_beautiful.midea import (
APPLIANCE_TYPE_AIRCON,
APPLIANCE_TYPE_DEHUMIDIFIER,
)
from midea_beautiful.util import very_verbose
from custom_components.midea_dehumidifier_lan.const import (
_ALWAYS_CREATE,
CONF_MOBILE_APP,
CONF_TOKEN_KEY,
UNKNOWN_IP,
)
_SUPPORTABLE_APPLIANCES = {
APPLIANCE_TYPE_AIRCON: AirConditionerAppliance.supported,
APPLIANCE_TYPE_DEHUMIDIFIER: DehumidifierAppliance.supported,
}
def _redact(data: dict[str, Any], key: str, char="*", length: int = 0) -> None:
"""Redacts/obfuscates key in disctionary"""
if data.get(key) is not None:
to_redact = str(data[key])
if length <= 0 or length >= len(to_redact):
data[key] = char * len(to_redact)
else:
data[key] = to_redact[:-length] + char * length
def _redact_device_conf(device) -> None:
_redact(device, CONF_TOKEN)
_redact(device, CONF_TOKEN_KEY)
_redact(device, CONF_UNIQUE_ID, length=8)
_redact(device, CONF_ID, length=4)
class RedactedConf:
"""Outputs redacted configuration dictionary by removing or masking
confidential data."""
def __init__(self, data: dict[str, Any]) -> None:
"""Remove sensitive information from configuration"""
self.conf = data
@property
def __dict__(self) -> dict[str, Any]:
conf = deepcopy(self.conf)
_redact(conf, CONF_USERNAME)
_redact(conf, CONF_PASSWORD)
_redact_device_conf(conf)
if conf.get(CONF_DEVICES) and isinstance(conf.get(CONF_DEVICES), list):
for device in conf[CONF_DEVICES]:
if device and isinstance(device, dict):
_redact_device_conf(device)
return conf
def __str__(self) -> str:
"""Remove sensitive information from configuration"""
return str(self.__dict__)
def is_enabled_by_capabilities(capabilities: dict[str, Any], capability: str) -> bool:
"""Returns True if given capability is enabled"""
if capability in _ALWAYS_CREATE:
return True
if not capabilities or capabilities.get(capability, False):
return True
return False
def is_climate(appliance: LanDevice) -> bool:
"""True if appliance is air conditioner"""
return AirConditionerAppliance.supported(appliance.type)
def is_dehumidifier(appliance: LanDevice) -> bool:
"""True if appliance is dehumidifier"""
return DehumidifierAppliance.supported(appliance.type)
def supported_appliance(conf: dict, appliance: LanDevice) -> bool:
"""Checks if appliance is supported by integration"""
included = conf.get(CONF_INCLUDE, [])
for type_id, check in _SUPPORTABLE_APPLIANCES.items():
if type_id in included and check(appliance.type):
return True
return False
class ApplianceCoordinator(ABC): # pylint: disable=too-few-public-methods
"""Abstract interface for Appliance update coordinators"""
appliance: LanDevice
available: bool
device: dict[str, Any]
def is_climate(self) -> bool:
"""True if appliance is air conditioner"""
return is_climate(self.appliance)
def is_dehumidifier(self) -> bool:
"""True if appliance is dehumidifier"""
return is_dehumidifier(self.appliance)
@final
def dehumidifier(self) -> DehumidifierAppliance:
"""Returns state as dehumidifier"""
return cast(DehumidifierAppliance, self.appliance.state)
@final
def airconditioner(self) -> AirConditionerAppliance:
"""Returns state as air conditioner"""
return cast(AirConditionerAppliance, self.appliance.state)
class AbstractHub(ABC):
"""Interface for central class for interacting with appliances"""
coordinators: list[ApplianceCoordinator]
config: dict[str, Any]
errors: dict[str, Any]
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
self.client = MideaClient(hass)
self.cloud: MideaCloud | None = None
self.hass = hass
self.config_entry = config_entry
@abstractmethod
async def async_discover_device(
self, device: dict[str, Any], initial_discovery=False
) -> Tuple[bool, LanDevice | None]:
"""Finds device on local network or cloud"""
return False, None
@abstractmethod
async def async_update_config(self) -> None:
"""Updates config entry from Hub's data"""
class MideaClient:
"""Delegate to midea API"""
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
async def async_debug_mode(self, activate: bool) -> None:
"""Activated advanced debug mode."""
very_verbose(activate)
if activate:
await self.hass.services.async_call(
domain=hass_logger.DOMAIN,
service=hass_logger.SERVICE_SET_LEVEL,
service_data={"midea_beautiful": "DEBUG"},
)
async def async_connect_to_cloud(self, conf: dict[str, Any]) -> MideaCloud:
"""Delegate to midea_beautiful_api.connect_to_cloud"""
return await self.hass.async_add_executor_job(
self.connect_to_cloud,
conf,
)
# pylint: disable=no-self-use
def connect_to_cloud(self, conf: dict[str, Any]) -> MideaCloud:
"""Delegate to midea_beautiful_api.connect_to_cloud"""
return midea_beautiful_api.connect_to_cloud(
account=conf[CONF_USERNAME],
password=conf[CONF_PASSWORD],
appname=conf[CONF_MOBILE_APP],
)
def appliance_state( # pylint: disable=too-many-arguments,no-self-use
self,
address: str = None,
token: str = None,
key: str = None,
cloud: MideaCloud = None,
use_cloud: bool = False,
appliance_id: str = None,
):
"""Delegate to midea_beautiful_api.appliance_state"""
return midea_beautiful_api.appliance_state(
address=address,
token=token,
key=key,
cloud=cloud,
use_cloud=use_cloud,
appliance_id=appliance_id,
retries=5,
cloud_timeout=6,
)
def find_appliances( # pylint: disable=too-many-arguments,no-self-use
self,
cloud: MideaCloud = None,
addresses: list[str] = None,
retries: int = 3,
timeout: int = 3,
) -> list[LanDevice]:
"""Delegate to midea_beautiful_api.find_appliances"""
return midea_beautiful_api.find_appliances(
cloud=cloud,
addresses=addresses,
retries=retries,
timeout=timeout,
)
# pylint: disable=no-self-use
async def async_list_appliances(self, cloud: MideaCloud) -> list:
"""Delegate to midea_beautiful_api.connect_to_cloud"""
return await self.hass.async_add_executor_job(
cloud.list_appliances,
)
def address_ok(address: str | None) -> bool:
"""Returns True if address is not known"""
return address is not None and address != UNKNOWN_IP