Merge pull request #1661 from jaharkes/service-call-validation

Service call validation
This commit is contained in:
Jan Harkes 2016-03-31 23:36:11 -04:00
commit b45bbbcecf
5 changed files with 85 additions and 96 deletions

View File

@ -8,6 +8,8 @@ import logging
import os
import csv
import voluptuous as vol
from homeassistant.components import (
group, discovery, wemo, wink, isy994,
zwave, insteon_hub, mysensors, tellstick, vera)
@ -18,7 +20,7 @@ from homeassistant.const import (
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
import homeassistant.util as util
import homeassistant.helpers.config_validation as cv
import homeassistant.util.color as color_util
@ -77,6 +79,37 @@ PROP_TO_ATTR = {
'xy_color': ATTR_XY_COLOR,
}
# Service call validation schemas
VALID_TRANSITION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900))
LIGHT_TURN_ON_SCHEMA = vol.Schema({
ATTR_ENTITY_ID: cv.entity_ids,
ATTR_PROFILE: str,
ATTR_TRANSITION: VALID_TRANSITION,
ATTR_BRIGHTNESS: cv.byte,
ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)),
vol.Coerce(tuple)),
ATTR_XY_COLOR: vol.All(vol.ExactSequence((cv.small_float, cv.small_float)),
vol.Coerce(tuple)),
ATTR_COLOR_TEMP: vol.All(int, vol.Range(min=154, max=500)),
ATTR_FLASH: [FLASH_SHORT, FLASH_LONG],
ATTR_EFFECT: [EFFECT_COLORLOOP, EFFECT_RANDOM, EFFECT_WHITE],
})
LIGHT_TURN_OFF_SCHEMA = vol.Schema({
ATTR_ENTITY_ID: cv.entity_ids,
ATTR_TRANSITION: VALID_TRANSITION,
})
LIGHT_TOGGLE_SCHEMA = vol.Schema({
ATTR_ENTITY_ID: cv.entity_ids,
ATTR_TRANSITION: VALID_TRANSITION,
})
PROFILE_SCHEMA = vol.Schema(
vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte))
)
_LOGGER = logging.getLogger(__name__)
@ -156,31 +189,22 @@ def setup(hass, config):
next(reader, None)
try:
for profile_id, color_x, color_y, brightness in reader:
profiles[profile_id] = (float(color_x), float(color_y),
int(brightness))
except ValueError:
# ValueError if not 4 values per row
# ValueError if convert to float/int failed
_LOGGER.error(
"Error parsing light profiles from %s", profile_path)
for rec in reader:
profile, color_x, color_y, brightness = PROFILE_SCHEMA(rec)
profiles[profile] = (color_x, color_y, brightness)
except vol.MultipleInvalid as ex:
_LOGGER.error("Error parsing light profile from %s: %s",
profile_path, ex)
return False
def handle_light_service(service):
"""Hande a turn light on or off service call."""
# Get and validate data
dat = service.data
# Get the validated data
params = service.data.copy()
# Convert the entity ids to valid light ids
target_lights = component.extract_from_service(service)
params = {}
transition = util.convert(dat.get(ATTR_TRANSITION), int)
if transition is not None:
params[ATTR_TRANSITION] = transition
params.pop(ATTR_ENTITY_ID, None)
service_fun = None
if service.service == SERVICE_TURN_OFF:
@ -198,63 +222,11 @@ def setup(hass, config):
return
# Processing extra data for turn light on request.
# We process the profile first so that we get the desired
# behavior that extra service data attributes overwrite
# profile values.
profile = profiles.get(dat.get(ATTR_PROFILE))
profile = profiles.get(params.pop(ATTR_PROFILE, None))
if profile:
*params[ATTR_XY_COLOR], params[ATTR_BRIGHTNESS] = profile
if ATTR_BRIGHTNESS in dat:
# We pass in the old value as the default parameter if parsing
# of the new one goes wrong.
params[ATTR_BRIGHTNESS] = util.convert(
dat.get(ATTR_BRIGHTNESS), int, params.get(ATTR_BRIGHTNESS))
if ATTR_XY_COLOR in dat:
try:
# xy_color should be a list containing 2 floats.
xycolor = dat.get(ATTR_XY_COLOR)
# Without this check, a xycolor with value '99' would work.
if not isinstance(xycolor, str):
params[ATTR_XY_COLOR] = [float(val) for val in xycolor]
except (TypeError, ValueError):
# TypeError if xy_color is not iterable
# ValueError if value could not be converted to float
pass
if ATTR_COLOR_TEMP in dat:
# color_temp should be an int of mireds value
colortemp = dat.get(ATTR_COLOR_TEMP)
# Without this check, a ctcolor with value '99' would work
# These values are based on Philips Hue, may need ajustment later
if isinstance(colortemp, int) and 154 <= colortemp <= 500:
params[ATTR_COLOR_TEMP] = colortemp
if ATTR_RGB_COLOR in dat:
try:
# rgb_color should be a list containing 3 ints
rgb_color = dat.get(ATTR_RGB_COLOR)
if len(rgb_color) == 3:
params[ATTR_RGB_COLOR] = [int(val) for val in rgb_color]
except (TypeError, ValueError):
# TypeError if rgb_color is not iterable
# ValueError if not all values can be converted to int
pass
if dat.get(ATTR_FLASH) in (FLASH_SHORT, FLASH_LONG):
params[ATTR_FLASH] = dat[ATTR_FLASH]
if dat.get(ATTR_EFFECT) in (EFFECT_COLORLOOP, EFFECT_WHITE,
EFFECT_RANDOM):
params[ATTR_EFFECT] = dat[ATTR_EFFECT]
params.setdefault(ATTR_XY_COLOR, profile[:2])
params.setdefault(ATTR_BRIGHTNESS, profile[2])
for light in target_lights:
light.turn_on(**params)
@ -267,13 +239,16 @@ def setup(hass, config):
descriptions = load_yaml_config_file(
os.path.join(os.path.dirname(__file__), 'services.yaml'))
hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_light_service,
descriptions.get(SERVICE_TURN_ON))
descriptions.get(SERVICE_TURN_ON),
schema=LIGHT_TURN_ON_SCHEMA)
hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_light_service,
descriptions.get(SERVICE_TURN_OFF))
descriptions.get(SERVICE_TURN_OFF),
schema=LIGHT_TURN_OFF_SCHEMA)
hass.services.register(DOMAIN, SERVICE_TOGGLE, handle_light_service,
descriptions.get(SERVICE_TOGGLE))
descriptions.get(SERVICE_TOGGLE),
schema=LIGHT_TOGGLE_SCHEMA)
return True

