mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
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:
parent
8792fd22b9
commit
9b1a75a74b
@ -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),
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
23
homeassistant/components/google_assistant/helpers.py
Normal file
23
homeassistant/components/google_assistant/helpers.py
Normal 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 {}
|
@ -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(
|
||||
|
@ -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}
|
||||
|
510
homeassistant/components/google_assistant/trait.py
Normal file
510
homeassistant/components/google_assistant/trait.py
Normal 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)
|
@ -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)
|
||||
|
@ -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."""
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
}]
|
||||
|
@ -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')
|
||||
|
@ -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"
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
569
tests/components/google_assistant/test_trait.py
Normal file
569
tests/components/google_assistant/test_trait.py
Normal 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
|
||||
}
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user