Refactor Google Assistant (#12959)

* Refactor Google Assistant

* Fix cloud test

* Fix supported features media player demo

* Fix query

* Fix execute

* Fix demo media player tests

* Add tests for traits

* Lint

* Lint

* Add integration tests

* Add more tests

* update logging

* Catch out of range temp errrors

* Fix cloud error

* Lint
This commit is contained in:
Paulus Schoutsen 2018-03-08 14:39:10 -08:00 committed by GitHub
parent 8792fd22b9
commit 9b1a75a74b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1676 additions and 822 deletions

View File

@ -15,12 +15,12 @@ import async_timeout
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ( 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 import entityfilter, config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.components.alexa import smart_home as alexa_sh 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 . import http_api, iot
from .const import CONFIG_DIR, DOMAIN, SERVERS from .const import CONFIG_DIR, DOMAIN, SERVERS
@ -51,7 +51,6 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({
GOOGLE_ENTITY_SCHEMA = vol.Schema({ GOOGLE_ENTITY_SCHEMA = vol.Schema({
vol.Optional(CONF_NAME): cv.string, 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]) vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string])
}) })
@ -175,7 +174,7 @@ class Cloud:
"""If an entity should be exposed.""" """If an entity should be exposed."""
return conf['filter'](entity.entity_id) return conf['filter'](entity.entity_id)
self._gactions_config = ga_sh.Config( self._gactions_config = ga_h.Config(
should_expose=should_expose, should_expose=should_expose,
agent_user_id=self.claims['cognito:username'], agent_user_id=self.claims['cognito:username'],
entity_config=conf.get(CONF_ENTITY_CONFIG), entity_config=conf.get(CONF_ENTITY_CONFIG),

View File

@ -1,6 +1,7 @@
"""Module to handle messages from Home Assistant cloud.""" """Module to handle messages from Home Assistant cloud."""
import asyncio import asyncio
import logging import logging
import pprint
from aiohttp import hdrs, client_exceptions, WSMsgType from aiohttp import hdrs, client_exceptions, WSMsgType
@ -154,7 +155,9 @@ class CloudIoT:
disconnect_warn = 'Received invalid JSON.' disconnect_warn = 'Received invalid JSON.'
break break
_LOGGER.debug("Received message: %s", msg) if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Received message:\n%s\n",
pprint.pformat(msg))
response = { response = {
'msgid': msg['msgid'], 'msgid': msg['msgid'],
@ -176,7 +179,9 @@ class CloudIoT:
_LOGGER.exception("Error handling message") _LOGGER.exception("Error handling message")
response['error'] = 'exception' 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) yield from client.send_json(response)
except client_exceptions.WSServerHandshakeError as err: except client_exceptions.WSServerHandshakeError as err:

View File

@ -17,7 +17,7 @@ import voluptuous as vol
from homeassistant.core import HomeAssistant # NOQA from homeassistant.core import HomeAssistant # NOQA
from typing import Dict, Any # 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 import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
@ -31,7 +31,6 @@ from .const import (
) )
from .auth import GoogleAssistantAuthView from .auth import GoogleAssistantAuthView
from .http import async_register_http from .http import async_register_http
from .smart_home import MAPPING_COMPONENT
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -41,7 +40,6 @@ DEFAULT_AGENT_USER_ID = 'home-assistant'
ENTITY_SCHEMA = vol.Schema({ ENTITY_SCHEMA = vol.Schema({
vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_TYPE): vol.In(MAPPING_COMPONENT),
vol.Optional(CONF_EXPOSE): cv.boolean, vol.Optional(CONF_EXPOSE): cv.boolean,
vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_ROOM_HINT): cv.string vol.Optional(CONF_ROOM_HINT): cv.string

View File

@ -22,25 +22,6 @@ DEFAULT_EXPOSED_DOMAINS = [
CLIMATE_MODE_HEATCOOL = 'heatcool' CLIMATE_MODE_HEATCOOL = 'heatcool'
CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_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.' PREFIX_TYPES = 'action.devices.types.'
TYPE_LIGHT = PREFIX_TYPES + 'LIGHT' TYPE_LIGHT = PREFIX_TYPES + 'LIGHT'
TYPE_SWITCH = PREFIX_TYPES + 'SWITCH' TYPE_SWITCH = PREFIX_TYPES + 'SWITCH'
@ -50,3 +31,12 @@ TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT'
SERVICE_REQUEST_SYNC = 'request_sync' SERVICE_REQUEST_SYNC = 'request_sync'
HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' HOMEGRAPH_URL = 'https://homegraph.googleapis.com/'
REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync' 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'

View File

@ -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 {}

View File

@ -10,8 +10,6 @@ import logging
from aiohttp.hdrs import AUTHORIZATION from aiohttp.hdrs import AUTHORIZATION
from aiohttp.web import Request, Response # NOQA from aiohttp.web import Request, Response # NOQA
from homeassistant.const import HTTP_UNAUTHORIZED
# Typing imports # Typing imports
# pylint: disable=using-constant-test,unused-import,ungrouped-imports # pylint: disable=using-constant-test,unused-import,ungrouped-imports
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
@ -27,7 +25,8 @@ from .const import (
CONF_ENTITY_CONFIG, CONF_ENTITY_CONFIG,
CONF_EXPOSE, 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__) _LOGGER = logging.getLogger(__name__)
@ -83,8 +82,7 @@ class GoogleAssistantView(HomeAssistantView):
"""Handle Google Assistant requests.""" """Handle Google Assistant requests."""
auth = request.headers.get(AUTHORIZATION, None) auth = request.headers.get(AUTHORIZATION, None)
if 'Bearer {}'.format(self.access_token) != auth: if 'Bearer {}'.format(self.access_token) != auth:
return self.json_message( return self.json_message("missing authorization", status_code=401)
"missing authorization", status_code=HTTP_UNAUTHORIZED)
message = yield from request.json() # type: dict message = yield from request.json() # type: dict
result = yield from async_handle_message( result = yield from async_handle_message(

View File

@ -1,5 +1,6 @@
"""Support for Google Assistant Smart Home API.""" """Support for Google Assistant Smart Home API."""
import asyncio import collections
from itertools import product
import logging import logging
# Typing imports # Typing imports
@ -9,116 +10,99 @@ from aiohttp.web import Request, Response # NOQA
from typing import Dict, Tuple, Any, Optional # NOQA from typing import Dict, Tuple, Any, Optional # NOQA
from homeassistant.helpers.entity import Entity # NOQA from homeassistant.helpers.entity import Entity # NOQA
from homeassistant.core import HomeAssistant # NOQA from homeassistant.core import HomeAssistant # NOQA
from homeassistant.util import color
from homeassistant.util.unit_system import UnitSystem # NOQA from homeassistant.util.unit_system import UnitSystem # NOQA
from homeassistant.util.decorator import Registry from homeassistant.util.decorator import Registry
from homeassistant.core import callback
from homeassistant.const import ( from homeassistant.const import (
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES)
STATE_OFF, SERVICE_TURN_OFF, SERVICE_TURN_ON,
TEMP_FAHRENHEIT, TEMP_CELSIUS,
CONF_NAME, CONF_TYPE
)
from homeassistant.components import ( from homeassistant.components import (
switch, light, cover, media_player, group, fan, scene, script, climate, 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 ( 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, TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, TYPE_THERMOSTAT,
CONF_ALIASES, CONF_ROOM_HINT, CLIMATE_SUPPORTED_MODES, CONF_ALIASES, CONF_ROOM_HINT,
CLIMATE_MODE_HEATCOOL ERR_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE,
ERR_UNKNOWN_ERROR
) )
from .helpers import SmartHomeError
HANDLERS = Registry() HANDLERS = Registry()
QUERY_HANDLERS = Registry()
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# Mapping is [actions schema, primary trait, optional features] DOMAIN_TO_GOOGLE_TYPES = {
# optional is SUPPORT_* = (trait, command) group.DOMAIN: TYPE_SWITCH,
MAPPING_COMPONENT = { scene.DOMAIN: TYPE_SCENE,
group.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], script.DOMAIN: TYPE_SCENE,
scene.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None], switch.DOMAIN: TYPE_SWITCH,
script.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None], fan.DOMAIN: TYPE_SWITCH,
switch.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], light.DOMAIN: TYPE_LIGHT,
fan.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], cover.DOMAIN: TYPE_SWITCH,
light.DOMAIN: [ media_player.DOMAIN: TYPE_SWITCH,
TYPE_LIGHT, TRAIT_ONOFF, { climate.DOMAIN: TYPE_THERMOSTAT,
light.SUPPORT_BRIGHTNESS: TRAIT_BRIGHTNESS,
light.SUPPORT_RGB_COLOR: TRAIT_RGB_COLOR,
light.SUPPORT_COLOR_TEMP: TRAIT_COLOR_TEMP,
} }
],
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]
"""Error code used for SmartHomeError class.""" def deep_update(target, source):
ERROR_NOT_SUPPORTED = "notSupported" """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 SmartHomeError(Exception): class _GoogleEntity:
"""Google Assistant Smart Home errors.""" """Adaptation of Entity expressed in Google's terms."""
def __init__(self, code, msg): def __init__(self, hass, config, state):
"""Log error code.""" self.hass = hass
super(SmartHomeError, self).__init__(msg) self.config = config
_LOGGER.error( self.state = state
"An error has occurred in Google SmartHome: %s."
"Error code: %s", msg, code
)
self.code = code
@property
def entity_id(self):
"""Return entity ID."""
return self.state.entity_id
class Config: @callback
"""Hold the configuration for Google Assistant.""" def traits(self):
"""Return traits for entity."""
state = self.state
domain = state.domain
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
def __init__(self, should_expose, agent_user_id, entity_config=None): return [Trait(state) for Trait in trait.TRAITS
"""Initialize the configuration.""" if Trait.supported(domain, features)]
self.should_expose = should_expose
self.agent_user_id = agent_user_id
self.entity_config = entity_config or {}
@callback
def sync_serialize(self):
"""Serialize entity for a SYNC response.
def entity_to_device(entity: Entity, config: Config, units: UnitSystem): https://developers.google.com/actions/smarthome/create-app#actiondevicessync
"""Convert a hass entity into a google actions device.""" """
entity_config = config.entity_config.get(entity.entity_id, {}) traits = self.traits()
google_domain = entity_config.get(CONF_TYPE) state = self.state
class_data = MAPPING_COMPONENT.get(
google_domain or entity.domain)
if class_data is None: # Found no supported traits for this entity
if not traits:
return None return None
device = { entity_config = self.config.entity_config.get(state.entity_id, {})
'id': entity.entity_id,
'name': {},
'attributes': {},
'traits': [],
'willReportState': False,
}
device['type'] = class_data[0]
device['traits'].append(class_data[1])
# handle custom names device = {
device['name']['name'] = entity_config.get(CONF_NAME) or entity.name '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],
}
# use aliases # use aliases
aliases = entity_config.get(CONF_ALIASES) aliases = entity_config.get(CONF_ALIASES)
@ -130,326 +114,118 @@ def entity_to_device(entity: Entity, config: Config, units: UnitSystem):
if room: if room:
device['roomHint'] = room device['roomHint'] = room
# add trait if entity supports feature for trt in traits:
if class_data[2]: device['attributes'].update(trt.sync_attributes())
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 return device
@callback
def query_serialize(self):
"""Serialize entity for a QUERY response.
def celsius(deg: Optional[float], units: UnitSystem) -> Optional[float]: https://developers.google.com/actions/smarthome/create-app#actiondevicesquery
"""Convert a float to Celsius and rounds to one decimal place.""" """
if deg is None: state = self.state
return None
return round(METRIC_SYSTEM.temperature(deg, units.temperature_unit), 1)
if state.state == STATE_UNAVAILABLE:
return {'online': False}
@QUERY_HANDLERS.register(sensor.DOMAIN) attrs = {'online': True}
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: 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( raise SmartHomeError(
ERROR_NOT_SUPPORTED, ERR_NOT_SUPPORTED,
"Sensor type {} is not supported".format(google_domain) 'Unable to execute {} for {}'.format(command,
) self.state.entity_id))
# check if we have a string value to convert it to number @callback
value = entity.state def async_update(self):
if isinstance(entity.state, str): """Update the entity with latest info from Home Assistant."""
try: self.state = self.hass.states.get(self.entity_id)
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) async def async_handle_message(hass, config, message):
def query_response_climate( """Handle incoming API messages."""
entity: Entity, config: Config, units: UnitSystem) -> dict: response = await _process(hass, config, message)
"""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}
if 'errorCode' in response['payload']:
@QUERY_HANDLERS.register(media_player.DOMAIN) _LOGGER.error('Error handling message %s: %s',
def query_response_media_player( message, response['payload'])
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)
return response return response
def query_device(entity: Entity, config: Config, units: UnitSystem) -> dict: async def _process(hass, config, message):
"""Take an entity and return a properly formatted device object.""" """Process a message."""
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."""
request_id = message.get('requestId') # type: str request_id = message.get('requestId') # type: str
inputs = message.get('inputs') # type: list inputs = message.get('inputs') # type: list
if len(inputs) > 1: if len(inputs) != 1:
_LOGGER.warning('Got unexpected more than 1 input. %s', message) return {
'requestId': request_id,
'payload': {'errorCode': ERR_PROTOCOL_ERROR}
}
# Only use first input handler = HANDLERS.get(inputs[0].get('intent'))
intent = inputs[0].get('intent')
payload = inputs[0].get('payload')
handler = HANDLERS.get(intent) if handler is None:
return {
if handler: 'requestId': request_id,
result = yield from handler(hass, config, payload) 'payload': {'errorCode': ERR_PROTOCOL_ERROR}
else: }
result = {'errorCode': 'protocolError'}
try:
result = await handler(hass, config, inputs[0].get('payload'))
return {'requestId': request_id, 'payload': result} 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') @HANDLERS.register('action.devices.SYNC')
@asyncio.coroutine async def async_devices_sync(hass, config, payload):
def async_devices_sync(hass, config: Config, payload): """Handle action.devices.SYNC request.
"""Handle action.devices.SYNC request."""
https://developers.google.com/actions/smarthome/create-app#actiondevicessync
"""
devices = [] devices = []
for entity in hass.states.async_all(): for state in hass.states.async_all():
if not config.should_expose(entity): if not config.should_expose(state):
continue continue
device = entity_to_device(entity, config, hass.config.units) entity = _GoogleEntity(hass, config, state)
if device is None: serialized = entity.sync_serialize()
_LOGGER.warning("No mapping for %s domain", entity.domain)
if serialized is None:
_LOGGER.debug("No mapping for %s domain", entity.state)
continue continue
devices.append(device) devices.append(serialized)
return { return {
'agentUserId': config.agent_user_id, 'agentUserId': config.agent_user_id,
@ -458,53 +234,79 @@ def async_devices_sync(hass, config: Config, payload):
@HANDLERS.register('action.devices.QUERY') @HANDLERS.register('action.devices.QUERY')
@asyncio.coroutine async def async_devices_query(hass, config, payload):
def async_devices_query(hass, config, payload): """Handle action.devices.QUERY request.
"""Handle action.devices.QUERY request."""
https://developers.google.com/actions/smarthome/create-app#actiondevicesquery
"""
devices = {} devices = {}
for device in payload.get('devices', []): for device in payload.get('devices', []):
devid = device.get('id') devid = device['id']
# In theory this should never happen
if not devid:
_LOGGER.error('Device missing ID: %s', device)
continue
state = hass.states.get(devid) state = hass.states.get(devid)
if not state: if not state:
# If we can't find a state, the device is offline # If we can't find a state, the device is offline
devices[devid] = {'online': False} devices[devid] = {'online': False}
else: continue
try:
devices[devid] = query_device(state, config, hass.config.units) devices[devid] = _GoogleEntity(hass, config, state).query_serialize()
except SmartHomeError as error:
devices[devid] = {'errorCode': error.code}
return {'devices': devices} return {'devices': devices}
@HANDLERS.register('action.devices.EXECUTE') @HANDLERS.register('action.devices.EXECUTE')
@asyncio.coroutine async def handle_devices_execute(hass, config, payload):
def handle_devices_execute(hass, config, payload): """Handle action.devices.EXECUTE request.
"""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)
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}

