"""Support for Tuya Fan.""" from __future__ import annotations from typing import Any from tuya_sharing import CustomerDevice, Manager from homeassistant.components.fan import ( DIRECTION_FORWARD, DIRECTION_REVERSE, FanEntity, FanEntityFeature, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, ) from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import ( DPCodeBooleanWrapper, DPCodeEnumWrapper, EnumTypeData, IntegerTypeData, find_dpcode, ) from .util import get_dpcode _DIRECTION_DPCODES = (DPCode.FAN_DIRECTION,) _MODE_DPCODES = (DPCode.FAN_MODE, DPCode.MODE) _OSCILLATE_DPCODES = (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) _SPEED_DPCODES = ( DPCode.FAN_SPEED_PERCENT, DPCode.FAN_SPEED, DPCode.SPEED, DPCode.FAN_SPEED_ENUM, ) _SWITCH_DPCODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) TUYA_SUPPORT_TYPE: set[DeviceCategory] = { DeviceCategory.CS, DeviceCategory.FS, DeviceCategory.FSD, DeviceCategory.FSKG, DeviceCategory.KJ, DeviceCategory.KS, } def _has_a_valid_dpcode(device: CustomerDevice) -> bool: """Check if the device has at least one valid DP code.""" properties_to_check: list[DPCode | tuple[DPCode, ...] | None] = [ # Main control switch _SWITCH_DPCODES, # Other properties _SPEED_DPCODES, _OSCILLATE_DPCODES, _DIRECTION_DPCODES, ] return any(get_dpcode(device, code) for code in properties_to_check) async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya fan dynamically through tuya discovery.""" manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered tuya fan.""" entities: list[TuyaFanEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] if device.category in TUYA_SUPPORT_TYPE and _has_a_valid_dpcode(device): entities.append( TuyaFanEntity( device, manager, mode_wrapper=DPCodeEnumWrapper.find_dpcode( device, _MODE_DPCODES, prefer_function=True ), switch_wrapper=DPCodeBooleanWrapper.find_dpcode( device, _SWITCH_DPCODES, prefer_function=True ), ) ) async_add_entities(entities) async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) ) class TuyaFanEntity(TuyaEntity, FanEntity): """Tuya Fan Device.""" _direction: EnumTypeData | None = None _oscillate: DPCode | None = None _speed: IntegerTypeData | None = None _speeds: EnumTypeData | None = None _attr_name = None def __init__( self, device: CustomerDevice, device_manager: Manager, *, mode_wrapper: DPCodeEnumWrapper | None, switch_wrapper: DPCodeBooleanWrapper | None, ) -> None: """Init Tuya Fan Device.""" super().__init__(device, device_manager) self._mode_wrapper = mode_wrapper self._switch_wrapper = switch_wrapper if mode_wrapper: self._attr_supported_features |= FanEntityFeature.PRESET_MODE self._attr_preset_modes = mode_wrapper.type_information.range # Find speed controls, can be either percentage or a set of speeds if int_type := find_dpcode( self.device, _SPEED_DPCODES, dptype=DPType.INTEGER, prefer_function=True ): self._attr_supported_features |= FanEntityFeature.SET_SPEED self._speed = int_type elif enum_type := find_dpcode( self.device, _SPEED_DPCODES, dptype=DPType.ENUM, prefer_function=True ): self._attr_supported_features |= FanEntityFeature.SET_SPEED self._speeds = enum_type if dpcode := get_dpcode(self.device, _OSCILLATE_DPCODES): self._oscillate = dpcode self._attr_supported_features |= FanEntityFeature.OSCILLATE if enum_type := find_dpcode( self.device, _DIRECTION_DPCODES, dptype=DPType.ENUM, prefer_function=True ): self._direction = enum_type self._attr_supported_features |= FanEntityFeature.DIRECTION if switch_wrapper: self._attr_supported_features |= ( FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" await self._async_send_dpcode_update(self._mode_wrapper, preset_mode) def set_direction(self, direction: str) -> None: """Set the direction of the fan.""" if self._direction is None: return self._send_command([{"code": self._direction.dpcode, "value": direction}]) def set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" if self._speed is not None: self._send_command( [ { "code": self._speed.dpcode, "value": int(self._speed.remap_value_from(percentage, 1, 100)), } ] ) return if self._speeds is not None: self._send_command( [ { "code": self._speeds.dpcode, "value": percentage_to_ordered_list_item( self._speeds.range, percentage ), } ] ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" await self._async_send_dpcode_update(self._switch_wrapper, False) async def async_turn_on( self, percentage: int | None = None, preset_mode: str | None = None, **kwargs: Any, ) -> None: """Turn on the fan.""" if self._switch_wrapper is None: return commands: list[dict[str, str | bool | int]] = [ self._switch_wrapper.get_update_command(self.device, True) ] if percentage is not None and self._speed is not None: commands.append( { "code": self._speed.dpcode, "value": int(self._speed.remap_value_from(percentage, 1, 100)), } ) if percentage is not None and self._speeds is not None: commands.append( { "code": self._speeds.dpcode, "value": percentage_to_ordered_list_item( self._speeds.range, percentage ), } ) if preset_mode is not None and self._mode_wrapper: commands.append( self._mode_wrapper.get_update_command(self.device, preset_mode) ) await self._async_send_commands(commands) def oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" if self._oscillate is None: return self._send_command([{"code": self._oscillate, "value": oscillating}]) @property def is_on(self) -> bool | None: """Return true if fan is on.""" return self._read_wrapper(self._switch_wrapper) @property def current_direction(self) -> str | None: """Return the current direction of the fan.""" if ( self._direction is None or (value := self.device.status.get(self._direction.dpcode)) is None ): return None if value.lower() == DIRECTION_FORWARD: return DIRECTION_FORWARD if value.lower() == DIRECTION_REVERSE: return DIRECTION_REVERSE return None @property def oscillating(self) -> bool | None: """Return true if the fan is oscillating.""" if self._oscillate is None: return None return self.device.status.get(self._oscillate) @property def preset_mode(self) -> str | None: """Return the current preset_mode.""" return self._read_wrapper(self._mode_wrapper) @property def percentage(self) -> int | None: """Return the current speed.""" if self._speed is not None: if (value := self.device.status.get(self._speed.dpcode)) is None: return None return int(self._speed.remap_value_to(value, 1, 100)) if self._speeds is not None: if ( value := self.device.status.get(self._speeds.dpcode) ) is None or value not in self._speeds.range: return None return ordered_list_item_to_percentage(self._speeds.range, value) return None @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" if self._speeds is not None: return len(self._speeds.range) return 100