<commit_before>"""
Support for the Xiaomi vacuum cleaner robot.

For more details about this platform, please refer to the documentation
https://home-assistant.io/components/vacuum.xiaomi_miio/
"""
import asyncio
from functools import partial
import logging

import voluptuous as vol

from homeassistant.components.vacuum import (
    ATTR_CLEANED_AREA, DOMAIN, PLATFORM_SCHEMA, SUPPORT_BATTERY,
    SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE,
    SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STOP,
    SUPPORT_STATE, SUPPORT_START, VACUUM_SERVICE_SCHEMA, StateVacuumDevice,
    STATE_CLEANING, STATE_DOCKED, STATE_PAUSED, STATE_IDLE, STATE_RETURNING,
    STATE_ERROR)
from homeassistant.const import (
    ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON)
import homeassistant.helpers.config_validation as cv

REQUIREMENTS = ['python-miio==0.4.1', 'construct==2.9.41']

_LOGGER = logging.getLogger(__name__)

DEFAULT_NAME = 'Xiaomi Vacuum cleaner'
DATA_KEY = 'vacuum.xiaomi_miio'

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
    vol.Required(CONF_HOST): cv.string,
    vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)),
    vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
}, extra=vol.ALLOW_EXTRA)

SERVICE_MOVE_REMOTE_CONTROL = 'xiaomi_remote_control_move'
SERVICE_MOVE_REMOTE_CONTROL_STEP = 'xiaomi_remote_control_move_step'
SERVICE_START_REMOTE_CONTROL = 'xiaomi_remote_control_start'
SERVICE_STOP_REMOTE_CONTROL = 'xiaomi_remote_control_stop'

FAN_SPEEDS = {
    'Quiet': 38,
    'Balanced': 60,
    'Turbo': 77,
    'Max': 90}

ATTR_CLEANING_TIME = 'cleaning_time'
ATTR_DO_NOT_DISTURB = 'do_not_disturb'
ATTR_DO_NOT_DISTURB_START = 'do_not_disturb_start'
ATTR_DO_NOT_DISTURB_END = 'do_not_disturb_end'
ATTR_MAIN_BRUSH_LEFT = 'main_brush_left'
ATTR_SIDE_BRUSH_LEFT = 'side_brush_left'
ATTR_FILTER_LEFT = 'filter_left'
ATTR_SENSOR_DIRTY_LEFT = 'sensor_dirty_left'
ATTR_CLEANING_COUNT = 'cleaning_count'
ATTR_CLEANED_TOTAL_AREA = 'total_cleaned_area'
ATTR_CLEANING_TOTAL_TIME = 'total_cleaning_time'
ATTR_ERROR = 'error'
ATTR_RC_DURATION = 'duration'
ATTR_RC_ROTATION = 'rotation'
ATTR_RC_VELOCITY = 'velocity'

SERVICE_SCHEMA_REMOTE_CONTROL = VACUUM_SERVICE_SCHEMA.extend({
    vol.Optional(ATTR_RC_VELOCITY):
        vol.All(vol.Coerce(float), vol.Clamp(min=-0.29, max=0.29)),
    vol.Optional(ATTR_RC_ROTATION):
        vol.All(vol.Coerce(int), vol.Clamp(min=-179, max=179)),
    vol.Optional(ATTR_RC_DURATION): cv.positive_int,
})

SERVICE_TO_METHOD = {
    SERVICE_START_REMOTE_CONTROL: {'method': 'async_remote_control_start'},
    SERVICE_STOP_REMOTE_CONTROL: {'method': 'async_remote_control_stop'},
    SERVICE_MOVE_REMOTE_CONTROL: {
        'method': 'async_remote_control_move',
        'schema': SERVICE_SCHEMA_REMOTE_CONTROL},
    SERVICE_MOVE_REMOTE_CONTROL_STEP: {
        'method': 'async_remote_control_move_step',
        'schema': SERVICE_SCHEMA_REMOTE_CONTROL},
}

SUPPORT_XIAOMI = SUPPORT_STATE | SUPPORT_PAUSE | \
                 SUPPORT_STOP | SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | \
                 SUPPORT_SEND_COMMAND | SUPPORT_LOCATE | \
                 SUPPORT_BATTERY | SUPPORT_CLEAN_SPOT | SUPPORT_START

STATE_CODE_TO_STATE = {
    2: STATE_IDLE,
    3: STATE_IDLE,
    5: STATE_CLEANING,
    6: STATE_RETURNING,
    8: STATE_DOCKED,
    9: STATE_ERROR,
    10: STATE_PAUSED,
    11: STATE_CLEANING,
    12: STATE_ERROR,
    15: STATE_RETURNING,
    16: STATE_CLEANING,
    17: STATE_CLEANING,
}

@asyncio.coroutine
def async_setup_platform(hass, config, async_add_entities,
                         discovery_info=None):
    """Set up the Xiaomi vacuum cleaner robot platform."""
    from miio import Vacuum
    if DATA_KEY not in hass.data:
        hass.data[DATA_KEY] = {}

    host = config.get(CONF_HOST)
    name = config.get(CONF_NAME)
    token = config.get(CONF_TOKEN)

    # Create handler
    _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5])
    vacuum = Vacuum(host, token)

    mirobo = MiroboVacuum(name, vacuum)
    hass.data[DATA_KEY][host] = mirobo

    async_add_entities([mirobo], update_before_add=True)

    @asyncio.coroutine
    def async_service_handler(service):
        """Map services to methods on MiroboVacuum."""
        method = SERVICE_TO_METHOD.get(service.service)
        params = {key: value for key, value in service.data.items()
                  if key != ATTR_ENTITY_ID}
        entity_ids = service.data.get(ATTR_ENTITY_ID)
        if entity_ids:
            target_vacuums = [vac for vac in hass.data[DATA_KEY].values()
                              if vac.entity_id in entity_ids]
        else:
            target_vacuums = hass.data[DATA_KEY].values()

        update_tasks = []
        for vacuum in target_vacuums:
            yield from getattr(vacuum, method['method'])(**params)

        for vacuum in target_vacuums:
            update_coro = vacuum.async_update_ha_state(True)
            update_tasks.append(update_coro)

        if update_tasks:
            yield from asyncio.wait(update_tasks, loop=hass.loop)

    for vacuum_service in SERVICE_TO_METHOD:
        schema = SERVICE_TO_METHOD[vacuum_service].get(
            'schema', VACUUM_SERVICE_SCHEMA)
        hass.services.async_register(
            DOMAIN, vacuum_service, async_service_handler,
            schema=schema)

class MiroboVacuum(StateVacuumDevice):
    """Representation of a Xiaomi Vacuum cleaner robot."""

    def __init__(self, name, vacuum):
        """Initialize the Xiaomi vacuum cleaner robot handler."""
        self._name = name
        self._vacuum = vacuum

        self.vacuum_state = None
        self._available = False

        self.consumable_state = None
        self.clean_history = None
        self.dnd_state = None

    @property
    def name(self):
        """Return the name of the device."""
        return self._name

    @property
    def state(self):
        """Return the status of the vacuum cleaner."""
        if self.vacuum_state is not None:
            try:
                return STATE_CODE_TO_STATE[int(self.vacuum_state.state_code)]
            except KeyError:
                _LOGGER.error("STATE not supported: %s, state_code: %s",
                              self.vacuum_state.state,
                              self.vacuum_state.state_code)
                return None

    @property
    def battery_level(self):
        """Return the battery level of the vacuum cleaner."""
        if self.vacuum_state is not None:
            return self.vacuum_state.battery

    @property
    def fan_speed(self):
        """Return the fan speed of the vacuum cleaner."""
        if self.vacuum_state is not None:
            speed = self.vacuum_state.fanspeed
            if speed in FAN_SPEEDS.values():
                return [key for key, value in FAN_SPEEDS.items()
                        if value == speed][0]
            return speed

    @property
    def fan_speed_list(self):
        """Get the list of available fan speed steps of the vacuum cleaner."""
        return list(sorted(FAN_SPEEDS.keys(), key=lambda s: FAN_SPEEDS[s]))

    @property
    def device_state_attributes(self):
        """Return the specific state attributes of this vacuum cleaner."""
        attrs = {}
        if self.vacuum_state is not None:
            attrs.update({
                ATTR_DO_NOT_DISTURB:
                    STATE_ON if self.dnd_state.enabled else STATE_OFF,
                ATTR_DO_NOT_DISTURB_START: str(self.dnd_state.start),
                ATTR_DO_NOT_DISTURB_END: str(self.dnd_state.end),
                # Not working --> 'Cleaning mode':
                #    STATE_ON if self.vacuum_state.in_cleaning else STATE_OFF,
                ATTR_CLEANING_TIME: int(
                    self.vacuum_state.clean_time.total_seconds()
                    / 60),
                ATTR_CLEANED_AREA: int(self.vacuum_state.clean_area),
                ATTR_CLEANING_COUNT: int(self.clean_history.count),
                ATTR_CLEANED_TOTAL_AREA: int(self.clean_history.total_area),
                ATTR_CLEANING_TOTAL_TIME: int(
                    self.clean_history.total_duration.total_seconds()
                    / 60),
                ATTR_MAIN_BRUSH_LEFT: int(
                    self.consumable_state.main_brush_left.total_seconds()
                    / 3600),
                ATTR_SIDE_BRUSH_LEFT: int(
                    self.consumable_state.side_brush_left.total_seconds()
                    / 3600),
                ATTR_FILTER_LEFT: int(
                    self.consumable_state.filter_left.total_seconds()
                    / 3600),
                ATTR_SENSOR_DIRTY_LEFT: int(
                    self.consumable_state.sensor_dirty_left.total_seconds()
                    / 3600)
                })

            if self.vacuum_state.got_error:
                attrs[ATTR_ERROR] = self.vacuum_state.error
        return attrs

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

    @property
    def supported_features(self):
        """Flag vacuum cleaner robot features that are supported."""
        return SUPPORT_XIAOMI

    @asyncio.coroutine
    def _try_command(self, mask_error, func, *args, **kwargs):
        """Call a vacuum command handling error messages."""
        from miio import DeviceException
        try:
            yield from self.hass.async_add_job(partial(func, *args, **kwargs))
            return True
        except DeviceException as exc:
            _LOGGER.error(mask_error, exc)
            return False

    async def async_start(self):
        """Start or resume the cleaning task."""
        await self._try_command(
            "Unable to start the vacuum: %s", self._vacuum.start)

    async def async_pause(self):
        """Pause the cleaning task."""
        if self.state == STATE_CLEANING:
            await self._try_command(
                "Unable to set start/pause: %s", self._vacuum.pause)

    @asyncio.coroutine
    def async_stop(self, **kwargs):
        """Stop the vacuum cleaner."""
        yield from self._try_command(
            "Unable to stop: %s", self._vacuum.stop)

    @asyncio.coroutine
    def async_set_fan_speed(self, fan_speed, **kwargs):
        """Set fan speed."""
        if fan_speed.capitalize() in FAN_SPEEDS:
            fan_speed = FAN_SPEEDS[fan_speed.capitalize()]
        else:
            try:
                fan_speed = int(fan_speed)
            except ValueError as exc:
                _LOGGER.error("Fan speed step not recognized (%s). "
                              "Valid speeds are: %s", exc,
                              self.fan_speed_list)
                return
        yield from self._try_command(
            "Unable to set fan speed: %s",
            self._vacuum.set_fan_speed, fan_speed)

    @asyncio.coroutine
    def async_return_to_base(self, **kwargs):
        """Set the vacuum cleaner to return to the dock."""
        yield from self._try_command(
            "Unable to return home: %s", self._vacuum.home)

    @asyncio.coroutine
    def async_clean_spot(self, **kwargs):
        """Perform a spot clean-up."""
        yield from self._try_command(
            "Unable to start the vacuum for a spot clean-up: %s",
            self._vacuum.spot)

    @asyncio.coroutine
    def async_locate(self, **kwargs):
        """Locate the vacuum cleaner."""
        yield from self._try_command(
            "Unable to locate the botvac: %s", self._vacuum.find)

    @asyncio.coroutine
    def async_send_command(self, command, params=None, **kwargs):
        """Send raw command."""
        yield from self._try_command(
            "Unable to send command to the vacuum: %s",
            self._vacuum.raw_command, command, params)

    @asyncio.coroutine
    def async_remote_control_start(self):
        """Start remote control mode."""
        yield from self._try_command(
            "Unable to start remote control the vacuum: %s",
            self._vacuum.manual_start)

    @asyncio.coroutine
    def async_remote_control_stop(self):
        """Stop remote control mode."""
        yield from self._try_command(
            "Unable to stop remote control the vacuum: %s",
            self._vacuum.manual_stop)

    @asyncio.coroutine
    def async_remote_control_move(self,
                                  rotation: int = 0,
                                  velocity: float = 0.3,
                                  duration: int = 1500):
        """Move vacuum with remote control mode."""
        yield from self._try_command(
            "Unable to move with remote control the vacuum: %s",
            self._vacuum.manual_control,
            velocity=velocity, rotation=rotation, duration=duration)

    @asyncio.coroutine
    def async_remote_control_move_step(self,
                                       rotation: int = 0,
                                       velocity: float = 0.2,
                                       duration: int = 1500):
        """Move vacuum one step with remote control mode."""
        yield from self._try_command(
            "Unable to remote control the vacuum: %s",
            self._vacuum.manual_control_once,
            velocity=velocity, rotation=rotation, duration=duration)

    def update(self):
        """Fetch state from the device."""
        from miio import DeviceException
        try:
            state = self._vacuum.status()
            self.vacuum_state = state

            self.consumable_state = self._vacuum.consumable_status()
            self.clean_history = self._vacuum.clean_history()
            self.dnd_state = self._vacuum.dnd_status()

            self._available = True
        except OSError as exc:
            _LOGGER.error("Got OSError while fetching the state: %s", exc)
        except DeviceException as exc:
            _LOGGER.warning("Got exception while fetching the state: %s", exc)
<commit_msg>Restore status attribute for xiaomi_vacuum (#16366)<commit_after>"""
Support for the Xiaomi vacuum cleaner robot.

For more details about this platform, please refer to the documentation
https://home-assistant.io/components/vacuum.xiaomi_miio/
"""
import asyncio
from functools import partial
import logging

import voluptuous as vol

from homeassistant.components.vacuum import (
    ATTR_CLEANED_AREA, DOMAIN, PLATFORM_SCHEMA, SUPPORT_BATTERY,
    SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE,
    SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STOP,
    SUPPORT_STATE, SUPPORT_START, VACUUM_SERVICE_SCHEMA, StateVacuumDevice,
    STATE_CLEANING, STATE_DOCKED, STATE_PAUSED, STATE_IDLE, STATE_RETURNING,
    STATE_ERROR)
from homeassistant.const import (
    ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON)
import homeassistant.helpers.config_validation as cv

REQUIREMENTS = ['python-miio==0.4.1', 'construct==2.9.41']

_LOGGER = logging.getLogger(__name__)

DEFAULT_NAME = 'Xiaomi Vacuum cleaner'
DATA_KEY = 'vacuum.xiaomi_miio'

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
    vol.Required(CONF_HOST): cv.string,
    vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)),
    vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
}, extra=vol.ALLOW_EXTRA)

SERVICE_MOVE_REMOTE_CONTROL = 'xiaomi_remote_control_move'
SERVICE_MOVE_REMOTE_CONTROL_STEP = 'xiaomi_remote_control_move_step'
SERVICE_START_REMOTE_CONTROL = 'xiaomi_remote_control_start'
SERVICE_STOP_REMOTE_CONTROL = 'xiaomi_remote_control_stop'

FAN_SPEEDS = {
    'Quiet': 38,
    'Balanced': 60,
    'Turbo': 77,
    'Max': 90}

ATTR_CLEANING_TIME = 'cleaning_time'
ATTR_DO_NOT_DISTURB = 'do_not_disturb'
ATTR_DO_NOT_DISTURB_START = 'do_not_disturb_start'
ATTR_DO_NOT_DISTURB_END = 'do_not_disturb_end'
ATTR_MAIN_BRUSH_LEFT = 'main_brush_left'
ATTR_SIDE_BRUSH_LEFT = 'side_brush_left'
ATTR_FILTER_LEFT = 'filter_left'
ATTR_SENSOR_DIRTY_LEFT = 'sensor_dirty_left'
ATTR_CLEANING_COUNT = 'cleaning_count'
ATTR_CLEANED_TOTAL_AREA = 'total_cleaned_area'
ATTR_CLEANING_TOTAL_TIME = 'total_cleaning_time'
ATTR_ERROR = 'error'
ATTR_RC_DURATION = 'duration'
ATTR_RC_ROTATION = 'rotation'
ATTR_RC_VELOCITY = 'velocity'
ATTR_STATUS = 'status'

SERVICE_SCHEMA_REMOTE_CONTROL = VACUUM_SERVICE_SCHEMA.extend({
    vol.Optional(ATTR_RC_VELOCITY):
        vol.All(vol.Coerce(float), vol.Clamp(min=-0.29, max=0.29)),
    vol.Optional(ATTR_RC_ROTATION):
        vol.All(vol.Coerce(int), vol.Clamp(min=-179, max=179)),
    vol.Optional(ATTR_RC_DURATION): cv.positive_int,
})

SERVICE_TO_METHOD = {
    SERVICE_START_REMOTE_CONTROL: {'method': 'async_remote_control_start'},
    SERVICE_STOP_REMOTE_CONTROL: {'method': 'async_remote_control_stop'},
    SERVICE_MOVE_REMOTE_CONTROL: {
        'method': 'async_remote_control_move',
        'schema': SERVICE_SCHEMA_REMOTE_CONTROL},
    SERVICE_MOVE_REMOTE_CONTROL_STEP: {
        'method': 'async_remote_control_move_step',
        'schema': SERVICE_SCHEMA_REMOTE_CONTROL},
}

SUPPORT_XIAOMI = SUPPORT_STATE | SUPPORT_PAUSE | \
                 SUPPORT_STOP | SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | \
                 SUPPORT_SEND_COMMAND | SUPPORT_LOCATE | \
                 SUPPORT_BATTERY | SUPPORT_CLEAN_SPOT | SUPPORT_START

STATE_CODE_TO_STATE = {
    2: STATE_IDLE,
    3: STATE_IDLE,
    5: STATE_CLEANING,
    6: STATE_RETURNING,
    8: STATE_DOCKED,
    9: STATE_ERROR,
    10: STATE_PAUSED,
    11: STATE_CLEANING,
    12: STATE_ERROR,
    15: STATE_RETURNING,
    16: STATE_CLEANING,
    17: STATE_CLEANING,
}

@asyncio.coroutine
def async_setup_platform(hass, config, async_add_entities,
                         discovery_info=None):
    """Set up the Xiaomi vacuum cleaner robot platform."""
    from miio import Vacuum
    if DATA_KEY not in hass.data:
        hass.data[DATA_KEY] = {}

    host = config.get(CONF_HOST)
    name = config.get(CONF_NAME)
    token = config.get(CONF_TOKEN)

    # Create handler
    _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5])
    vacuum = Vacuum(host, token)

    mirobo = MiroboVacuum(name, vacuum)
    hass.data[DATA_KEY][host] = mirobo

    async_add_entities([mirobo], update_before_add=True)

    @asyncio.coroutine
    def async_service_handler(service):
        """Map services to methods on MiroboVacuum."""
        method = SERVICE_TO_METHOD.get(service.service)
        params = {key: value for key, value in service.data.items()
                  if key != ATTR_ENTITY_ID}
        entity_ids = service.data.get(ATTR_ENTITY_ID)
        if entity_ids:
            target_vacuums = [vac for vac in hass.data[DATA_KEY].values()
                              if vac.entity_id in entity_ids]
        else:
            target_vacuums = hass.data[DATA_KEY].values()

        update_tasks = []
        for vacuum in target_vacuums:
            yield from getattr(vacuum, method['method'])(**params)

        for vacuum in target_vacuums:
            update_coro = vacuum.async_update_ha_state(True)
            update_tasks.append(update_coro)

        if update_tasks:
            yield from asyncio.wait(update_tasks, loop=hass.loop)

    for vacuum_service in SERVICE_TO_METHOD:
        schema = SERVICE_TO_METHOD[vacuum_service].get(
            'schema', VACUUM_SERVICE_SCHEMA)
        hass.services.async_register(
            DOMAIN, vacuum_service, async_service_handler,
            schema=schema)

class MiroboVacuum(StateVacuumDevice):
    """Representation of a Xiaomi Vacuum cleaner robot."""

    def __init__(self, name, vacuum):
        """Initialize the Xiaomi vacuum cleaner robot handler."""
        self._name = name
        self._vacuum = vacuum

        self.vacuum_state = None
        self._available = False

        self.consumable_state = None
        self.clean_history = None
        self.dnd_state = None

    @property
    def name(self):
        """Return the name of the device."""
        return self._name

    @property
    def state(self):
        """Return the status of the vacuum cleaner."""
        if self.vacuum_state is not None:
            try:
                return STATE_CODE_TO_STATE[int(self.vacuum_state.state_code)]
            except KeyError:
                _LOGGER.error("STATE not supported: %s, state_code: %s",
                              self.vacuum_state.state,
                              self.vacuum_state.state_code)
                return None

    @property
    def battery_level(self):
        """Return the battery level of the vacuum cleaner."""
        if self.vacuum_state is not None:
            return self.vacuum_state.battery

    @property
    def fan_speed(self):
        """Return the fan speed of the vacuum cleaner."""
        if self.vacuum_state is not None:
            speed = self.vacuum_state.fanspeed
            if speed in FAN_SPEEDS.values():
                return [key for key, value in FAN_SPEEDS.items()
                        if value == speed][0]
            return speed

    @property
    def fan_speed_list(self):
        """Get the list of available fan speed steps of the vacuum cleaner."""
        return list(sorted(FAN_SPEEDS.keys(), key=lambda s: FAN_SPEEDS[s]))

    @property
    def device_state_attributes(self):
        """Return the specific state attributes of this vacuum cleaner."""
        attrs = {}
        if self.vacuum_state is not None:
            attrs.update({
                ATTR_DO_NOT_DISTURB:
                    STATE_ON if self.dnd_state.enabled else STATE_OFF,
                ATTR_DO_NOT_DISTURB_START: str(self.dnd_state.start),
                ATTR_DO_NOT_DISTURB_END: str(self.dnd_state.end),
                # Not working --> 'Cleaning mode':
                #    STATE_ON if self.vacuum_state.in_cleaning else STATE_OFF,
                ATTR_CLEANING_TIME: int(
                    self.vacuum_state.clean_time.total_seconds()
                    / 60),
                ATTR_CLEANED_AREA: int(self.vacuum_state.clean_area),
                ATTR_CLEANING_COUNT: int(self.clean_history.count),
                ATTR_CLEANED_TOTAL_AREA: int(self.clean_history.total_area),
                ATTR_CLEANING_TOTAL_TIME: int(
                    self.clean_history.total_duration.total_seconds()
                    / 60),
                ATTR_MAIN_BRUSH_LEFT: int(
                    self.consumable_state.main_brush_left.total_seconds()
                    / 3600),
                ATTR_SIDE_BRUSH_LEFT: int(
                    self.consumable_state.side_brush_left.total_seconds()
                    / 3600),
                ATTR_FILTER_LEFT: int(
                    self.consumable_state.filter_left.total_seconds()
                    / 3600),
                ATTR_SENSOR_DIRTY_LEFT: int(
                    self.consumable_state.sensor_dirty_left.total_seconds()
                    / 3600),
                ATTR_STATUS: str(self.vacuum_state.state)
                })

            if self.vacuum_state.got_error:
                attrs[ATTR_ERROR] = self.vacuum_state.error
        return attrs

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

    @property
    def supported_features(self):
        """Flag vacuum cleaner robot features that are supported."""
        return SUPPORT_XIAOMI

    @asyncio.coroutine
    def _try_command(self, mask_error, func, *args, **kwargs):
        """Call a vacuum command handling error messages."""
        from miio import DeviceException
        try:
            yield from self.hass.async_add_job(partial(func, *args, **kwargs))
            return True
        except DeviceException as exc:
            _LOGGER.error(mask_error, exc)
            return False

    async def async_start(self):
        """Start or resume the cleaning task."""
        await self._try_command(
            "Unable to start the vacuum: %s", self._vacuum.start)

    async def async_pause(self):
        """Pause the cleaning task."""
        if self.state == STATE_CLEANING:
            await self._try_command(
                "Unable to set start/pause: %s", self._vacuum.pause)

    @asyncio.coroutine
    def async_stop(self, **kwargs):
        """Stop the vacuum cleaner."""
        yield from self._try_command(
            "Unable to stop: %s", self._vacuum.stop)

    @asyncio.coroutine
    def async_set_fan_speed(self, fan_speed, **kwargs):
        """Set fan speed."""
        if fan_speed.capitalize() in FAN_SPEEDS:
            fan_speed = FAN_SPEEDS[fan_speed.capitalize()]
        else:
            try:
                fan_speed = int(fan_speed)
            except ValueError as exc:
                _LOGGER.error("Fan speed step not recognized (%s). "
                              "Valid speeds are: %s", exc,
                              self.fan_speed_list)
                return
        yield from self._try_command(
            "Unable to set fan speed: %s",
            self._vacuum.set_fan_speed, fan_speed)

    @asyncio.coroutine
    def async_return_to_base(self, **kwargs):
        """Set the vacuum cleaner to return to the dock."""
        yield from self._try_command(
            "Unable to return home: %s", self._vacuum.home)

    @asyncio.coroutine
    def async_clean_spot(self, **kwargs):
        """Perform a spot clean-up."""
        yield from self._try_command(
            "Unable to start the vacuum for a spot clean-up: %s",
            self._vacuum.spot)

    @asyncio.coroutine
    def async_locate(self, **kwargs):
        """Locate the vacuum cleaner."""
        yield from self._try_command(
            "Unable to locate the botvac: %s", self._vacuum.find)

    @asyncio.coroutine
    def async_send_command(self, command, params=None, **kwargs):
        """Send raw command."""
        yield from self._try_command(
            "Unable to send command to the vacuum: %s",
            self._vacuum.raw_command, command, params)

    @asyncio.coroutine
    def async_remote_control_start(self):
        """Start remote control mode."""
        yield from self._try_command(
            "Unable to start remote control the vacuum: %s",
            self._vacuum.manual_start)

    @asyncio.coroutine
    def async_remote_control_stop(self):
        """Stop remote control mode."""
        yield from self._try_command(
            "Unable to stop remote control the vacuum: %s",
            self._vacuum.manual_stop)

    @asyncio.coroutine
    def async_remote_control_move(self,
                                  rotation: int = 0,
                                  velocity: float = 0.3,
                                  duration: int = 1500):
        """Move vacuum with remote control mode."""
        yield from self._try_command(
            "Unable to move with remote control the vacuum: %s",
            self._vacuum.manual_control,
            velocity=velocity, rotation=rotation, duration=duration)

    @asyncio.coroutine
    def async_remote_control_move_step(self,
                                       rotation: int = 0,
                                       velocity: float = 0.2,
                                       duration: int = 1500):
        """Move vacuum one step with remote control mode."""
        yield from self._try_command(
            "Unable to remote control the vacuum: %s",
            self._vacuum.manual_control_once,
            velocity=velocity, rotation=rotation, duration=duration)

    def update(self):
        """Fetch state from the device."""
        from miio import DeviceException
        try:
            state = self._vacuum.status()
            self.vacuum_state = state

            self.consumable_state = self._vacuum.consumable_status()
            self.clean_history = self._vacuum.clean_history()
            self.dnd_state = self._vacuum.dnd_status()

            self._available = True
        except OSError as exc:
            _LOGGER.error("Got OSError while fetching the state: %s", exc)
        except DeviceException as exc:
            _LOGGER.warning("Got exception while fetching the state: %s", exc)
<|endoftext|>


<commit_before>"""Vizio SmartCast Device support."""
from datetime import timedelta
import logging
from typing import Any, Callable, Dict, List, Optional

from pyvizio import VizioAsync
from pyvizio.api.apps import find_app_name
from pyvizio.const import APP_HOME, APPS, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP

from homeassistant.components.media_player import (
    DEVICE_CLASS_SPEAKER,
    SUPPORT_SELECT_SOUND_MODE,
    MediaPlayerEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
    CONF_ACCESS_TOKEN,
    CONF_DEVICE_CLASS,
    CONF_EXCLUDE,
    CONF_HOST,
    CONF_INCLUDE,
    CONF_NAME,
    STATE_OFF,
    STATE_ON,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
    async_dispatcher_connect,
    async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType

from .const import (
    CONF_ADDITIONAL_CONFIGS,
    CONF_APPS,
    CONF_VOLUME_STEP,
    DEFAULT_TIMEOUT,
    DEFAULT_VOLUME_STEP,
    DEVICE_ID,
    DOMAIN,
    ICON,
    SUPPORTED_COMMANDS,
    VIZIO_AUDIO_SETTINGS,
    VIZIO_DEVICE_CLASSES,
    VIZIO_SOUND_MODE,
)

_LOGGER = logging.getLogger(__name__)

SCAN_INTERVAL = timedelta(seconds=10)
PARALLEL_UPDATES = 0

async def async_setup_entry(
    hass: HomeAssistantType,
    config_entry: ConfigEntry,
    async_add_entities: Callable[[List[Entity], bool], None],
) -> None:
    """Set up a Vizio media player entry."""
    host = config_entry.data[CONF_HOST]
    token = config_entry.data.get(CONF_ACCESS_TOKEN)
    name = config_entry.data[CONF_NAME]
    device_class = config_entry.data[CONF_DEVICE_CLASS]

    # If config entry options not set up, set them up, otherwise assign values managed in options
    volume_step = config_entry.options.get(
        CONF_VOLUME_STEP, config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP)
    )

    params = {}
    if not config_entry.options:
        params["options"] = {CONF_VOLUME_STEP: volume_step}
        include_or_exclude_key = next(
            (
                key
                for key in config_entry.data.get(CONF_APPS, {})
                if key in [CONF_INCLUDE, CONF_EXCLUDE]
            ),
            None,
        )
        if include_or_exclude_key:
            params["options"][CONF_APPS] = {
                include_or_exclude_key: config_entry.data[CONF_APPS][
                    include_or_exclude_key
                ].copy()
            }

    if not config_entry.data.get(CONF_VOLUME_STEP):
        new_data = config_entry.data.copy()
        new_data.update({CONF_VOLUME_STEP: volume_step})
        params["data"] = new_data

    if params:
        hass.config_entries.async_update_entry(config_entry, **params)

    device = VizioAsync(
        DEVICE_ID,
        host,
        name,
        auth_token=token,
        device_type=VIZIO_DEVICE_CLASSES[device_class],
        session=async_get_clientsession(hass, False),
        timeout=DEFAULT_TIMEOUT,
    )

    if not await device.can_connect_with_auth_check():
        _LOGGER.warning("Failed to connect to %s", host)
        raise PlatformNotReady

    entity = VizioDevice(config_entry, device, name, device_class,)

    async_add_entities([entity], update_before_add=True)

class VizioDevice(MediaPlayerEntity):
    """Media Player implementation which performs REST requests to device."""

    def __init__(
        self,
        config_entry: ConfigEntry,
        device: VizioAsync,
        name: str,
        device_class: str,
    ) -> None:
        """Initialize Vizio device."""
        self._config_entry = config_entry
        self._async_unsub_listeners = []

        self._name = name
        self._state = None
        self._volume_level = None
        self._volume_step = config_entry.options[CONF_VOLUME_STEP]
        self._is_volume_muted = None
        self._current_input = None
        self._current_app = None
        self._current_app_config = None
        self._current_sound_mode = None
        self._available_sound_modes = None
        self._available_inputs = []
        self._available_apps = []
        self._conf_apps = config_entry.options.get(CONF_APPS, {})
        self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get(
            CONF_ADDITIONAL_CONFIGS, []
        )
        self._device_class = device_class
        self._supported_commands = SUPPORTED_COMMANDS[device_class]
        self._device = device
        self._max_volume = float(self._device.get_max_volume())
        self._icon = ICON[device_class]
        self._available = True
        self._model = None
        self._sw_version = None

    def _apps_list(self, apps: List[str]) -> List[str]:
        """Return process apps list based on configured filters."""
        if self._conf_apps.get(CONF_INCLUDE):
            return [app for app in apps if app in self._conf_apps[CONF_INCLUDE]]

        if self._conf_apps.get(CONF_EXCLUDE):
            return [app for app in apps if app not in self._conf_apps[CONF_EXCLUDE]]

        return apps

    async def async_update(self) -> None:
        """Retrieve latest state of the device."""
        if not self._model:
            self._model = await self._device.get_model_name()

        if not self._sw_version:
            self._sw_version = await self._device.get_version()

        is_on = await self._device.get_power_state(log_api_exception=False)

        if is_on is None:
            if self._available:
                _LOGGER.warning(
                    "Lost connection to %s", self._config_entry.data[CONF_HOST]
                )
                self._available = False
            return

        if not self._available:
            _LOGGER.info(
                "Restored connection to %s", self._config_entry.data[CONF_HOST]
            )
            self._available = True

        if not is_on:
            self._state = STATE_OFF
            self._volume_level = None
            self._is_volume_muted = None
            self._current_input = None
            self._available_inputs = None
            self._current_app = None
            self._current_app_config = None
            self._available_apps = None
            self._current_sound_mode = None
            self._available_sound_modes = None
            return

        self._state = STATE_ON

        audio_settings = await self._device.get_all_settings(
            VIZIO_AUDIO_SETTINGS, log_api_exception=False
        )
        if audio_settings is not None:
            self._volume_level = float(audio_settings["volume"]) / self._max_volume
            if "mute" in audio_settings:
                self._is_volume_muted = audio_settings["mute"].lower() == "on"
            else:
                self._is_volume_muted = None

            if VIZIO_SOUND_MODE in audio_settings:
                self._supported_commands |= SUPPORT_SELECT_SOUND_MODE
                self._current_sound_mode = audio_settings[VIZIO_SOUND_MODE]
                if self._available_sound_modes is None:
                    self._available_sound_modes = await self._device.get_setting_options(
                        VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE
                    )
            else:
                self._supported_commands ^= SUPPORT_SELECT_SOUND_MODE

        input_ = await self._device.get_current_input(log_api_exception=False)
        if input_ is not None:
            self._current_input = input_

        inputs = await self._device.get_inputs_list(log_api_exception=False)

        # If no inputs returned, end update
        if not inputs:
            return

        self._available_inputs = [input_.name for input_ in inputs]

        # Return before setting app variables if INPUT_APPS isn't in available inputs
        if self._device_class == DEVICE_CLASS_SPEAKER or not any(
            app for app in INPUT_APPS if app in self._available_inputs
        ):
            return

        # Create list of available known apps from known app list after
        # filtering by CONF_INCLUDE/CONF_EXCLUDE
        if not self._available_apps:
            self._available_apps = self._apps_list(self._device.get_apps_list())

        self._current_app_config = await self._device.get_current_app_config(
            log_api_exception=False
        )

        self._current_app = find_app_name(
            self._current_app_config, [APP_HOME, *APPS, *self._additional_app_configs]
        )

        if self._current_app == NO_APP_RUNNING:
            self._current_app = None

    def _get_additional_app_names(self) -> List[Dict[str, Any]]:
        """Return list of additional apps that were included in configuration.yaml."""
        return [
            additional_app["name"] for additional_app in self._additional_app_configs
        ]

    @staticmethod
    async def _async_send_update_options_signal(
        hass: HomeAssistantType, config_entry: ConfigEntry
    ) -> None:
        """Send update event when Vizio config entry is updated."""
        # Move this method to component level if another entity ever gets added for a single config entry.
        # See here: https://github.com/home-assistant/home-assistant/pull/30653#discussion_r366426121
        async_dispatcher_send(hass, config_entry.entry_id, config_entry)

    async def _async_update_options(self, config_entry: ConfigEntry) -> None:
        """Update options if the update signal comes from this entity."""
        self._volume_step = config_entry.options[CONF_VOLUME_STEP]
        self._conf_apps.update(config_entry.options.get(CONF_APPS, {}))

    async def async_added_to_hass(self):
        """Register callbacks when entity is added."""
        # Register callback for when config entry is updated.
        self._async_unsub_listeners.append(
            self._config_entry.add_update_listener(
                self._async_send_update_options_signal
            )
        )

        # Register callback for update event
        self._async_unsub_listeners.append(
            async_dispatcher_connect(
                self.hass, self._config_entry.entry_id, self._async_update_options
            )
        )

    async def async_will_remove_from_hass(self):
        """Disconnect callbacks when entity is removed."""
        for listener in self._async_unsub_listeners:
            listener()

        self._async_unsub_listeners.clear()

    @property
    def available(self) -> bool:
        """Return the availabiliity of the device."""
        return self._available

    @property
    def state(self) -> str:
        """Return the state of the device."""
        return self._state

    @property
    def name(self) -> str:
        """Return the name of the device."""
        return self._name

    @property
    def icon(self) -> str:
        """Return the icon of the device."""
        return self._icon

    @property
    def volume_level(self) -> float:
        """Return the volume level of the device."""
        return self._volume_level

    @property
    def is_volume_muted(self):
        """Boolean if volume is currently muted."""
        return self._is_volume_muted

    @property
    def source(self) -> str:
        """Return current input of the device."""
        if self._current_app is not None and self._current_input in INPUT_APPS:
            return self._current_app

        return self._current_input

    @property
    def source_list(self) -> List[str]:
        """Return list of available inputs of the device."""
        # If Smartcast app is in input list, and the app list has been retrieved,
        # show the combination with , otherwise just return inputs
        if self._available_apps:
            return [
                *[
                    _input
                    for _input in self._available_inputs
                    if _input not in INPUT_APPS
                ],
                *self._available_apps,
                *[
                    app
                    for app in self._get_additional_app_names()
                    if app not in self._available_apps
                ],
            ]

        return self._available_inputs

    @property
    def app_id(self) -> Optional[str]:
        """Return the ID of the current app if it is unknown by pyvizio."""
        if self._current_app_config and self.app_name == UNKNOWN_APP:
            return {
                "APP_ID": self._current_app_config.APP_ID,
                "NAME_SPACE": self._current_app_config.NAME_SPACE,
                "MESSAGE": self._current_app_config.MESSAGE,
            }

        return None

    @property
    def app_name(self) -> Optional[str]:
        """Return the friendly name of the current app."""
        return self._current_app

    @property
    def supported_features(self) -> int:
        """Flag device features that are supported."""
        return self._supported_commands

    @property
    def unique_id(self) -> str:
        """Return the unique id of the device."""
        return self._config_entry.unique_id

    @property
    def device_info(self) -> Dict[str, Any]:
        """Return device registry information."""
        return {
            "identifiers": {(DOMAIN, self._config_entry.unique_id)},
            "name": self.name,
            "manufacturer": "VIZIO",
            "model": self._model,
            "sw_version": self._sw_version,
        }

    @property
    def device_class(self) -> str:
        """Return device class for entity."""
        return self._device_class

    @property
    def sound_mode(self) -> Optional[str]:
        """Name of the current sound mode."""
        return self._current_sound_mode

    @property
    def sound_mode_list(self) -> Optional[List[str]]:
        """List of available sound modes."""
        return self._available_sound_modes

    async def async_select_sound_mode(self, sound_mode):
        """Select sound mode."""
        if sound_mode in self._available_sound_modes:
            await self._device.set_setting(
                VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, sound_mode
            )

    async def async_turn_on(self) -> None:
        """Turn the device on."""
        await self._device.pow_on()

    async def async_turn_off(self) -> None:
        """Turn the device off."""
        await self._device.pow_off()

    async def async_mute_volume(self, mute: bool) -> None:
        """Mute the volume."""
        if mute:
            await self._device.mute_on()
            self._is_volume_muted = True
        else:
            await self._device.mute_off()
            self._is_volume_muted = False

    async def async_media_previous_track(self) -> None:
        """Send previous channel command."""
        await self._device.ch_down()

    async def async_media_next_track(self) -> None:
        """Send next channel command."""
        await self._device.ch_up()

    async def async_select_source(self, source: str) -> None:
        """Select input source."""
        if source in self._available_inputs:
            await self._device.set_input(source)
        elif source in self._get_additional_app_names():
            await self._device.launch_app_config(
                **next(
                    app["config"]
                    for app in self._additional_app_configs
                    if app["name"] == source
                )
            )
        elif source in self._available_apps:
            await self._device.launch_app(source)

    async def async_volume_up(self) -> None:
        """Increase volume of the device."""
        await self._device.vol_up(num=self._volume_step)

        if self._volume_level is not None:
            self._volume_level = min(
                1.0, self._volume_level + self._volume_step / self._max_volume
            )

    async def async_volume_down(self) -> None:
        """Decrease volume of the device."""
        await self._device.vol_down(num=self._volume_step)

        if self._volume_level is not None:
            self._volume_level = max(
                0.0, self._volume_level - self._volume_step / self._max_volume
            )

    async def async_set_volume_level(self, volume: float) -> None:
        """Set volume level."""
        if self._volume_level is not None:
            if volume > self._volume_level:
                num = int(self._max_volume * (volume - self._volume_level))
                await self._device.vol_up(num=num)
                self._volume_level = volume

            elif volume < self._volume_level:
                num = int(self._max_volume * (self._volume_level - volume))
                await self._device.vol_down(num=num)
                self._volume_level = volume
<commit_msg>Reduce Vizio API calls (#35726)<commit_after>"""Vizio SmartCast Device support."""
from datetime import timedelta
import logging
from typing import Any, Callable, Dict, List, Optional

from pyvizio import VizioAsync
from pyvizio.api.apps import find_app_name
from pyvizio.const import APP_HOME, APPS, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP

from homeassistant.components.media_player import (
    DEVICE_CLASS_SPEAKER,
    SUPPORT_SELECT_SOUND_MODE,
    MediaPlayerEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
    CONF_ACCESS_TOKEN,
    CONF_DEVICE_CLASS,
    CONF_EXCLUDE,
    CONF_HOST,
    CONF_INCLUDE,
    CONF_NAME,
    STATE_OFF,
    STATE_ON,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
    async_dispatcher_connect,
    async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType

from .const import (
    CONF_ADDITIONAL_CONFIGS,
    CONF_APPS,
    CONF_VOLUME_STEP,
    DEFAULT_TIMEOUT,
    DEFAULT_VOLUME_STEP,
    DEVICE_ID,
    DOMAIN,
    ICON,
    SUPPORTED_COMMANDS,
    VIZIO_AUDIO_SETTINGS,
    VIZIO_DEVICE_CLASSES,
    VIZIO_SOUND_MODE,
)

_LOGGER = logging.getLogger(__name__)

SCAN_INTERVAL = timedelta(seconds=10)
PARALLEL_UPDATES = 0

async def async_setup_entry(
    hass: HomeAssistantType,
    config_entry: ConfigEntry,
    async_add_entities: Callable[[List[Entity], bool], None],
) -> None:
    """Set up a Vizio media player entry."""
    host = config_entry.data[CONF_HOST]
    token = config_entry.data.get(CONF_ACCESS_TOKEN)
    name = config_entry.data[CONF_NAME]
    device_class = config_entry.data[CONF_DEVICE_CLASS]

    # If config entry options not set up, set them up, otherwise assign values managed in options
    volume_step = config_entry.options.get(
        CONF_VOLUME_STEP, config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP)
    )

    params = {}
    if not config_entry.options:
        params["options"] = {CONF_VOLUME_STEP: volume_step}
        include_or_exclude_key = next(
            (
                key
                for key in config_entry.data.get(CONF_APPS, {})
                if key in [CONF_INCLUDE, CONF_EXCLUDE]
            ),
            None,
        )
        if include_or_exclude_key:
            params["options"][CONF_APPS] = {
                include_or_exclude_key: config_entry.data[CONF_APPS][
                    include_or_exclude_key
                ].copy()
            }

    if not config_entry.data.get(CONF_VOLUME_STEP):
        new_data = config_entry.data.copy()
        new_data.update({CONF_VOLUME_STEP: volume_step})
        params["data"] = new_data

    if params:
        hass.config_entries.async_update_entry(config_entry, **params)

    device = VizioAsync(
        DEVICE_ID,
        host,
        name,
        auth_token=token,
        device_type=VIZIO_DEVICE_CLASSES[device_class],
        session=async_get_clientsession(hass, False),
        timeout=DEFAULT_TIMEOUT,
    )

    if not await device.can_connect_with_auth_check():
        _LOGGER.warning("Failed to connect to %s", host)
        raise PlatformNotReady

    entity = VizioDevice(config_entry, device, name, device_class,)

    async_add_entities([entity], update_before_add=True)

class VizioDevice(MediaPlayerEntity):
    """Media Player implementation which performs REST requests to device."""

    def __init__(
        self,
        config_entry: ConfigEntry,
        device: VizioAsync,
        name: str,
        device_class: str,
    ) -> None:
        """Initialize Vizio device."""
        self._config_entry = config_entry
        self._async_unsub_listeners = []

        self._name = name
        self._state = None
        self._volume_level = None
        self._volume_step = config_entry.options[CONF_VOLUME_STEP]
        self._is_volume_muted = None
        self._current_input = None
        self._current_app = None
        self._current_app_config = None
        self._current_sound_mode = None
        self._available_sound_modes = []
        self._available_inputs = []
        self._available_apps = []
        self._conf_apps = config_entry.options.get(CONF_APPS, {})
        self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get(
            CONF_ADDITIONAL_CONFIGS, []
        )
        self._device_class = device_class
        self._supported_commands = SUPPORTED_COMMANDS[device_class]
        self._device = device
        self._max_volume = float(self._device.get_max_volume())
        self._icon = ICON[device_class]
        self._available = True
        self._model = None
        self._sw_version = None

    def _apps_list(self, apps: List[str]) -> List[str]:
        """Return process apps list based on configured filters."""
        if self._conf_apps.get(CONF_INCLUDE):
            return [app for app in apps if app in self._conf_apps[CONF_INCLUDE]]

        if self._conf_apps.get(CONF_EXCLUDE):
            return [app for app in apps if app not in self._conf_apps[CONF_EXCLUDE]]

        return apps

    async def async_update(self) -> None:
        """Retrieve latest state of the device."""
        if not self._model:
            self._model = await self._device.get_model_name()

        if not self._sw_version:
            self._sw_version = await self._device.get_version()

        is_on = await self._device.get_power_state(log_api_exception=False)

        if is_on is None:
            if self._available:
                _LOGGER.warning(
                    "Lost connection to %s", self._config_entry.data[CONF_HOST]
                )
                self._available = False
            return

        if not self._available:
            _LOGGER.info(
                "Restored connection to %s", self._config_entry.data[CONF_HOST]
            )
            self._available = True

        if not is_on:
            self._state = STATE_OFF
            self._volume_level = None
            self._is_volume_muted = None
            self._current_input = None
            self._current_app = None
            self._current_app_config = None
            self._current_sound_mode = None
            return

        self._state = STATE_ON

        audio_settings = await self._device.get_all_settings(
            VIZIO_AUDIO_SETTINGS, log_api_exception=False
        )
        if audio_settings is not None:
            self._volume_level = float(audio_settings["volume"]) / self._max_volume
            if "mute" in audio_settings:
                self._is_volume_muted = audio_settings["mute"].lower() == "on"
            else:
                self._is_volume_muted = None

            if VIZIO_SOUND_MODE in audio_settings:
                self._supported_commands |= SUPPORT_SELECT_SOUND_MODE
                self._current_sound_mode = audio_settings[VIZIO_SOUND_MODE]
                if not self._available_sound_modes:
                    self._available_sound_modes = await self._device.get_setting_options(
                        VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE
                    )
            else:
                self._supported_commands ^= SUPPORT_SELECT_SOUND_MODE

        input_ = await self._device.get_current_input(log_api_exception=False)
        if input_ is not None:
            self._current_input = input_

        if not self._available_inputs:
            inputs = await self._device.get_inputs_list(log_api_exception=False)

            # If no inputs returned, end update
            if not inputs:
                return

            self._available_inputs = [input_.name for input_ in inputs]

        # Return before setting app variables if INPUT_APPS isn't in available inputs
        if self._device_class == DEVICE_CLASS_SPEAKER or not any(
            app for app in INPUT_APPS if app in self._available_inputs
        ):
            return

        # Create list of available known apps from known app list after
        # filtering by CONF_INCLUDE/CONF_EXCLUDE
        self._available_apps = self._apps_list(self._device.get_apps_list())

        self._current_app_config = await self._device.get_current_app_config(
            log_api_exception=False
        )

        self._current_app = find_app_name(
            self._current_app_config, [APP_HOME, *APPS, *self._additional_app_configs]
        )

        if self._current_app == NO_APP_RUNNING:
            self._current_app = None

    def _get_additional_app_names(self) -> List[Dict[str, Any]]:
        """Return list of additional apps that were included in configuration.yaml."""
        return [
            additional_app["name"] for additional_app in self._additional_app_configs
        ]

    @staticmethod
    async def _async_send_update_options_signal(
        hass: HomeAssistantType, config_entry: ConfigEntry
    ) -> None:
        """Send update event when Vizio config entry is updated."""
        # Move this method to component level if another entity ever gets added for a single config entry.
        # See here: https://github.com/home-assistant/home-assistant/pull/30653#discussion_r366426121
        async_dispatcher_send(hass, config_entry.entry_id, config_entry)

    async def _async_update_options(self, config_entry: ConfigEntry) -> None:
        """Update options if the update signal comes from this entity."""
        self._volume_step = config_entry.options[CONF_VOLUME_STEP]
        self._conf_apps.update(config_entry.options.get(CONF_APPS, {}))

    async def async_added_to_hass(self):
        """Register callbacks when entity is added."""
        # Register callback for when config entry is updated.
        self._async_unsub_listeners.append(
            self._config_entry.add_update_listener(
                self._async_send_update_options_signal
            )
        )

        # Register callback for update event
        self._async_unsub_listeners.append(
            async_dispatcher_connect(
                self.hass, self._config_entry.entry_id, self._async_update_options
            )
        )

    async def async_will_remove_from_hass(self):
        """Disconnect callbacks when entity is removed."""
        for listener in self._async_unsub_listeners:
            listener()

        self._async_unsub_listeners.clear()

    @property
    def available(self) -> bool:
        """Return the availabiliity of the device."""
        return self._available

    @property
    def state(self) -> str:
        """Return the state of the device."""
        return self._state

    @property
    def name(self) -> str:
        """Return the name of the device."""
        return self._name

    @property
    def icon(self) -> str:
        """Return the icon of the device."""
        return self._icon

    @property
    def volume_level(self) -> float:
        """Return the volume level of the device."""
        return self._volume_level

    @property
    def is_volume_muted(self):
        """Boolean if volume is currently muted."""
        return self._is_volume_muted

    @property
    def source(self) -> str:
        """Return current input of the device."""
        if self._current_app is not None and self._current_input in INPUT_APPS:
            return self._current_app

        return self._current_input

    @property
    def source_list(self) -> List[str]:
        """Return list of available inputs of the device."""
        # If Smartcast app is in input list, and the app list has been retrieved,
        # show the combination with , otherwise just return inputs
        if self._available_apps:
            return [
                *[
                    _input
                    for _input in self._available_inputs
                    if _input not in INPUT_APPS
                ],
                *self._available_apps,
                *[
                    app
                    for app in self._get_additional_app_names()
                    if app not in self._available_apps
                ],
            ]

        return self._available_inputs

    @property
    def app_id(self) -> Optional[str]:
        """Return the ID of the current app if it is unknown by pyvizio."""
        if self._current_app_config and self.app_name == UNKNOWN_APP:
            return {
                "APP_ID": self._current_app_config.APP_ID,
                "NAME_SPACE": self._current_app_config.NAME_SPACE,
                "MESSAGE": self._current_app_config.MESSAGE,
            }

        return None

    @property
    def app_name(self) -> Optional[str]:
        """Return the friendly name of the current app."""
        return self._current_app

    @property
    def supported_features(self) -> int:
        """Flag device features that are supported."""
        return self._supported_commands

    @property
    def unique_id(self) -> str:
        """Return the unique id of the device."""
        return self._config_entry.unique_id

    @property
    def device_info(self) -> Dict[str, Any]:
        """Return device registry information."""
        return {
            "identifiers": {(DOMAIN, self._config_entry.unique_id)},
            "name": self.name,
            "manufacturer": "VIZIO",
            "model": self._model,
            "sw_version": self._sw_version,
        }

    @property
    def device_class(self) -> str:
        """Return device class for entity."""
        return self._device_class

    @property
    def sound_mode(self) -> Optional[str]:
        """Name of the current sound mode."""
        return self._current_sound_mode

    @property
    def sound_mode_list(self) -> Optional[List[str]]:
        """List of available sound modes."""
        return self._available_sound_modes

    async def async_select_sound_mode(self, sound_mode):
        """Select sound mode."""
        if sound_mode in self._available_sound_modes:
            await self._device.set_setting(
                VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, sound_mode
            )

    async def async_turn_on(self) -> None:
        """Turn the device on."""
        await self._device.pow_on()

    async def async_turn_off(self) -> None:
        """Turn the device off."""
        await self._device.pow_off()

    async def async_mute_volume(self, mute: bool) -> None:
        """Mute the volume."""
        if mute:
            await self._device.mute_on()
            self._is_volume_muted = True
        else:
            await self._device.mute_off()
            self._is_volume_muted = False

    async def async_media_previous_track(self) -> None:
        """Send previous channel command."""
        await self._device.ch_down()

    async def async_media_next_track(self) -> None:
        """Send next channel command."""
        await self._device.ch_up()

    async def async_select_source(self, source: str) -> None:
        """Select input source."""
        if source in self._available_inputs:
            await self._device.set_input(source)
        elif source in self._get_additional_app_names():
            await self._device.launch_app_config(
                **next(
                    app["config"]
                    for app in self._additional_app_configs
                    if app["name"] == source
                )
            )
        elif source in self._available_apps:
            await self._device.launch_app(source)

    async def async_volume_up(self) -> None:
        """Increase volume of the device."""
        await self._device.vol_up(num=self._volume_step)

        if self._volume_level is not None:
            self._volume_level = min(
                1.0, self._volume_level + self._volume_step / self._max_volume
            )

    async def async_volume_down(self) -> None:
        """Decrease volume of the device."""
        await self._device.vol_down(num=self._volume_step)

        if self._volume_level is not None:
            self._volume_level = max(
                0.0, self._volume_level - self._volume_step / self._max_volume
            )

    async def async_set_volume_level(self, volume: float) -> None:
        """Set volume level."""
        if self._volume_level is not None:
            if volume > self._volume_level:
                num = int(self._max_volume * (volume - self._volume_level))
                await self._device.vol_up(num=num)
                self._volume_level = volume

            elif volume < self._volume_level:
                num = int(self._max_volume * (self._volume_level - volume))
                await self._device.vol_down(num=num)
                self._volume_level = volume
<|endoftext|>


<commit_before>"""Motor speed support for Xiaomi Mi Air Humidifier."""
from __future__ import annotations

from dataclasses import dataclass
from enum import Enum

from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import DEGREE, TIME_MINUTES
from homeassistant.core import callback

from .const import (
    CONF_DEVICE,
    CONF_FLOW_TYPE,
    CONF_MODEL,
    DOMAIN,
    FEATURE_FLAGS_AIRFRESH,
    FEATURE_FLAGS_AIRHUMIDIFIER_CA4,
    FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB,
    FEATURE_FLAGS_AIRPURIFIER_2S,
    FEATURE_FLAGS_AIRPURIFIER_MIIO,
    FEATURE_FLAGS_AIRPURIFIER_MIOT,
    FEATURE_FLAGS_AIRPURIFIER_PRO,
    FEATURE_FLAGS_AIRPURIFIER_PRO_V7,
    FEATURE_FLAGS_AIRPURIFIER_V1,
    FEATURE_FLAGS_AIRPURIFIER_V3,
    FEATURE_FLAGS_FAN,
    FEATURE_FLAGS_FAN_P5,
    FEATURE_SET_DELAY_OFF_COUNTDOWN,
    FEATURE_SET_FAN_LEVEL,
    FEATURE_SET_FAVORITE_LEVEL,
    FEATURE_SET_MOTOR_SPEED,
    FEATURE_SET_OSCILLATION_ANGLE,
    FEATURE_SET_OSCILLATION_ANGLE_MAX_140,
    FEATURE_SET_VOLUME,
    KEY_COORDINATOR,
    KEY_DEVICE,
    MODEL_AIRFRESH_VA2,
    MODEL_AIRHUMIDIFIER_CA1,
    MODEL_AIRHUMIDIFIER_CA4,
    MODEL_AIRHUMIDIFIER_CB1,
    MODEL_AIRPURIFIER_2S,
    MODEL_AIRPURIFIER_PRO,
    MODEL_AIRPURIFIER_PRO_V7,
    MODEL_AIRPURIFIER_V1,
    MODEL_AIRPURIFIER_V3,
    MODEL_FAN_P5,
    MODEL_FAN_SA1,
    MODEL_FAN_V2,
    MODEL_FAN_V3,
    MODEL_FAN_ZA1,
    MODEL_FAN_ZA3,
    MODEL_FAN_ZA4,
    MODELS_PURIFIER_MIIO,
    MODELS_PURIFIER_MIOT,
)
from .device import XiaomiCoordinatedMiioEntity

ATTR_DELAY_OFF_COUNTDOWN = "delay_off_countdown"
ATTR_FAN_LEVEL = "fan_level"
ATTR_FAVORITE_LEVEL = "favorite_level"
ATTR_MOTOR_SPEED = "motor_speed"
ATTR_OSCILLATION_ANGLE = "angle"
ATTR_VOLUME = "volume"

@dataclass
class XiaomiMiioNumberDescription(NumberEntityDescription):
    """A class that describes number entities."""

    min_value: float | None = None
    max_value: float | None = None
    step: float | None = None
    available_with_device_off: bool = True
    method: str | None = None

NUMBER_TYPES = {
    FEATURE_SET_MOTOR_SPEED: XiaomiMiioNumberDescription(
        key=ATTR_MOTOR_SPEED,
        name="Motor Speed",
        icon="mdi:fast-forward-outline",
        unit_of_measurement="rpm",
        min_value=200,
        max_value=2000,
        step=10,
        available_with_device_off=False,
        method="async_set_motor_speed",
    ),
    FEATURE_SET_FAVORITE_LEVEL: XiaomiMiioNumberDescription(
        key=ATTR_FAVORITE_LEVEL,
        name="Favorite Level",
        icon="mdi:star-cog",
        min_value=0,
        max_value=17,
        step=1,
        method="async_set_favorite_level",
    ),
    FEATURE_SET_FAN_LEVEL: XiaomiMiioNumberDescription(
        key=ATTR_FAN_LEVEL,
        name="Fan Level",
        icon="mdi:fan",
        min_value=1,
        max_value=3,
        step=1,
        method="async_set_fan_level",
    ),
    FEATURE_SET_VOLUME: XiaomiMiioNumberDescription(
        key=ATTR_VOLUME,
        name="Volume",
        icon="mdi:volume-high",
        min_value=0,
        max_value=100,
        step=1,
        method="async_set_volume",
    ),
    FEATURE_SET_OSCILLATION_ANGLE: XiaomiMiioNumberDescription(
        key=ATTR_OSCILLATION_ANGLE,
        name="Oscillation Angle",
        icon="mdi:angle-acute",
        unit_of_measurement=DEGREE,
        min_value=1,
        max_value=120,
        step=1,
        method="async_set_oscillation_angle",
    ),
    FEATURE_SET_OSCILLATION_ANGLE_MAX_140: XiaomiMiioNumberDescription(
        key=ATTR_OSCILLATION_ANGLE,
        name="Oscillation Angle",
        icon="mdi:angle-acute",
        unit_of_measurement=DEGREE,
        min_value=30,
        max_value=140,
        step=30,
        method="async_set_oscillation_angle",
    ),
    FEATURE_SET_DELAY_OFF_COUNTDOWN: XiaomiMiioNumberDescription(
        key=ATTR_DELAY_OFF_COUNTDOWN,
        name="Delay Off Countdown",
        icon="mdi:fan-off",
        unit_of_measurement=TIME_MINUTES,
        min_value=0,
        max_value=480,
        step=1,
        method="async_set_delay_off_countdown",
    ),
}

MODEL_TO_FEATURES_MAP = {
    MODEL_AIRFRESH_VA2: FEATURE_FLAGS_AIRFRESH,
    MODEL_AIRHUMIDIFIER_CA1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB,
    MODEL_AIRHUMIDIFIER_CA4: FEATURE_FLAGS_AIRHUMIDIFIER_CA4,
    MODEL_AIRHUMIDIFIER_CB1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB,
    MODEL_AIRPURIFIER_2S: FEATURE_FLAGS_AIRPURIFIER_2S,
    MODEL_AIRPURIFIER_PRO: FEATURE_FLAGS_AIRPURIFIER_PRO,
    MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7,
    MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1,
    MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3,
    MODEL_FAN_P5: FEATURE_FLAGS_FAN_P5,
    MODEL_FAN_SA1: FEATURE_FLAGS_FAN,
    MODEL_FAN_V2: FEATURE_FLAGS_FAN,
    MODEL_FAN_V3: FEATURE_FLAGS_FAN,
    MODEL_FAN_ZA1: FEATURE_FLAGS_FAN,
    MODEL_FAN_ZA3: FEATURE_FLAGS_FAN,
    MODEL_FAN_ZA4: FEATURE_FLAGS_FAN,
}

async def async_setup_entry(hass, config_entry, async_add_entities):
    """Set up the Selectors from a config entry."""
    entities = []
    if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE:
        return
    model = config_entry.data[CONF_MODEL]
    device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE]

    if model in MODEL_TO_FEATURES_MAP:
        features = MODEL_TO_FEATURES_MAP[model]
    elif model in MODELS_PURIFIER_MIIO:
        features = FEATURE_FLAGS_AIRPURIFIER_MIIO
    elif model in MODELS_PURIFIER_MIOT:
        features = FEATURE_FLAGS_AIRPURIFIER_MIOT
    else:
        return

    for feature, description in NUMBER_TYPES.items():
        if feature & features:
            entities.append(
                XiaomiNumberEntity(
                    f"{config_entry.title} {description.name}",
                    device,
                    config_entry,
                    f"{description.key}_{config_entry.unique_id}",
                    hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR],
                    description,
                )
            )

    async_add_entities(entities)

class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity):
    """Representation of a generic Xiaomi attribute selector."""

    def __init__(self, name, device, entry, unique_id, coordinator, description):
        """Initialize the generic Xiaomi attribute selector."""
        super().__init__(name, device, entry, unique_id, coordinator)

        self._attr_min_value = description.min_value
        self._attr_max_value = description.max_value
        self._attr_step = description.step
        self._attr_value = self._extract_value_from_attribute(
            coordinator.data, description.key
        )
        self.entity_description = description

    @property
    def available(self):
        """Return the number controller availability."""
        if (
            super().available
            and not self.coordinator.data.is_on
            and not self.entity_description.available_with_device_off
        ):
            return False
        return super().available

    @staticmethod
    def _extract_value_from_attribute(state, attribute):
        value = getattr(state, attribute)
        if isinstance(value, Enum):
            return value.value

        return value

    async def async_set_value(self, value):
        """Set an option of the miio device."""
        method = getattr(self, self.entity_description.method)
        if await method(int(value)):
            self._attr_value = value
            self.async_write_ha_state()

    @callback
    def _handle_coordinator_update(self):
        """Fetch state from the device."""
        # On state change the device doesn't provide the new state immediately.
        self._attr_value = self._extract_value_from_attribute(
            self.coordinator.data, self.entity_description.key
        )
        self.async_write_ha_state()

    async def async_set_motor_speed(self, motor_speed: int = 400):
        """Set the target motor speed."""
        return await self._try_command(
            "Setting the target motor speed of the miio device failed.",
            self._device.set_speed,
            motor_speed,
        )

    async def async_set_favorite_level(self, level: int = 1):
        """Set the favorite level."""
        return await self._try_command(
            "Setting the favorite level of the miio device failed.",
            self._device.set_favorite_level,
            level,
        )

    async def async_set_fan_level(self, level: int = 1):
        """Set the fan level."""
        return await self._try_command(
            "Setting the favorite level of the miio device failed.",
            self._device.set_fan_level,
            level,
        )

    async def async_set_volume(self, volume: int = 50):
        """Set the volume."""
        return await self._try_command(
            "Setting the volume of the miio device failed.",
            self._device.set_volume,
            volume,
        )

    async def async_set_oscillation_angle(self, angle: int):
        """Set the volume."""
        return await self._try_command(
            "Setting angle of the miio device failed.", self._device.set_angle, angle
        )

    async def async_set_delay_off_countdown(self, delay_off_countdown: int):
        """Set the delay off countdown."""
        return await self._try_command(
            "Setting delay off miio device failed.",
            self._device.delay_off,
            delay_off_countdown * 60,
        )
<commit_msg>Address late review for Xiaomi Miio number platform (#55275)<commit_after>"""Motor speed support for Xiaomi Mi Air Humidifier."""
from __future__ import annotations

from dataclasses import dataclass
from enum import Enum

from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import DEGREE, TIME_MINUTES
from homeassistant.core import callback

from .const import (
    CONF_DEVICE,
    CONF_FLOW_TYPE,
    CONF_MODEL,
    DOMAIN,
    FEATURE_FLAGS_AIRFRESH,
    FEATURE_FLAGS_AIRHUMIDIFIER_CA4,
    FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB,
    FEATURE_FLAGS_AIRPURIFIER_2S,
    FEATURE_FLAGS_AIRPURIFIER_MIIO,
    FEATURE_FLAGS_AIRPURIFIER_MIOT,
    FEATURE_FLAGS_AIRPURIFIER_PRO,
    FEATURE_FLAGS_AIRPURIFIER_PRO_V7,
    FEATURE_FLAGS_AIRPURIFIER_V1,
    FEATURE_FLAGS_AIRPURIFIER_V3,
    FEATURE_FLAGS_FAN,
    FEATURE_FLAGS_FAN_P5,
    FEATURE_SET_DELAY_OFF_COUNTDOWN,
    FEATURE_SET_FAN_LEVEL,
    FEATURE_SET_FAVORITE_LEVEL,
    FEATURE_SET_MOTOR_SPEED,
    FEATURE_SET_OSCILLATION_ANGLE,
    FEATURE_SET_OSCILLATION_ANGLE_MAX_140,
    FEATURE_SET_VOLUME,
    KEY_COORDINATOR,
    KEY_DEVICE,
    MODEL_AIRFRESH_VA2,
    MODEL_AIRHUMIDIFIER_CA1,
    MODEL_AIRHUMIDIFIER_CA4,
    MODEL_AIRHUMIDIFIER_CB1,
    MODEL_AIRPURIFIER_2S,
    MODEL_AIRPURIFIER_PRO,
    MODEL_AIRPURIFIER_PRO_V7,
    MODEL_AIRPURIFIER_V1,
    MODEL_AIRPURIFIER_V3,
    MODEL_FAN_P5,
    MODEL_FAN_SA1,
    MODEL_FAN_V2,
    MODEL_FAN_V3,
    MODEL_FAN_ZA1,
    MODEL_FAN_ZA3,
    MODEL_FAN_ZA4,
    MODELS_PURIFIER_MIIO,
    MODELS_PURIFIER_MIOT,
)
from .device import XiaomiCoordinatedMiioEntity

ATTR_DELAY_OFF_COUNTDOWN = "delay_off_countdown"
ATTR_FAN_LEVEL = "fan_level"
ATTR_FAVORITE_LEVEL = "favorite_level"
ATTR_MOTOR_SPEED = "motor_speed"
ATTR_OSCILLATION_ANGLE = "angle"
ATTR_VOLUME = "volume"

@dataclass
class XiaomiMiioNumberDescription(NumberEntityDescription):
    """A class that describes number entities."""

    min_value: float | None = None
    max_value: float | None = None
    step: float | None = None
    available_with_device_off: bool = True
    method: str | None = None

NUMBER_TYPES = {
    FEATURE_SET_MOTOR_SPEED: XiaomiMiioNumberDescription(
        key=ATTR_MOTOR_SPEED,
        name="Motor Speed",
        icon="mdi:fast-forward-outline",
        unit_of_measurement="rpm",
        min_value=200,
        max_value=2000,
        step=10,
        available_with_device_off=False,
        method="async_set_motor_speed",
    ),
    FEATURE_SET_FAVORITE_LEVEL: XiaomiMiioNumberDescription(
        key=ATTR_FAVORITE_LEVEL,
        name="Favorite Level",
        icon="mdi:star-cog",
        min_value=0,
        max_value=17,
        step=1,
        method="async_set_favorite_level",
    ),
    FEATURE_SET_FAN_LEVEL: XiaomiMiioNumberDescription(
        key=ATTR_FAN_LEVEL,
        name="Fan Level",
        icon="mdi:fan",
        min_value=1,
        max_value=3,
        step=1,
        method="async_set_fan_level",
    ),
    FEATURE_SET_VOLUME: XiaomiMiioNumberDescription(
        key=ATTR_VOLUME,
        name="Volume",
        icon="mdi:volume-high",
        min_value=0,
        max_value=100,
        step=1,
        method="async_set_volume",
    ),
    FEATURE_SET_OSCILLATION_ANGLE: XiaomiMiioNumberDescription(
        key=ATTR_OSCILLATION_ANGLE,
        name="Oscillation Angle",
        icon="mdi:angle-acute",
        unit_of_measurement=DEGREE,
        min_value=1,
        max_value=120,
        step=1,
        method="async_set_oscillation_angle",
    ),
    FEATURE_SET_OSCILLATION_ANGLE_MAX_140: XiaomiMiioNumberDescription(
        key=ATTR_OSCILLATION_ANGLE,
        name="Oscillation Angle",
        icon="mdi:angle-acute",
        unit_of_measurement=DEGREE,
        min_value=30,
        max_value=140,
        step=30,
        method="async_set_oscillation_angle",
    ),
    FEATURE_SET_DELAY_OFF_COUNTDOWN: XiaomiMiioNumberDescription(
        key=ATTR_DELAY_OFF_COUNTDOWN,
        name="Delay Off Countdown",
        icon="mdi:fan-off",
        unit_of_measurement=TIME_MINUTES,
        min_value=0,
        max_value=480,
        step=1,
        method="async_set_delay_off_countdown",
    ),
}

MODEL_TO_FEATURES_MAP = {
    MODEL_AIRFRESH_VA2: FEATURE_FLAGS_AIRFRESH,
    MODEL_AIRHUMIDIFIER_CA1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB,
    MODEL_AIRHUMIDIFIER_CA4: FEATURE_FLAGS_AIRHUMIDIFIER_CA4,
    MODEL_AIRHUMIDIFIER_CB1: FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB,
    MODEL_AIRPURIFIER_2S: FEATURE_FLAGS_AIRPURIFIER_2S,
    MODEL_AIRPURIFIER_PRO: FEATURE_FLAGS_AIRPURIFIER_PRO,
    MODEL_AIRPURIFIER_PRO_V7: FEATURE_FLAGS_AIRPURIFIER_PRO_V7,
    MODEL_AIRPURIFIER_V1: FEATURE_FLAGS_AIRPURIFIER_V1,
    MODEL_AIRPURIFIER_V3: FEATURE_FLAGS_AIRPURIFIER_V3,
    MODEL_FAN_P5: FEATURE_FLAGS_FAN_P5,
    MODEL_FAN_SA1: FEATURE_FLAGS_FAN,
    MODEL_FAN_V2: FEATURE_FLAGS_FAN,
    MODEL_FAN_V3: FEATURE_FLAGS_FAN,
    MODEL_FAN_ZA1: FEATURE_FLAGS_FAN,
    MODEL_FAN_ZA3: FEATURE_FLAGS_FAN,
    MODEL_FAN_ZA4: FEATURE_FLAGS_FAN,
}

async def async_setup_entry(hass, config_entry, async_add_entities):
    """Set up the Selectors from a config entry."""
    entities = []
    if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE:
        return
    model = config_entry.data[CONF_MODEL]
    device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE]

    if model in MODEL_TO_FEATURES_MAP:
        features = MODEL_TO_FEATURES_MAP[model]
    elif model in MODELS_PURIFIER_MIIO:
        features = FEATURE_FLAGS_AIRPURIFIER_MIIO
    elif model in MODELS_PURIFIER_MIOT:
        features = FEATURE_FLAGS_AIRPURIFIER_MIOT
    else:
        return

    for feature, description in NUMBER_TYPES.items():
        if feature & features:
            entities.append(
                XiaomiNumberEntity(
                    f"{config_entry.title} {description.name}",
                    device,
                    config_entry,
                    f"{description.key}_{config_entry.unique_id}",
                    hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR],
                    description,
                )
            )

    async_add_entities(entities)

class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity):
    """Representation of a generic Xiaomi attribute selector."""

    def __init__(self, name, device, entry, unique_id, coordinator, description):
        """Initialize the generic Xiaomi attribute selector."""
        super().__init__(name, device, entry, unique_id, coordinator)

        self._attr_min_value = description.min_value
        self._attr_max_value = description.max_value
        self._attr_step = description.step
        self._attr_value = self._extract_value_from_attribute(
            coordinator.data, description.key
        )
        self.entity_description = description

    @property
    def available(self):
        """Return the number controller availability."""
        if (
            super().available
            and not self.coordinator.data.is_on
            and not self.entity_description.available_with_device_off
        ):
            return False
        return super().available

    @staticmethod
    def _extract_value_from_attribute(state, attribute):
        value = getattr(state, attribute)
        if isinstance(value, Enum):
            return value.value

        return value

    async def async_set_value(self, value):
        """Set an option of the miio device."""
        method = getattr(self, self.entity_description.method)
        if await method(int(value)):
            self._attr_value = value
            self.async_write_ha_state()

    @callback
    def _handle_coordinator_update(self):
        """Fetch state from the device."""
        # On state change the device doesn't provide the new state immediately.
        self._attr_value = self._extract_value_from_attribute(
            self.coordinator.data, self.entity_description.key
        )
        self.async_write_ha_state()

    async def async_set_motor_speed(self, motor_speed: int = 400) -> bool:
        """Set the target motor speed."""
        return await self._try_command(
            "Setting the target motor speed of the miio device failed.",
            self._device.set_speed,
            motor_speed,
        )

    async def async_set_favorite_level(self, level: int = 1) -> bool:
        """Set the favorite level."""
        return await self._try_command(
            "Setting the favorite level of the miio device failed.",
            self._device.set_favorite_level,
            level,
        )

    async def async_set_fan_level(self, level: int = 1) -> bool:
        """Set the fan level."""
        return await self._try_command(
            "Setting the favorite level of the miio device failed.",
            self._device.set_fan_level,
            level,
        )

    async def async_set_volume(self, volume: int = 50) -> bool:
        """Set the volume."""
        return await self._try_command(
            "Setting the volume of the miio device failed.",
            self._device.set_volume,
            volume,
        )

    async def async_set_oscillation_angle(self, angle: int) -> bool:
        """Set the volume."""
        return await self._try_command(
            "Setting angle of the miio device failed.", self._device.set_angle, angle
        )

    async def async_set_delay_off_countdown(self, delay_off_countdown: int) -> bool:
        """Set the delay off countdown."""
        return await self._try_command(
            "Setting delay off miio device failed.",
            self._device.delay_off,
            delay_off_countdown * 60,
        )
<|endoftext|>


<commit_before># -*- coding: utf-8 -*-

import re
from coaster.utils import sorted_timezones
from wtforms.widgets import CheckboxInput, ListWidget
from wtforms.ext.sqlalchemy.fields import QuerySelectMultipleField
from baseframe import _, __
import baseframe.forms as forms
from baseframe.forms.sqlalchemy import AvailableName, QuerySelectField
from ..models import RSVP_STATUS

__all__ = [
    'ProjectForm', 'SubprojectForm', 'RsvpForm', 'EventForm', 'TicketTypeForm',
    'TicketClientForm', 'ProjectTransitionForm'
]

valid_color_re = re.compile(r'^[a-fA-F\d]{6}|[a-fA-F\d]{3}$')

class ProjectForm(forms.Form):
    name = forms.StringField(__("URL name"), validators=[forms.validators.DataRequired(), forms.ValidName(), AvailableName()])
    title = forms.StringField(__("Title"), validators=[forms.validators.DataRequired()])
    location = forms.StringField(__("Location"),
        validators=[forms.validators.DataRequired(), forms.validators.Length(max=50)],
        description=__("Eg. Bangalore, Mumbai, Pune"))
    date = forms.DateField(__("Start date"),
        validators=[forms.validators.DataRequired(__("Start date is required"))])
    date_upto = forms.DateField(__("End date"),
        validators=[
            forms.validators.DataRequired(__("End date is required")),
            forms.validators.GreaterThan('date', __("End date cannot be before start date"))
            ])
    tagline = forms.StringField(__("Tagline"), validators=[forms.validators.DataRequired()],
        description=__("This is displayed on the card on the homepage"))
    website = forms.URLField(__("Website"),
        validators=[forms.validators.Optional(), forms.validators.Length(max=2000)])
    description = forms.MarkdownField(__("Description"), validators=[forms.validators.DataRequired()],
        description=__("About Event"))
    instructions = forms.MarkdownField(__("Instructions"),
        description=__("These instructions will be shown to the proposer with their submission form"), default=u'')
    timezone = forms.SelectField(__("Timezone"),
        description=__("The timezone in which this event occurs"),
        validators=[forms.validators.DataRequired()], choices=sorted_timezones(), default=u'UTC')
    bg_image = forms.URLField(__("Background image URL"), description=u"Background image for the mobile app",
        validators=[forms.validators.Optional(), forms.validators.ValidUrl(), forms.validators.Length(max=2000)])
    bg_color = forms.StringField(__("Background color"),
        description=__("RGB color for the event, shown on the mobile app. Enter without the '#'. E.g. CCCCCC."),
        validators=[forms.validators.Optional(), forms.validators.Length(max=6)],
        default=u"CCCCCC")
    explore_url = forms.URLField(__("Explore tab URL"),
        description=__(u"Page containing the explore tab’s contents, for the mobile app"),
        validators=[forms.validators.Optional(), forms.validators.Length(max=2000)])
    parent_project = QuerySelectField(__(u"Parent project"), get_label='title', allow_blank=True, blank_text=__(u"None"))

    admin_team = QuerySelectField(u"Admin team", validators=[forms.validators.DataRequired(__(u"Please select a team"))],
        get_label='title', allow_blank=False,
        description=__(u"The administrators of this project"))
    review_team = QuerySelectField(u"Review team", validators=[forms.validators.DataRequired(__(u"Please select a team"))],
        get_label='title', allow_blank=False,
        description=__(u"Reviewers can see contact details of proposers, but can’t change settings"))
    checkin_team = QuerySelectField(u"Checkin team", validators=[forms.validators.DataRequired(__(u"Please select a team"))],
        get_label='title', allow_blank=False,
        description=__(u"Team members can check in users at an event"))
    allow_rsvp = forms.BooleanField(__("Allow site visitors to RSVP (login required)"))
    buy_tickets_url = forms.URLField(__("URL to buy tickets"),
        description=__(u"Eg: Explara, Instamojo"),
        validators=[forms.validators.Optional(), forms.validators.Length(max=2000)])

    def set_queries(self):
        profile_teams = self.edit_parent.teams
        self.admin_team.query = profile_teams
        self.review_team.query = profile_teams
        self.checkin_team.query = profile_teams

    def validate_bg_color(self, field):
        if not valid_color_re.match(field.data):
            raise forms.ValidationError("Please enter a valid color code")

class ProjectTransitionForm(forms.Form):
    transition = forms.SelectField(__("Status"), validators=[
                                   forms.validators.DataRequired()])

    def set_queries(self):
        """
        value: transition method name
        label: transition object itself
        We need the whole object to get the additional metadata in templates
        """
        self.transition.choices = self.edit_obj.state.transitions().items()

class SubprojectForm(ProjectForm):
    inherit_sections = forms.BooleanField(__("Inherit sections from parent project?"), default=True)

class RsvpForm(forms.Form):
    status = forms.RadioField("Status", choices=[(k, RSVP_STATUS[k].title) for k in RSVP_STATUS.USER_CHOICES])

class EventForm(forms.Form):
    title = forms.StringField(__("Title"), validators=[forms.validators.DataRequired()])
    badge_template = forms.URLField(__("Badge template URL"), description=u"URL of background image for the badge",
        validators=[forms.validators.Optional(), forms.validators.ValidUrl(), forms.validators.Length(max=2000)])

class TicketClientForm(forms.Form):
    name = forms.StringField(__("Name"), validators=[forms.validators.DataRequired()])
    clientid = forms.StringField(__("Client id"), validators=[forms.validators.DataRequired()])
    client_eventid = forms.StringField(__("Client event id"), validators=[forms.validators.DataRequired()])
    client_secret = forms.StringField(__("Client event secret"), validators=[forms.validators.DataRequired()])
    client_access_token = forms.StringField(__("Client access token"), validators=[forms.validators.DataRequired()])

class TicketTypeForm(forms.Form):
    title = forms.StringField(__("Title"), validators=[forms.validators.DataRequired()])
    events = QuerySelectMultipleField(__("Events"),
        widget=ListWidget(), option_widget=CheckboxInput(), allow_blank=True, get_label='title', query_factory=lambda: [])
<commit_msg>Fix date validator<commit_after># -*- coding: utf-8 -*-

import re
from coaster.utils import sorted_timezones
from wtforms.widgets import CheckboxInput, ListWidget
from wtforms.ext.sqlalchemy.fields import QuerySelectMultipleField
from baseframe import __
import baseframe.forms as forms
from baseframe.forms.sqlalchemy import AvailableName, QuerySelectField
from ..models import RSVP_STATUS

__all__ = [
    'ProjectForm', 'SubprojectForm', 'RsvpForm', 'EventForm', 'TicketTypeForm',
    'TicketClientForm', 'ProjectTransitionForm'
]

valid_color_re = re.compile(r'^[a-fA-F\d]{6}|[a-fA-F\d]{3}$')

class ProjectForm(forms.Form):
    name = forms.StringField(__("URL name"), validators=[forms.validators.DataRequired(), forms.ValidName(), AvailableName()])
    title = forms.StringField(__("Title"), validators=[forms.validators.DataRequired()])
    location = forms.StringField(__("Location"),
        validators=[forms.validators.DataRequired(), forms.validators.Length(max=50)],
        description=__("Eg. Bangalore, Mumbai, Pune"))
    date = forms.DateField(__("Start date"),
        validators=[forms.validators.DataRequired(__("Start date is required"))])
    date_upto = forms.DateField(__("End date"),
        validators=[
            forms.validators.DataRequired(__("End date is required")),
            forms.validators.GreaterThanEqualTo('date', __("End date cannot be before start date"))
            ])
    tagline = forms.StringField(__("Tagline"), validators=[forms.validators.DataRequired()],
        description=__("This is displayed on the card on the homepage"))
    website = forms.URLField(__("Website"),
        validators=[forms.validators.Optional(), forms.validators.Length(max=2000)])
    description = forms.MarkdownField(__("Description"), validators=[forms.validators.DataRequired()],
        description=__("About Event"))
    instructions = forms.MarkdownField(__("Instructions"),
        description=__("These instructions will be shown to the proposer with their submission form"), default=u'')
    timezone = forms.SelectField(__("Timezone"),
        description=__("The timezone in which this event occurs"),
        validators=[forms.validators.DataRequired()], choices=sorted_timezones(), default=u'UTC')
    bg_image = forms.URLField(__("Background image URL"), description=u"Background image for the mobile app",
        validators=[forms.validators.Optional(), forms.validators.ValidUrl(), forms.validators.Length(max=2000)])
    bg_color = forms.StringField(__("Background color"),
        description=__("RGB color for the event, shown on the mobile app. Enter without the '#'. E.g. CCCCCC."),
        validators=[forms.validators.Optional(), forms.validators.Length(max=6)],
        default=u"CCCCCC")
    explore_url = forms.URLField(__("Explore tab URL"),
        description=__(u"Page containing the explore tab’s contents, for the mobile app"),
        validators=[forms.validators.Optional(), forms.validators.Length(max=2000)])
    parent_project = QuerySelectField(__(u"Parent project"), get_label='title', allow_blank=True, blank_text=__(u"None"))

    admin_team = QuerySelectField(u"Admin team", validators=[forms.validators.DataRequired(__(u"Please select a team"))],
        get_label='title', allow_blank=False,
        description=__(u"The administrators of this project"))
    review_team = QuerySelectField(u"Review team", validators=[forms.validators.DataRequired(__(u"Please select a team"))],
        get_label='title', allow_blank=False,
        description=__(u"Reviewers can see contact details of proposers, but can’t change settings"))
    checkin_team = QuerySelectField(u"Checkin team", validators=[forms.validators.DataRequired(__(u"Please select a team"))],
        get_label='title', allow_blank=False,
        description=__(u"Team members can check in users at an event"))
    allow_rsvp = forms.BooleanField(__("Allow site visitors to RSVP (login required)"))
    buy_tickets_url = forms.URLField(__("URL to buy tickets"),
        description=__(u"Eg: Explara, Instamojo"),
        validators=[forms.validators.Optional(), forms.validators.Length(max=2000)])

    def set_queries(self):
        profile_teams = self.edit_parent.teams
        self.admin_team.query = profile_teams
        self.review_team.query = profile_teams
        self.checkin_team.query = profile_teams

    def validate_bg_color(self, field):
        if not valid_color_re.match(field.data):
            raise forms.ValidationError("Please enter a valid color code")

class ProjectTransitionForm(forms.Form):
    transition = forms.SelectField(__("Status"), validators=[
                                   forms.validators.DataRequired()])

    def set_queries(self):
        """
        value: transition method name
        label: transition object itself
        We need the whole object to get the additional metadata in templates
        """
        self.transition.choices = self.edit_obj.state.transitions().items()

class SubprojectForm(ProjectForm):
    inherit_sections = forms.BooleanField(__("Inherit sections from parent project?"), default=True)

class RsvpForm(forms.Form):
    status = forms.RadioField("Status", choices=[(k, RSVP_STATUS[k].title) for k in RSVP_STATUS.USER_CHOICES])

class EventForm(forms.Form):
    title = forms.StringField(__("Title"), validators=[forms.validators.DataRequired()])
    badge_template = forms.URLField(__("Badge template URL"), description=u"URL of background image for the badge",
        validators=[forms.validators.Optional(), forms.validators.ValidUrl(), forms.validators.Length(max=2000)])

class TicketClientForm(forms.Form):
    name = forms.StringField(__("Name"), validators=[forms.validators.DataRequired()])
    clientid = forms.StringField(__("Client id"), validators=[forms.validators.DataRequired()])
    client_eventid = forms.StringField(__("Client event id"), validators=[forms.validators.DataRequired()])
    client_secret = forms.StringField(__("Client event secret"), validators=[forms.validators.DataRequired()])
    client_access_token = forms.StringField(__("Client access token"), validators=[forms.validators.DataRequired()])

class TicketTypeForm(forms.Form):
    title = forms.StringField(__("Title"), validators=[forms.validators.DataRequired()])
    events = QuerySelectMultipleField(__("Events"),
        widget=ListWidget(), option_widget=CheckboxInput(), allow_blank=True, get_label='title', query_factory=lambda: [])
<|endoftext|>