View File

@ -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)

View File

@ -86,8 +86,6 @@ LIGHT_PROFILES_FILE = "light_profiles.csv"
PROP_TO_ATTR = { PROP_TO_ATTR = {
'brightness': ATTR_BRIGHTNESS, 'brightness': ATTR_BRIGHTNESS,
'color_temp': ATTR_COLOR_TEMP, 'color_temp': ATTR_COLOR_TEMP,
'min_mireds': ATTR_MIN_MIREDS,
'max_mireds': ATTR_MAX_MIREDS,
'rgb_color': ATTR_RGB_COLOR, 'rgb_color': ATTR_RGB_COLOR,
'xy_color': ATTR_XY_COLOR, 'xy_color': ATTR_XY_COLOR,
'white_value': ATTR_WHITE_VALUE, 'white_value': ATTR_WHITE_VALUE,
@ -476,6 +474,10 @@ class Light(ToggleEntity):
"""Return optional state attributes.""" """Return optional state attributes."""
data = {} 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: if self.is_on:
for prop, attr in PROP_TO_ATTR.items(): for prop, attr in PROP_TO_ATTR.items():
value = getattr(self, prop) value = getattr(self, prop)

View File

@ -37,11 +37,13 @@ YOUTUBE_PLAYER_SUPPORT = \
MUSIC_PLAYER_SUPPORT = \ MUSIC_PLAYER_SUPPORT = \
SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST | \ 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 = \ NETFLIX_PLAYER_SUPPORT = \
SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ 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): class AbstractDemoPlayer(MediaPlayerDevice):
@ -284,15 +286,7 @@ class DemoMusicPlayer(AbstractDemoPlayer):
@property @property
def supported_features(self): def supported_features(self):
"""Flag media player features that are supported.""" """Flag media player features that are supported."""
support = MUSIC_PLAYER_SUPPORT return 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
def media_previous_track(self): def media_previous_track(self):
"""Send previous track command.""" """Send previous track command."""
@ -379,15 +373,7 @@ class DemoTVShowPlayer(AbstractDemoPlayer):
@property @property
def supported_features(self): def supported_features(self):
"""Flag media player features that are supported.""" """Flag media player features that are supported."""
support = NETFLIX_PLAYER_SUPPORT return 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
def media_previous_track(self): def media_previous_track(self):
"""Send previous track command.""" """Send previous track command."""

View File

@ -318,7 +318,6 @@ def test_handler_google_actions(hass):
'entity_config': { 'entity_config': {
'switch.test': { 'switch.test': {
'name': 'Config name', 'name': 'Config name',
'type': 'light',
'aliases': 'Config alias' 'aliases': 'Config alias'
} }
} }
@ -347,7 +346,7 @@ def test_handler_google_actions(hass):
assert device['id'] == 'switch.test' assert device['id'] == 'switch.test'
assert device['name']['name'] == 'Config name' assert device['name']['name'] == 'Config name'
assert device['name']['nicknames'] == ['Config alias'] 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): async def test_refresh_token_expired(hass):

