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