From c85a7934ed5cc28282be678b511af1cd93a34727 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Feb 2020 16:13:29 -0800 Subject: [PATCH] Add brightness_step to light.turn_on (#31452) * Clean up light turn on service * Add brightness_step to turn_on schema * Fix import * Fix imports 2 * Fix RFLink test --- homeassistant/components/light/__init__.py | 111 +++++++++--------- homeassistant/components/light/intent.py | 2 +- homeassistant/components/light/services.yaml | 6 + homeassistant/components/xiaomi_miio/light.py | 3 +- tests/components/light/test_init.py | 34 ++++++ tests/components/rflink/test_light.py | 18 ++- .../custom_components/test/light.py | 14 ++- 7 files changed, 115 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 791f7328cf8..5b9b923cc56 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -1,5 +1,4 @@ """Provides functionality to interact with lights.""" -import asyncio import csv from datetime import timedelta import logging @@ -8,15 +7,12 @@ from typing import Dict, Optional, Tuple import voluptuous as vol -from homeassistant.auth.permissions.const import POLICY_CONTROL from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.exceptions import Unauthorized, UnknownUser import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -61,6 +57,8 @@ ATTR_WHITE_VALUE = "white_value" # Brightness of the light, 0..255 or percentage ATTR_BRIGHTNESS = "brightness" ATTR_BRIGHTNESS_PCT = "brightness_pct" +ATTR_BRIGHTNESS_STEP = "brightness_step" +ATTR_BRIGHTNESS_STEP_PCT = "brightness_step_pct" # String representing a profile (built-in ones or external defined). ATTR_PROFILE = "profile" @@ -87,12 +85,16 @@ LIGHT_PROFILES_FILE = "light_profiles.csv" VALID_TRANSITION = vol.All(vol.Coerce(float), vol.Clamp(min=0, max=6553)) VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)) VALID_BRIGHTNESS_PCT = vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) +VALID_BRIGHTNESS_STEP = vol.All(vol.Coerce(int), vol.Clamp(min=-255, max=255)) +VALID_BRIGHTNESS_STEP_PCT = vol.All(vol.Coerce(float), vol.Clamp(min=-100, max=100)) LIGHT_TURN_ON_SCHEMA = { vol.Exclusive(ATTR_PROFILE, COLOR_GROUP): cv.string, ATTR_TRANSITION: VALID_TRANSITION, - ATTR_BRIGHTNESS: VALID_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT, + vol.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS, + vol.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT, + vol.Exclusive(ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP, + vol.Exclusive(ATTR_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP_PCT, vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string, vol.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP): vol.All( vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple) @@ -169,7 +171,7 @@ def preprocess_turn_off(params): """Process data for turning light off if brightness is 0.""" if ATTR_BRIGHTNESS in params and params[ATTR_BRIGHTNESS] == 0: # Zero brightness: Light will be turned off - params = {k: v for k, v in params.items() if k in [ATTR_TRANSITION, ATTR_FLASH]} + params = {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)} return (True, params) # Light should be turned off return (False, None) # Light should be turned on @@ -187,70 +189,65 @@ async def async_setup(hass, config): if not profiles_valid: return False - async def async_handle_light_on_service(service): - """Handle a turn light on service call.""" - # Get the validated data - params = service.data.copy() + def preprocess_data(data): + """Preprocess the service data.""" + base = {} - # Convert the entity ids to valid light ids - target_lights = await component.async_extract_from_service(service) - params.pop(ATTR_ENTITY_ID, None) + for entity_field in cv.ENTITY_SERVICE_FIELDS: + if entity_field in data: + base[entity_field] = data.pop(entity_field) - if service.context.user_id: - user = await hass.auth.async_get_user(service.context.user_id) - if user is None: - raise UnknownUser(context=service.context) + preprocess_turn_on_alternatives(data) + turn_lights_off, off_params = preprocess_turn_off(data) - entity_perms = user.permissions.check_entity + base["params"] = data + base["turn_lights_off"] = turn_lights_off + base["off_params"] = off_params - for light in target_lights: - if not entity_perms(light, POLICY_CONTROL): - raise Unauthorized( - context=service.context, - entity_id=light, - permission=POLICY_CONTROL, - ) + return base - preprocess_turn_on_alternatives(params) - turn_lights_off, off_params = preprocess_turn_off(params) + async def async_handle_light_on_service(light, call): + """Handle turning a light on. - poll_lights = [] - change_tasks = [] - for light in target_lights: - light.async_set_context(service.context) + If brightness is set to 0, this service will turn the light off. + """ + params = call.data["params"] + turn_light_off = call.data["turn_lights_off"] + off_params = call.data["off_params"] + + if not params: + default_profile = Profiles.get_default(light.entity_id) + + if default_profile is not None: + params = {ATTR_PROFILE: default_profile} + preprocess_turn_on_alternatives(params) + turn_light_off, off_params = preprocess_turn_off(params) + + elif ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params: + brightness = light.brightness if light.is_on else 0 + + params = params.copy() + + if ATTR_BRIGHTNESS_STEP in params: + brightness += params.pop(ATTR_BRIGHTNESS_STEP) - pars = params - off_pars = off_params - turn_light_off = turn_lights_off - if not pars: - pars = params.copy() - pars[ATTR_PROFILE] = Profiles.get_default(light.entity_id) - preprocess_turn_on_alternatives(pars) - turn_light_off, off_pars = preprocess_turn_off(pars) - if turn_light_off: - task = light.async_request_call(light.async_turn_off(**off_pars)) else: - task = light.async_request_call(light.async_turn_on(**pars)) + brightness += int(params.pop(ATTR_BRIGHTNESS_STEP_PCT) / 100 * 255) - change_tasks.append(task) + params[ATTR_BRIGHTNESS] = max(0, min(255, brightness)) + turn_light_off, off_params = preprocess_turn_off(params) - if light.should_poll: - poll_lights.append(light) - - if change_tasks: - await asyncio.wait(change_tasks) - - if poll_lights: - await asyncio.wait( - [light.async_update_ha_state(True) for light in poll_lights] - ) + if turn_light_off: + await light.async_turn_off(**off_params) + else: + await light.async_turn_on(**params) # Listen for light on and light off service calls. - hass.services.async_register( - DOMAIN, + + component.async_register_entity_service( SERVICE_TURN_ON, + vol.All(cv.make_entity_service_schema(LIGHT_TURN_ON_SCHEMA), preprocess_data), async_handle_light_on_service, - schema=cv.make_entity_service_schema(LIGHT_TURN_ON_SCHEMA), ) component.async_register_entity_service( diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index ea8899c44fc..c172ac1330a 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -1,6 +1,7 @@ """Intents for the light integration.""" import voluptuous as vol +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv @@ -8,7 +9,6 @@ import homeassistant.util.color as color_util from . import ( ATTR_BRIGHTNESS_PCT, - ATTR_ENTITY_ID, ATTR_RGB_COLOR, DOMAIN, SERVICE_TURN_ON, diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 449e5ea5aaf..a2b71f5632b 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -36,6 +36,12 @@ turn_on: brightness_pct: description: Number between 0..100 indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. example: 47 + brightness_step: + description: Change brightness by an amount. Should be between -255..255. + example: -25.5 + brightness_step_pct: + description: Change brightness by a percentage. Should be between -100..100. + example: -10 profile: description: Name of a light profile to use. example: relax diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index bcc83bae454..61462bcdbc0 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -19,7 +19,6 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, - ATTR_ENTITY_ID, ATTR_HS_COLOR, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, @@ -27,7 +26,7 @@ from homeassistant.components.light import ( SUPPORT_COLOR_TEMP, Light, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.util import color, dt diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 676fa4ec849..49bc626a957 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -462,3 +462,37 @@ async def test_light_turn_on_auth(hass, hass_admin_user): True, core.Context(user_id=hass_admin_user.id), ) + + +async def test_light_brightness_step(hass): + """Test that light context works.""" + platform = getattr(hass.components, "test.light") + platform.init() + entity = platform.ENTITIES[0] + entity.supported_features = light.SUPPORT_BRIGHTNESS + entity.brightness = 100 + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.attributes["brightness"] == 100 + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity.entity_id, "brightness_step": -10}, + True, + ) + + _, data = entity.last_call("turn_on") + assert data["brightness"] == 90, data + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity.entity_id, "brightness_step_pct": 10}, + True, + ) + + _, data = entity.last_call("turn_on") + assert data["brightness"] == 125, data diff --git a/tests/components/rflink/test_light.py b/tests/components/rflink/test_light.py index 970c532f22e..5dc06b5b2ff 100644 --- a/tests/components/rflink/test_light.py +++ b/tests/components/rflink/test_light.py @@ -298,18 +298,16 @@ async def test_signal_repetitions_cancelling(hass, monkeypatch): DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DOMAIN + ".test"} ) - hass.async_create_task( - hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: DOMAIN + ".test"} - ) + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: DOMAIN + ".test"}, blocking=True ) - await hass.async_block_till_done() - - assert protocol.send_command_ack.call_args_list[0][0][1] == "off" - assert protocol.send_command_ack.call_args_list[1][0][1] == "on" - assert protocol.send_command_ack.call_args_list[2][0][1] == "on" - assert protocol.send_command_ack.call_args_list[3][0][1] == "on" + assert [call[0][1] for call in protocol.send_command_ack.call_args_list] == [ + "off", + "on", + "on", + "on", + ] async def test_type_toggle(hass, monkeypatch): diff --git a/tests/testing_config/custom_components/test/light.py b/tests/testing_config/custom_components/test/light.py index 4b018adb5cb..d3f96c367d8 100644 --- a/tests/testing_config/custom_components/test/light.py +++ b/tests/testing_config/custom_components/test/light.py @@ -3,6 +3,7 @@ Provide a mock light platform. Call init before using it in your tests to ensure clean test data. """ +from homeassistant.components.light import Light from homeassistant.const import STATE_OFF, STATE_ON from tests.common import MockToggleEntity @@ -18,9 +19,9 @@ def init(empty=False): [] if empty else [ - MockToggleEntity("Ceiling", STATE_ON), - MockToggleEntity("Ceiling", STATE_OFF), - MockToggleEntity(None, STATE_OFF), + MockLight("Ceiling", STATE_ON), + MockLight("Ceiling", STATE_OFF), + MockLight(None, STATE_OFF), ] ) @@ -30,3 +31,10 @@ async def async_setup_platform( ): """Return mock entities.""" async_add_entities_callback(ENTITIES) + + +class MockLight(MockToggleEntity, Light): + """Mock light class.""" + + brightness = None + supported_features = 0