diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index c7014fd05f6..c8b108003b7 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -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 diff --git a/homeassistant/core.py b/homeassistant/core.py index f9333594c0e..f82bfd7b244 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -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 diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 1b83b2ee879..157d1e626df 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -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)) diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 7df35d9f3f8..1f6619c8b12 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -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) diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 10f352580db..d9343908f46 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -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):