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/xiaomi_miot/cover.py
"""Support for Curtain and Airer."""
import logging
from datetime import timedelta

from homeassistant.components.cover import (
    DOMAIN as ENTITY_DOMAIN,
    CoverEntity as BaseEntity,
    CoverEntityFeature,
    CoverDeviceClass,
)

from . import (
    DOMAIN,
    XIAOMI_CONFIG_SCHEMA as PLATFORM_SCHEMA,  # noqa: F401
    HassEntry,
    XEntity,
    async_setup_config_entry,
    bind_services_to_entries,
)
from .core.miot_spec import MiotProperty
from .core.converters import MiotPropConv, MiotTargetPositionConv

_LOGGER = logging.getLogger(__name__)
DATA_KEY = f'{ENTITY_DOMAIN}.{DOMAIN}'
SCAN_INTERVAL = timedelta(seconds=60)

SERVICE_TO_METHOD = {}


async def async_setup_entry(hass, config_entry, async_add_entities):
    HassEntry.init(hass, config_entry).new_adder(ENTITY_DOMAIN, async_add_entities)
    await async_setup_config_entry(hass, config_entry, async_setup_platform, async_add_entities, ENTITY_DOMAIN)


async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
    hass.data.setdefault(DATA_KEY, {})
    hass.data[DOMAIN]['add_entities'][ENTITY_DOMAIN] = async_add_entities
    config['hass'] = hass
    bind_services_to_entries(hass, SERVICE_TO_METHOD)


