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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQdoLT4SC1w5LUgDQA3vEq
This commit is contained in:
Claude
2026-08-19 09:12:00 +00:00
parent 8694fc917a
commit c377df6e95
4 changed files with 213 additions and 74 deletions
@@ -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)))
@@ -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:
@@ -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": {
@@ -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