diff --git a/homeassistant/components/xiaomi_miio/services.py b/homeassistant/components/xiaomi_miio/services.py index 7ae0140202e8..e82ffa536f57 100644 --- a/homeassistant/components/xiaomi_miio/services.py +++ b/homeassistant/components/xiaomi_miio/services.py @@ -5,6 +5,8 @@ import logging import voluptuous as vol +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -53,21 +55,7 @@ SERVICE_GOTO = "vacuum_goto" # Light Services ATTR_TIME_PERIOD = "time_period" XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) -SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_SCENE): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=6))} -) -SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_TIME_PERIOD): cv.positive_time_period} -) LIGHT_SERVICE_TO_METHOD = { - SERVICE_SET_DELAYED_TURN_OFF: ServiceMethodDetails( - method="async_set_delayed_turn_off", - schema=SERVICE_SCHEMA_SET_DELAYED_TURN_OFF, - ), - SERVICE_SET_SCENE: ServiceMethodDetails( - method="async_set_scene", - schema=SERVICE_SCHEMA_SET_SCENE, - ), SERVICE_REMINDER_ON: ServiceMethodDetails(method="async_reminder_on"), SERVICE_REMINDER_OFF: ServiceMethodDetails(method="async_reminder_off"), SERVICE_NIGHT_LIGHT_MODE_ON: ServiceMethodDetails( @@ -86,20 +74,11 @@ SWITCH_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids} SWITCH_SERVICE_SCHEMA_POWER_MODE = SWITCH_SERVICE_SCHEMA.extend( {vol.Required(ATTR_MODE): vol.All(vol.In(["green", "normal"]))} ) -SWITCH_SERVICE_SCHEMA_POWER_PRICE = SWITCH_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_PRICE): cv.positive_float} -) SWITCH_SERVICE_TO_METHOD = { - SERVICE_SET_WIFI_LED_ON: ServiceMethodDetails(method="async_set_wifi_led_on"), - SERVICE_SET_WIFI_LED_OFF: ServiceMethodDetails(method="async_set_wifi_led_off"), SERVICE_SET_POWER_MODE: ServiceMethodDetails( method="async_set_power_mode", schema=SWITCH_SERVICE_SCHEMA_POWER_MODE, ), - SERVICE_SET_POWER_PRICE: ServiceMethodDetails( - method="async_set_power_price", - schema=SWITCH_SERVICE_SCHEMA_POWER_PRICE, - ), } # Fan Services @@ -125,6 +104,55 @@ def async_setup_services(hass: HomeAssistant) -> None: _async_setup_light_services(hass) _async_setup_switch_services(hass) + # Light Services + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_SCENE, + entity_domain=LIGHT_DOMAIN, + schema={ + vol.Required(ATTR_SCENE): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=6)) + }, + func="async_set_scene", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_DELAYED_TURN_OFF, + entity_domain=LIGHT_DOMAIN, + schema={vol.Required(ATTR_TIME_PERIOD): cv.positive_time_period}, + func="async_set_delayed_turn_off", + ) + + # Switch Services + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_WIFI_LED_ON, + entity_domain=SWITCH_DOMAIN, + schema=None, + func="async_set_wifi_led_on", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_WIFI_LED_OFF, + entity_domain=SWITCH_DOMAIN, + schema=None, + func="async_set_wifi_led_off", + ) + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_POWER_PRICE, + entity_domain=SWITCH_DOMAIN, + schema={vol.Required(ATTR_PRICE): cv.positive_float}, + func="async_set_power_price", + ) + # Vacuum Services service.async_register_platform_entity_service( hass, @@ -248,6 +276,7 @@ def _async_setup_light_services(hass: HomeAssistant) -> None: for target_device in target_devices: if not hasattr(target_device, method.method): continue + target_device.async_set_context(call.context) await getattr(target_device, method.method)(**params) update_tasks.append( asyncio.create_task(target_device.async_update_ha_state(True)) @@ -286,6 +315,7 @@ def _async_setup_switch_services(hass: HomeAssistant) -> None: for device in devices: if not hasattr(device, method.method): continue + device.async_set_context(call.context) await getattr(device, method.method)(**params) update_tasks.append(asyncio.create_task(device.async_update_ha_state(True))) @@ -324,6 +354,7 @@ def _async_setup_fan_services(hass: HomeAssistant) -> None: entity_method = getattr(entity, method.method, None) if not entity_method: continue + entity.async_set_context(call.context) await entity_method(**params) update_tasks.append(asyncio.create_task(entity.async_update_ha_state(True))) diff --git a/homeassistant/components/xiaomi_miio/services.yaml b/homeassistant/components/xiaomi_miio/services.yaml index 0b3bd6435e41..dd53e3f98df0 100644 --- a/homeassistant/components/xiaomi_miio/services.yaml +++ b/homeassistant/components/xiaomi_miio/services.yaml @@ -21,12 +21,11 @@ fan_set_extra_features: max: 1 light_set_scene: + target: + entity: + integration: xiaomi_miio + domain: light fields: - entity_id: - selector: - entity: - integration: xiaomi_miio - domain: light scene: required: true selector: @@ -35,12 +34,11 @@ light_set_scene: max: 6 light_set_delayed_turn_off: + target: + entity: + integration: xiaomi_miio + domain: light fields: - entity_id: - selector: - entity: - integration: xiaomi_miio - domain: light time_period: required: true example: "5, '0:05', {'minutes': 5}" @@ -128,28 +126,23 @@ remote_set_led_off: domain: remote switch_set_wifi_led_on: - fields: - entity_id: - selector: - entity: - integration: xiaomi_miio - domain: switch + target: + entity: + integration: xiaomi_miio + domain: switch switch_set_wifi_led_off: - fields: - entity_id: - selector: - entity: - integration: xiaomi_miio - domain: switch + target: + entity: + integration: xiaomi_miio + domain: switch switch_set_power_price: + target: + entity: + integration: xiaomi_miio + domain: switch fields: - entity_id: - selector: - entity: - integration: xiaomi_miio - domain: switch mode: required: true selector: diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index dd7781d97a45..0bd2bcec7af6 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -416,10 +416,6 @@ "light_set_delayed_turn_off": { "description": "Sets the delayed turning off of a light.", "fields": { - "entity_id": { - "description": "[%key:component::xiaomi_miio::services::light_set_scene::fields::entity_id::description%]", - "name": "Entity ID" - }, "time_period": { "description": "Time period for the delayed turning off.", "name": "Time period" @@ -430,10 +426,6 @@ "light_set_scene": { "description": "Sets a fixed scene.", "fields": { - "entity_id": { - "description": "Name of the light entity.", - "name": "Entity ID" - }, "scene": { "description": "Number of the fixed scene.", "name": "Scene" @@ -480,10 +472,6 @@ "switch_set_power_price": { "description": "Sets the power price.", "fields": { - "entity_id": { - "description": "[%key:component::xiaomi_miio::services::fan_reset_filter::fields::entity_id::description%]", - "name": "Entity ID" - }, "mode": { "description": "Power price.", "name": "[%key:common::config_flow::data::mode%]" @@ -493,22 +481,10 @@ }, "switch_set_wifi_led_off": { "description": "Turns off the Wi-Fi LED of a switch.", - "fields": { - "entity_id": { - "description": "[%key:component::xiaomi_miio::services::fan_reset_filter::fields::entity_id::description%]", - "name": "Entity ID" - } - }, "name": "Switch set Wi-Fi LED off" }, "switch_set_wifi_led_on": { "description": "Turns on the Wi-Fi LED of a switch.", - "fields": { - "entity_id": { - "description": "[%key:component::xiaomi_miio::services::fan_reset_filter::fields::entity_id::description%]", - "name": "Entity ID" - } - }, "name": "Switch set Wi-Fi LED on" }, "vacuum_clean_segment": { diff --git a/tests/components/xiaomi_miio/test_services.py b/tests/components/xiaomi_miio/test_services.py new file mode 100644 index 000000000000..898f66090182 --- /dev/null +++ b/tests/components/xiaomi_miio/test_services.py @@ -0,0 +1,139 @@ +"""Tests for the xiaomi_miio services.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from homeassistant.components.xiaomi_miio.const import ( + CONF_FLOW_TYPE, + DOMAIN, + SERVICE_EYECARE_MODE_ON, + SERVICE_SET_SCENE, +) +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_TOKEN, + Platform, +) +from homeassistant.core import Context, HomeAssistant + +from . import TEST_MAC + +from tests.common import MockConfigEntry + +CEILING_MODEL = "philips.light.ceiling" +EYECARE_MODEL = "philips.light.sread1" +CEILING_ENTITY_ID = "light.test_light" +EYECARE_ENTITY_ID = "light.test_light_eyecare" + + +@pytest.fixture(name="mock_light") +def mock_light_fixture() -> Generator[MagicMock]: + """Mock the light device.""" + status = Mock( + is_on=True, + brightness=50, + color_temperature=50, + scene=1, + delay_off_countdown=0, + smart_night_light=False, + eyecare=False, + reminder=False, + ambient=False, + ambient_brightness=0, + ) + mock_light = MagicMock() + mock_light.status = Mock(return_value=status) + # The entity is polled again after the service call, so let the mocked + # device apply the change to make the resulting state write observable + mock_light.set_scene = Mock( + side_effect=lambda scene: setattr(status, "scene", scene) + ) + mock_light.eyecare_on = Mock(side_effect=lambda: setattr(status, "eyecare", True)) + + with ( + patch( + "homeassistant.components.xiaomi_miio.get_platforms", + return_value=[Platform.LIGHT], + ), + patch( + "homeassistant.components.xiaomi_miio.light.Ceil", return_value=mock_light + ), + patch( + "homeassistant.components.xiaomi_miio.light.PhilipsEyecare", + return_value=mock_light, + ), + ): + yield mock_light + + +async def setup_light(hass: HomeAssistant, model: str, title: str) -> MockConfigEntry: + """Set up a xiaomi_miio light.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=f"123456-{model}", + title=title, + data={ + CONF_FLOW_TYPE: CONF_DEVICE, + CONF_HOST: "192.168.1.100", + CONF_TOKEN: "12345678901234567890123456789012", + CONF_MODEL: model, + CONF_MAC: TEST_MAC, + }, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return config_entry + + +@pytest.mark.usefixtures("mock_light") +async def test_entity_service_forwards_context( + hass: HomeAssistant, mock_light: MagicMock +) -> None: + """Test a service using the entity service helper attributes the caller.""" + await setup_light(hass, CEILING_MODEL, "Test Light") + + context = Context() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SCENE, + {"entity_id": CEILING_ENTITY_ID, "scene": 2}, + blocking=True, + context=context, + ) + + mock_light.set_scene.assert_called_once_with(2) + state = hass.states.get(CEILING_ENTITY_ID) + assert state.attributes["scene"] == 2 + assert state.context is context + + +@pytest.mark.usefixtures("mock_light") +async def test_legacy_service_forwards_context( + hass: HomeAssistant, mock_light: MagicMock +) -> None: + """Test a service kept in-line attributes the caller. + + These services cannot use the entity service helper because the entities + sharing the data key only partially implement the methods. + """ + await setup_light(hass, EYECARE_MODEL, "Test Light Eyecare") + + context = Context() + await hass.services.async_call( + DOMAIN, + SERVICE_EYECARE_MODE_ON, + {"entity_id": EYECARE_ENTITY_ID}, + blocking=True, + context=context, + ) + + mock_light.eyecare_on.assert_called_once_with() + state = hass.states.get(EYECARE_ENTITY_ID) + assert state.attributes["eyecare_mode"] is True + assert state.context is context