mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-27 12:24:19 +02:00
Register service actions in async_setup for Bronze tier compliance (#1403)
* Register service actions in async_setup for Bronze tier compliance - Move 'apply' and 'set_manual_control' service registration from async_setup_entry to async_setup. - Move service handlers to module-level functions in switch.py. - Update apply_service_schema to support dynamic defaults for transition duration. - Clean up related unused imports and fix Python 3.10 syntax compatibility. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Clean up and add tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix lint errors * Automated update of generated docs * Re-add types * Make transition not required again * fix: validate global service targets * fix: document optional apply transition * fix: derive service docs from schema markers * docs: clarify service target options * fix: preserve entity service target handling --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt <bas@nijho.lt>
This commit is contained in:
parent
ac0336fe34
commit
623dd65aef
11 changed files with 483 additions and 164 deletions
|
|
@ -1,12 +1,21 @@
|
|||
"""Tests for Adaptive Lighting integration."""
|
||||
|
||||
import pytest
|
||||
import voluptuous.error
|
||||
from homeassistant.components import adaptive_lighting
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
CONF_LIGHTS,
|
||||
DEFAULT_NAME,
|
||||
SERVICE_APPLY,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
UNDO_UPDATE_LISTENER,
|
||||
)
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import service
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
|
@ -53,3 +62,126 @@ async def test_unload_entry(hass):
|
|||
|
||||
assert entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert adaptive_lighting.DOMAIN not in hass.data
|
||||
|
||||
|
||||
async def test_services_survive_entry_unload_and_reload(hass):
|
||||
"""Test integration services remain registered across entry lifecycle."""
|
||||
assert await async_setup_component(hass, adaptive_lighting.DOMAIN, {})
|
||||
service_names = (
|
||||
SERVICE_APPLY,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
)
|
||||
services = hass.services.async_services()[adaptive_lighting.DOMAIN]
|
||||
assert SERVICE_APPLY in services
|
||||
assert SERVICE_SET_MANUAL_CONTROL in services
|
||||
if hasattr(service, "async_register_platform_entity_service"):
|
||||
assert SERVICE_CHANGE_SWITCH_SETTINGS in services
|
||||
else:
|
||||
assert SERVICE_CHANGE_SWITCH_SETTINGS not in services
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=adaptive_lighting.DOMAIN,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
registered = {
|
||||
name: hass.services.async_services()[adaptive_lighting.DOMAIN][name]
|
||||
for name in service_names
|
||||
}
|
||||
switch = hass.data[adaptive_lighting.DOMAIN][entry.entry_id][SWITCH_DOMAIN]
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
|
||||
for name in service_names:
|
||||
assert (
|
||||
hass.services.async_services()[adaptive_lighting.DOMAIN][name]
|
||||
is registered[name]
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="No Adaptive Lighting"):
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_APPLY,
|
||||
{ATTR_ENTITY_ID: switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
for name in service_names:
|
||||
assert (
|
||||
hass.services.async_services()[adaptive_lighting.DOMAIN][name]
|
||||
is registered[name]
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
{ATTR_ENTITY_ID: switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_service_call_without_loaded_entry(hass):
|
||||
"""Test global services reject calls when no profile is loaded."""
|
||||
assert await async_setup_component(hass, adaptive_lighting.DOMAIN, {})
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="No Adaptive Lighting"):
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_APPLY,
|
||||
{CONF_LIGHTS: ["light.test"]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
pending_entry = MockConfigEntry(
|
||||
domain=adaptive_lighting.DOMAIN,
|
||||
data={CONF_NAME: "pending"},
|
||||
)
|
||||
pending_entry.add_to_hass(hass)
|
||||
hass.data[adaptive_lighting.DOMAIN] = {pending_entry.entry_id: {}}
|
||||
with pytest.raises(ServiceValidationError, match="not found in any switch"):
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_APPLY,
|
||||
{CONF_LIGHTS: ["light.test"]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_apply_rejects_unknown_light(hass):
|
||||
"""Test the apply service rejects an unknown light target."""
|
||||
entry = MockConfigEntry(
|
||||
domain=adaptive_lighting.DOMAIN,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
with pytest.raises(ServiceValidationError, match="not found in any switch"):
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_APPLY,
|
||||
{CONF_LIGHTS: ["light.does_not_exist"]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_change_switch_settings_requires_entity_target(hass):
|
||||
"""Test change_switch_settings rejects a missing entity target."""
|
||||
entry = MockConfigEntry(
|
||||
domain=adaptive_lighting.DOMAIN,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
with pytest.raises(
|
||||
voluptuous.error.MultipleInvalid,
|
||||
match=r"must contain at least one of entity_id.*area_id",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
adaptive_lighting.DOMAIN,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
{},
|
||||
blocking=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ from homeassistant.components.template.light import StateLightEntity as LightTem
|
|||
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_AREA_ID,
|
||||
ATTR_DEVICE_ID,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_LIGHTS,
|
||||
|
|
@ -108,6 +109,7 @@ from homeassistant.const import (
|
|||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import Context, Event, HomeAssistant, State
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
from homeassistant.helpers import area_registry as ar
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers.entity_platform import async_get_platforms
|
||||
|
|
@ -1488,6 +1490,54 @@ async def test_apply_service(hass):
|
|||
assert old_state[ATTR_COLOR_TEMP_KELVIN] == new_state[ATTR_COLOR_TEMP_KELVIN]
|
||||
|
||||
|
||||
async def test_apply_service_uses_each_switch_transition(hass):
|
||||
"""Test global apply resolves omitted transition for each profile."""
|
||||
await setup_lights(hass)
|
||||
_, switch_1 = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "switch 1",
|
||||
CONF_LIGHTS: [ENTITY_LIGHT_1],
|
||||
CONF_INITIAL_TRANSITION: 3,
|
||||
},
|
||||
)
|
||||
_, switch_2 = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "switch 2",
|
||||
CONF_LIGHTS: [ENTITY_LIGHT_2],
|
||||
CONF_INITIAL_TRANSITION: 7,
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(switch_1, "_adapt_light", new=AsyncMock()) as adapt_1,
|
||||
patch.object(switch_2, "_adapt_light", new=AsyncMock()) as adapt_2,
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_APPLY,
|
||||
{ATTR_ENTITY_ID: [switch_1.entity_id, switch_2.entity_id]},
|
||||
blocking=True,
|
||||
)
|
||||
assert adapt_1.await_args.kwargs["transition"] == 3
|
||||
assert adapt_2.await_args.kwargs["transition"] == 7
|
||||
|
||||
adapt_1.reset_mock()
|
||||
adapt_2.reset_mock()
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_APPLY,
|
||||
{
|
||||
ATTR_ENTITY_ID: [switch_1.entity_id, switch_2.entity_id],
|
||||
CONF_TRANSITION: 0,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
assert adapt_1.await_args.kwargs["transition"] == 0
|
||||
assert adapt_2.await_args.kwargs["transition"] == 0
|
||||
|
||||
|
||||
async def test_switch_off_on_off(hass):
|
||||
"""Test switch rapid off_on_off."""
|
||||
|
||||
|
|
@ -1837,10 +1887,16 @@ def test_is_our_context():
|
|||
|
||||
async def test_unload_switch(hass):
|
||||
"""Test removing Adaptive Lighting."""
|
||||
entry, _ = await setup_switch(hass, {})
|
||||
entry, switch = await setup_switch(hass, {})
|
||||
switch.manager.set_auto_reset_manual_control_times([ENTITY_LIGHT_1], 60)
|
||||
switch.manager.set_manual_control_attributes(ENTITY_LIGHT_1)
|
||||
timer = switch.manager.auto_reset_manual_control_timers[ENTITY_LIGHT_1]
|
||||
assert timer.is_running()
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert DOMAIN not in hass.data
|
||||
assert not timer.is_running()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", [STATE_ON, STATE_OFF, None])
|
||||
|
|
@ -2152,6 +2208,81 @@ async def test_change_switch_settings_service(hass):
|
|||
assert switch._sun_light_settings.min_color_temp == 2500
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["entity", "area", "device", "all"])
|
||||
async def test_change_switch_settings_entity_targets(hass, device_registry, target):
|
||||
"""Test settings changes through Home Assistant entity targets."""
|
||||
_, switch = await setup_switch(hass, {})
|
||||
mock_area_registry(hass)
|
||||
registry_entry = entity_registry.async_get(hass).async_get(switch.entity_id)
|
||||
assert registry_entry is not None
|
||||
assert registry_entry.device_id is not None
|
||||
device_registry.async_update_device(
|
||||
registry_entry.device_id,
|
||||
area_id="test-area",
|
||||
)
|
||||
service_data = {
|
||||
"entity": {ATTR_ENTITY_ID: switch.entity_id},
|
||||
"area": {ATTR_AREA_ID: "test-area"},
|
||||
"device": {ATTR_DEVICE_ID: registry_entry.device_id},
|
||||
"all": {ATTR_ENTITY_ID: "all"},
|
||||
}[target]
|
||||
|
||||
with patch.object(
|
||||
switch,
|
||||
"_set_changeable_settings",
|
||||
wraps=switch._set_changeable_settings,
|
||||
) as set_settings:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
{**service_data, CONF_MAX_BRIGHTNESS: 50},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
set_settings.assert_called_once()
|
||||
assert switch._sun_light_settings.max_brightness == 50
|
||||
|
||||
|
||||
async def test_change_switch_settings_ignores_unknown_entity(hass):
|
||||
"""Test an unknown entity target does not change a loaded profile."""
|
||||
_, switch = await setup_switch(hass, {})
|
||||
|
||||
with patch.object(switch, "_set_changeable_settings") as set_settings:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
{
|
||||
ATTR_ENTITY_ID: "switch.does_not_exist",
|
||||
CONF_MAX_BRIGHTNESS: 50,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
set_settings.assert_not_called()
|
||||
|
||||
|
||||
async def test_change_switch_settings_checks_entity_permissions(
|
||||
hass,
|
||||
hass_read_only_user,
|
||||
):
|
||||
"""Test settings changes require permission to control the target entity."""
|
||||
_, switch = await setup_switch(hass, {})
|
||||
|
||||
with pytest.raises(Unauthorized):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
{
|
||||
ATTR_ENTITY_ID: switch.entity_id,
|
||||
CONF_MAX_BRIGHTNESS: 50,
|
||||
},
|
||||
blocking=True,
|
||||
context=Context(user_id=hass_read_only_user.id),
|
||||
)
|
||||
|
||||
assert switch._sun_light_settings.max_brightness == DEFAULT_MAX_BRIGHTNESS
|
||||
|
||||
|
||||
async def test_cancellable_service_calls_task(hass):
|
||||
"""Test the creation and execution of the task that wraps adaptation service calls."""
|
||||
light, *_ = await setup_lights(hass)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue