diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 3657b64b989..adf0b8f51b6 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -15,12 +15,12 @@ import async_timeout import voluptuous as vol from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME, CONF_TYPE) + EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME) from homeassistant.helpers import entityfilter, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util from homeassistant.components.alexa import smart_home as alexa_sh -from homeassistant.components.google_assistant import smart_home as ga_sh +from homeassistant.components.google_assistant import helpers as ga_h from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS @@ -51,7 +51,6 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({ GOOGLE_ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE): vol.In(ga_sh.MAPPING_COMPONENT), vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]) }) @@ -175,7 +174,7 @@ class Cloud: """If an entity should be exposed.""" return conf['filter'](entity.entity_id) - self._gactions_config = ga_sh.Config( + self._gactions_config = ga_h.Config( should_expose=should_expose, agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 91fbc85df6b..7cf8e50e866 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -1,6 +1,7 @@ """Module to handle messages from Home Assistant cloud.""" import asyncio import logging +import pprint from aiohttp import hdrs, client_exceptions, WSMsgType @@ -154,7 +155,9 @@ class CloudIoT: disconnect_warn = 'Received invalid JSON.' break - _LOGGER.debug("Received message: %s", msg) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Received message:\n%s\n", + pprint.pformat(msg)) response = { 'msgid': msg['msgid'], @@ -176,7 +179,9 @@ class CloudIoT: _LOGGER.exception("Error handling message") response['error'] = 'exception' - _LOGGER.debug("Publishing message: %s", response) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Publishing message:\n%s\n", + pprint.pformat(response)) yield from client.send_json(response) except client_exceptions.WSServerHandshakeError as err: diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 20dee082a08..676654c2c91 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -17,7 +17,7 @@ import voluptuous as vol from homeassistant.core import HomeAssistant # NOQA from typing import Dict, Any # NOQA -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_NAME from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.loader import bind_hass @@ -31,7 +31,6 @@ from .const import ( ) from .auth import GoogleAssistantAuthView from .http import async_register_http -from .smart_home import MAPPING_COMPONENT _LOGGER = logging.getLogger(__name__) @@ -41,7 +40,6 @@ DEFAULT_AGENT_USER_ID = 'home-assistant' ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE): vol.In(MAPPING_COMPONENT), vol.Optional(CONF_EXPOSE): cv.boolean, vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_ROOM_HINT): cv.string diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 1f1ae4682b4..12888ea2cf6 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -22,25 +22,6 @@ DEFAULT_EXPOSED_DOMAINS = [ CLIMATE_MODE_HEATCOOL = 'heatcool' CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_HEATCOOL} -PREFIX_TRAITS = 'action.devices.traits.' -TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff' -TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness' -TRAIT_RGB_COLOR = PREFIX_TRAITS + 'ColorSpectrum' -TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature' -TRAIT_SCENE = PREFIX_TRAITS + 'Scene' -TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' - -PREFIX_COMMANDS = 'action.devices.commands.' -COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' -COMMAND_BRIGHTNESS = PREFIX_COMMANDS + 'BrightnessAbsolute' -COMMAND_COLOR = PREFIX_COMMANDS + 'ColorAbsolute' -COMMAND_ACTIVATESCENE = PREFIX_COMMANDS + 'ActivateScene' -COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( - PREFIX_COMMANDS + 'ThermostatTemperatureSetpoint') -COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( - PREFIX_COMMANDS + 'ThermostatTemperatureSetRange') -COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' - PREFIX_TYPES = 'action.devices.types.' TYPE_LIGHT = PREFIX_TYPES + 'LIGHT' TYPE_SWITCH = PREFIX_TYPES + 'SWITCH' @@ -50,3 +31,12 @@ TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT' SERVICE_REQUEST_SYNC = 'request_sync' HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync' + +# Error codes used for SmartHomeError class +# https://developers.google.com/actions/smarthome/create-app#error_responses +ERR_DEVICE_OFFLINE = "deviceOffline" +ERR_DEVICE_NOT_FOUND = "deviceNotFound" +ERR_VALUE_OUT_OF_RANGE = "valueOutOfRange" +ERR_NOT_SUPPORTED = "notSupported" +ERR_PROTOCOL_ERROR = 'protocolError' +ERR_UNKNOWN_ERROR = 'unknownError' diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py new file mode 100644 index 00000000000..ef6ae109eb5 --- /dev/null +++ b/homeassistant/components/google_assistant/helpers.py @@ -0,0 +1,23 @@ +"""Helper classes for Google Assistant integration.""" + + +class SmartHomeError(Exception): + """Google Assistant Smart Home errors. + + https://developers.google.com/actions/smarthome/create-app#error_responses + """ + + def __init__(self, code, msg): + """Log error code.""" + super().__init__(msg) + self.code = code + + +class Config: + """Hold the configuration for Google Assistant.""" + + def __init__(self, should_expose, agent_user_id, entity_config=None): + """Initialize the configuration.""" + self.should_expose = should_expose + self.agent_user_id = agent_user_id + self.entity_config = entity_config or {} diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index f376435d2ef..0caea3aadf4 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -10,8 +10,6 @@ import logging from aiohttp.hdrs import AUTHORIZATION from aiohttp.web import Request, Response # NOQA -from homeassistant.const import HTTP_UNAUTHORIZED - # Typing imports # pylint: disable=using-constant-test,unused-import,ungrouped-imports from homeassistant.components.http import HomeAssistantView @@ -27,7 +25,8 @@ from .const import ( CONF_ENTITY_CONFIG, CONF_EXPOSE, ) -from .smart_home import async_handle_message, Config +from .smart_home import async_handle_message +from .helpers import Config _LOGGER = logging.getLogger(__name__) @@ -83,8 +82,7 @@ class GoogleAssistantView(HomeAssistantView): """Handle Google Assistant requests.""" auth = request.headers.get(AUTHORIZATION, None) if 'Bearer {}'.format(self.access_token) != auth: - return self.json_message( - "missing authorization", status_code=HTTP_UNAUTHORIZED) + return self.json_message("missing authorization", status_code=401) message = yield from request.json() # type: dict result = yield from async_handle_message( diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index f638b847bcb..48d24c00b97 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,5 +1,6 @@ """Support for Google Assistant Smart Home API.""" -import asyncio +import collections +from itertools import product import logging # Typing imports @@ -9,447 +10,222 @@ from aiohttp.web import Request, Response # NOQA from typing import Dict, Tuple, Any, Optional # NOQA from homeassistant.helpers.entity import Entity # NOQA from homeassistant.core import HomeAssistant # NOQA -from homeassistant.util import color from homeassistant.util.unit_system import UnitSystem # NOQA from homeassistant.util.decorator import Registry +from homeassistant.core import callback from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, - STATE_OFF, SERVICE_TURN_OFF, SERVICE_TURN_ON, - TEMP_FAHRENHEIT, TEMP_CELSIUS, - CONF_NAME, CONF_TYPE -) + CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES) from homeassistant.components import ( switch, light, cover, media_player, group, fan, scene, script, climate, - sensor ) -from homeassistant.util.unit_system import METRIC_SYSTEM +from . import trait from .const import ( - COMMAND_COLOR, - COMMAND_BRIGHTNESS, COMMAND_ONOFF, COMMAND_ACTIVATESCENE, - COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, - COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, COMMAND_THERMOSTAT_SET_MODE, - TRAIT_ONOFF, TRAIT_BRIGHTNESS, TRAIT_COLOR_TEMP, - TRAIT_RGB_COLOR, TRAIT_SCENE, TRAIT_TEMPERATURE_SETTING, TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, TYPE_THERMOSTAT, - CONF_ALIASES, CONF_ROOM_HINT, CLIMATE_SUPPORTED_MODES, - CLIMATE_MODE_HEATCOOL + CONF_ALIASES, CONF_ROOM_HINT, + ERR_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, + ERR_UNKNOWN_ERROR ) +from .helpers import SmartHomeError HANDLERS = Registry() -QUERY_HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) -# Mapping is [actions schema, primary trait, optional features] -# optional is SUPPORT_* = (trait, command) -MAPPING_COMPONENT = { - group.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], - scene.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None], - script.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None], - switch.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], - fan.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], - light.DOMAIN: [ - TYPE_LIGHT, TRAIT_ONOFF, { - light.SUPPORT_BRIGHTNESS: TRAIT_BRIGHTNESS, - light.SUPPORT_RGB_COLOR: TRAIT_RGB_COLOR, - light.SUPPORT_COLOR_TEMP: TRAIT_COLOR_TEMP, +DOMAIN_TO_GOOGLE_TYPES = { + group.DOMAIN: TYPE_SWITCH, + scene.DOMAIN: TYPE_SCENE, + script.DOMAIN: TYPE_SCENE, + switch.DOMAIN: TYPE_SWITCH, + fan.DOMAIN: TYPE_SWITCH, + light.DOMAIN: TYPE_LIGHT, + cover.DOMAIN: TYPE_SWITCH, + media_player.DOMAIN: TYPE_SWITCH, + climate.DOMAIN: TYPE_THERMOSTAT, +} + + +def deep_update(target, source): + """Update a nested dictionary with another nested dictionary.""" + for key, value in source.items(): + if isinstance(value, collections.Mapping): + target[key] = deep_update(target.get(key, {}), value) + else: + target[key] = value + return target + + +class _GoogleEntity: + """Adaptation of Entity expressed in Google's terms.""" + + def __init__(self, hass, config, state): + self.hass = hass + self.config = config + self.state = state + + @property + def entity_id(self): + """Return entity ID.""" + return self.state.entity_id + + @callback + def traits(self): + """Return traits for entity.""" + state = self.state + domain = state.domain + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + return [Trait(state) for Trait in trait.TRAITS + if Trait.supported(domain, features)] + + @callback + def sync_serialize(self): + """Serialize entity for a SYNC response. + + https://developers.google.com/actions/smarthome/create-app#actiondevicessync + """ + traits = self.traits() + state = self.state + + # Found no supported traits for this entity + if not traits: + return None + + entity_config = self.config.entity_config.get(state.entity_id, {}) + + device = { + 'id': state.entity_id, + 'name': { + 'name': entity_config.get(CONF_NAME) or state.name + }, + 'attributes': {}, + 'traits': [trait.name for trait in traits], + 'willReportState': False, + 'type': DOMAIN_TO_GOOGLE_TYPES[state.domain], } - ], - cover.DOMAIN: [ - TYPE_SWITCH, TRAIT_ONOFF, { - cover.SUPPORT_SET_POSITION: TRAIT_BRIGHTNESS - } - ], - media_player.DOMAIN: [ - TYPE_SWITCH, TRAIT_ONOFF, { - media_player.SUPPORT_VOLUME_SET: TRAIT_BRIGHTNESS - } - ], - climate.DOMAIN: [TYPE_THERMOSTAT, TRAIT_TEMPERATURE_SETTING, None], -} # type: Dict[str, list] + + # use aliases + aliases = entity_config.get(CONF_ALIASES) + if aliases: + device['name']['nicknames'] = aliases + + # add room hint if annotated + room = entity_config.get(CONF_ROOM_HINT) + if room: + device['roomHint'] = room + + for trt in traits: + device['attributes'].update(trt.sync_attributes()) + + return device + + @callback + def query_serialize(self): + """Serialize entity for a QUERY response. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesquery + """ + state = self.state + + if state.state == STATE_UNAVAILABLE: + return {'online': False} + + attrs = {'online': True} + + for trt in self.traits(): + deep_update(attrs, trt.query_attributes()) + + return attrs + + async def execute(self, command, params): + """Execute a command. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + """ + executed = False + for trt in self.traits(): + if trt.can_execute(command, params): + await trt.execute(self.hass, command, params) + executed = True + break + + if not executed: + raise SmartHomeError( + ERR_NOT_SUPPORTED, + 'Unable to execute {} for {}'.format(command, + self.state.entity_id)) + + @callback + def async_update(self): + """Update the entity with latest info from Home Assistant.""" + self.state = self.hass.states.get(self.entity_id) -"""Error code used for SmartHomeError class.""" -ERROR_NOT_SUPPORTED = "notSupported" +async def async_handle_message(hass, config, message): + """Handle incoming API messages.""" + response = await _process(hass, config, message) - -class SmartHomeError(Exception): - """Google Assistant Smart Home errors.""" - - def __init__(self, code, msg): - """Log error code.""" - super(SmartHomeError, self).__init__(msg) - _LOGGER.error( - "An error has occurred in Google SmartHome: %s." - "Error code: %s", msg, code - ) - self.code = code - - -class Config: - """Hold the configuration for Google Assistant.""" - - def __init__(self, should_expose, agent_user_id, entity_config=None): - """Initialize the configuration.""" - self.should_expose = should_expose - self.agent_user_id = agent_user_id - self.entity_config = entity_config or {} - - -def entity_to_device(entity: Entity, config: Config, units: UnitSystem): - """Convert a hass entity into a google actions device.""" - entity_config = config.entity_config.get(entity.entity_id, {}) - google_domain = entity_config.get(CONF_TYPE) - class_data = MAPPING_COMPONENT.get( - google_domain or entity.domain) - - if class_data is None: - return None - - device = { - 'id': entity.entity_id, - 'name': {}, - 'attributes': {}, - 'traits': [], - 'willReportState': False, - } - device['type'] = class_data[0] - device['traits'].append(class_data[1]) - - # handle custom names - device['name']['name'] = entity_config.get(CONF_NAME) or entity.name - - # use aliases - aliases = entity_config.get(CONF_ALIASES) - if aliases: - device['name']['nicknames'] = aliases - - # add room hint if annotated - room = entity_config.get(CONF_ROOM_HINT) - if room: - device['roomHint'] = room - - # add trait if entity supports feature - if class_data[2]: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - for feature, trait in class_data[2].items(): - if feature & supported > 0: - device['traits'].append(trait) - - # Actions require this attributes for a device - # supporting temperature - # For IKEA trådfri, these attributes only seem to - # be set only if the device is on? - if trait == TRAIT_COLOR_TEMP: - if entity.attributes.get( - light.ATTR_MAX_MIREDS) is not None: - device['attributes']['temperatureMinK'] = \ - int(round(color.color_temperature_mired_to_kelvin( - entity.attributes.get(light.ATTR_MAX_MIREDS)))) - if entity.attributes.get( - light.ATTR_MIN_MIREDS) is not None: - device['attributes']['temperatureMaxK'] = \ - int(round(color.color_temperature_mired_to_kelvin( - entity.attributes.get(light.ATTR_MIN_MIREDS)))) - - if entity.domain == climate.DOMAIN: - modes = [] - for mode in entity.attributes.get(climate.ATTR_OPERATION_LIST, []): - if mode in CLIMATE_SUPPORTED_MODES: - modes.append(mode) - elif mode == climate.STATE_AUTO: - modes.append(CLIMATE_MODE_HEATCOOL) - - device['attributes'] = { - 'availableThermostatModes': ','.join(modes), - 'thermostatTemperatureUnit': - 'F' if units.temperature_unit == TEMP_FAHRENHEIT else 'C', - } - _LOGGER.debug('Thermostat attributes %s', device['attributes']) - - if entity.domain == sensor.DOMAIN: - if google_domain == climate.DOMAIN: - unit_of_measurement = entity.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, - units.temperature_unit - ) - - device['attributes'] = { - 'thermostatTemperatureUnit': - 'F' if unit_of_measurement == TEMP_FAHRENHEIT else 'C', - } - _LOGGER.debug('Sensor attributes %s', device['attributes']) - - return device - - -def celsius(deg: Optional[float], units: UnitSystem) -> Optional[float]: - """Convert a float to Celsius and rounds to one decimal place.""" - if deg is None: - return None - return round(METRIC_SYSTEM.temperature(deg, units.temperature_unit), 1) - - -@QUERY_HANDLERS.register(sensor.DOMAIN) -def query_response_sensor( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a sensor entity to a QUERY response.""" - entity_config = config.entity_config.get(entity.entity_id, {}) - google_domain = entity_config.get(CONF_TYPE) - - if google_domain != climate.DOMAIN: - raise SmartHomeError( - ERROR_NOT_SUPPORTED, - "Sensor type {} is not supported".format(google_domain) - ) - - # check if we have a string value to convert it to number - value = entity.state - if isinstance(entity.state, str): - try: - value = float(value) - except ValueError: - value = None - - if value is None: - raise SmartHomeError( - ERROR_NOT_SUPPORTED, - "Invalid value {} for the climate sensor" - .format(entity.state) - ) - - # detect if we report temperature or humidity - unit_of_measurement = entity.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, - units.temperature_unit - ) - if unit_of_measurement in [TEMP_FAHRENHEIT, TEMP_CELSIUS]: - value = celsius(value, units) - attr = 'thermostatTemperatureAmbient' - elif unit_of_measurement == '%': - attr = 'thermostatHumidityAmbient' - else: - raise SmartHomeError( - ERROR_NOT_SUPPORTED, - "Unit {} is not supported by the climate sensor" - .format(unit_of_measurement) - ) - - return {attr: value} - - -@QUERY_HANDLERS.register(climate.DOMAIN) -def query_response_climate( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a climate entity to a QUERY response.""" - mode = entity.attributes.get(climate.ATTR_OPERATION_MODE) - if mode is None: - mode = entity.state - mode = mode.lower() - if mode not in CLIMATE_SUPPORTED_MODES: - mode = 'heat' - attrs = entity.attributes - response = { - 'thermostatMode': mode, - 'thermostatTemperatureSetpoint': - celsius(attrs.get(climate.ATTR_TEMPERATURE), units), - 'thermostatTemperatureAmbient': - celsius(attrs.get(climate.ATTR_CURRENT_TEMPERATURE), units), - 'thermostatTemperatureSetpointHigh': - celsius(attrs.get(climate.ATTR_TARGET_TEMP_HIGH), units), - 'thermostatTemperatureSetpointLow': - celsius(attrs.get(climate.ATTR_TARGET_TEMP_LOW), units), - 'thermostatHumidityAmbient': - attrs.get(climate.ATTR_CURRENT_HUMIDITY), - } - return {k: v for k, v in response.items() if v is not None} - - -@QUERY_HANDLERS.register(media_player.DOMAIN) -def query_response_media_player( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a media_player entity to a QUERY response.""" - level = entity.attributes.get( - media_player.ATTR_MEDIA_VOLUME_LEVEL, - 1.0 if entity.state != STATE_OFF else 0.0) - # Convert 0.0-1.0 to 0-255 - brightness = int(level * 100) - - return {'brightness': brightness} - - -@QUERY_HANDLERS.register(light.DOMAIN) -def query_response_light( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a light entity to a QUERY response.""" - response = {} # type: Dict[str, Any] - - brightness = entity.attributes.get(light.ATTR_BRIGHTNESS) - if brightness is not None: - response['brightness'] = int(100 * (brightness / 255)) - - supported_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported_features & \ - (light.SUPPORT_COLOR_TEMP | light.SUPPORT_RGB_COLOR): - response['color'] = {} - - if entity.attributes.get(light.ATTR_COLOR_TEMP) is not None: - response['color']['temperature'] = \ - int(round(color.color_temperature_mired_to_kelvin( - entity.attributes.get(light.ATTR_COLOR_TEMP)))) - - if entity.attributes.get(light.ATTR_COLOR_NAME) is not None: - response['color']['name'] = \ - entity.attributes.get(light.ATTR_COLOR_NAME) - - if entity.attributes.get(light.ATTR_RGB_COLOR) is not None: - color_rgb = entity.attributes.get(light.ATTR_RGB_COLOR) - if color_rgb is not None: - response['color']['spectrumRGB'] = \ - int(color.color_rgb_to_hex( - color_rgb[0], color_rgb[1], color_rgb[2]), 16) + if 'errorCode' in response['payload']: + _LOGGER.error('Error handling message %s: %s', + message, response['payload']) return response -def query_device(entity: Entity, config: Config, units: UnitSystem) -> dict: - """Take an entity and return a properly formatted device object.""" - state = entity.state != STATE_OFF - defaults = { - 'on': state, - 'online': True - } - - handler = QUERY_HANDLERS.get(entity.domain) - if callable(handler): - defaults.update(handler(entity, config, units)) - - return defaults - - -# erroneous bug on old pythons and pylint -# https://github.com/PyCQA/pylint/issues/1212 -# pylint: disable=invalid-sequence-index -def determine_service( - entity_id: str, command: str, params: dict, - units: UnitSystem) -> Tuple[str, dict]: - """ - Determine service and service_data. - - Attempt to return a tuple of service and service_data based on the entity - and action requested. - """ - _LOGGER.debug("Handling command %s with data %s", command, params) - domain = entity_id.split('.')[0] - service_data = {ATTR_ENTITY_ID: entity_id} # type: Dict[str, Any] - # special media_player handling - if domain == media_player.DOMAIN and command == COMMAND_BRIGHTNESS: - brightness = params.get('brightness', 0) - service_data[media_player.ATTR_MEDIA_VOLUME_LEVEL] = brightness / 100 - return (media_player.SERVICE_VOLUME_SET, service_data) - - # special cover handling - if domain == cover.DOMAIN: - if command == COMMAND_BRIGHTNESS: - service_data['position'] = params.get('brightness', 0) - return (cover.SERVICE_SET_COVER_POSITION, service_data) - if command == COMMAND_ONOFF and params.get('on') is True: - return (cover.SERVICE_OPEN_COVER, service_data) - return (cover.SERVICE_CLOSE_COVER, service_data) - - # special climate handling - if domain == climate.DOMAIN: - if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: - service_data['temperature'] = \ - units.temperature( - params['thermostatTemperatureSetpoint'], TEMP_CELSIUS) - return (climate.SERVICE_SET_TEMPERATURE, service_data) - if command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: - service_data['target_temp_high'] = units.temperature( - params.get('thermostatTemperatureSetpointHigh', 25), - TEMP_CELSIUS) - service_data['target_temp_low'] = units.temperature( - params.get('thermostatTemperatureSetpointLow', 18), - TEMP_CELSIUS) - return (climate.SERVICE_SET_TEMPERATURE, service_data) - if command == COMMAND_THERMOSTAT_SET_MODE: - mode = params['thermostatMode'] - - if mode == CLIMATE_MODE_HEATCOOL: - mode = climate.STATE_AUTO - - service_data['operation_mode'] = mode - return (climate.SERVICE_SET_OPERATION_MODE, service_data) - - if command == COMMAND_BRIGHTNESS: - brightness = params.get('brightness') - service_data['brightness'] = int(brightness / 100 * 255) - return (SERVICE_TURN_ON, service_data) - - if command == COMMAND_COLOR: - color_data = params.get('color') - if color_data is not None: - if color_data.get('temperature', 0) > 0: - service_data[light.ATTR_KELVIN] = color_data.get('temperature') - return (SERVICE_TURN_ON, service_data) - if color_data.get('spectrumRGB', 0) > 0: - # blue is 255 so pad up to 6 chars - hex_value = \ - ('%0x' % int(color_data.get('spectrumRGB'))).zfill(6) - service_data[light.ATTR_RGB_COLOR] = \ - color.rgb_hex_to_rgb_list(hex_value) - return (SERVICE_TURN_ON, service_data) - - if command == COMMAND_ACTIVATESCENE: - return (SERVICE_TURN_ON, service_data) - - if COMMAND_ONOFF == command: - if params.get('on') is True: - return (SERVICE_TURN_ON, service_data) - return (SERVICE_TURN_OFF, service_data) - - return (None, service_data) - - -@asyncio.coroutine -def async_handle_message(hass, config, message): - """Handle incoming API messages.""" +async def _process(hass, config, message): + """Process a message.""" request_id = message.get('requestId') # type: str inputs = message.get('inputs') # type: list - if len(inputs) > 1: - _LOGGER.warning('Got unexpected more than 1 input. %s', message) + if len(inputs) != 1: + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + } - # Only use first input - intent = inputs[0].get('intent') - payload = inputs[0].get('payload') + handler = HANDLERS.get(inputs[0].get('intent')) - handler = HANDLERS.get(intent) + if handler is None: + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + } - if handler: - result = yield from handler(hass, config, payload) - else: - result = {'errorCode': 'protocolError'} - - return {'requestId': request_id, 'payload': result} + try: + result = await handler(hass, config, inputs[0].get('payload')) + return {'requestId': request_id, 'payload': result} + except SmartHomeError as err: + return { + 'requestId': request_id, + 'payload': {'errorCode': err.code} + } + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception('Unexpected error') + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_UNKNOWN_ERROR} + } @HANDLERS.register('action.devices.SYNC') -@asyncio.coroutine -def async_devices_sync(hass, config: Config, payload): - """Handle action.devices.SYNC request.""" +async def async_devices_sync(hass, config, payload): + """Handle action.devices.SYNC request. + + https://developers.google.com/actions/smarthome/create-app#actiondevicessync + """ devices = [] - for entity in hass.states.async_all(): - if not config.should_expose(entity): + for state in hass.states.async_all(): + if not config.should_expose(state): continue - device = entity_to_device(entity, config, hass.config.units) - if device is None: - _LOGGER.warning("No mapping for %s domain", entity.domain) + entity = _GoogleEntity(hass, config, state) + serialized = entity.sync_serialize() + + if serialized is None: + _LOGGER.debug("No mapping for %s domain", entity.state) continue - devices.append(device) + devices.append(serialized) return { 'agentUserId': config.agent_user_id, @@ -458,53 +234,79 @@ def async_devices_sync(hass, config: Config, payload): @HANDLERS.register('action.devices.QUERY') -@asyncio.coroutine -def async_devices_query(hass, config, payload): - """Handle action.devices.QUERY request.""" +async def async_devices_query(hass, config, payload): + """Handle action.devices.QUERY request. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesquery + """ devices = {} for device in payload.get('devices', []): - devid = device.get('id') - # In theory this should never happen - if not devid: - _LOGGER.error('Device missing ID: %s', device) - continue - + devid = device['id'] state = hass.states.get(devid) + if not state: # If we can't find a state, the device is offline devices[devid] = {'online': False} - else: - try: - devices[devid] = query_device(state, config, hass.config.units) - except SmartHomeError as error: - devices[devid] = {'errorCode': error.code} + continue + + devices[devid] = _GoogleEntity(hass, config, state).query_serialize() return {'devices': devices} @HANDLERS.register('action.devices.EXECUTE') -@asyncio.coroutine -def handle_devices_execute(hass, config, payload): - """Handle action.devices.EXECUTE request.""" - commands = [] - for command in payload.get('commands', []): - ent_ids = [ent.get('id') for ent in command.get('devices', [])] - for execution in command.get('execution'): - for eid in ent_ids: - success = False - domain = eid.split('.')[0] - (service, service_data) = determine_service( - eid, execution.get('command'), execution.get('params'), - hass.config.units) - if domain == "group": - domain = "homeassistant" - success = yield from hass.services.async_call( - domain, service, service_data, blocking=True) - result = {"ids": [eid], "states": {}} - if success: - result['status'] = 'SUCCESS' - else: - result['status'] = 'ERROR' - commands.append(result) +async def handle_devices_execute(hass, config, payload): + """Handle action.devices.EXECUTE request. - return {'commands': commands} + https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + """ + entities = {} + results = {} + + for command in payload['commands']: + for device, execution in product(command['devices'], + command['execution']): + entity_id = device['id'] + + # Happens if error occurred. Skip entity for further processing + if entity_id in results: + continue + + if entity_id not in entities: + state = hass.states.get(entity_id) + + if state is None: + results[entity_id] = { + 'ids': [entity_id], + 'status': 'ERROR', + 'errorCode': ERR_DEVICE_OFFLINE + } + continue + + entities[entity_id] = _GoogleEntity(hass, config, state) + + try: + await entities[entity_id].execute(execution['command'], + execution.get('params', {})) + except SmartHomeError as err: + results[entity_id] = { + 'ids': [entity_id], + 'status': 'ERROR', + 'errorCode': err.code + } + + final_results = list(results.values()) + + for entity in entities.values(): + if entity.entity_id in results: + continue + + entity.async_update() + + final_results.append({ + 'ids': [entity.entity_id], + 'status': 'SUCCESS', + 'states': entity.query_serialize(), + }) + + return {'commands': final_results} diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py new file mode 100644 index 00000000000..e0edc017ed3 --- /dev/null +++ b/homeassistant/components/google_assistant/trait.py @@ -0,0 +1,510 @@ +"""Implement the Smart Home traits.""" +from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.components import ( + climate, + cover, + group, + fan, + media_player, + light, + scene, + script, + switch, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.util import color as color_util, temperature as temp_util + +from .const import ERR_VALUE_OUT_OF_RANGE +from .helpers import SmartHomeError + +PREFIX_TRAITS = 'action.devices.traits.' +TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff' +TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness' +TRAIT_COLOR_SPECTRUM = PREFIX_TRAITS + 'ColorSpectrum' +TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature' +TRAIT_SCENE = PREFIX_TRAITS + 'Scene' +TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' + +PREFIX_COMMANDS = 'action.devices.commands.' +COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' +COMMAND_BRIGHTNESS_ABSOLUTE = PREFIX_COMMANDS + 'BrightnessAbsolute' +COMMAND_COLOR_ABSOLUTE = PREFIX_COMMANDS + 'ColorAbsolute' +COMMAND_ACTIVATE_SCENE = PREFIX_COMMANDS + 'ActivateScene' +COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( + PREFIX_COMMANDS + 'ThermostatTemperatureSetpoint') +COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( + PREFIX_COMMANDS + 'ThermostatTemperatureSetRange') +COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' + + +TRAITS = [] + + +def register_trait(trait): + """Decorator to register a trait.""" + TRAITS.append(trait) + return trait + + +def _google_temp_unit(state): + """Return Google temperature unit.""" + if (state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == + TEMP_FAHRENHEIT): + return 'F' + return 'C' + + +class _Trait: + """Represents a Trait inside Google Assistant skill.""" + + commands = [] + + def __init__(self, state): + """Initialize a trait for a state.""" + self.state = state + + def sync_attributes(self): + """Return attributes for a sync request.""" + raise NotImplementedError + + def query_attributes(self): + """Return the attributes of this trait for this entity.""" + raise NotImplementedError + + def can_execute(self, command, params): + """Test if command can be executed.""" + return command in self.commands + + async def execute(self, hass, command, params): + """Execute a trait command.""" + raise NotImplementedError + + +@register_trait +class BrightnessTrait(_Trait): + """Trait to control brightness of a device. + + https://developers.google.com/actions/smarthome/traits/brightness + """ + + name = TRAIT_BRIGHTNESS + commands = [ + COMMAND_BRIGHTNESS_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain == light.DOMAIN: + return features & light.SUPPORT_BRIGHTNESS + elif domain == cover.DOMAIN: + return features & cover.SUPPORT_SET_POSITION + elif domain == media_player.DOMAIN: + return features & media_player.SUPPORT_VOLUME_SET + + return False + + def sync_attributes(self): + """Return brightness attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return brightness query attributes.""" + domain = self.state.domain + response = {} + + if domain == light.DOMAIN: + brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS) + if brightness is not None: + response['brightness'] = int(100 * (brightness / 255)) + + elif domain == cover.DOMAIN: + position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) + if position is not None: + response['brightness'] = position + + elif domain == media_player.DOMAIN: + level = self.state.attributes.get( + media_player.ATTR_MEDIA_VOLUME_LEVEL) + if level is not None: + # Convert 0.0-1.0 to 0-255 + response['brightness'] = int(level * 100) + + return response + + async def execute(self, hass, command, params): + """Execute a brightness command.""" + domain = self.state.domain + + if domain == light.DOMAIN: + await hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_BRIGHTNESS_PCT: params['brightness'] + }, blocking=True) + elif domain == cover.DOMAIN: + await hass.services.async_call( + cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION, { + ATTR_ENTITY_ID: self.state.entity_id, + cover.ATTR_POSITION: params['brightness'] + }, blocking=True) + elif domain == media_player.DOMAIN: + await hass.services.async_call( + media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: + params['brightness'] / 100 + }, blocking=True) + + +@register_trait +class OnOffTrait(_Trait): + """Trait to offer basic on and off functionality. + + https://developers.google.com/actions/smarthome/traits/onoff + """ + + name = TRAIT_ONOFF + commands = [ + COMMAND_ONOFF + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + return domain in ( + group.DOMAIN, + switch.DOMAIN, + fan.DOMAIN, + light.DOMAIN, + cover.DOMAIN, + media_player.DOMAIN, + ) + + def sync_attributes(self): + """Return OnOff attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return OnOff query attributes.""" + if self.state.domain == cover.DOMAIN: + return {'on': self.state.state != cover.STATE_CLOSED} + return {'on': self.state.state != STATE_OFF} + + async def execute(self, hass, command, params): + """Execute an OnOff command.""" + domain = self.state.domain + + if domain == cover.DOMAIN: + service_domain = domain + if params['on']: + service = cover.SERVICE_OPEN_COVER + else: + service = cover.SERVICE_CLOSE_COVER + + elif domain == group.DOMAIN: + service_domain = HA_DOMAIN + service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF + + else: + service_domain = domain + service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF + + await hass.services.async_call(service_domain, service, { + ATTR_ENTITY_ID: self.state.entity_id + }, blocking=True) + + +@register_trait +class ColorSpectrumTrait(_Trait): + """Trait to offer color spectrum functionality. + + https://developers.google.com/actions/smarthome/traits/colorspectrum + """ + + name = TRAIT_COLOR_SPECTRUM + commands = [ + COMMAND_COLOR_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != light.DOMAIN: + return False + + return features & (light.SUPPORT_RGB_COLOR | light.SUPPORT_XY_COLOR) + + def sync_attributes(self): + """Return color spectrum attributes for a sync request.""" + # Other colorModel is hsv + return {'colorModel': 'rgb'} + + def query_attributes(self): + """Return color spectrum query attributes.""" + response = {} + + # No need to handle XY color because light component will always + # convert XY to RGB if possible (which is when brightness is available) + color_rgb = self.state.attributes.get(light.ATTR_RGB_COLOR) + if color_rgb is not None: + response['color'] = { + 'spectrumRGB': int(color_util.color_rgb_to_hex( + color_rgb[0], color_rgb[1], color_rgb[2]), 16), + } + + return response + + def can_execute(self, command, params): + """Test if command can be executed.""" + return (command in self.commands and + 'spectrumRGB' in params.get('color', {})) + + async def execute(self, hass, command, params): + """Execute a color spectrum command.""" + # Convert integer to hex format and left pad with 0's till length 6 + hex_value = "{0:06x}".format(params['color']['spectrumRGB']) + color = color_util.rgb_hex_to_rgb_list(hex_value) + + await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_RGB_COLOR: color + }, blocking=True) + + +@register_trait +class ColorTemperatureTrait(_Trait): + """Trait to offer color temperature functionality. + + https://developers.google.com/actions/smarthome/traits/colortemperature + """ + + name = TRAIT_COLOR_TEMP + commands = [ + COMMAND_COLOR_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != light.DOMAIN: + return False + + return features & light.SUPPORT_COLOR_TEMP + + def sync_attributes(self): + """Return color temperature attributes for a sync request.""" + attrs = self.state.attributes + return { + 'temperatureMinK': color_util.color_temperature_mired_to_kelvin( + attrs.get(light.ATTR_MIN_MIREDS)), + 'temperatureMaxK': color_util.color_temperature_mired_to_kelvin( + attrs.get(light.ATTR_MAX_MIREDS)), + } + + def query_attributes(self): + """Return color temperature query attributes.""" + response = {} + + temp = self.state.attributes.get(light.ATTR_COLOR_TEMP) + if temp is not None: + response['color'] = { + 'temperature': + color_util.color_temperature_mired_to_kelvin(temp) + } + + return response + + def can_execute(self, command, params): + """Test if command can be executed.""" + return (command in self.commands and + 'temperature' in params.get('color', {})) + + async def execute(self, hass, command, params): + """Execute a color temperature command.""" + await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_KELVIN: params['color']['temperature'], + }, blocking=True) + + +@register_trait +class SceneTrait(_Trait): + """Trait to offer scene functionality. + + https://developers.google.com/actions/smarthome/traits/scene + """ + + name = TRAIT_SCENE + commands = [ + COMMAND_ACTIVATE_SCENE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + return domain in (scene.DOMAIN, script.DOMAIN) + + def sync_attributes(self): + """Return scene attributes for a sync request.""" + # Neither supported domain can support sceneReversible + return {} + + def query_attributes(self): + """Return scene query attributes.""" + return {} + + async def execute(self, hass, command, params): + """Execute a scene command.""" + # Don't block for scripts as they can be slow. + await hass.services.async_call(self.state.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id + }, blocking=self.state.domain != script.DOMAIN) + + +@register_trait +class TemperatureSettingTrait(_Trait): + """Trait to offer handling both temperature point and modes functionality. + + https://developers.google.com/actions/smarthome/traits/temperaturesetting + """ + + name = TRAIT_TEMPERATURE_SETTING + commands = [ + COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, + COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, + COMMAND_THERMOSTAT_SET_MODE, + ] + # We do not support "on" as we are unable to know how to restore + # the last mode. + hass_to_google = { + climate.STATE_HEAT: 'heat', + climate.STATE_COOL: 'cool', + climate.STATE_OFF: 'off', + climate.STATE_AUTO: 'heatcool', + } + google_to_hass = {value: key for key, value in hass_to_google.items()} + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != climate.DOMAIN: + return False + + return features & climate.SUPPORT_OPERATION_MODE + + def sync_attributes(self): + """Return temperature point and modes attributes for a sync request.""" + modes = [] + for mode in self.state.attributes.get(climate.ATTR_OPERATION_LIST, []): + google_mode = self.hass_to_google.get(mode) + if google_mode is not None: + modes.append(google_mode) + + return { + 'availableThermostatModes': ','.join(modes), + 'thermostatTemperatureUnit': _google_temp_unit(self.state), + } + + def query_attributes(self): + """Return temperature point and modes query attributes.""" + attrs = self.state.attributes + response = {} + + operation = attrs.get(climate.ATTR_OPERATION_MODE) + if operation is not None and operation in self.hass_to_google: + response['thermostatMode'] = self.hass_to_google[operation] + + unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT] + + current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE) + if current_temp is not None: + response['thermostatTemperatureAmbient'] = \ + round(temp_util.convert(current_temp, unit, TEMP_CELSIUS), 1) + + current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY) + if current_humidity is not None: + response['thermostatHumidityAmbient'] = current_humidity + + if (operation == climate.STATE_AUTO and + climate.ATTR_TARGET_TEMP_HIGH in attrs and + climate.ATTR_TARGET_TEMP_LOW in attrs): + response['thermostatTemperatureSetpointHigh'] = \ + round(temp_util.convert(attrs[climate.ATTR_TARGET_TEMP_HIGH], + unit, TEMP_CELSIUS), 1) + response['thermostatTemperatureSetpointLow'] = \ + round(temp_util.convert(attrs[climate.ATTR_TARGET_TEMP_LOW], + unit, TEMP_CELSIUS), 1) + else: + target_temp = attrs.get(climate.ATTR_TEMPERATURE) + if target_temp is not None: + response['thermostatTemperatureSetpoint'] = round( + temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1) + + return response + + async def execute(self, hass, command, params): + """Execute a temperature point or mode command.""" + # All sent in temperatures are always in Celsius + unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT] + min_temp = self.state.attributes[climate.ATTR_MIN_TEMP] + max_temp = self.state.attributes[climate.ATTR_MAX_TEMP] + + if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: + temp = temp_util.convert(params['thermostatTemperatureSetpoint'], + TEMP_CELSIUS, unit) + + if temp < min_temp or temp > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Temperature should be between {} and {}".format(min_temp, + max_temp)) + + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_TEMPERATURE: temp + }, blocking=True) + + elif command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: + temp_high = temp_util.convert( + params['thermostatTemperatureSetpointHigh'], TEMP_CELSIUS, + unit) + + if temp_high < min_temp or temp_high > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Upper bound for temperature range should be between " + "{} and {}".format(min_temp, max_temp)) + + temp_low = temp_util.convert( + params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS, unit) + + if temp_low < min_temp or temp_low > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Lower bound for temperature range should be between " + "{} and {}".format(min_temp, max_temp)) + + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_TARGET_TEMP_HIGH: temp_high, + climate.ATTR_TARGET_TEMP_LOW: temp_low, + }, blocking=True) + + elif command == COMMAND_THERMOSTAT_SET_MODE: + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_OPERATION_MODE: + self.google_to_hass[params['thermostatMode']], + }, blocking=True) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d7862f81975..a3a962a7e34 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -86,8 +86,6 @@ LIGHT_PROFILES_FILE = "light_profiles.csv" PROP_TO_ATTR = { 'brightness': ATTR_BRIGHTNESS, 'color_temp': ATTR_COLOR_TEMP, - 'min_mireds': ATTR_MIN_MIREDS, - 'max_mireds': ATTR_MAX_MIREDS, 'rgb_color': ATTR_RGB_COLOR, 'xy_color': ATTR_XY_COLOR, 'white_value': ATTR_WHITE_VALUE, @@ -476,6 +474,10 @@ class Light(ToggleEntity): """Return optional state attributes.""" data = {} + if self.supported_features & SUPPORT_COLOR_TEMP: + data[ATTR_MIN_MIREDS] = self.min_mireds + data[ATTR_MAX_MIREDS] = self.max_mireds + if self.is_on: for prop, attr in PROP_TO_ATTR.items(): value = getattr(self, prop) diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index 9e4e912f314..2be7ad431cf 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -37,11 +37,13 @@ YOUTUBE_PLAYER_SUPPORT = \ MUSIC_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST | \ - SUPPORT_PLAY | SUPPORT_SHUFFLE_SET + SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK NETFLIX_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK class AbstractDemoPlayer(MediaPlayerDevice): @@ -284,15 +286,7 @@ class DemoMusicPlayer(AbstractDemoPlayer): @property def supported_features(self): """Flag media player features that are supported.""" - support = MUSIC_PLAYER_SUPPORT - - if self._cur_track > 0: - support |= SUPPORT_PREVIOUS_TRACK - - if self._cur_track < len(self.tracks) - 1: - support |= SUPPORT_NEXT_TRACK - - return support + return MUSIC_PLAYER_SUPPORT def media_previous_track(self): """Send previous track command.""" @@ -379,15 +373,7 @@ class DemoTVShowPlayer(AbstractDemoPlayer): @property def supported_features(self): """Flag media player features that are supported.""" - support = NETFLIX_PLAYER_SUPPORT - - if self._cur_episode > 1: - support |= SUPPORT_PREVIOUS_TRACK - - if self._cur_episode < self._episode_count: - support |= SUPPORT_NEXT_TRACK - - return support + return NETFLIX_PLAYER_SUPPORT def media_previous_track(self): """Send previous track command.""" diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index d6a26ee37e0..f4ae81ad2f2 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -318,7 +318,6 @@ def test_handler_google_actions(hass): 'entity_config': { 'switch.test': { 'name': 'Config name', - 'type': 'light', 'aliases': 'Config alias' } } @@ -347,7 +346,7 @@ def test_handler_google_actions(hass): assert device['id'] == 'switch.test' assert device['name']['name'] == 'Config name' assert device['name']['nicknames'] == ['Config alias'] - assert device['type'] == 'action.devices.types.LIGHT' + assert device['type'] == 'action.devices.types.SWITCH' async def test_refresh_token_expired(hass): diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 022cf852b88..6c4dd713b32 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -36,7 +36,7 @@ DEMO_DEVICES = [{ 'traits': [ 'action.devices.traits.OnOff' ], - 'type': 'action.devices.types.LIGHT', # This is used for custom type + 'type': 'action.devices.types.SWITCH', 'willReportState': False }, { @@ -230,20 +230,4 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.TemperatureSetting'], 'type': 'action.devices.types.THERMOSTAT', 'willReportState': False -}, { - 'id': 'sensor.outside_temperature', - 'name': { - 'name': 'Outside Temperature' - }, - 'traits': ['action.devices.traits.TemperatureSetting'], - 'type': 'action.devices.types.THERMOSTAT', - 'willReportState': False -}, { - 'id': 'sensor.outside_humidity', - 'name': { - 'name': 'Outside Humidity' - }, - 'traits': ['action.devices.traits.TemperatureSetting'], - 'type': 'action.devices.types.THERMOSTAT', - 'willReportState': False }] diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 43c36d1ca2a..cb319b67bb2 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -8,9 +8,8 @@ import pytest from homeassistant import core, const, setup from homeassistant.components import ( - fan, cover, light, switch, climate, async_setup, media_player, sensor) + fan, cover, light, switch, climate, async_setup, media_player) from homeassistant.components import google_assistant as ga -from homeassistant.util.unit_system import IMPERIAL_SYSTEM from . import DEMO_DEVICES @@ -41,17 +40,6 @@ def assistant_client(loop, hass, test_client): 'aliases': ['top lights', 'ceiling lights'], 'name': 'Roof Lights', }, - 'switch.decorative_lights': { - 'type': 'light' - }, - 'sensor.outside_humidity': { - 'type': 'climate', - 'expose': True - }, - 'sensor.outside_temperature': { - 'type': 'climate', - 'expose': True - } } } })) @@ -105,13 +93,6 @@ def hass_fixture(loop, hass): }] })) - loop.run_until_complete( - setup.async_setup_component(hass, sensor.DOMAIN, { - 'sensor': [{ - 'platform': 'demo' - }] - })) - return hass @@ -196,7 +177,6 @@ def test_query_request(hass_fixture, assistant_client): assert devices['light.kitchen_lights']['color']['spectrumRGB'] == 16727919 assert devices['light.kitchen_lights']['color']['temperature'] == 4166 assert devices['media_player.lounge_room']['on'] is True - assert devices['media_player.lounge_room']['brightness'] == 100 @asyncio.coroutine @@ -213,8 +193,6 @@ def test_query_climate_request(hass_fixture, assistant_client): {'id': 'climate.hvac'}, {'id': 'climate.heatpump'}, {'id': 'climate.ecobee'}, - {'id': 'sensor.outside_temperature'}, - {'id': 'sensor.outside_humidity'} ] } }] @@ -227,47 +205,39 @@ def test_query_climate_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid devices = body['payload']['devices'] - assert devices == { - 'climate.heatpump': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': 20.0, - 'thermostatTemperatureAmbient': 25.0, - 'thermostatMode': 'heat', - }, - 'climate.ecobee': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpointHigh': 24, - 'thermostatTemperatureAmbient': 23, - 'thermostatMode': 'heat', - 'thermostatTemperatureSetpointLow': 21 - }, - 'climate.hvac': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': 21, - 'thermostatTemperatureAmbient': 22, - 'thermostatMode': 'cool', - 'thermostatHumidityAmbient': 54, - }, - 'sensor.outside_temperature': { - 'on': True, - 'online': True, - 'thermostatTemperatureAmbient': 15.6 - }, - 'sensor.outside_humidity': { - 'on': True, - 'online': True, - 'thermostatHumidityAmbient': 54.0 - } + assert len(devices) == 3 + assert devices['climate.heatpump'] == { + 'online': True, + 'thermostatTemperatureSetpoint': 20.0, + 'thermostatTemperatureAmbient': 25.0, + 'thermostatMode': 'heat', + } + assert devices['climate.ecobee'] == { + 'online': True, + 'thermostatTemperatureSetpointHigh': 24, + 'thermostatTemperatureAmbient': 23, + 'thermostatMode': 'heatcool', + 'thermostatTemperatureSetpointLow': 21 + } + assert devices['climate.hvac'] == { + 'online': True, + 'thermostatTemperatureSetpoint': 21, + 'thermostatTemperatureAmbient': 22, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, } @asyncio.coroutine def test_query_climate_request_f(hass_fixture, assistant_client): """Test a query request.""" - hass_fixture.config.units = IMPERIAL_SYSTEM + # Mock demo devices as fahrenheit to see if we convert to celsius + for entity_id in ('climate.hvac', 'climate.heatpump', 'climate.ecobee'): + state = hass_fixture.states.get(entity_id) + attr = dict(state.attributes) + attr[const.ATTR_UNIT_OF_MEASUREMENT] = const.TEMP_FAHRENHEIT + hass_fixture.states.async_set(entity_id, state.state, attr) + reqid = '5711642932632160984' data = { 'requestId': @@ -279,7 +249,6 @@ def test_query_climate_request_f(hass_fixture, assistant_client): {'id': 'climate.hvac'}, {'id': 'climate.heatpump'}, {'id': 'climate.ecobee'}, - {'id': 'sensor.outside_temperature'} ] } }] @@ -292,35 +261,26 @@ def test_query_climate_request_f(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid devices = body['payload']['devices'] - assert devices == { - 'climate.heatpump': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': -6.7, - 'thermostatTemperatureAmbient': -3.9, - 'thermostatMode': 'heat', - }, - 'climate.ecobee': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpointHigh': -4.4, - 'thermostatTemperatureAmbient': -5, - 'thermostatMode': 'heat', - 'thermostatTemperatureSetpointLow': -6.1, - }, - 'climate.hvac': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': -6.1, - 'thermostatTemperatureAmbient': -5.6, - 'thermostatMode': 'cool', - 'thermostatHumidityAmbient': 54, - }, - 'sensor.outside_temperature': { - 'on': True, - 'online': True, - 'thermostatTemperatureAmbient': -9.1 - } + assert len(devices) == 3 + assert devices['climate.heatpump'] == { + 'online': True, + 'thermostatTemperatureSetpoint': -6.7, + 'thermostatTemperatureAmbient': -3.9, + 'thermostatMode': 'heat', + } + assert devices['climate.ecobee'] == { + 'online': True, + 'thermostatTemperatureSetpointHigh': -4.4, + 'thermostatTemperatureAmbient': -5, + 'thermostatMode': 'heatcool', + 'thermostatTemperatureSetpointLow': -6.1, + } + assert devices['climate.hvac'] == { + 'online': True, + 'thermostatTemperatureSetpoint': -6.1, + 'thermostatTemperatureAmbient': -5.6, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, } @@ -359,19 +319,6 @@ def test_execute_request(hass_fixture, assistant_client): "brightness": 70 } }] - }, { - "devices": [{ - "id": "light.kitchen_lights", - }], - "execution": [{ - "command": "action.devices.commands.ColorAbsolute", - "params": { - "color": { - "spectrumRGB": 16711680, - "temperature": 2100 - } - } - }] }, { "devices": [{ "id": "light.kitchen_lights", @@ -415,13 +362,14 @@ def test_execute_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid commands = body['payload']['commands'] - assert len(commands) == 8 + assert len(commands) == 6 + + assert not any(result['status'] == 'ERROR' for result in commands) ceiling = hass_fixture.states.get('light.ceiling_lights') assert ceiling.state == 'off' kitchen = hass_fixture.states.get('light.kitchen_lights') - assert kitchen.attributes.get(light.ATTR_COLOR_TEMP) == 476 assert kitchen.attributes.get(light.ATTR_RGB_COLOR) == (255, 0, 0) bed = hass_fixture.states.get('light.bed_light') diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index bb8f1b706e6..8d139fa8211 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,191 +1,246 @@ -"""The tests for the Google Actions component.""" -# pylint: disable=protected-access -import asyncio - -from homeassistant import const +"""Test Google Smart Home.""" +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.setup import async_setup_component from homeassistant.components import climate -from homeassistant.components import google_assistant as ga -from homeassistant.util.unit_system import (IMPERIAL_SYSTEM, METRIC_SYSTEM) - -DETERMINE_SERVICE_TESTS = [{ # Test light brightness - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_BRIGHTNESS, - 'params': { - 'brightness': 95 - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'brightness': 242} - ) -}, { # Test light color temperature - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'temperature': 2300, - 'name': 'warm white' - } - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'kelvin': 2300} - ) -}, { # Test light color blue - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'spectrumRGB': 255, - 'name': 'blue' - } - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'rgb_color': [0, 0, 255]} - ) -}, { # Test light color yellow - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'spectrumRGB': 16776960, - 'name': 'yellow' - } - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'rgb_color': [255, 255, 0]} - ) -}, { # Test unhandled action/service - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'unhandled': 2300 - } - }, - 'expected': ( - None, - {'entity_id': 'light.test'} - ) -}, { # Test switch to light custom type - 'entity_id': 'switch.decorative_lights', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': True - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'switch.decorative_lights'} - ) -}, { # Test light on / off - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': False - }, - 'expected': (const.SERVICE_TURN_OFF, {'entity_id': 'light.test'}) -}, { - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': True - }, - 'expected': (const.SERVICE_TURN_ON, {'entity_id': 'light.test'}) -}, { # Test Cover open close - 'entity_id': 'cover.bedroom', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': True - }, - 'expected': (const.SERVICE_OPEN_COVER, {'entity_id': 'cover.bedroom'}), -}, { - 'entity_id': 'cover.bedroom', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': False - }, - 'expected': (const.SERVICE_CLOSE_COVER, {'entity_id': 'cover.bedroom'}), -}, { # Test cover position - 'entity_id': 'cover.bedroom', - 'command': ga.const.COMMAND_BRIGHTNESS, - 'params': { - 'brightness': 50 - }, - 'expected': ( - const.SERVICE_SET_COVER_POSITION, - {'entity_id': 'cover.bedroom', 'position': 50} - ), -}, { # Test media_player volume - 'entity_id': 'media_player.living_room', - 'command': ga.const.COMMAND_BRIGHTNESS, - 'params': { - 'brightness': 30 - }, - 'expected': ( - const.SERVICE_VOLUME_SET, - {'entity_id': 'media_player.living_room', 'volume_level': 0.3} - ), -}, { # Test climate temperature - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, - 'params': {'thermostatTemperatureSetpoint': 24.5}, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', 'temperature': 24.5} - ), -}, { # Test climate temperature Fahrenheit - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, - 'params': {'thermostatTemperatureSetpoint': 24.5}, - 'units': IMPERIAL_SYSTEM, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', 'temperature': 76.1} - ), -}, { # Test climate temperature range - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, - 'params': { - 'thermostatTemperatureSetpointHigh': 24.5, - 'thermostatTemperatureSetpointLow': 20.5, - }, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', - 'target_temp_high': 24.5, 'target_temp_low': 20.5} - ), -}, { # Test climate temperature range Fahrenheit - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, - 'params': { - 'thermostatTemperatureSetpointHigh': 24.5, - 'thermostatTemperatureSetpointLow': 20.5, - }, - 'units': IMPERIAL_SYSTEM, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', - 'target_temp_high': 76.1, 'target_temp_low': 68.9} - ), -}, { # Test climate operation mode - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_SET_MODE, - 'params': {'thermostatMode': 'heat'}, - 'expected': ( - climate.SERVICE_SET_OPERATION_MODE, - {'entity_id': 'climate.living_room', 'operation_mode': 'heat'} - ), -}] +from homeassistant.components.google_assistant import ( + const, trait, helpers, smart_home as sh) +from homeassistant.components.light.demo import DemoLight -@asyncio.coroutine -def test_determine_service(): - """Test all branches of determine service.""" - for test in DETERMINE_SERVICE_TESTS: - result = ga.smart_home.determine_service( - test['entity_id'], - test['command'], - test['params'], - test.get('units', METRIC_SYSTEM)) - assert result == test['expected'] +BASIC_CONFIG = helpers.Config( + should_expose=lambda state: True, + agent_user_id='test-agent', +) +REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' + + +async def test_sync_message(hass): + """Test a sync message.""" + light = DemoLight( + None, 'Demo Light', + state=False, + rgb=[237, 224, 33] + ) + light.hass = hass + light.entity_id = 'light.demo_light' + await light.async_update_ha_state() + + # This should not show up in the sync request + hass.states.async_set('sensor.no_match', 'something') + + # Excluded via config + hass.states.async_set('light.not_expose', 'on') + + config = helpers.Config( + should_expose=lambda state: state.entity_id != 'light.not_expose', + agent_user_id='test-agent', + entity_config={ + 'light.demo_light': { + const.CONF_ROOM_HINT: 'Living Room', + const.CONF_ALIASES: ['Hello', 'World'] + } + } + ) + + result = await sh.async_handle_message(hass, config, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'agentUserId': 'test-agent', + 'devices': [{ + 'id': 'light.demo_light', + 'name': { + 'name': 'Demo Light', + 'nicknames': [ + 'Hello', + 'World', + ] + }, + 'traits': [ + trait.TRAIT_BRIGHTNESS, + trait.TRAIT_ONOFF, + trait.TRAIT_COLOR_SPECTRUM, + trait.TRAIT_COLOR_TEMP, + ], + 'type': sh.TYPE_LIGHT, + 'willReportState': False, + 'attributes': { + 'colorModel': 'rgb', + 'temperatureMinK': 6493, + 'temperatureMaxK': 2000, + }, + 'roomHint': 'Living Room' + }] + } + } + + +async def test_query_message(hass): + """Test a sync message.""" + light = DemoLight( + None, 'Demo Light', + state=False, + rgb=[237, 224, 33] + ) + light.hass = hass + light.entity_id = 'light.demo_light' + await light.async_update_ha_state() + + light2 = DemoLight( + None, 'Another Light', + state=True, + rgb=[237, 224, 33], + ct=400, + brightness=78, + ) + light2.hass = hass + light2.entity_id = 'light.another_light' + await light2.async_update_ha_state() + + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.QUERY", + "payload": { + "devices": [{ + "id": "light.demo_light", + }, { + "id": "light.another_light", + }, { + "id": "light.non_existing", + }] + } + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'devices': { + 'light.non_existing': { + 'online': False, + }, + 'light.demo_light': { + 'on': False, + 'online': True, + }, + 'light.another_light': { + 'on': True, + 'online': True, + 'brightness': 30, + 'color': { + 'spectrumRGB': 15589409, + 'temperature': 2500, + } + }, + } + } + } + + +async def test_execute(hass): + """Test an execute command.""" + await async_setup_component(hass, 'light', { + 'light': {'platform': 'demo'} + }) + await hass.services.async_call( + 'light', 'turn_off', {'entity_id': 'light.ceiling_lights'}, + blocking=True) + + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "light.non_existing"}, + {"id": "light.ceiling_lights"}, + ], + "execution": [{ + "command": "action.devices.commands.OnOff", + "params": { + "on": True + } + }, { + "command": + "action.devices.commands.BrightnessAbsolute", + "params": { + "brightness": 20 + } + }] + }] + } + }] + }) + + assert result == { + "requestId": REQ_ID, + "payload": { + "commands": [{ + "ids": ['light.non_existing'], + "status": "ERROR", + "errorCode": "deviceOffline" + }, { + "ids": ['light.ceiling_lights'], + "status": "SUCCESS", + "states": { + "on": True, + "online": True, + 'brightness': 20, + 'color': { + 'spectrumRGB': 15589409, + 'temperature': 2631, + }, + } + }] + } + } + + +async def test_raising_error_trait(hass): + """Test raising an error while executing a trait command.""" + hass.states.async_set('climate.bla', climate.STATE_HEAT, { + climate.ATTR_MIN_TEMP: 15, + climate.ATTR_MAX_TEMP: 30, + ATTR_SUPPORTED_FEATURES: climate.SUPPORT_OPERATION_MODE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + }) + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "climate.bla"}, + ], + "execution": [{ + "command": "action.devices.commands." + "ThermostatTemperatureSetpoint", + "params": { + "thermostatTemperatureSetpoint": 10 + } + }] + }] + } + }] + }) + + assert result == { + "requestId": REQ_ID, + "payload": { + "commands": [{ + "ids": ['climate.bla'], + "status": "ERROR", + "errorCode": "valueOutOfRange" + }] + } + } diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py new file mode 100644 index 00000000000..512c9612e60 --- /dev/null +++ b/tests/components/google_assistant/test_trait.py @@ -0,0 +1,569 @@ +"""Tests for the Google Assistant traits.""" +import pytest + +from homeassistant.const import ( + STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.core import State, DOMAIN as HA_DOMAIN +from homeassistant.components import ( + climate, + cover, + fan, + media_player, + light, + scene, + script, + switch, +) +from homeassistant.components.google_assistant import trait, helpers + +from tests.common import async_mock_service + + +async def test_brightness_light(hass): + """Test brightness trait support for light domain.""" + assert trait.BrightnessTrait.supported(light.DOMAIN, + light.SUPPORT_BRIGHTNESS) + + trt = trait.BrightnessTrait(State('light.bla', light.STATE_ON, { + light.ATTR_BRIGHTNESS: 243 + })) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'brightness': 95 + } + + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_BRIGHTNESS_ABSOLUTE, { + 'brightness': 50 + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + light.ATTR_BRIGHTNESS_PCT: 50 + } + + +async def test_brightness_cover(hass): + """Test brightness trait support for cover domain.""" + assert trait.BrightnessTrait.supported(cover.DOMAIN, + cover.SUPPORT_SET_POSITION) + + trt = trait.BrightnessTrait(State('cover.bla', cover.STATE_OPEN, { + cover.ATTR_CURRENT_POSITION: 75 + })) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'brightness': 75 + } + + calls = async_mock_service( + hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + await trt.execute(hass, trait.COMMAND_BRIGHTNESS_ABSOLUTE, { + 'brightness': 50 + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'cover.bla', + cover.ATTR_POSITION: 50 + } + + +async def test_brightness_media_player(hass): + """Test brightness trait support for media player domain.""" + assert trait.BrightnessTrait.supported(media_player.DOMAIN, + media_player.SUPPORT_VOLUME_SET) + + trt = trait.BrightnessTrait(State( + 'media_player.bla', media_player.STATE_PLAYING, { + media_player.ATTR_MEDIA_VOLUME_LEVEL: .3 + })) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'brightness': 30 + } + + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET) + await trt.execute(hass, trait.COMMAND_BRIGHTNESS_ABSOLUTE, { + 'brightness': 60 + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'media_player.bla', + media_player.ATTR_MEDIA_VOLUME_LEVEL: .6 + } + + +async def test_onoff_group(hass): + """Test OnOff trait support for group domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('group.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('group.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'group.bla', + } + + off_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'group.bla', + } + + +async def test_onoff_switch(hass): + """Test OnOff trait support for switch domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('switch.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('switch.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'switch.bla', + } + + off_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'switch.bla', + } + + +async def test_onoff_fan(hass): + """Test OnOff trait support for fan domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('fan.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('fan.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'fan.bla', + } + + off_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'fan.bla', + } + + +async def test_onoff_light(hass): + """Test OnOff trait support for light domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('light.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('light.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + } + + off_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + } + + +async def test_onoff_cover(hass): + """Test OnOff trait support for cover domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('cover.bla', cover.STATE_OPEN)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('cover.bla', cover.STATE_CLOSED)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'cover.bla', + } + + off_calls = async_mock_service(hass, cover.DOMAIN, + cover.SERVICE_CLOSE_COVER) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'cover.bla', + } + + +async def test_onoff_media_player(hass): + """Test OnOff trait support for media_player domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('media_player.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('media_player.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'media_player.bla', + } + + off_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'media_player.bla', + } + + +async def test_color_spectrum_light(hass): + """Test ColorSpectrum trait support for light domain.""" + assert not trait.ColorSpectrumTrait.supported(light.DOMAIN, 0) + assert trait.ColorSpectrumTrait.supported(light.DOMAIN, + light.SUPPORT_RGB_COLOR) + assert trait.ColorSpectrumTrait.supported(light.DOMAIN, + light.SUPPORT_XY_COLOR) + + trt = trait.ColorSpectrumTrait(State('light.bla', STATE_ON, { + light.ATTR_RGB_COLOR: [255, 10, 10] + })) + + assert trt.sync_attributes() == { + 'colorModel': 'rgb' + } + + assert trt.query_attributes() == { + 'color': { + 'spectrumRGB': 16714250 + } + } + + assert not trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'temperature': 400 + } + }) + assert trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'spectrumRGB': 16715792 + } + }) + + calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'spectrumRGB': 1052927 + } + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + light.ATTR_RGB_COLOR: [16, 16, 255] + } + + +async def test_color_temperature_light(hass): + """Test ColorTemperature trait support for light domain.""" + assert not trait.ColorTemperatureTrait.supported(light.DOMAIN, 0) + assert trait.ColorTemperatureTrait.supported(light.DOMAIN, + light.SUPPORT_COLOR_TEMP) + + trt = trait.ColorTemperatureTrait(State('light.bla', STATE_ON, { + light.ATTR_MIN_MIREDS: 200, + light.ATTR_COLOR_TEMP: 300, + light.ATTR_MAX_MIREDS: 500, + })) + + assert trt.sync_attributes() == { + 'temperatureMinK': 5000, + 'temperatureMaxK': 2000, + } + + assert trt.query_attributes() == { + 'color': { + 'temperature': 3333 + } + } + + assert trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'temperature': 400 + } + }) + assert not trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'spectrumRGB': 16715792 + } + }) + + calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'temperature': 2857 + } + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + light.ATTR_KELVIN: 2857 + } + + +async def test_scene_scene(hass): + """Test Scene trait support for scene domain.""" + assert trait.SceneTrait.supported(scene.DOMAIN, 0) + + trt = trait.SceneTrait(State('scene.bla', scene.STATE)) + assert trt.sync_attributes() == {} + assert trt.query_attributes() == {} + assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) + + calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_ACTIVATE_SCENE, {}) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'scene.bla', + } + + +async def test_scene_script(hass): + """Test Scene trait support for script domain.""" + assert trait.SceneTrait.supported(script.DOMAIN, 0) + + trt = trait.SceneTrait(State('script.bla', STATE_OFF)) + assert trt.sync_attributes() == {} + assert trt.query_attributes() == {} + assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) + + calls = async_mock_service(hass, script.DOMAIN, SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_ACTIVATE_SCENE, {}) + + # We don't wait till script execution is done. + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'script.bla', + } + + +async def test_temperature_setting_climate_range(hass): + """Test TemperatureSetting trait support for climate domain - range.""" + assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0) + assert trait.TemperatureSettingTrait.supported( + climate.DOMAIN, climate.SUPPORT_OPERATION_MODE) + + trt = trait.TemperatureSettingTrait(State( + 'climate.bla', climate.STATE_AUTO, { + climate.ATTR_CURRENT_TEMPERATURE: 70, + climate.ATTR_CURRENT_HUMIDITY: 25, + climate.ATTR_OPERATION_MODE: climate.STATE_AUTO, + climate.ATTR_OPERATION_LIST: [ + climate.STATE_OFF, + climate.STATE_COOL, + climate.STATE_HEAT, + climate.STATE_AUTO, + ], + climate.ATTR_TARGET_TEMP_HIGH: 75, + climate.ATTR_TARGET_TEMP_LOW: 65, + climate.ATTR_MIN_TEMP: 50, + climate.ATTR_MAX_TEMP: 80, + ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT, + })) + assert trt.sync_attributes() == { + 'availableThermostatModes': 'off,cool,heat,heatcool', + 'thermostatTemperatureUnit': 'F', + } + assert trt.query_attributes() == { + 'thermostatMode': 'heatcool', + 'thermostatTemperatureAmbient': 21.1, + 'thermostatHumidityAmbient': 25, + 'thermostatTemperatureSetpointLow': 18.3, + 'thermostatTemperatureSetpointHigh': 23.9, + } + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {}) + + calls = async_mock_service( + hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) + await trt.execute(hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, { + 'thermostatTemperatureSetpointHigh': 25, + 'thermostatTemperatureSetpointLow': 20, + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + climate.ATTR_TARGET_TEMP_HIGH: 77, + climate.ATTR_TARGET_TEMP_LOW: 68, + } + + calls = async_mock_service( + hass, climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE) + await trt.execute(hass, trait.COMMAND_THERMOSTAT_SET_MODE, { + 'thermostatMode': 'heatcool', + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + climate.ATTR_OPERATION_MODE: climate.STATE_AUTO, + } + + with pytest.raises(helpers.SmartHomeError): + await trt.execute( + hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { + 'thermostatTemperatureSetpoint': -100, + }) + + +async def test_temperature_setting_climate_setpoint(hass): + """Test TemperatureSetting trait support for climate domain - setpoint.""" + assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0) + assert trait.TemperatureSettingTrait.supported( + climate.DOMAIN, climate.SUPPORT_OPERATION_MODE) + + trt = trait.TemperatureSettingTrait(State( + 'climate.bla', climate.STATE_AUTO, { + climate.ATTR_OPERATION_MODE: climate.STATE_COOL, + climate.ATTR_OPERATION_LIST: [ + climate.STATE_OFF, + climate.STATE_COOL, + ], + climate.ATTR_MIN_TEMP: 10, + climate.ATTR_MAX_TEMP: 30, + climate.ATTR_TEMPERATURE: 18, + climate.ATTR_CURRENT_TEMPERATURE: 20, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + })) + assert trt.sync_attributes() == { + 'availableThermostatModes': 'off,cool', + 'thermostatTemperatureUnit': 'C', + } + assert trt.query_attributes() == { + 'thermostatMode': 'cool', + 'thermostatTemperatureAmbient': 20, + 'thermostatTemperatureSetpoint': 18, + } + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {}) + + calls = async_mock_service( + hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) + + with pytest.raises(helpers.SmartHomeError): + await trt.execute( + hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { + 'thermostatTemperatureSetpoint': -100, + }) + + await trt.execute(hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { + 'thermostatTemperatureSetpoint': 19, + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + climate.ATTR_TEMPERATURE: 19 + } diff --git a/tests/components/media_player/test_demo.py b/tests/components/media_player/test_demo.py index a798c5f3987..65ca2eb6a01 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -154,29 +154,21 @@ class TestDemoMediaPlayer(unittest.TestCase): {'media_player': {'platform': 'demo'}}) state = self.hass.states.get(entity_id) assert 1 == state.attributes.get('media_track') - assert 0 == (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_next_track(self.hass, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 2 == state.attributes.get('media_track') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_next_track(self.hass, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 3 == state.attributes.get('media_track') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_previous_track(self.hass, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 2 == state.attributes.get('media_track') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) assert setup_component( self.hass, mp.DOMAIN, @@ -184,22 +176,16 @@ class TestDemoMediaPlayer(unittest.TestCase): ent_id = 'media_player.lounge_room' state = self.hass.states.get(ent_id) assert 1 == state.attributes.get('media_episode') - assert 0 == (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_next_track(self.hass, ent_id) self.hass.block_till_done() state = self.hass.states.get(ent_id) assert 2 == state.attributes.get('media_episode') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_previous_track(self.hass, ent_id) self.hass.block_till_done() state = self.hass.states.get(ent_id) assert 1 == state.attributes.get('media_episode') - assert 0 == (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) @patch('homeassistant.components.media_player.demo.DemoYoutubePlayer.' 'media_seek', autospec=True)