From c377df6e95cc39ecc35e32c84471feff10ffbf55 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 09:10:34 +0000 Subject: [PATCH] Forward service call context to entities in xiaomi_miio services The light, switch and fan services were registered with hass.services.async_register and dispatched to entities by hand, so nothing set the context and the resulting state writes were not attributed to the caller. Five of them target methods that every entity in their data key implements, so they move to async_register_platform_entity_service, which sets the context and also applies the caller's entity permissions. Their entity_id field becomes a target. The other nine only exist on some of the entities sharing a data key, which the entity service helper cannot express, so they keep their current dispatch and set the context themselves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DQdoLT4SC1w5LUgDQA3vEq --- .../components/xiaomi_miio/services.py | 77 +++++++--- .../components/xiaomi_miio/services.yaml | 47 +++--- .../components/xiaomi_miio/strings.json | 24 --- tests/components/xiaomi_miio/test_services.py | 139 ++++++++++++++++++ 4 files changed, 213 insertions(+), 74 deletions(-) create mode 100644 tests/components/xiaomi_miio/test_services.py 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