View File

@ -14,6 +14,8 @@ import threading
import time
from types import MappingProxyType
import voluptuous as vol
import homeassistant.helpers.temperature as temp_helper
import homeassistant.util as util
import homeassistant.util.dt as dt_util
@ -494,13 +496,14 @@ class StateMachine(object):
class Service(object):
"""Represents a callable service."""
__slots__ = ['func', 'description', 'fields']
__slots__ = ['func', 'description', 'fields', 'schema']
def __init__(self, func, description, fields):
def __init__(self, func, description, fields, schema):
"""Initialize a service."""
self.func = func
self.description = description or ''
self.fields = fields or {}
self.schema = schema
def as_dict(self):
"""Return dictionary representation of this service."""
@ -511,7 +514,14 @@ class Service(object):
def __call__(self, call):
"""Execute the service."""
self.func(call)
try:
if self.schema:
call.data = self.schema(call.data)
self.func(call)
except vol.MultipleInvalid as ex:
_LOGGER.error('Invalid service data for %s.%s: %s',
call.domain, call.service, ex)
# pylint: disable=too-few-public-methods
@ -560,16 +570,20 @@ class ServiceRegistry(object):
"""Test if specified service exists."""
return service in self._services.get(domain, [])
def register(self, domain, service, service_func, description=None):
# pylint: disable=too-many-arguments
def register(self, domain, service, service_func, description=None,
schema=None):
"""
Register a service.
Description is a dict containing key 'description' to describe
the service and a key 'fields' to describe the fields.
Schema is called to coerce and validate the service data.
"""
description = description or {}
service_obj = Service(service_func, description.get('description'),
description.get('fields', {}))
description.get('fields', {}), schema)
with self._lock:
if domain in self._services:
self._services[domain][service] = service_obj

View File

