mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-15 16:24:04 +02:00
Compare commits
7 commits
main
...
bronze-act
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df4f623639 | ||
|
|
0020cdd241 | ||
|
|
154e2211c5 | ||
|
|
114688613e | ||
|
|
282d7631c8 | ||
|
|
ab50cc9e3f | ||
|
|
3952b4767a |
7 changed files with 327 additions and 181 deletions
|
|
@ -199,7 +199,7 @@ adaptive_lighting:
|
||||||
|:-------------------------|:--------------------------------------------------------------------------------------|:-----------|:---------------------|
|
|:-------------------------|:--------------------------------------------------------------------------------------|:-----------|:---------------------|
|
||||||
| `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 |
|
| `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 |
|
| `transition` | Duration of transition when lights change, in seconds. 🕑 | ✅ | `float` 0-6553 |
|
||||||
| `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool |
|
| `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool |
|
||||||
| `adapt_color` | Whether to adapt the color on supporting lights. 🌈 | ❌ | bool |
|
| `adapt_color` | Whether to adapt the color on supporting lights. 🌈 | ❌ | bool |
|
||||||
| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | ❌ | bool |
|
| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | ❌ | bool |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Adaptive Lighting integration in Home-Assistant."""
|
"""Adaptive Lighting integration in Home-Assistant."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from functools import partial
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
@ -14,7 +15,18 @@ from .const import (
|
||||||
ATTR_ADAPTIVE_LIGHTING_MANAGER,
|
ATTR_ADAPTIVE_LIGHTING_MANAGER,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
SERVICE_APPLY,
|
||||||
|
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||||
|
SERVICE_SET_MANUAL_CONTROL,
|
||||||
|
SET_MANUAL_CONTROL_SCHEMA,
|
||||||
UNDO_UPDATE_LISTENER,
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
@ -47,6 +59,27 @@ async def reload_configuration_yaml(event: Event) -> None:
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||||
"""Import integration from config."""
|
"""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,
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
domain=DOMAIN,
|
||||||
|
service=SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||||
|
service_func=partial(handle_change_switch_settings, hass),
|
||||||
|
schema=change_switch_settings_schema(),
|
||||||
|
)
|
||||||
|
|
||||||
if DOMAIN in config:
|
if DOMAIN in config:
|
||||||
for entry in config[DOMAIN]:
|
for entry in config[DOMAIN]:
|
||||||
hass.async_create_task(
|
hass.async_create_task(
|
||||||
|
|
|
||||||
|
|
@ -459,16 +459,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 the schema for the apply service."""
|
||||||
return vol.Schema(
|
return vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(CONF_ENTITY_ID): cv.entity_ids, # type: ignore[arg-type]
|
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_LIGHTS, default=[]): cv.entity_ids, # type: ignore[arg-type]
|
||||||
vol.Optional(
|
vol.Optional(CONF_TRANSITION): VALID_TRANSITION,
|
||||||
CONF_TRANSITION,
|
|
||||||
default=initial_transition,
|
|
||||||
): VALID_TRANSITION,
|
|
||||||
vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean,
|
vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean,
|
||||||
vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean,
|
vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean,
|
||||||
vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean,
|
vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean,
|
||||||
|
|
@ -477,6 +474,20 @@ def apply_service_schema(initial_transition: int = 1) -> vol.Schema:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def change_switch_settings_schema() -> vol.Schema:
|
||||||
|
"""Return the schema for the change_switch_settings service."""
|
||||||
|
args: dict[vol.Marker, Any] = {
|
||||||
|
vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
|
||||||
|
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 vol.Schema(args)
|
||||||
|
|
||||||
|
|
||||||
SET_MANUAL_CONTROL_SCHEMA = vol.Schema(
|
SET_MANUAL_CONTROL_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(CONF_ENTITY_ID): cv.entity_ids, # type: ignore[arg-type]
|
vol.Optional(CONF_ENTITY_ID): cv.entity_ids, # type: ignore[arg-type]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Utility functions for HA core."""
|
"""Utility functions for HA core."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
|
@ -80,9 +81,8 @@ def setup_service_call_interceptor(
|
||||||
"Error for call '%s' in service_func_proxy",
|
"Error for call '%s' in service_func_proxy",
|
||||||
call.data,
|
call.data,
|
||||||
)
|
)
|
||||||
# Call original service handler with processed data
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
|
# Call original service handler with processed data
|
||||||
target = existing_service.job.target
|
target = existing_service.job.target
|
||||||
if asyncio.iscoroutinefunction(target):
|
if asyncio.iscoroutinefunction(target):
|
||||||
await target(call)
|
await target(call)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, Any
|
||||||
import homeassistant.helpers.config_validation as cv
|
import homeassistant.helpers.config_validation as cv
|
||||||
import homeassistant.util.dt as dt_util
|
import homeassistant.util.dt as dt_util
|
||||||
import ulid_transform
|
import ulid_transform
|
||||||
import voluptuous as vol
|
|
||||||
from homeassistant.components.light import (
|
from homeassistant.components.light import (
|
||||||
ATTR_BRIGHTNESS,
|
ATTR_BRIGHTNESS,
|
||||||
ATTR_COLOR_TEMP_KELVIN,
|
ATTR_COLOR_TEMP_KELVIN,
|
||||||
|
|
@ -55,9 +54,9 @@ from homeassistant.core import (
|
||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
ServiceCall,
|
ServiceCall,
|
||||||
State,
|
State,
|
||||||
callback,
|
|
||||||
)
|
)
|
||||||
from homeassistant.helpers import entity_platform, entity_registry
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
|
from homeassistant.helpers import entity_registry
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.entity_component import async_update_entity
|
from homeassistant.helpers.entity_component import async_update_entity
|
||||||
from homeassistant.helpers.event import (
|
from homeassistant.helpers.event import (
|
||||||
|
|
@ -137,15 +136,10 @@ from .const import (
|
||||||
ICON_COLOR_TEMP,
|
ICON_COLOR_TEMP,
|
||||||
ICON_MAIN,
|
ICON_MAIN,
|
||||||
ICON_SLEEP,
|
ICON_SLEEP,
|
||||||
SERVICE_APPLY,
|
|
||||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
|
||||||
SERVICE_SET_MANUAL_CONTROL,
|
|
||||||
SET_MANUAL_CONTROL_SCHEMA,
|
|
||||||
SLEEP_MODE_SWITCH,
|
SLEEP_MODE_SWITCH,
|
||||||
TURNING_OFF_DELAY,
|
TURNING_OFF_DELAY,
|
||||||
VALIDATION_TUPLES,
|
VALIDATION_TUPLES,
|
||||||
TakeOverControlMode,
|
TakeOverControlMode,
|
||||||
apply_service_schema,
|
|
||||||
replace_none_str,
|
replace_none_str,
|
||||||
)
|
)
|
||||||
from .hass_utils import area_entities, setup_service_call_interceptor
|
from .hass_utils import area_entities, setup_service_call_interceptor
|
||||||
|
|
@ -162,9 +156,7 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import NoEventData, VolDictType
|
from homeassistant.helpers.typing import NoEventData
|
||||||
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=10)
|
SCAN_INTERVAL = timedelta(seconds=10)
|
||||||
|
|
@ -284,7 +276,7 @@ def _switches_from_service_call(
|
||||||
service_call: ServiceCall,
|
service_call: ServiceCall,
|
||||||
) -> AdaptiveSwitches:
|
) -> AdaptiveSwitches:
|
||||||
data = service_call.data
|
data = service_call.data
|
||||||
lights = data[CONF_LIGHTS]
|
lights = data.get(CONF_LIGHTS)
|
||||||
switch_entity_ids: list[str] | None = data.get("entity_id")
|
switch_entity_ids: list[str] | None = data.get("entity_id")
|
||||||
|
|
||||||
if not lights and not switch_entity_ids:
|
if not lights and not switch_entity_ids:
|
||||||
|
|
@ -295,7 +287,7 @@ def _switches_from_service_call(
|
||||||
" use case. Currently, you must pass either an adaptive-lighting switch or"
|
" use case. Currently, you must pass either an adaptive-lighting switch or"
|
||||||
" the lights to an `adaptive_lighting` service call."
|
" the lights to an `adaptive_lighting` service call."
|
||||||
)
|
)
|
||||||
raise ValueError(msg)
|
raise ServiceValidationError(msg)
|
||||||
|
|
||||||
if switch_entity_ids is not None:
|
if switch_entity_ids is not None:
|
||||||
if len(switch_entity_ids) > 1 and lights:
|
if len(switch_entity_ids) > 1 and lights:
|
||||||
|
|
@ -303,13 +295,18 @@ def _switches_from_service_call(
|
||||||
"adaptive-lighting: Cannot pass multiple switches with lights argument."
|
"adaptive-lighting: Cannot pass multiple switches with lights argument."
|
||||||
f" Invalid service data received: {service_call.data}"
|
f" Invalid service data received: {service_call.data}"
|
||||||
)
|
)
|
||||||
raise ValueError(msg)
|
raise ServiceValidationError(msg)
|
||||||
switches: AdaptiveSwitches = []
|
switches: AdaptiveSwitches = []
|
||||||
ent_reg = entity_registry.async_get(hass)
|
ent_reg = entity_registry.async_get(hass)
|
||||||
for entity_id in switch_entity_ids:
|
for entity_id in switch_entity_ids:
|
||||||
ent_entry = ent_reg.async_get(entity_id)
|
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)
|
||||||
config_id = ent_entry.config_entry_id
|
config_id = ent_entry.config_entry_id
|
||||||
|
if config_id not in hass.data[DOMAIN]:
|
||||||
|
msg = f"adaptive-lighting: Entity '{entity_id}' does not belong to this integration or is not loaded."
|
||||||
|
raise ServiceValidationError(msg)
|
||||||
switches.append(hass.data[DOMAIN][config_id][SWITCH_DOMAIN])
|
switches.append(hass.data[DOMAIN][config_id][SWITCH_DOMAIN])
|
||||||
return switches
|
return switches
|
||||||
|
|
||||||
|
|
@ -321,15 +318,17 @@ def _switches_from_service_call(
|
||||||
"adaptive-lighting: Incorrect data provided in service call."
|
"adaptive-lighting: Incorrect data provided in service call."
|
||||||
f" Entities not found in the integration. Service data: {service_call.data}"
|
f" Entities not found in the integration. Service data: {service_call.data}"
|
||||||
)
|
)
|
||||||
raise ValueError(msg)
|
raise ServiceValidationError(msg)
|
||||||
|
|
||||||
|
|
||||||
async def handle_change_switch_settings(
|
async def handle_change_switch_settings(
|
||||||
switch: AdaptiveSwitch,
|
hass: HomeAssistant,
|
||||||
service_call: ServiceCall,
|
service_call: ServiceCall,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Allows HASS to change config values via a service call."""
|
"""Allows HASS to change config values via a service call."""
|
||||||
data = service_call.data
|
data = service_call.data
|
||||||
|
switches = _switches_from_service_call(hass, service_call)
|
||||||
|
for switch in switches:
|
||||||
which = data.get(CONF_USE_DEFAULTS, "current")
|
which = data.get(CONF_USE_DEFAULTS, "current")
|
||||||
if which == "current": # use whatever we're already using.
|
if which == "current": # use whatever we're already using.
|
||||||
defaults = switch._current_settings # pylint: disable=protected-access
|
defaults = switch._current_settings # pylint: disable=protected-access
|
||||||
|
|
@ -361,7 +360,86 @@ 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,
|
||||||
|
)
|
||||||
|
# Handle optional transition
|
||||||
|
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(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
|
@ -431,113 +509,6 @@ async def async_setup_entry( # noqa: PLR0915
|
||||||
update_before_add=True,
|
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,
|
|
||||||
)
|
|
||||||
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(
|
def validate(
|
||||||
config_entry: ConfigEntry | None,
|
config_entry: ConfigEntry | None,
|
||||||
|
|
@ -1681,8 +1652,8 @@ class SimpleSwitch(SwitchEntity, RestoreEntity):
|
||||||
self._state = False
|
self._state = False
|
||||||
|
|
||||||
|
|
||||||
type AdaptiveSwitches = list[AdaptiveSwitch]
|
AdaptiveSwitches = list[AdaptiveSwitch]
|
||||||
type AdaptiveSwitchMap = dict[AdaptiveSwitch, list[str]]
|
AdaptiveSwitchMap = dict[AdaptiveSwitch, list[str]]
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveLightingManager:
|
class AdaptiveLightingManager:
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ Applies the current Adaptive Lighting settings to lights on demand. Useful for f
|
||||||
|:-------------------------|:--------------------------------------------------------------------------------------|:-----------|:---------------------|
|
|:-------------------------|:--------------------------------------------------------------------------------------|:-----------|:---------------------|
|
||||||
| `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 |
|
| `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 |
|
| `transition` | Duration of transition when lights change, in seconds. 🕑 | ✅ | `float` 0-6553 |
|
||||||
| `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool |
|
| `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool |
|
||||||
| `adapt_color` | Whether to adapt the color on supporting lights. 🌈 | ❌ | bool |
|
| `adapt_color` | Whether to adapt the color on supporting lights. 🌈 | ❌ | bool |
|
||||||
| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | ❌ | bool |
|
| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | ❌ | bool |
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ from homeassistant.components.light import (
|
||||||
)
|
)
|
||||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||||
|
from homeassistant.helpers.normalized_name_base_registry import (
|
||||||
|
NormalizedNameBaseRegistryItems,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# HA >= 2025.8
|
# HA >= 2025.8
|
||||||
|
|
@ -113,6 +116,8 @@ from homeassistant.const import (
|
||||||
)
|
)
|
||||||
from homeassistant.const import __version__ as ha_version
|
from homeassistant.const import __version__ as ha_version
|
||||||
from homeassistant.core import Context, Event, HomeAssistant, State
|
from homeassistant.core import Context, Event, HomeAssistant, State
|
||||||
|
from homeassistant.core_config import async_process_ha_core_config
|
||||||
|
from homeassistant.exceptions import ServiceValidationError
|
||||||
from homeassistant.helpers import area_registry as ar
|
from homeassistant.helpers import area_registry as ar
|
||||||
from homeassistant.helpers import entity_registry
|
from homeassistant.helpers import entity_registry
|
||||||
from homeassistant.helpers.entity_platform import async_get_platforms
|
from homeassistant.helpers.entity_platform import async_get_platforms
|
||||||
|
|
@ -375,19 +380,6 @@ async def test_adaptive_lighting_switches(hass):
|
||||||
assert len(data.keys()) == 5
|
assert len(data.keys()) == 5
|
||||||
|
|
||||||
|
|
||||||
def async_process_ha_core_config(hass, config):
|
|
||||||
"""Set up the Home Assistant configuration."""
|
|
||||||
try:
|
|
||||||
# ha >= "2023.11.0"
|
|
||||||
from homeassistant.core_config import async_process_ha_core_config
|
|
||||||
|
|
||||||
return async_process_ha_core_config(hass, config)
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
import homeassistant.config as config_util
|
|
||||||
|
|
||||||
return config_util.async_process_ha_core_config(hass, config)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("lat", "long", "timezone"), LAT_LONG_TZS)
|
@pytest.mark.parametrize(("lat", "long", "timezone"), LAT_LONG_TZS)
|
||||||
async def test_adaptive_lighting_time_zones_with_default_settings(
|
async def test_adaptive_lighting_time_zones_with_default_settings(
|
||||||
hass,
|
hass,
|
||||||
|
|
@ -890,7 +882,7 @@ async def test_auto_reset_manual_control(hass):
|
||||||
|
|
||||||
async def test_adaptation_attribute_selection(hass):
|
async def test_adaptation_attribute_selection(hass):
|
||||||
"""Test the 'manual control' tracking."""
|
"""Test the 'manual control' tracking."""
|
||||||
switch, (light, *_) = await setup_lights_and_switch(hass)
|
switch, (_, *_) = await setup_lights_and_switch(hass)
|
||||||
|
|
||||||
# Assert default settings
|
# Assert default settings
|
||||||
assert switch._take_over_control
|
assert switch._take_over_control
|
||||||
|
|
@ -1480,7 +1472,7 @@ async def test_offset_too_large(hass):
|
||||||
which makes the adaptive lighting algorithm fail with a ValueError.
|
which makes the adaptive lighting algorithm fail with a ValueError.
|
||||||
"""
|
"""
|
||||||
_, switch = await setup_switch(hass, {CONF_SUNRISE_OFFSET: 3600 * 12})
|
_, switch = await setup_switch(hass, {CONF_SUNRISE_OFFSET: 3600 * 12})
|
||||||
with pytest.raises(ValueError, match="sun events.*not in the expected order"):
|
with pytest.raises(ValueError, match=r"sun events.*not in the expected order"):
|
||||||
await switch._update_attrs_and_maybe_adapt_lights(
|
await switch._update_attrs_and_maybe_adapt_lights(
|
||||||
context=switch.create_context("test"),
|
context=switch.create_context("test"),
|
||||||
)
|
)
|
||||||
|
|
@ -1584,10 +1576,6 @@ def mock_area_registry(
|
||||||
# https://github.com/home-assistant/core/pull/114777
|
# https://github.com/home-assistant/core/pull/114777
|
||||||
registry.areas = ar.AreaRegistryItems()
|
registry.areas = ar.AreaRegistryItems()
|
||||||
elif dt == datetime.date(2024, 4, 1):
|
elif dt == datetime.date(2024, 4, 1):
|
||||||
from homeassistant.helpers.normalized_name_base_registry import (
|
|
||||||
NormalizedNameBaseRegistryItems,
|
|
||||||
)
|
|
||||||
|
|
||||||
registry.areas = NormalizedNameBaseRegistryItems()
|
registry.areas = NormalizedNameBaseRegistryItems()
|
||||||
else:
|
else:
|
||||||
registry.areas = OrderedDict()
|
registry.areas = OrderedDict()
|
||||||
|
|
@ -2005,7 +1993,7 @@ async def test_proactive_multiple_lights_all_at_once(hass):
|
||||||
|
|
||||||
async def test_proactive_multiple_lights_turn_on_non_managed_light(hass):
|
async def test_proactive_multiple_lights_turn_on_non_managed_light(hass):
|
||||||
"""Create switch and demo lights."""
|
"""Create switch and demo lights."""
|
||||||
lights, switch1, switch2 = await setup_proactive_multiple_lights_two_switches(hass)
|
lights, _, _ = await setup_proactive_multiple_lights_two_switches(hass)
|
||||||
turn_ons = await _turn_on_and_track_event_contexts(hass, "test1", lights)
|
turn_ons = await _turn_on_and_track_event_contexts(hass, "test1", lights)
|
||||||
assert len(turn_ons) == 3, turn_ons
|
assert len(turn_ons) == 3, turn_ons
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
@ -2027,7 +2015,7 @@ async def test_proactive_multiple_lights_turn_on_non_managed_light(hass):
|
||||||
|
|
||||||
async def test_proactive_multiple_lights_turn_on_managed_lights_only(hass):
|
async def test_proactive_multiple_lights_turn_on_managed_lights_only(hass):
|
||||||
"""Create switch and demo lights."""
|
"""Create switch and demo lights."""
|
||||||
lights, switch1, switch2 = await setup_proactive_multiple_lights_two_switches(hass)
|
lights, _, _ = await setup_proactive_multiple_lights_two_switches(hass)
|
||||||
_LOGGER.debug("Start test_proactive_multiple_lights_all_at_once")
|
_LOGGER.debug("Start test_proactive_multiple_lights_all_at_once")
|
||||||
# Setup demo lights and turn on
|
# Setup demo lights and turn on
|
||||||
events = await _turn_on_and_track_event_contexts(
|
events = await _turn_on_and_track_event_contexts(
|
||||||
|
|
@ -2154,7 +2142,7 @@ async def test_adapt_until_sleep_and_rgb_colors(hass):
|
||||||
hass,
|
hass,
|
||||||
{"latitude": lat, "longitude": long, "time_zone": timezone, "country": "US"},
|
{"latitude": lat, "longitude": long, "time_zone": timezone, "country": "US"},
|
||||||
)
|
)
|
||||||
switch, lights = await setup_lights_and_switch(
|
switch, _ = await setup_lights_and_switch(
|
||||||
hass,
|
hass,
|
||||||
{
|
{
|
||||||
CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour),
|
CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour),
|
||||||
|
|
@ -2716,7 +2704,7 @@ async def test_skipped_lights_context_not_from_arbitrary_switch(hass):
|
||||||
See: https://github.com/basnijholt/adaptive-lighting/pull/1348
|
See: https://github.com/basnijholt/adaptive-lighting/pull/1348
|
||||||
"""
|
"""
|
||||||
# Setup two switches with different lights
|
# Setup two switches with different lights
|
||||||
lights, switch1, switch2 = await setup_proactive_multiple_lights_two_switches(hass)
|
lights, _, _ = await setup_proactive_multiple_lights_two_switches(hass)
|
||||||
|
|
||||||
# Turn on all three lights at once:
|
# Turn on all three lights at once:
|
||||||
# - ENTITY_LIGHT_1 is in switch1
|
# - ENTITY_LIGHT_1 is in switch1
|
||||||
|
|
@ -2914,3 +2902,146 @@ async def test_adapt_only_on_bare_turn_on_respects_pause_changed_mode(hass, inte
|
||||||
f"With take_over_control_mode=PAUSE_CHANGED and only brightness marked "
|
f"With take_over_control_mode=PAUSE_CHANGED and only brightness marked "
|
||||||
f"as manually controlled, color_temp should still be adapted."
|
f"as manually controlled, color_temp should still be adapted."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_service_validation_error_invalid_entity(hass):
|
||||||
|
"""Test that ServiceValidationError is raised for invalid entities."""
|
||||||
|
await setup_lights_and_switch(hass)
|
||||||
|
|
||||||
|
# Test change_switch_settings with non-existent entity
|
||||||
|
with pytest.raises(ServiceValidationError, match="not found in registry"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||||
|
{ATTR_ENTITY_ID: "switch.non_existent"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_service_validation_error_missing_input(hass):
|
||||||
|
"""Test that ServiceValidationError is raised for missing input."""
|
||||||
|
await setup_lights_and_switch(hass)
|
||||||
|
|
||||||
|
# Test change_switch_settings with no entity and no lights
|
||||||
|
with pytest.raises(
|
||||||
|
ServiceValidationError,
|
||||||
|
match="Neither a switch nor a light was provided",
|
||||||
|
):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||||
|
{},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_change_switch_settings_multiple_entities(hass):
|
||||||
|
"""Test change_switch_settings with multiple entities."""
|
||||||
|
# Setup two switches
|
||||||
|
await setup_lights(hass)
|
||||||
|
_, switch1 = await setup_switch(
|
||||||
|
hass,
|
||||||
|
{CONF_LIGHTS: [ENTITY_LIGHT_1], CONF_NAME: "switch1"},
|
||||||
|
)
|
||||||
|
_, switch2 = await setup_switch(
|
||||||
|
hass,
|
||||||
|
{CONF_LIGHTS: [ENTITY_LIGHT_2], CONF_NAME: "switch2"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert switch1._sun_light_settings.min_color_temp != 3000
|
||||||
|
assert switch2._sun_light_settings.min_color_temp != 3000
|
||||||
|
|
||||||
|
# Call service for both switches
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: [
|
||||||
|
"switch.adaptive_lighting_switch1",
|
||||||
|
"switch.adaptive_lighting_switch2",
|
||||||
|
],
|
||||||
|
"min_color_temp": 3000,
|
||||||
|
"use_defaults": "configuration", # Required to set new value
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert switch1._sun_light_settings.min_color_temp == 3000
|
||||||
|
assert switch2._sun_light_settings.min_color_temp == 3000
|
||||||
|
|
||||||
|
|
||||||
|
async def test_apply_service_validation(hass):
|
||||||
|
"""Test validation for apply service."""
|
||||||
|
await setup_lights_and_switch(hass)
|
||||||
|
|
||||||
|
with pytest.raises(ServiceValidationError, match="not found in registry"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_APPLY,
|
||||||
|
{ATTR_ENTITY_ID: "switch.non_existent"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_set_manual_control_validation(hass):
|
||||||
|
"""Test validation for set_manual_control service."""
|
||||||
|
await setup_lights_and_switch(hass)
|
||||||
|
|
||||||
|
with pytest.raises(ServiceValidationError, match="not found in registry"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_SET_MANUAL_CONTROL,
|
||||||
|
{ATTR_ENTITY_ID: "switch.non_existent"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"use_target",
|
||||||
|
[False, True],
|
||||||
|
ids=["entity_id_in_data", "entity_id_in_target"],
|
||||||
|
)
|
||||||
|
async def test_change_switch_settings_backward_compatibility(hass, use_target):
|
||||||
|
"""Test change_switch_settings works with both calling conventions.
|
||||||
|
|
||||||
|
This is a regression test to ensure backward compatibility when
|
||||||
|
change_switch_settings was converted from an entity service to a domain service.
|
||||||
|
|
||||||
|
Previously (entity service): entity_id was passed via `target` parameter
|
||||||
|
Now (domain service): entity_id is passed via `data` parameter
|
||||||
|
|
||||||
|
Both conventions should work to avoid breaking existing automations.
|
||||||
|
"""
|
||||||
|
switch, _ = await setup_lights_and_switch(hass)
|
||||||
|
|
||||||
|
# Verify initial state
|
||||||
|
original_min_color_temp = switch._sun_light_settings.min_color_temp
|
||||||
|
new_min_color_temp = 3000
|
||||||
|
assert original_min_color_temp != new_min_color_temp
|
||||||
|
|
||||||
|
if use_target:
|
||||||
|
# Old convention: entity_id in target (entity service style)
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||||
|
{"min_color_temp": new_min_color_temp},
|
||||||
|
target={"entity_id": ENTITY_SWITCH},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# New convention: entity_id in data (domain service style)
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: ENTITY_SWITCH,
|
||||||
|
"min_color_temp": new_min_color_temp,
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both conventions should result in the setting being changed
|
||||||
|
assert switch._sun_light_settings.min_color_temp == new_min_color_temp, (
|
||||||
|
f"change_switch_settings failed with {'target' if use_target else 'data'} convention. "
|
||||||
|
f"Expected min_color_temp={new_min_color_temp}, got {switch._sun_light_settings.min_color_temp}"
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue