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/smartthinq_sensors/fan.py
"""Platform for LGE fan integration."""

from __future__ import annotations

from dataclasses import dataclass
import logging
from typing import Any, Awaitable, Callable

from homeassistant.components.fan import (
    FanEntity,
    FanEntityDescription,
    FanEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.percentage import (
    ordered_list_item_to_percentage,
    percentage_to_ordered_list_item,
)

from . import LGEDevice
from .const import DOMAIN, LGE_DEVICES, LGE_DISCOVERY_NEW
from .wideq import DeviceType, HoodFeatures, MicroWaveFeatures

ATTR_FAN_MODE = "fan_mode"
ATTR_FAN_MODES = "fan_modes"
DEFAULT_KEY = "default"

_LOGGER = logging.getLogger(__name__)


@dataclass
class LGEFanWrapperDescription:
    """A class that describes LG fan wrapper."""

    fanspeed_fn: Callable[[Any], str] | None
    fanspeeds_fn: Callable[[Any], list[str]]
    set_fanspeed_fn: Callable[[Any], Awaitable[None]]
    fanpreset_fn: Callable[[Any], str] | None = None
    fanpresets_fn: Callable[[Any], list[str]] | None = None
    set_fanpreset_fn: Callable[[Any], Awaitable[None]] | None = None
    turn_off_fn: Callable[[Any], Awaitable[None]] | None = None
    turn_on_fn: Callable[[Any], Awaitable[None]] | None = None


FAN_WRAPPER = LGEFanWrapperDescription(
    fanspeed_fn=lambda x: x.state.fan_speed,
    fanspeeds_fn=lambda x: x.device.fan_speeds,
    set_fanspeed_fn=lambda x, option: x.device.set_fan_speed(option),
    turn_off_fn=lambda x: x.device.power(False),
    turn_on_fn=lambda x: x.device.power(True),
)
AIRPURIFIER_WRAPPER = LGEFanWrapperDescription(
    fanspeed_fn=lambda x: x.state.fan_speed,
    fanspeeds_fn=lambda x: x.device.fan_speeds,
    set_fanspeed_fn=lambda x, option: x.device.set_fan_speed(option),
    fanpreset_fn=lambda x: x.state.fan_preset,
    fanpresets_fn=lambda x: x.device.fan_presets,
    set_fanpreset_fn=lambda x, option: x.device.set_fan_preset(option),
    turn_off_fn=lambda x: x.device.power(False),
    turn_on_fn=lambda x: x.device.power(True),
)
HOOD_WRAPPER = LGEFanWrapperDescription(
    fanspeed_fn=None,
    fanspeeds_fn=lambda x: x.device.vent_speeds,
    set_fanspeed_fn=lambda x, option: x.device.set_vent_speed(option),
)
MICROWAVE_WRAPPER = LGEFanWrapperDescription(
    fanspeed_fn=None,
    fanspeeds_fn=lambda x: x.device.vent_speeds,
    set_fanspeed_fn=lambda x, option: x.device.set_vent_speed(option),
)


@dataclass
class ThinQFanRequiredKeysMixin:
    """Mixin for required keys."""

    wrapper_description: LGEFanWrapperDescription


@dataclass
class ThinQFanEntityDescription(FanEntityDescription, ThinQFanRequiredKeysMixin):
    """A class that describes ThinQ fan entities."""


FAN_DEVICE: tuple[ThinQFanEntityDescription, ...] = (
    ThinQFanEntityDescription(
        key=DEFAULT_KEY,
        name=None,
        wrapper_description=FAN_WRAPPER,
    ),
)
AIRPURIFIER_DEVICE: tuple[ThinQFanEntityDescription, ...] = (
    ThinQFanEntityDescription(
        key=DEFAULT_KEY,
        name=None,
        icon="mdi:air-purifier",
        wrapper_description=AIRPURIFIER_WRAPPER,
    ),
)
HOOD_DEVICE: tuple[ThinQFanEntityDescription, ...] = (
    ThinQFanEntityDescription(
        key=HoodFeatures.VENT_SPEED,
        name="Fan",
        wrapper_description=HOOD_WRAPPER,
    ),
)
MICROWAVE_DEVICE: tuple[ThinQFanEntityDescription, ...] = (
    ThinQFanEntityDescription(
        key=MicroWaveFeatures.VENT_SPEED,
        name="Fan",
        wrapper_description=MICROWAVE_WRAPPER,
    ),
)

FAN_ENTITIES = {
    DeviceType.FAN: FAN_DEVICE,
    DeviceType.AIR_PURIFIER: AIRPURIFIER_DEVICE,
    DeviceType.HOOD: HOOD_DEVICE,
    DeviceType.MICROWAVE: MICROWAVE_DEVICE,
}


def _fan_exist(lge_device: LGEDevice, fan_desc: ThinQFanEntityDescription) -> bool:
    """Check if a fan exist for device."""
    feature = fan_desc.key
    if feature == DEFAULT_KEY:
        return True

    if feature in lge_device.available_features:
        return True

    return False


async def async_setup_entry(
    hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
    """Set up LGE device fan based on config_entry."""
    entry_config = hass.data[DOMAIN]
    lge_cfg_devices = entry_config.get(LGE_DEVICES)

    _LOGGER.debug("Starting LGE ThinQ fan setup...")

    @callback
    def _async_discover_device(lge_devices: dict) -> None:
        """Add entities for a discovered ThinQ device."""

        if not lge_devices:
            return

        # Fan devices
        lge_fan = [
            LGEFan(lge_device, fan_desc)
            for dev_type, fan_descs in FAN_ENTITIES.items()
            for fan_desc in fan_descs
            for lge_device in lge_devices.get(dev_type, [])
            if _fan_exist(lge_device, fan_desc)
        ]

        async_add_entities(lge_fan)

    _async_discover_device(lge_cfg_devices)

    entry.async_on_unload(
        async_dispatcher_connect(hass, LGE_DISCOVERY_NEW, _async_discover_device)
    )


class LGEFanWrapper:
    """Wrapper class for LG fan device."""

    def __init__(
        self,
        api: LGEDevice,
        descr: LGEFanWrapperDescription,
        feature_key: str | None = None,
    ) -> None:
        """Initialize the wrapper."""
        self._api = api
        self._description = descr
        self._feature_key = feature_key
        self._turn_off_speed = None
        self._last_speed = None
        self._avl_speeds = self._get_fan_speeds()

    def _get_fan_speeds(self) -> list[str]:
        """List of available speeds."""
        avl_speeds = self._description.fanspeeds_fn(self._api).copy()
        if self._description.turn_off_fn is None:
            self._turn_off_speed = avl_speeds.pop(0)
        return avl_speeds

    @property
    def fan_speed(self) -> str | None:
        """Return current speed."""
        if self._description.fanspeed_fn:
            return self._description.fanspeed_fn(self._api)
        if feature := self._feature_key:
            return self._api.state.device_features.get(feature)
        return None

    @property
    def fan_speeds(self) -> list[str]:
        """List of available speeds."""
        return self._avl_speeds

    @property
    def fan_preset(self) -> str | None:
        """Return current preset."""
        if self._description.fanpreset_fn is None:
            return None
        return self._description.fanpreset_fn(self._api)

    @property
    def fan_presets(self) -> list[str]:
        """List of available presets."""
        if self._description.fanpresets_fn is None:
            return []
        return self._description.fanpresets_fn(self._api)

    @property
    def is_on(self) -> bool:
        """Return if fan is on."""
        if self._description.turn_off_fn is None:
            return self.fan_speed != self._turn_off_speed
        return self._api.state.is_on

    async def async_set_speed(self, speed: str) -> None:
        """Set fan speed."""
        await self._description.set_fanspeed_fn(self._api, speed)

    async def async_set_preset(self, preset: str) -> None:
        """Set fan preset."""
        if self._description.set_fanpreset_fn is None:
            return NotImplementedError()
        await self._description.set_fanpreset_fn(self._api, preset)

    async def async_turn_on(
        self, speed: str | None = None, preset: str | None = None
    ) -> None:
        """Turn on the fan."""
        on_speed = speed
        if self._description.turn_on_fn is not None:
            await self._description.turn_on_fn(self._api)
        elif not on_speed:
            on_speed = self._last_speed or self.fan_speeds[0]

        if on_speed:
            await self.async_set_speed(on_speed)
        elif preset:
            await self.async_set_preset(preset)

    async def async_turn_off(self) -> None:
        """Turn on the fan."""
        if self._description.turn_off_fn is None:
            self._last_speed = self.fan_speed
            await self.async_set_speed(self._turn_off_speed)
        else:
            await self._description.turn_off_fn(self._api)


class LGEBaseFan(CoordinatorEntity, FanEntity):
    """Base fan device."""

    def __init__(self, api: LGEDevice):
        """Initialize the base fan."""
        super().__init__(api.coordinator)
        self._api = api
        self._attr_device_info = api.device_info

    @property
    def available(self) -> bool:
        """Return True if entity is available."""
        return self._api.available


class LGEFan(LGEBaseFan):
    """LG Fan device."""

    entity_description: ThinQFanEntityDescription
    _attr_has_entity_name = True

    def __init__(self, api: LGEDevice, description: ThinQFanEntityDescription) -> None:
        """Initialize the fan."""
        super().__init__(api)
        wrapper = LGEFanWrapper(api, description.wrapper_description, description.key)
        self.entity_description = description
        self._attr_unique_id = f"{api.unique_id}-FAN"
        if description.key != DEFAULT_KEY:
            self._attr_unique_id += f"-{description.key}"
        self._attr_speed_count = len(wrapper.fan_speeds)
        if presets := wrapper.fan_presets:
            self._attr_preset_modes = presets
        self._wrapper = wrapper

    @property
    def supported_features(self) -> FanEntityFeature:
        """Return the list of supported features."""
        features = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
        if self.speed_count > 1:
            features |= FanEntityFeature.SET_SPEED
        if self.preset_modes is not None:
            features |= FanEntityFeature.PRESET_MODE
        return features

    @property
    def extra_state_attributes(self):
        """Return the optional state attributes with device specific additions."""
        state = {}
        if fan_modes := self._wrapper.fan_speeds:
            state[ATTR_FAN_MODES] = fan_modes
            if fan_mode := self._wrapper.fan_speed:
                state[ATTR_FAN_MODE] = fan_mode

        return state

    @property
    def percentage(self) -> int | None:
        """Return the current speed percentage."""
        if not self._wrapper.is_on:
            return 0
        if self._wrapper.fan_speed is None and self._wrapper.fan_preset:
            return None
        if self.speed_count <= 1:
            return 100
        return ordered_list_item_to_percentage(
            self._wrapper.fan_speeds, self._wrapper.fan_speed
        )

    @property
    def preset_mode(self) -> str | None:
        """Return the current preset mode, e.g., auto, smart, interval, favorite."""
        if self.preset_modes is None:
            return None
        if not self._wrapper.is_on:
            return None
        return self._wrapper.fan_preset

    async def async_set_percentage(self, percentage: int) -> None:
        """Set the speed of the fan, as a percentage."""
        if self.speed_count == 0:
            raise NotImplementedError()

        if percentage == 0:
            if self.preset_mode is None:
                await self.async_turn_off()
            return

        named_speed = percentage_to_ordered_list_item(
            self._wrapper.fan_speeds, percentage
        )
        if not self._wrapper.is_on:
            await self._wrapper.async_turn_on(speed=named_speed)
        else:
            await self._wrapper.async_set_speed(named_speed)
        self._api.async_set_updated()

    async def async_set_preset_mode(self, preset_mode: str) -> None:
        """Set new preset mode."""
        if self.preset_modes is None:
            raise NotImplementedError()
        if not self._wrapper.is_on:
            await self._wrapper.async_turn_on(preset=preset_mode)
        else:
            await self._wrapper.async_set_preset(preset_mode)
        self._api.async_set_updated()

    async def async_turn_on(
        self,
        percentage: int | None = None,
        preset_mode: str | None = None,
        **kwargs,
    ) -> None:
        """Turn on the fan."""
        if preset_mode and self.preset_modes:
            await self.async_set_preset_mode(preset_mode)
        elif percentage or self.speed_count == 1:
            await self.async_set_percentage(percentage or 100)
        else:
            await self._wrapper.async_turn_on()
            self._api.async_set_updated()

    async def async_turn_off(self, **kwargs) -> None:
        """Turn the entity off."""
        if not self._wrapper.is_on:
            return
        await self._wrapper.async_turn_off()
        self._api.async_set_updated()