class CoverEntity(XEntity, BaseEntity):
    _attr_is_closed = None
    _attr_device_class = None
    _attr_target_cover_position = None
    _attr_supported_features = CoverEntityFeature(0)
    _conv_status = None
    _conv_motor: MiotPropConv = None
    _conv_current_position = None
    _conv_target_position = None
    _current_range = None
    _target_range = (0, 100)
    _is_airer = None
    _motor_reverse = None
    _position_reverse = None
    _open_texts = ['open', 'up']
    _close_texts = ['close', 'down']
    _closed_position = 0
    _deviated_position = 0
    _target_position_props = None
    _cover_position_mapping = None
    _target2current_position = None

    def on_init(self):
        self._attr_available = self.device.available

        models = f'{self.device.model} {self.device.info.urn}'
        self._is_airer = 'airer' in models
        if self._is_airer:
            self._position_reverse = True
        elif 'curtain' in models:
            self._attr_device_class = CoverDeviceClass.CURTAIN
        elif 'wopener' in models or 'window-opener' in models:
            self._attr_device_class = CoverDeviceClass.WINDOW

        self._motor_reverse = self.custom_config_bool('motor_reverse', self._motor_reverse)
        self._position_reverse = self.custom_config_bool('position_reverse', self._position_reverse)
        self._open_texts = self.custom_config_list('open_texts', self._open_texts)
        self._close_texts = self.custom_config_list('close_texts', self._close_texts)
        if self._motor_reverse:
            self._open_texts, self._close_texts = self._close_texts, self._open_texts
        self._target_position_props = self.custom_config_list('target_position_props') or []
        self._cover_position_mapping = self.custom_config_json('cover_position_mapping') or {}

        if not self.conv.attrs:
            self.conv.attrs.append(self.conv.full_name)
        for attr in self.conv.attrs:
            conv = self.device.find_converter(attr)
            prop = getattr(conv, 'prop', None) if conv else None
            if not isinstance(prop, MiotProperty):
                continue
            elif prop.in_list(['status']):
                self._conv_status = conv
            elif prop.in_list(['motor_control']):
                self._conv_motor = conv
                self._attr_supported_features |= CoverEntityFeature.OPEN
                self._attr_supported_features |= CoverEntityFeature.CLOSE
                if prop.list_first('Stop', 'Pause') is not None:
                    self._attr_supported_features |= CoverEntityFeature.STOP
            elif prop.in_list(['current_position']):
                if prop.value_range:
                    self._conv_current_position = conv
                    self._current_range = (prop.range_min(), prop.range_max())
                    self.log.debug('current_position: %s', conv)
                elif prop.value_list and self._cover_position_mapping and not self._conv_current_position:
                    self._conv_current_position = conv
                    self._current_range = (0, 100)
                    self.log.debug('current_position: %s', conv)
            elif prop.value_range and isinstance(conv, MiotTargetPositionConv):
                self._conv_target_position = conv
                self._target_range = conv.ranged
                self._attr_supported_features |= CoverEntityFeature.SET_POSITION
            elif prop.value_range and prop.in_list(self._target_position_props):
                self._conv_target_position = conv
                self._target_range = (prop.range_min(), prop.range_max())
                self._attr_supported_features |= CoverEntityFeature.SET_POSITION

        if self.custom_config_bool('disable_target_position'):
            self._conv_target_position = None
            self._attr_supported_features &= ~CoverEntityFeature.SET_POSITION

        self._deviated_position = self.custom_config_integer('deviated_position', 2)
        if self._current_range:
            pos = self._current_range[0] + self._deviated_position
            self._closed_position = self.custom_config_integer('closed_position', pos)
        self._target2current_position = self.custom_config_bool('target2current_position', not self._conv_current_position)

        if self._motor_reverse or self._position_reverse:
            self._attr_extra_state_attributes.update({
                'motor_reverse': self._motor_reverse,
                'position_reverse': self._position_reverse,
            })
        if self._closed_position:
            self._attr_extra_state_attributes.update({
                'closed_position': self._closed_position,
                'deviated_position': self._deviated_position,
            })

    def set_state(self, data: dict):
        prop_status = getattr(self._conv_status, 'prop', None) if self._conv_status else None
        if prop_status:
            val = self._conv_status.value_from_dict(data)
            if val is not None:
                self._attr_is_closed = None
                self._attr_is_opening = None
                self._attr_is_closing = None
            if val in prop_status.list_search('Closed'):
                self._attr_is_closed = True
            elif val in prop_status.list_search('Opened'):
                self._attr_is_closed = False
            elif val in prop_status.list_search('Opening'):
                self._attr_is_opening = True
            elif val in prop_status.list_search('Closing'):
                self._attr_is_closing = True
            elif val in prop_status.list_search('Rising'):
                self._attr_is_closing = self._position_reverse
            elif val in prop_status.list_search('Falling', 'Dropping'):
                self._attr_is_opening = self._position_reverse
            elif val in prop_status.list_search('Stop Lower Limit', 'Stop At Lowest', 'Floor'):
                self._attr_is_closed = not self._position_reverse
            elif val in prop_status.list_search('Stop Upper Limit', 'Stop At Highest', 'Ceiling'):
                self._attr_is_closed = self._position_reverse
            elif self._is_airer and val in prop_status.list_search('Down'):
                self._attr_is_closed = False
        if self._conv_current_position:
            val = self._conv_current_position.value_from_dict(data)
            if val is not None:
                val = int(val)
                if self._cover_position_mapping:
                    val = self._cover_position_mapping.get(val, val)
                if self._position_reverse:
                    val = self._current_range[1] - val
                self._attr_current_cover_position = val
        if self._conv_target_position:
            val = self._conv_target_position.value_from_dict(data)
            if val is not None:
                val = int(val)
                if self._position_reverse:
                    val = self._target_range[1] - val
                self._attr_target_cover_position = val
        if self._target2current_position:
            self._attr_current_cover_position = self._attr_target_cover_position
            self._attr_extra_state_attributes.update({
                'target2current_position': True,
            })
        if (val := self._attr_current_cover_position) is not None:
            closed = val <= self._closed_position
            if self._deviated_position is None:
                pass
            elif val <= self._deviated_position:
                self._attr_current_cover_position = 0
                closed = not self._is_airer
            elif val >= (100 - self._deviated_position):
                self._attr_current_cover_position = 100
                closed = self._is_airer
            if self._attr_is_closed is None or not prop_status:
                self._attr_is_closed = closed
        self._attr_extra_state_attributes.update({
            'state_is_closed': self._attr_is_closed,
        })

    async def async_open_cover(self, **kwargs):
        if conv := self._conv_motor:
            val = conv.prop.list_first(*self._open_texts)
            if val is not None:
                await self.device.async_write({conv.full_name: val})
                return
            self.log.warning('No open command found in motor control property: %s', self._open_texts)
        await self.async_set_cover_position(0 if self._position_reverse else 100)

    async def async_close_cover(self, **kwargs):
        if conv := self._conv_motor:
            val = conv.prop.list_first(*self._close_texts)
            if val is not None:
                await self.device.async_write({conv.full_name: val})
                return
            self.log.warning('No close command found in motor control property: %s', [self._close_texts, conv.prop.value_list])
        await self.async_set_cover_position(100 if self._position_reverse else 0)

    async def async_stop_cover(self, **kwargs):
        if not self._conv_motor:
            return
        val = self._conv_motor.prop.list_first('Stop', 'Pause')
        if val is not None:
            await self.device.async_write({self._conv_motor.full_name: val})

    async def async_set_cover_position(self, position, **kwargs):
        if not self._conv_target_position:
            return
        if self._position_reverse:
            position = self._target_range[1] - position
        await self.device.async_write({self._conv_target_position.full_name: position})


XEntity.CLS[ENTITY_DOMAIN] = CoverEntity