View File

@ -36,7 +36,7 @@ DEMO_DEVICES = [{
'traits': [ 'traits': [
'action.devices.traits.OnOff' 'action.devices.traits.OnOff'
], ],
'type': 'action.devices.types.LIGHT', # This is used for custom type 'type': 'action.devices.types.SWITCH',
'willReportState': 'willReportState':
False False
}, { }, {
@ -230,20 +230,4 @@ DEMO_DEVICES = [{
'traits': ['action.devices.traits.TemperatureSetting'], 'traits': ['action.devices.traits.TemperatureSetting'],
'type': 'action.devices.types.THERMOSTAT', 'type': 'action.devices.types.THERMOSTAT',
'willReportState': False '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
}] }]

View File

@ -8,9 +8,8 @@ import pytest
from homeassistant import core, const, setup from homeassistant import core, const, setup
from homeassistant.components import ( 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.components import google_assistant as ga
from homeassistant.util.unit_system import IMPERIAL_SYSTEM
from . import DEMO_DEVICES from . import DEMO_DEVICES
@ -41,17 +40,6 @@ def assistant_client(loop, hass, test_client):
'aliases': ['top lights', 'ceiling lights'], 'aliases': ['top lights', 'ceiling lights'],
'name': 'Roof 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 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']['spectrumRGB'] == 16727919
assert devices['light.kitchen_lights']['color']['temperature'] == 4166 assert devices['light.kitchen_lights']['color']['temperature'] == 4166
assert devices['media_player.lounge_room']['on'] is True assert devices['media_player.lounge_room']['on'] is True
assert devices['media_player.lounge_room']['brightness'] == 100
@asyncio.coroutine @asyncio.coroutine
@ -213,8 +193,6 @@ def test_query_climate_request(hass_fixture, assistant_client):
{'id': 'climate.hvac'}, {'id': 'climate.hvac'},
{'id': 'climate.heatpump'}, {'id': 'climate.heatpump'},
{'id': 'climate.ecobee'}, {'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() body = yield from result.json()
assert body.get('requestId') == reqid assert body.get('requestId') == reqid
devices = body['payload']['devices'] devices = body['payload']['devices']
assert devices == { assert len(devices) == 3
'climate.heatpump': { assert devices['climate.heatpump'] == {
'on': True,
'online': True, 'online': True,
'thermostatTemperatureSetpoint': 20.0, 'thermostatTemperatureSetpoint': 20.0,
'thermostatTemperatureAmbient': 25.0, 'thermostatTemperatureAmbient': 25.0,
'thermostatMode': 'heat', 'thermostatMode': 'heat',
}, }
'climate.ecobee': { assert devices['climate.ecobee'] == {
'on': True,
'online': True, 'online': True,
'thermostatTemperatureSetpointHigh': 24, 'thermostatTemperatureSetpointHigh': 24,
'thermostatTemperatureAmbient': 23, 'thermostatTemperatureAmbient': 23,
'thermostatMode': 'heat', 'thermostatMode': 'heatcool',
'thermostatTemperatureSetpointLow': 21 'thermostatTemperatureSetpointLow': 21
}, }
'climate.hvac': { assert devices['climate.hvac'] == {
'on': True,
'online': True, 'online': True,
'thermostatTemperatureSetpoint': 21, 'thermostatTemperatureSetpoint': 21,
'thermostatTemperatureAmbient': 22, 'thermostatTemperatureAmbient': 22,
'thermostatMode': 'cool', 'thermostatMode': 'cool',
'thermostatHumidityAmbient': 54, 'thermostatHumidityAmbient': 54,
},
'sensor.outside_temperature': {
'on': True,
'online': True,
'thermostatTemperatureAmbient': 15.6
},
'sensor.outside_humidity': {
'on': True,
'online': True,
'thermostatHumidityAmbient': 54.0
}
} }
@asyncio.coroutine @asyncio.coroutine
def test_query_climate_request_f(hass_fixture, assistant_client): def test_query_climate_request_f(hass_fixture, assistant_client):
"""Test a query request.""" """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' reqid = '5711642932632160984'
data = { data = {
'requestId': 'requestId':
@ -279,7 +249,6 @@ def test_query_climate_request_f(hass_fixture, assistant_client):
{'id': 'climate.hvac'}, {'id': 'climate.hvac'},
{'id': 'climate.heatpump'}, {'id': 'climate.heatpump'},
{'id': 'climate.ecobee'}, {'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() body = yield from result.json()
assert body.get('requestId') == reqid assert body.get('requestId') == reqid
devices = body['payload']['devices'] devices = body['payload']['devices']
assert devices == { assert len(devices) == 3
'climate.heatpump': { assert devices['climate.heatpump'] == {
'on': True,
'online': True, 'online': True,
'thermostatTemperatureSetpoint': -6.7, 'thermostatTemperatureSetpoint': -6.7,
'thermostatTemperatureAmbient': -3.9, 'thermostatTemperatureAmbient': -3.9,
'thermostatMode': 'heat', 'thermostatMode': 'heat',
}, }
'climate.ecobee': { assert devices['climate.ecobee'] == {
'on': True,
'online': True, 'online': True,
'thermostatTemperatureSetpointHigh': -4.4, 'thermostatTemperatureSetpointHigh': -4.4,
'thermostatTemperatureAmbient': -5, 'thermostatTemperatureAmbient': -5,
'thermostatMode': 'heat', 'thermostatMode': 'heatcool',
'thermostatTemperatureSetpointLow': -6.1, 'thermostatTemperatureSetpointLow': -6.1,
}, }
'climate.hvac': { assert devices['climate.hvac'] == {
'on': True,
'online': True, 'online': True,
'thermostatTemperatureSetpoint': -6.1, 'thermostatTemperatureSetpoint': -6.1,
'thermostatTemperatureAmbient': -5.6, 'thermostatTemperatureAmbient': -5.6,
'thermostatMode': 'cool', 'thermostatMode': 'cool',
'thermostatHumidityAmbient': 54, 'thermostatHumidityAmbient': 54,
},
'sensor.outside_temperature': {
'on': True,
'online': True,
'thermostatTemperatureAmbient': -9.1
}
} }
@ -359,19 +319,6 @@ def test_execute_request(hass_fixture, assistant_client):
"brightness": 70 "brightness": 70
} }
}] }]
}, {
"devices": [{
"id": "light.kitchen_lights",
}],
"execution": [{
"command": "action.devices.commands.ColorAbsolute",
"params": {
"color": {
"spectrumRGB": 16711680,
"temperature": 2100
}
}
}]
}, { }, {
"devices": [{ "devices": [{
"id": "light.kitchen_lights", "id": "light.kitchen_lights",
@ -415,13 +362,14 @@ def test_execute_request(hass_fixture, assistant_client):
body = yield from result.json() body = yield from result.json()
assert body.get('requestId') == reqid assert body.get('requestId') == reqid
commands = body['payload']['commands'] 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') ceiling = hass_fixture.states.get('light.ceiling_lights')
assert ceiling.state == 'off' assert ceiling.state == 'off'
kitchen = hass_fixture.states.get('light.kitchen_lights') 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) assert kitchen.attributes.get(light.ATTR_RGB_COLOR) == (255, 0, 0)
bed = hass_fixture.states.get('light.bed_light') bed = hass_fixture.states.get('light.bed_light')

View File

@ -1,191 +1,246 @@
"""The tests for the Google Actions component.""" """Test Google Smart Home."""
# pylint: disable=protected-access from homeassistant.const import (
import asyncio ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS)
from homeassistant.setup import async_setup_component
from homeassistant import const
from homeassistant.components import climate from homeassistant.components import climate
from homeassistant.components import google_assistant as ga from homeassistant.components.google_assistant import (
from homeassistant.util.unit_system import (IMPERIAL_SYSTEM, METRIC_SYSTEM) const, trait, helpers, smart_home as sh)
from homeassistant.components.light.demo import DemoLight
DETERMINE_SERVICE_TESTS = [{ # Test light brightness
'entity_id': 'light.test', BASIC_CONFIG = helpers.Config(
'command': ga.const.COMMAND_BRIGHTNESS, should_expose=lambda state: True,
'params': { agent_user_id='test-agent',
'brightness': 95
},
'expected': (
const.SERVICE_TURN_ON,
{'entity_id': 'light.test', 'brightness': 242}
) )
}, { # Test light color temperature REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf'
'entity_id': 'light.test',
'command': ga.const.COMMAND_COLOR,
'params': { async def test_sync_message(hass):
'color': { """Test a sync message."""
'temperature': 2300, light = DemoLight(
'name': 'warm white' 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']
} }
},
'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', result = await sh.async_handle_message(hass, config, {
'command': ga.const.COMMAND_COLOR, "requestId": REQ_ID,
'params': { "inputs": [{
'color': { "intent": "action.devices.SYNC"
'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'}
),
}] }]
})
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'
}]
}
}
@asyncio.coroutine async def test_query_message(hass):
def test_determine_service(): """Test a sync message."""
"""Test all branches of determine service.""" light = DemoLight(
for test in DETERMINE_SERVICE_TESTS: None, 'Demo Light',
result = ga.smart_home.determine_service( state=False,
test['entity_id'], rgb=[237, 224, 33]
test['command'], )
test['params'], light.hass = hass
test.get('units', METRIC_SYSTEM)) light.entity_id = 'light.demo_light'
assert result == test['expected'] 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"
}]
}
}

View File

@ -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
}

View File

@ -154,29 +154,21 @@ class TestDemoMediaPlayer(unittest.TestCase):
{'media_player': {'platform': 'demo'}}) {'media_player': {'platform': 'demo'}})
state = self.hass.states.get(entity_id) state = self.hass.states.get(entity_id)
assert 1 == state.attributes.get('media_track') 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) mp.media_next_track(self.hass, entity_id)
self.hass.block_till_done() self.hass.block_till_done()
state = self.hass.states.get(entity_id) state = self.hass.states.get(entity_id)
assert 2 == state.attributes.get('media_track') 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) mp.media_next_track(self.hass, entity_id)
self.hass.block_till_done() self.hass.block_till_done()
state = self.hass.states.get(entity_id) state = self.hass.states.get(entity_id)
assert 3 == state.attributes.get('media_track') 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) mp.media_previous_track(self.hass, entity_id)
self.hass.block_till_done() self.hass.block_till_done()
state = self.hass.states.get(entity_id) state = self.hass.states.get(entity_id)
assert 2 == state.attributes.get('media_track') assert 2 == state.attributes.get('media_track')
assert 0 < (mp.SUPPORT_PREVIOUS_TRACK &
state.attributes.get('supported_features'))
assert setup_component( assert setup_component(
self.hass, mp.DOMAIN, self.hass, mp.DOMAIN,
@ -184,22 +176,16 @@ class TestDemoMediaPlayer(unittest.TestCase):
ent_id = 'media_player.lounge_room' ent_id = 'media_player.lounge_room'
state = self.hass.states.get(ent_id) state = self.hass.states.get(ent_id)
assert 1 == state.attributes.get('media_episode') 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) mp.media_next_track(self.hass, ent_id)
self.hass.block_till_done() self.hass.block_till_done()
state = self.hass.states.get(ent_id) state = self.hass.states.get(ent_id)
assert 2 == state.attributes.get('media_episode') 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) mp.media_previous_track(self.hass, ent_id)
self.hass.block_till_done() self.hass.block_till_done()
state = self.hass.states.get(ent_id) state = self.hass.states.get(ent_id)
assert 1 == state.attributes.get('media_episode') 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.' @patch('homeassistant.components.media_player.demo.DemoYoutubePlayer.'
'media_seek', autospec=True) 'media_seek', autospec=True)