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

View File

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

View File

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

View File

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

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.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(

View File

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

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 = {
'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)

View File

@ -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."""

View File

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

View File

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

View File

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

View File

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

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'}})
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)