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:
Adam DeMuri 2026-09-06 05:04:18 -06:00 committed by GitHub
commit 623dd65aef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 483 additions and 164 deletions

View file

@ -202,6 +202,7 @@ adaptive_lighting:
#### `adaptive_lighting.apply`
`adaptive_lighting.apply` applies Adaptive Lighting settings to lights on demand.
Provide a switch in `entity_id`, a list of `lights`, or both.
<!-- CODE:START -->
<!-- from adaptive_lighting._docs_helpers import generate_apply_markdown_table -->
@ -212,7 +213,7 @@ adaptive_lighting:
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
| Service data attribute | Description | Required | Type |
|:-------------------------|:--------------------------------------------------------------------------------------|:-----------|:---------------------|
| `entity_id` | The `entity_id` of the switch with the settings to apply. 📝 | | list of `entity_id`s |
| `entity_id` | The `entity_id` of the switch with the settings to apply. 📝 | | list of `entity_id`s |
| `lights` | A light (or list of lights) to apply the settings to. 💡 | ❌ | list of `entity_id`s |
| `transition` | Duration of transition when lights change, in seconds. 🕑 | ❌ | `float` 0-6553 |
| `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool |
@ -224,6 +225,7 @@ adaptive_lighting:
#### `adaptive_lighting.set_manual_control`
`adaptive_lighting.set_manual_control` can mark (or unmark) whether a light is "manually controlled", meaning that when a light has `manual_control`, the light is not adapted.
Provide a switch in `entity_id`, a list of `lights`, or both.
<!-- CODE:START -->
<!-- from adaptive_lighting._docs_helpers import generate_set_manual_control_markdown_table -->
@ -234,7 +236,7 @@ adaptive_lighting:
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
| Service data attribute | Description | Required | Type |
|:-------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|:-----------------------------------------|
| `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | | list of `entity_id`s |
| `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | | list of `entity_id`s |
| `lights` | entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 | ❌ | list of `entity_id`s |
| `manual_control` | Whether to add ("true") or remove ("false") all adapted attributes of the light from the "manual_control" list, or the name of an attribute for selective addition. 🔒 | ❌ | bool or one of `['brightness', 'color']` |

View file

@ -1,20 +1,33 @@
"""Adaptive Lighting integration in Home-Assistant."""
import logging
from functools import partial
from typing import Any
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_SOURCE
from homeassistant.const import CONF_SOURCE, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import service
from .const import (
_DOMAIN_SCHEMA, # pyright: ignore[reportPrivateUsage]
ATTR_ADAPTIVE_LIGHTING_MANAGER,
CONF_NAME,
DOMAIN,
SERVICE_APPLY,
SERVICE_CHANGE_SWITCH_SETTINGS,
SERVICE_SET_MANUAL_CONTROL,
SET_MANUAL_CONTROL_SCHEMA,
UNDO_UPDATE_LISTENER,
apply_service_schema,
change_switch_settings_schema,
)
from .switch import (
handle_apply_service,
handle_change_switch_settings,
handle_set_manual_control_service,
)
_LOGGER = logging.getLogger(__name__)
@ -47,6 +60,34 @@ async def reload_configuration_yaml(event: Event) -> None:
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
"""Import integration from config."""
hass.services.async_register(
domain=DOMAIN,
service=SERVICE_APPLY,
service_func=partial(handle_apply_service, hass),
schema=apply_service_schema(),
)
hass.services.async_register(
domain=DOMAIN,
service=SERVICE_SET_MANUAL_CONTROL,
service_func=partial(handle_set_manual_control_service, hass),
schema=SET_MANUAL_CONTROL_SCHEMA,
)
if register_platform_service := getattr(
service,
"async_register_platform_entity_service",
None,
):
register_platform_service(
hass,
DOMAIN,
SERVICE_CHANGE_SWITCH_SETTINGS,
entity_domain=Platform.SWITCH,
func=handle_change_switch_settings,
schema=change_switch_settings_schema(),
)
if DOMAIN in config:
for entry in config[DOMAIN]:
hass.async_create_task(

View file

@ -74,22 +74,21 @@ def generate_config_markdown_table() -> str:
return df.to_markdown(index=False)
def _schema_to_dict(schema: vol.Schema) -> dict[str, tuple[Any, Any]]:
result: dict[str, tuple[Any, Any]] = {}
def _schema_to_dict(schema: vol.Schema) -> dict[str, tuple[bool, Any]]:
result: dict[str, tuple[bool, Any]] = {}
for key, value in schema.schema.items():
if isinstance(key, vol.Optional):
default_value = key.default
result[key.schema] = (default_value, value)
if isinstance(key, vol.Required | vol.Optional):
required = isinstance(key, vol.Required) and key.default == vol.UNDEFINED
result[key.schema] = (required, value)
return result
def _generate_service_markdown_table(
schema: dict[str, tuple[Any, Any]] | vol.Schema,
schema: vol.Schema,
alternative_docs: dict[str, str] | None = None,
) -> str:
schema_dict = _schema_to_dict(schema) if isinstance(schema, vol.Schema) else schema
rows: list[dict[str, str]] = []
for k, (default, type_) in schema_dict.items():
for k, (required, type_) in _schema_to_dict(schema).items():
if alternative_docs is not None and k in alternative_docs:
description = alternative_docs[k]
else:
@ -97,7 +96,7 @@ def _generate_service_markdown_table(
row = {
"Service data attribute": f"`{k}`",
"Description": description,
"Required": "" if default == vol.UNDEFINED else "",
"Required": "" if required else "",
"Type": _type_to_str(type_),
}
rows.append(row)

View file

@ -473,16 +473,13 @@ _DOMAIN_SCHEMA = vol.Schema(
)
def apply_service_schema(initial_transition: int = 1) -> vol.Schema:
def apply_service_schema() -> vol.Schema:
"""Return the schema for the apply service."""
return vol.Schema(
{
vol.Optional(CONF_ENTITY_ID): cv.entity_ids, # type: ignore[arg-type]
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, # type: ignore[arg-type]
vol.Optional(
CONF_TRANSITION,
default=initial_transition,
): VALID_TRANSITION,
vol.Optional(CONF_TRANSITION): VALID_TRANSITION,
vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean,
vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean,
vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean,
@ -491,6 +488,19 @@ def apply_service_schema(initial_transition: int = 1) -> vol.Schema:
)
def change_switch_settings_schema() -> dict[vol.Marker, Any]:
"""Return the schema for the change_switch_settings service."""
args: dict[vol.Marker, Any] = {
vol.Optional(CONF_USE_DEFAULTS, default="current"): cv.string,
}
# Modifying these after init isn't possible
skip = (CONF_INTERVAL, CONF_NAME, CONF_LIGHTS)
for k, _, valid in VALIDATION_TUPLES:
if k not in skip:
args[vol.Optional(k)] = valid
return args
SET_MANUAL_CONTROL_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ENTITY_ID): cv.entity_ids, # type: ignore[arg-type]

View file

@ -64,13 +64,11 @@ set_manual_control:
boolean: null
change_switch_settings:
description: Change any settings you'd like in the switch. All options here are the same as in the config flow.
target:
entity:
integration: adaptive_lighting
domain: switch
fields:
entity_id:
description: Entity ID of the switch. 📝
required: true
selector:
entity:
domain: switch
use_defaults:
description: 'Sets the default values not specified in this service call. Options: "current" (default, retains current values), "factory" (resets to documented defaults), or "configuration" (reverts to switch config defaults). ⚙️'
example: current

View file

@ -156,10 +156,6 @@
"name": "change_switch_settings",
"description": "Change any settings you'd like in the switch. All options here are the same as in the config flow.",
"fields": {
"entity_id": {
"description": "Entity ID of the switch. 📝",
"name": "entity_id"
},
"use_defaults": {
"description": "Sets the default values not specified in this service call. Options: \"current\" (default, retains current values), \"factory\" (resets to documented defaults), or \"configuration\" (reverts to switch config defaults). ⚙️",
"name": "use_defaults"

View file

@ -14,7 +14,6 @@ from typing import TYPE_CHECKING, Any
import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
import ulid_transform
import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
@ -58,7 +57,8 @@ from homeassistant.core import (
State,
callback,
)
from homeassistant.helpers import entity_platform, entity_registry
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_platform, entity_registry, service
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.helpers.event import (
@ -139,15 +139,12 @@ from .const import (
ICON_COLOR_TEMP,
ICON_MAIN,
ICON_SLEEP,
SERVICE_APPLY,
SERVICE_CHANGE_SWITCH_SETTINGS,
SERVICE_SET_MANUAL_CONTROL,
SET_MANUAL_CONTROL_SCHEMA,
SLEEP_MODE_SWITCH,
TURNING_OFF_DELAY,
VALIDATION_TUPLES,
TakeOverControlMode,
apply_service_schema,
change_switch_settings_schema,
replace_none_str,
)
from .hass_utils import area_entities, setup_service_call_interceptor
@ -164,7 +161,7 @@ if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import NoEventData, VolDictType
from homeassistant.helpers.typing import NoEventData
try:
from homeassistant.helpers.sun import get_astral_observer
@ -243,16 +240,22 @@ def _switches_with_lights(
) -> AdaptiveSwitches:
"""Get all switches that control at least one of the lights passed."""
config_entries = hass.config_entries.async_entries(DOMAIN)
data = hass.data[DOMAIN]
switches: AdaptiveSwitches = []
data = hass.data.get(DOMAIN, {})
loaded_switches: AdaptiveSwitches = []
for config in config_entries:
entry = data.get(config.entry_id)
if not isinstance(entry, dict) or SWITCH_DOMAIN not in entry:
continue
loaded_switches.append(entry[SWITCH_DOMAIN])
if not loaded_switches:
return []
all_check_lights = (
_expand_light_groups(hass, lights) if expand_light_groups else set(lights)
)
for config in config_entries:
entry = data.get(config.entry_id)
if entry is None: # entry might be disabled and therefore missing
continue
switch = data[config.entry_id][SWITCH_DOMAIN]
switches: AdaptiveSwitches = []
for switch in loaded_switches:
switch._expand_light_groups(hass=hass)
# Check if any of the lights are in the switch's lights
if set(switch.lights) & set(all_check_lights):
@ -299,7 +302,7 @@ def _switches_from_service_call(
service_call: ServiceCall,
) -> AdaptiveSwitches:
data = service_call.data
lights = data[CONF_LIGHTS]
lights = data.get(CONF_LIGHTS)
switch_entity_ids: list[str] | None = data.get("entity_id")
if not lights and not switch_entity_ids:
@ -310,7 +313,12 @@ def _switches_from_service_call(
" use case. Currently, you must pass either an adaptive-lighting switch or"
" the lights to an `adaptive_lighting` service call."
)
raise ValueError(msg)
raise ServiceValidationError(msg)
domain_data = hass.data.get(DOMAIN)
if not domain_data:
msg = "adaptive-lighting: No Adaptive Lighting config entries are loaded."
raise ServiceValidationError(msg)
if switch_entity_ids is not None:
if len(switch_entity_ids) > 1 and lights:
@ -318,32 +326,59 @@ def _switches_from_service_call(
"adaptive-lighting: Cannot pass multiple switches with lights argument."
f" Invalid service data received: {service_call.data}"
)
raise ValueError(msg)
raise ServiceValidationError(msg)
switches: AdaptiveSwitches = []
config_ids: set[str] = set()
ent_reg = entity_registry.async_get(hass)
for entity_id in switch_entity_ids:
ent_entry = ent_reg.async_get(entity_id)
assert ent_entry is not None
if ent_entry is None:
msg = f"adaptive-lighting: Entity '{entity_id}' not found in registry."
raise ServiceValidationError(msg)
if ent_entry.platform != DOMAIN:
msg = (
f"adaptive-lighting: Entity '{entity_id}' is not registered by"
" Adaptive Lighting."
)
raise ServiceValidationError(msg)
config_id = ent_entry.config_entry_id
switches.append(hass.data[DOMAIN][config_id][SWITCH_DOMAIN])
config_data = domain_data.get(config_id) if config_id else None
if (
config_id is None
or not isinstance(config_data, dict)
or SWITCH_DOMAIN not in config_data
):
msg = (
f"adaptive-lighting: Adaptive Lighting entry for entity '{entity_id}'"
" is not loaded."
)
raise ServiceValidationError(msg)
if config_id not in config_ids:
switches.append(config_data[SWITCH_DOMAIN])
config_ids.add(config_id)
return switches
if lights:
switch = _switch_with_lights(hass, lights)
try:
switch = _switch_with_lights(hass, lights)
except NoSwitchFoundError as err:
raise ServiceValidationError(str(err)) from err
return [switch]
msg = (
"adaptive-lighting: Incorrect data provided in service call."
f" Entities not found in the integration. Service data: {service_call.data}"
)
raise ValueError(msg)
raise ServiceValidationError(msg)
async def handle_change_switch_settings(
switch: AdaptiveSwitch,
switch: AdaptiveSwitch | SimpleSwitch,
service_call: ServiceCall,
) -> None:
"""Allows HASS to change config values via a service call."""
if not isinstance(switch, AdaptiveSwitch):
return
data = service_call.data
which = data.get(CONF_USE_DEFAULTS, "current")
if which == "current": # use whatever we're already using.
@ -376,7 +411,79 @@ async def handle_change_switch_settings(
)
async def async_setup_entry( # noqa: PLR0915
async def handle_apply_service(hass: HomeAssistant, service_call: ServiceCall) -> None:
"""Handle the entity service apply."""
data = service_call.data
_LOGGER.debug(
"Called 'adaptive_lighting.apply' service with '%s'",
data,
)
switches = _switches_from_service_call(hass, service_call)
lights = data[CONF_LIGHTS]
for switch in switches:
all_lights = switch.lights if not lights else _expand_light_groups(hass, lights)
switch.manager.lights.update(all_lights)
for light in all_lights:
if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light):
context = switch.create_context(
"service",
parent=service_call.context,
)
transition = data.get(CONF_TRANSITION)
if transition is None:
transition = switch.initial_transition
await switch._adapt_light( # pylint: disable=protected-access
light,
context=context,
transition=transition,
adapt_brightness=data[ATTR_ADAPT_BRIGHTNESS],
adapt_color=data[ATTR_ADAPT_COLOR],
prefer_rgb_color=data[CONF_PREFER_RGB_COLOR],
force=True,
)
async def handle_set_manual_control_service(
hass: HomeAssistant,
service_call: ServiceCall,
) -> None:
"""Set or unset lights as manually controlled."""
data = service_call.data
_LOGGER.debug(
"Called 'adaptive_lighting.set_manual_control' service with '%s'",
data,
)
switches = _switches_from_service_call(hass, service_call)
lights = data[CONF_LIGHTS]
for switch in switches:
all_lights = switch.lights if not lights else _expand_light_groups(hass, lights)
manual_attributes = manual_control_event_attribute_to_flags(
data[CONF_MANUAL_CONTROL],
)
if manual_attributes:
for light in all_lights:
switch.manager.set_manual_control_attributes(
light,
manual_attributes,
)
switch.fire_manual_control_event(light, service_call.context)
else:
switch.manager.reset(*all_lights)
if switch.is_on:
context = switch.create_context(
"service",
parent=service_call.context,
)
await switch._update_attrs_and_maybe_adapt_lights( # pylint: disable=protected-access
context=context,
lights=all_lights,
transition=switch.initial_transition,
force=True,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
@ -446,112 +553,14 @@ async def async_setup_entry( # noqa: PLR0915
update_before_add=True,
)
@callback
async def handle_apply(service_call: ServiceCall) -> None:
"""Handle the entity service apply."""
data = service_call.data
_LOGGER.debug(
"Called 'adaptive_lighting.apply' service with '%s'",
data,
if not hasattr(service, "async_register_platform_entity_service"):
platform = entity_platform.current_platform.get()
assert platform is not None
platform.async_register_entity_service(
SERVICE_CHANGE_SWITCH_SETTINGS,
change_switch_settings_schema(),
handle_change_switch_settings,
)
switches = _switches_from_service_call(hass, service_call)
lights = data[CONF_LIGHTS]
for switch in switches:
if not lights:
all_lights = switch.lights
else:
all_lights = _expand_light_groups(hass, lights)
switch.manager.lights.update(all_lights)
for light in all_lights:
if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light):
context = switch.create_context(
"service",
parent=service_call.context,
)
await switch._adapt_light( # pylint: disable=protected-access
light,
context=context,
transition=data[CONF_TRANSITION],
adapt_brightness=data[ATTR_ADAPT_BRIGHTNESS],
adapt_color=data[ATTR_ADAPT_COLOR],
prefer_rgb_color=data[CONF_PREFER_RGB_COLOR],
force=True,
)
@callback
async def handle_set_manual_control(service_call: ServiceCall) -> None:
"""Set or unset lights as 'manually controlled'."""
data = service_call.data
_LOGGER.debug(
"Called 'adaptive_lighting.set_manual_control' service with '%s'",
data,
)
switches = _switches_from_service_call(hass, service_call)
lights = data[CONF_LIGHTS]
for switch in switches:
if not lights:
all_lights = switch.lights
else:
all_lights = _expand_light_groups(hass, lights)
manual_attributes = manual_control_event_attribute_to_flags(
service_call.data[CONF_MANUAL_CONTROL],
)
if manual_attributes:
for light in all_lights:
switch.manager.set_manual_control_attributes(
light,
manual_attributes,
)
switch.fire_manual_control_event(
light,
service_call.context,
)
else:
switch.manager.reset(*all_lights)
if switch.is_on:
context = switch.create_context(
"service",
parent=service_call.context,
)
# pylint: disable=protected-access
await switch._update_attrs_and_maybe_adapt_lights(
context=context,
lights=all_lights,
transition=switch.initial_transition,
force=True,
)
# Register `apply` service
hass.services.async_register(
domain=DOMAIN,
service=SERVICE_APPLY,
service_func=handle_apply,
schema=apply_service_schema(switch.initial_transition),
)
# Register `set_manual_control` service
hass.services.async_register(
domain=DOMAIN,
service=SERVICE_SET_MANUAL_CONTROL,
service_func=handle_set_manual_control,
schema=SET_MANUAL_CONTROL_SCHEMA,
)
args: VolDictType = {vol.Optional(CONF_USE_DEFAULTS, default="current"): cv.string}
# Modifying these after init isn't possible
skip = (CONF_INTERVAL, CONF_NAME, CONF_LIGHTS)
for k, _, valid in VALIDATION_TUPLES:
if k not in skip:
args[vol.Optional(k)] = valid
platform = entity_platform.current_platform.get()
assert platform is not None
platform.async_register_entity_service(
SERVICE_CHANGE_SWITCH_SETTINGS,
args,
handle_change_switch_settings,
)
def validate(
@ -1846,9 +1855,12 @@ class AdaptiveLightingManager:
)
def disable(self) -> None:
"""Disable the listener by removing all subscribed handlers."""
"""Disable listeners and pending automatic manual-control resets."""
for remove in self.listener_removers:
remove()
for timer in self.auto_reset_manual_control_timers.values():
timer.cancel()
self.auto_reset_manual_control_timers.clear()
def set_proactively_adapting(self, context_id: str, entity_id: str) -> None:
"""Declare the adaptation with context_id as proactively adapting,

View file

@ -157,10 +157,6 @@
"name": "change_switch_settings",
"description": "Change any settings you'd like in the switch. All options here are the same as in the config flow.",
"fields": {
"entity_id": {
"description": "Entity ID of the switch. 📝",
"name": "entity_id"
},
"use_defaults": {
"description": "Sets the default values not specified in this service call. Options: \"current\" (default, retains current values), \"factory\" (resets to documented defaults), or \"configuration\" (reverts to switch config defaults). ⚙️",
"name": "use_defaults"

View file

@ -9,6 +9,7 @@ Adaptive Lighting provides three services for programmatic control, allowing you
## adaptive_lighting.apply
Applies the current Adaptive Lighting settings to lights on demand. Useful for forcing an immediate update or applying settings to lights that aren't in the regular adaptation cycle.
Provide a switch in `entity_id`, a list of `lights`, or both.
### Parameters
@ -20,7 +21,7 @@ Applies the current Adaptive Lighting settings to lights on demand. Useful for f
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
| Service data attribute | Description | Required | Type |
|:-------------------------|:--------------------------------------------------------------------------------------|:-----------|:---------------------|
| `entity_id` | The `entity_id` of the switch with the settings to apply. 📝 | | list of `entity_id`s |
| `entity_id` | The `entity_id` of the switch with the settings to apply. 📝 | | list of `entity_id`s |
| `lights` | A light (or list of lights) to apply the settings to. 💡 | ❌ | list of `entity_id`s |
| `transition` | Duration of transition when lights change, in seconds. 🕑 | ❌ | `float` 0-6553 |
| `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool |
@ -58,6 +59,7 @@ data:
## adaptive_lighting.set_manual_control
Marks or unmarks a light as "manually controlled". When a light is marked as manually controlled, Adaptive Lighting will not adjust it until the manual control flag is cleared.
Provide a switch in `entity_id`, a list of `lights`, or both.
### Parameters
@ -69,7 +71,7 @@ Marks or unmarks a light as "manually controlled". When a light is marked as man
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
| Service data attribute | Description | Required | Type |
|:-------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|:-----------------------------------------|
| `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | | list of `entity_id`s |
| `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | | list of `entity_id`s |
| `lights` | entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 | ❌ | list of `entity_id`s |
| `manual_control` | Whether to add ("true") or remove ("false") all adapted attributes of the light from the "manual_control" list, or the name of an attribute for selective addition. 🔒 | ❌ | bool or one of `['brightness', 'color']` |

View file

@ -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,
)

View file

@ -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)