Initialize docker stack repo
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user