@ -12,6 +12,8 @@ PLATFORM_SCHEMA = vol.Schema({
vol.Required(CONF_PLATFORM): str,
}, extra=vol.ALLOW_EXTRA)
byte = vol.All(vol.Coerce(int), vol.Range(min=0, max=255))
small_float = vol.All(vol.Coerce(float), vol.Range(min=0, max=1))
latitude = vol.All(vol.Coerce(float), vol.Range(min=-90, max=90))
longitude = vol.All(vol.Coerce(float), vol.Range(min=-180, max=180))

View File

@ -170,8 +170,8 @@ class TestLight(unittest.TestCase):
light.turn_on(self.hass, dev1.entity_id,
transition=10, brightness=20)
light.turn_on(
self.hass, dev2.entity_id, rgb_color=[255, 255, 255])
light.turn_on(self.hass, dev3.entity_id, xy_color=[.4, .6])
self.hass, dev2.entity_id, rgb_color=(255, 255, 255))
light.turn_on(self.hass, dev3.entity_id, xy_color=(.4, .6))
self.hass.pool.block_till_done()
@ -182,10 +182,10 @@ class TestLight(unittest.TestCase):
data)
method, data = dev2.last_call('turn_on')
self.assertEquals(data[light.ATTR_RGB_COLOR], [255, 255, 255])
self.assertEquals(data[light.ATTR_RGB_COLOR], (255, 255, 255))
method, data = dev3.last_call('turn_on')
self.assertEqual({light.ATTR_XY_COLOR: [.4, .6]}, data)
self.assertEqual({light.ATTR_XY_COLOR: (.4, .6)}, data)
# One of the light profiles
prof_name, prof_x, prof_y, prof_bri = 'relax', 0.5119, 0.4147, 144
@ -195,23 +195,24 @@ class TestLight(unittest.TestCase):
# Specify a profile and attributes to overwrite it
light.turn_on(
self.hass, dev2.entity_id,
profile=prof_name, brightness=100, xy_color=[.4, .6])
profile=prof_name, brightness=100, xy_color=(.4, .6))
self.hass.pool.block_till_done()
method, data = dev1.last_call('turn_on')
self.assertEqual(
{light.ATTR_BRIGHTNESS: prof_bri,
light.ATTR_XY_COLOR: [prof_x, prof_y]},
light.ATTR_XY_COLOR: (prof_x, prof_y)},
data)
method, data = dev2.last_call('turn_on')
self.assertEqual(
{light.ATTR_BRIGHTNESS: 100,
light.ATTR_XY_COLOR: [.4, .6]},
light.ATTR_XY_COLOR: (.4, .6)},
data)
# Test shitty data
light.turn_on(self.hass)
light.turn_on(self.hass, dev1.entity_id, profile="nonexisting")
light.turn_on(self.hass, dev2.entity_id, xy_color=["bla-di-bla", 5])
light.turn_on(self.hass, dev3.entity_id, rgb_color=[255, None, 2])
@ -227,7 +228,7 @@ class TestLight(unittest.TestCase):
method, data = dev3.last_call('turn_on')
self.assertEqual({}, data)
# faulty attributes should not overwrite profile data
# faulty attributes will not trigger a service call
light.turn_on(
self.hass, dev1.entity_id,
profile=prof_name, brightness='bright', rgb_color='yellowish')
@ -235,10 +236,7 @@ class TestLight(unittest.TestCase):
self.hass.pool.block_till_done()
method, data = dev1.last_call('turn_on')
self.assertEqual(
{light.ATTR_BRIGHTNESS: prof_bri,
light.ATTR_XY_COLOR: [prof_x, prof_y]},
data)
self.assertEqual({}, data)
def test_broken_light_profiles(self):
"""Test light profiles."""
@ -280,5 +278,5 @@ class TestLight(unittest.TestCase):
method, data = dev1.last_call('turn_on')
self.assertEqual(
{light.ATTR_XY_COLOR: [.4, .6], light.ATTR_BRIGHTNESS: 100},
{light.ATTR_XY_COLOR: (.4, .6), light.ATTR_BRIGHTNESS: 100},
data)

View File

@ -305,7 +305,7 @@ class TestLightMQTT(unittest.TestCase):
state = self.hass.states.get('light.test')
self.assertEqual(STATE_ON, state.state)
self.assertEqual([75, 75, 75], state.attributes['rgb_color'])
self.assertEqual((75, 75, 75), state.attributes['rgb_color'])
self.assertEqual(50, state.attributes['brightness'])
def test_show_brightness_if_only_command_topic(self):