mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-10 13:54:04 +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
|
|
@ -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']` |
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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']` |
|
||||
|
||||
|
|
|
|||
|
|
@ -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