HEX
Server: Apache/2.4.63 (Unix)
System: Linux Synopilou92 4.4.302+ #72806 SMP Mon Jul 21 23:16:00 CST 2025 x86_64
User: pilou92 (1026)
PHP: 8.0.30
Disabled: NONE
Upload Files
File: /volume1/docker/homeassistant2025/custom_components/midea_dehumidifier_lan/util.py
"""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