mirror of
https://github.com/home-assistant/core.git
synced 2026-08-19 21:01:05 +01:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user