feat: improve native group brightness and colour adaptation

This commit is contained in:
Ben Dennis 2026-10-13 11:40:14 +10:00
commit 4726f328bf
42 changed files with 12609 additions and 147 deletions

View file

@ -48,7 +48,8 @@ This feature is available when `take_over_control` is enabled.
Additionally, enabling `detect_non_ha_changes` allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings.
The `adaptive_lighting.manual_control` event is fired when a light is marked as "manually controlled," allowing for integration with automations 🤖.
With `expand_light_groups: false`, manual control belongs to the group. A direct member change cannot pause adaptation for only that member; use group-level manual control or enable expansion for individual tracking.
For Home Assistant light groups with `expand_light_groups: false`, manual control belongs to the group. A direct member change cannot pause adaptation for only that member; use group-level manual control or enable expansion for individual tracking.
Native groups that advertise `group_entities` also track their members. A newly turned-on member receives an individual adaptation, even if the group's cached settings already match; periodic adaptations still target the native group. A member's manual override pauses that attribute for the group because a group command cannot exclude one member. Resetting manual control for the group also clears its member overrides. Explicitly pausing a smaller native scene group carries that pause to its known members, protecting them from individual repairs and broader periodic group updates until reset. Resetting a native parent group, resetting the whole profile, or cycling its switch OFF/ON also releases the smaller-group pauses created through that profile. Turning a paused smaller group OFF releases its pause. Its group ID and affected members appear in the profile's manual-control attributes, including a group pause that remains after individual member resets. Unloading a profile releases only its own smaller-group pauses; pauses owned by another loaded profile remain active unless explicitly reset.
Explicit member targets in Adaptive Lighting services stay individual targets and do not mark or command the whole group.
Changing expansion at runtime discards tracking and pending adaptation for targets no longer used by any profile.
@ -807,10 +808,16 @@ This sends a single broadcast command to adjust all bulbs, improving response ti
As a rule of thumb, if you always control lights together (e.g., bulbs in a ceiling fixture), they should be in a Zigbee group.
Expose only the group (not individual bulbs) in Home Assistant Dashboards and external systems like Google Home or Apple HomeKit.
> :warning: **If you control lights individually, `manual_control` cannot behave correctly! If you need to control lights individually as well, use a [Home Assistant Light Group](https://www.home-assistant.io/integrations/group/).**
When Home Assistant exposes a native group's `group_entities` attribute, Adaptive Lighting observes individual member activations while keeping periodic updates targeted at the group. An isolated member activation receives its normal individual brightness and colour corrections, according to the enabled adaptation switches and `adapt_delay`, even when the group's cached settings already match. Without membership metadata, configure individual lights or a [Home Assistant Light Group](https://www.home-assistant.io/integrations/group/) if member tracking is needed.
A complete `light.turn_on` request, `scene.apply`, or Home Assistant scene activation can consolidate brightness and colour independently. Each shared attribute requires every native-group member to be intended ON, eligible for that attribute, and receive the same compatible value and transition. Already-ON members count towards scene coverage. Matching brightness can use one group command while different colours remain individual; matching colour can use one group command while different brightness levels remain individual. Matching brightness and colour can share a combined group command when the same profile controls both. Separate brightness and colour profiles retain separate group commands, and competing profiles prevent grouping only for the attribute they share. Brightness grouping also works when colour adaptation is disabled. Smaller scene groups are supported when all their advertised members have one unambiguous native-group owner for each grouped attribute.
Explicit scene values keep their requested brightness, colour and transition. When both attributes are computed by Adaptive Lighting, `separate_turn_on_commands` divides the requested transition between their commands. Colour waits for the brightness transition plus `send_split_delay`, including when one attribute uses a group and the other uses individual members. Explicit scene commands retain their requested transition rather than using this adaptive split policy. Partial requests, differing values or transitions, manual overrides and incompatible capabilities prevent grouping for the affected attribute. Unrelated groups proceed concurrently, and explicit values do not wait for unrelated adaptive delays.
Explicit power settings, Home Assistant light-profile defaults and permissions remain applicable, as do `only_once` and `skip_redundant_commands`. Individual corrections follow the existing external-turn-on policy. A delayed report from an accepted command does not duplicate the brightness or colour already supplied; newer requests and OFF events supersede pending work. Integration-owned scenes that do not expose their desired states cannot provide complete coverage, and independent service calls are handled separately even if they share a context or arrive close together.
When mixing group types, avoid nesting: do not add integration-level groups (e.g., Zigbee2MQTT groups) to a [Home Assistant Light Group](https://www.home-assistant.io/integrations/group/) that is managed by Adaptive Lighting, and do not nest Home Assistant Light Groups inside each other.
Adaptive Lighting cannot expand an integration-level group into its member lights, and nested groups make it unpredictable which entity Adaptive Lighting tracks and adapts, which can prevent lights from being adapted at all (see [#1378](https://github.com/basnijholt/adaptive-lighting/issues/1378)).
Native-group member observation does not expand periodic updates into individual commands. Nested groups make it unpredictable which entity Adaptive Lighting tracks and adapts, which can prevent lights from being adapted at all (see [#1378](https://github.com/basnijholt/adaptive-lighting/issues/1378)).
Instead, add the individual light entities or a single Zigbee group directly to the Adaptive Lighting configuration.
Also note that bulbs turned on via a Zigbee group broadcast may briefly flash their last (cached) brightness and color before the adapted values arrive; this happens inside the bulbs and cannot be prevented by Home Assistant or Adaptive Lighting.

View file

@ -265,6 +265,26 @@ def _identify_light_control_attributes(
return parameters
def adaptation_command_sequence(
service_data: ServiceData,
transition: float | None,
split_delay: float,
split: bool,
) -> tuple[list[ServiceData], float]:
"""Prepare axis payloads and their wait before removing already-applied data."""
service_datas = _split_service_call_data(service_data) if split else [service_data]
service_datas_length = len(service_datas)
if transition is not None:
transition_duration_per_data = transition / max(1, service_datas_length)
sleep_time = transition_duration_per_data + split_delay
else:
sleep_time = split_delay
return service_datas, sleep_time
def prepare_adaptation_data(
hass: HomeAssistant,
entity_id: str,
@ -283,15 +303,12 @@ def prepare_adaptation_data(
entity_id,
service_data,
)
service_datas = _split_service_call_data(service_data) if split else [service_data]
service_datas_length = len(service_datas)
if transition is not None:
transition_duration_per_data = transition / max(1, service_datas_length)
sleep_time = transition_duration_per_data + split_delay
else:
sleep_time = split_delay
service_datas, sleep_time = adaptation_command_sequence(
service_data,
transition,
split_delay,
split,
)
# Keep the original split timing, but omit attributes carried by an
# intercepted turn-on shared with other lights. Do this before state

View file

@ -2,8 +2,10 @@
import logging
from collections.abc import Awaitable, Callable
from inspect import isawaitable
from typing import Any, Protocol
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
from homeassistant.helpers.target import async_extract_referenced_entity_ids
from homeassistant.util.read_only_dict import ReadOnlyDict
@ -17,15 +19,28 @@ from .adaptation_utils import ServiceData
_LOGGER = logging.getLogger(__name__)
class ServiceContinuation(Protocol):
"""Invoke the original handler, optionally with an independent target call."""
def __call__(
self,
dispatch_call: ServiceCall | None = None,
) -> Awaitable[ServiceResponse]:
"""Dispatch once, preserving its response and exception."""
...
def target_entities(
hass: HomeAssistant,
service_data: ServiceData,
*,
expand_group: bool = False,
) -> set[str]:
"""Resolve all directly and indirectly targeted entities without groups."""
"""Resolve selected entities, optionally expanding HA helper groups."""
selected = async_extract_referenced_entity_ids(
hass,
TargetSelection(service_data),
expand_group=False,
expand_group=expand_group,
)
return selected.referenced | selected.indirectly_referenced
@ -35,11 +50,20 @@ def setup_service_call_interceptor(
domain: str,
service: str,
intercept_func: Callable[[ServiceCall, ServiceData], Awaitable[None] | None],
*,
around_func: (
Callable[[ServiceCall, ServiceContinuation], Awaitable[ServiceResponse]] | None
) = None,
) -> Callable[[], None]:
"""Inject a function into a registered service call to preprocess service data.
The injected interceptor function receives the service call and a writeable data dictionary
(the data of the service call is read-only) before the service call is executed.
The optional around function wraps both preprocessing and original dispatch,
and must invoke the supplied continuation to execute them. The continuation
accepts an optional independent ServiceCall for concurrent target dispatch.
Exceptions from
the around function or original service propagate without retrying dispatch.
"""
try:
# HACK: Access protected attribute of HA service registry.
@ -61,47 +85,59 @@ def setup_service_call_interceptor(
existing_service = registered_services[domain][service]
async def service_func_proxy(call: ServiceCall) -> None:
try:
# Convert read-only data to writeable dictionary for modification by interceptor
data = dict(call.data)
async def service_func_proxy(call: ServiceCall) -> ServiceResponse:
async def invoke_original(
dispatch_call: ServiceCall | None = None,
) -> ServiceResponse:
dispatch_call = call if dispatch_call is None else dispatch_call
try:
# Copy read-only data for modification by the interceptor.
data = dict(dispatch_call.data)
result = intercept_func(dispatch_call, data)
if result is not None:
await result
dispatch_call.data = ReadOnlyDict(data)
except Exception:
# Keep the existing fail-open behavior for preprocessing only.
_LOGGER.exception(
"Error for call '%s' in service_func_proxy",
dispatch_call.data,
)
# Call interceptor
result = intercept_func(call, data)
if result is not None:
await result
result = existing_service.job.target(dispatch_call)
if isawaitable(result):
return await result
return result
# Convert data back to read-only
call.data = ReadOnlyDict(data)
except Exception:
# Blindly catch all exceptions to avoid breaking light.turn_on
_LOGGER.exception(
"Error for call '%s' in service_func_proxy",
call.data,
)
# Call original service handler with processed data
import asyncio
if around_func is not None:
return await around_func(call, invoke_original)
return await invoke_original()
target = existing_service.job.target
if asyncio.iscoroutinefunction(target):
await target(call)
else:
target(call)
metadata: dict[str, Any] = {
"supports_response": existing_service.supports_response,
}
if hasattr(existing_service, "description_placeholders"):
metadata["description_placeholders"] = existing_service.description_placeholders
hass.services.async_register(
domain,
service,
service_func_proxy,
existing_service.schema,
**metadata,
)
def remove() -> None:
# Remove the interceptor by reinstalling the original service handler
# A later registration belongs to another owner; leave it in place.
current = registered_services.get(domain, {}).get(service)
if current is None or current.job.target is not service_func_proxy:
return
hass.services.async_register(
domain,
service,
existing_service.job.target,
existing_service.schema,
**metadata,
)
return remove

View file

@ -7,6 +7,6 @@
"documentation": "https://github.com/basnijholt/adaptive-lighting#readme",
"iot_class": "calculated",
"issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues",
"requirements": ["ulid-transform"],
"requirements": [],
"version": "1.32.0"
}

View file

@ -0,0 +1,199 @@
"""Manual overrides with independent direct and native-group contributions."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from .adaptation_utils import LightControlAttributes
if TYPE_CHECKING:
from collections.abc import Iterable
@dataclass(frozen=True)
class ManualOverrideReset:
"""Describe cleared history, changed flags and source provenance released."""
cleared_entities: frozenset[str]
affected_entities: frozenset[str]
affected_owners: frozenset[str] = frozenset()
released_entities: frozenset[str] = frozenset()
@dataclass
class _GroupOverride:
"""One profile's subgroup pause, including its surviving member contributions."""
members: set[str]
attributes: LightControlAttributes
original_members: frozenset[str]
class ManualOverrideState:
"""Own direct flags and subgroup provenance without HA lifecycle effects.
``direct`` remains a mutable dictionary for existing manager consumers.
A derived subgroup pause belongs to its requesting profile and applies to
the subgroup itself and its members. It never becomes an independent flag.
"""
def __init__(self) -> None:
"""Start without direct overrides or inherited contributions."""
self.direct: dict[str, LightControlAttributes] = {}
self._sources: dict[tuple[str, str], _GroupOverride] = {}
def effective(self, light: str) -> LightControlAttributes:
"""Return the union of direct and inherited attributes for a light."""
attributes = self.direct.get(light, LightControlAttributes.NONE)
for (_, group), source in self._sources.items():
if light == group or light in source.members:
attributes |= source.attributes
return attributes
def owned_entities(self, *, owner: str | None = None) -> set[str]:
"""Return source groups and surviving members requiring lifecycle tracking."""
entities: set[str] = set()
for (profile, group), source in self._sources.items():
if owner is None or profile == owner:
entities.add(group)
entities.update(source.members)
return entities
def set_direct(
self,
light: str,
attributes: LightControlAttributes,
) -> set[str]:
"""Replace direct attributes without changing inherited contributions."""
self.direct[light] = attributes
return {light}
def add_direct(
self,
light: str,
attributes: LightControlAttributes,
) -> set[str]:
"""Add only the requested attributes to the independent override."""
return self.set_direct(
light,
self.direct.get(light, LightControlAttributes.NONE) | attributes,
)
def replace_source(
self,
group: str,
members: Iterable[str],
attributes: LightControlAttributes,
*,
owner: str,
) -> set[str]:
"""Replace this profile's subgroup pause, preserving other sources."""
key = (owner, group)
member_ids = set(members)
affected = {group} | member_ids
if previous := self._sources.get(key):
affected.update(previous.members)
self._sources[key] = _GroupOverride(
member_ids,
attributes,
frozenset(member_ids),
)
return affected
def clear_owner(
self,
owner: str,
*,
within_members: Iterable[str] | None = None,
) -> ManualOverrideReset:
"""Release a profile's pauses, optionally limited to a requested parent group.
Original membership retains reset scope even after explicit member
resets have removed every inherited contribution from a subgroup.
"""
scope = None if within_members is None else set(within_members)
previously_owned = self.owned_entities()
groups = set()
affected = set()
for key, source in tuple(self._sources.items()):
if key[0] == owner and (
scope is None
or (source.original_members and source.original_members <= scope)
):
groups.add(key[1])
affected.update(source.members)
self._sources.pop(key)
affected.update(groups)
cleared = frozenset(group for group in groups if not self.effective(group))
return ManualOverrideReset(
cleared,
frozenset(affected),
frozenset({owner}) if groups else frozenset(),
frozenset(previously_owned - self.owned_entities()),
)
def clear(
self,
lights: Iterable[str],
*,
source_owner: str | None = None,
) -> ManualOverrideReset:
"""Clear direct flags and matching derived contributions for these lights.
Explicitly named reset entities clear every contribution. Profile resets
and implicit descendants of a native parent protect another profile's
sources; the longstanding global policy for direct flags is unchanged.
"""
cleared = frozenset(lights)
previously_owned = self.owned_entities()
affected = set(cleared)
owners = set()
for light in cleared:
self.direct[light] = LightControlAttributes.NONE
for key, source in tuple(self._sources.items()):
owner, group = key
if source_owner is not None and owner != source_owner:
continue
if group in cleared:
owners.add(owner)
affected.update(source.members)
self._sources.pop(key)
else:
if source.members & cleared:
owners.add(owner)
source.members.difference_update(cleared)
return ManualOverrideReset(
cleared,
frozenset(affected),
frozenset(owners),
frozenset(previously_owned - self.owned_entities()),
)
def expire(self, light: str) -> ManualOverrideReset:
"""Release expired contributions, including their final subgroup pause."""
keys = [key for key, source in self._sources.items() if light in source.members]
previously_owned = self.owned_entities()
change = self.clear([light])
cleared = set(change.cleared_entities)
affected = set(change.affected_entities)
for key in keys:
if (source := self._sources.get(key)) is not None and not source.members:
self._sources.pop(key)
group = key[1]
affected.add(group)
if not self.effective(group):
cleared.add(group)
return ManualOverrideReset(
frozenset(cleared),
frozenset(affected),
change.affected_owners,
frozenset(previously_owned - self.owned_entities()),
)
def forget(self, lights: Iterable[str]) -> ManualOverrideReset:
"""Retire entities, clearing contributions and removing their raw keys."""
change = self.clear(lights)
for light in change.cleared_entities:
self.direct.pop(light, None)
return change

View file

@ -0,0 +1,227 @@
"""Plans, execution status and report claims for native light requests."""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field, replace
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, NamedTuple
from .adaptation_utils import LightControlAttributes
if TYPE_CHECKING:
import asyncio
from homeassistant.core import Context
from .switch import AdaptiveSwitch
type Parameters = Mapping[str, Any]
type LightRequests = Mapping[str, Parameters]
def _snapshot_parameters(parameters: Parameters) -> Parameters:
"""Detach scalar and flat colour-vector parameters from mutable payloads."""
return MappingProxyType(
{
key: tuple(value) if isinstance(value, list) else value
for key, value in parameters.items()
},
)
@dataclass(frozen=True)
class NativeGroup:
"""Unambiguous owners for each attribute and the current native members."""
owners: Mapping[LightControlAttributes, AdaptiveSwitch]
members: frozenset[str]
def __post_init__(self) -> None:
"""Freeze the independent attribute owners with the membership snapshot."""
object.__setattr__(self, "owners", MappingProxyType(dict(self.owners)))
@dataclass(frozen=True)
class AttributePolicy:
"""One attribute's adaptation profile and captured settings revision."""
owner: AdaptiveSwitch
version: int
@dataclass(frozen=True)
class NativeIntent:
"""An immutable member intent, including its independent generations."""
colour: Parameters
explicit: LightControlAttributes
version: int
source: str
policies: Mapping[LightControlAttributes, AttributePolicy]
brightness: Parameters = field(default_factory=dict)
brightness_wait: float | None = None
def __post_init__(self) -> None:
"""Detach payloads from mutable preparation and dispatch dictionaries."""
object.__setattr__(self, "colour", _snapshot_parameters(self.colour))
object.__setattr__(self, "brightness", _snapshot_parameters(self.brightness))
object.__setattr__(self, "policies", MappingProxyType(dict(self.policies)))
@property
def attributes(self) -> LightControlAttributes:
"""The independent attributes for which this intent carries a command."""
return (
LightControlAttributes.COLOR if self.colour else LightControlAttributes.NONE
) | (
LightControlAttributes.BRIGHTNESS
if self.brightness
else LightControlAttributes.NONE
)
def select(self, attributes: LightControlAttributes) -> NativeIntent:
"""Retain only selected attributes without changing request identity."""
return replace(
self,
colour=self.colour if LightControlAttributes.COLOR in attributes else {},
brightness=(
self.brightness
if LightControlAttributes.BRIGHTNESS in attributes
else {}
),
explicit=self.explicit & attributes,
policies={
attribute: policy
for attribute, policy in self.policies.items()
if attribute in attributes
},
brightness_wait=(
self.brightness_wait
if LightControlAttributes.COLOR in attributes
else None
),
)
@property
def switch(self) -> AdaptiveSwitch:
"""Return the shared profile after combination eligibility is established."""
return next(iter(self.policies.values())).owner
@property
def parameters(self) -> Parameters:
"""Combine attributes after their timing compatibility has been checked."""
return {**self.brightness, **self.colour}
@dataclass(frozen=True)
class NativePlan:
"""Complete intent, fixed before the original service starts dispatching."""
context: Context
requests: LightRequests
groups: Mapping[str, NativeGroup]
intents: Mapping[str, NativeIntent]
def __post_init__(self) -> None:
"""Freeze the complete request snapshot after preparation finishes."""
object.__setattr__(
self,
"requests",
MappingProxyType(
{
entity: _snapshot_parameters(data)
for entity, data in self.requests.items()
},
),
)
object.__setattr__(self, "groups", MappingProxyType(dict(self.groups)))
object.__setattr__(self, "intents", MappingProxyType(dict(self.intents)))
@dataclass(frozen=True)
class DispatchDecision:
"""Original-handler payload and whether it owns this target's attributes."""
params: Parameters | None
forwarded: LightControlAttributes = LightControlAttributes.NONE
@dataclass
class BatchExecution:
"""Mutable outcomes; forwarding attributes is distinct from accepting power."""
forwarded: dict[str, LightControlAttributes] = field(default_factory=dict)
accepted_members: set[str] = field(default_factory=set)
failed_members: set[str] = field(default_factory=set)
errors: list[BaseException] = field(default_factory=list)
children: set[asyncio.Future[None]] = field(default_factory=set)
closed: bool = False
aborted: bool = False
def forward(
self,
members: Iterable[str],
attributes: LightControlAttributes,
) -> None:
"""The original handler owns these attributes before its result is known."""
for member in members:
self.forwarded[member] = (
self.forwarded.get(member, LightControlAttributes.NONE) | attributes
)
def accept(self, members: Iterable[str]) -> None:
"""The original handler accepted the target; deferred brightness may supply power."""
self.accepted_members.update(members)
def retire(self, members: Iterable[str]) -> None:
"""Unsuccessful or unobserved members must not be recovered or retried."""
self.failed_members.update(members)
def abort(self) -> None:
"""Cancellation or unrecoverable failure invalidates every report claim."""
self.aborted = True
def close(self) -> None:
"""End the invocation while keeping successful late-report claims valid."""
self.closed = True
# _run has already selected the exception to propagate. Late claims
# need outcome flags, not traceback frames retaining the original plan.
self.errors.clear()
@dataclass(frozen=True)
class NativeBatch:
"""An invocation pairs a fixed plan with its independently owned outcomes."""
plan: NativePlan
execution: BatchExecution = field(default_factory=BatchExecution)
@dataclass(frozen=True)
class AdaptiveRevision:
"""A computed adaptation claim is valid only for this owner's member revision."""
owner: AdaptiveSwitch
member: str
version: int
attributes: LightControlAttributes
@dataclass(frozen=True)
class ActivationClaim:
"""One expected report, without retaining the whole request plan."""
execution: BatchExecution
request_versions: tuple[tuple[str, int], ...]
adaptive_revisions: tuple[AdaptiveRevision, ...]
attributes: LightControlAttributes
aggregate_only: bool
required_context_id: str | None = None
class ActivationCoverage(NamedTuple):
"""Axes already covered by one consumed activation claim."""
colour: bool = False
brightness: bool = False
aggregate_only: bool = False

File diff suppressed because it is too large Load diff

View file

@ -160,6 +160,8 @@ from .helpers import (
remove_vowels,
short_hash,
)
from .manual_control import ManualOverrideReset, ManualOverrideState
from .native_groups import NativeGroupPlanner
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine, Iterable
@ -168,6 +170,8 @@ if TYPE_CHECKING:
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import NoEventData
from .native_group_models import ActivationClaim
try:
from homeassistant.helpers.sun import get_astral_observer
except ImportError: # `get_astral_observer` was added in HA 2026.7
@ -242,6 +246,8 @@ def _switches_with_lights(
hass: HomeAssistant,
lights: list[str],
expand_light_groups: bool = True,
*,
include_group_members: bool = False,
) -> AdaptiveSwitches:
"""Get all switches that control at least one of the lights passed."""
config_entries = hass.config_entries.async_entries(DOMAIN)
@ -258,9 +264,12 @@ def _switches_with_lights(
switches: AdaptiveSwitches = []
for switch in loaded_switches:
if switch._removed:
continue
switch._expand_light_groups()
check_lights = switch._resolve_lights(lights) if expand_light_groups else lights
if set(switch.lights) & set(check_lights):
targets = switch._tracked_lights if include_group_members else switch.lights
if set(targets) & set(check_lights):
switches.append(switch)
return switches
@ -403,7 +412,7 @@ async def handle_change_switch_settings(
data,
)
switch.manager.reset(*switch.lights, reset_manual_control=False)
switch._reset_lights(reset_manual_control=False)
if switch.is_on:
await switch._update_attrs_and_maybe_adapt_lights( # pylint: disable=protected-access
context=switch.create_context("service", parent=service_call.context),
@ -465,13 +474,31 @@ async def handle_set_manual_control_service(
if manual_attributes:
for light in all_lights:
switch.manager.set_manual_control_attributes(
light,
manual_attributes,
)
# A scene subgroup may not be a periodic command target. Carry
# its explicit pause to the known members so reactive repairs
# and the broader configured group also respect that pause.
members = _native_group_members(hass, [light])
if (
light not in switch.lights
and members
and members.keys() <= switch._group_members.keys()
):
switch.manager.set_group_manual_control_attributes(
light,
members,
manual_attributes,
owner=switch.unique_id,
)
else:
switch.manager.set_manual_control_attributes(
light,
manual_attributes,
)
switch.fire_manual_control_event(light, service_call.context)
else:
switch.manager.reset(*all_lights)
# Reset a native group's observed member overrides too, but keep
# the ensuing adaptation addressed to the requested group target.
switch._reset_lights(all_lights if lights else None)
if switch.is_on:
context = switch.create_context(
"service",
@ -668,6 +695,34 @@ def _is_light_group(state: State) -> bool:
)
def _native_group_members(
hass: HomeAssistant,
lights: list[str],
) -> dict[str, set[str]]:
"""Map advertised native members to their configured command targets.
HA publishes integration-specific membership as `group_entities`. Observing
it must not expand native group commands into individual periodic updates.
Legacy HA groups retain their existing `entity_id` expansion policy.
"""
parents: dict[str, set[str]] = {}
for light in lights:
state = hass.states.get(light)
if state is None or _is_light_group(state):
continue
members = state.attributes.get("group_entities", [])
if not isinstance(members, (list, tuple)):
continue
for member in members:
if (
isinstance(member, str)
and member.startswith(f"{LIGHT_DOMAIN}.")
and member != light
):
parents.setdefault(member, set()).add(light)
return parents
def _supported_features(hass: HomeAssistant, light: str) -> set[str]:
state = hass.states.get(light)
assert state is not None
@ -910,6 +965,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._interval: timedelta = data[CONF_INTERVAL]
self._configured_lights: list[str] = list(data[CONF_LIGHTS])
self.lights: list[str] = []
self._group_members: dict[str, set[str]] = {}
# backup data for use in change_switch_settings "configuration" CONF_USE_DEFAULTS
self._config_backup = deepcopy(data)
@ -1094,6 +1150,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
"""Remove the listeners upon removing the component."""
self._removed = True
self._remove_listeners()
self.manager.release_profile_manual_sources(self)
self._retire_lights(set(self._tracked_lights))
def _resolve_lights(self, lights: list[str] | None = None) -> list[str]:
"""Apply this profile's group policy, preserving explicit member targets."""
@ -1103,19 +1161,45 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
return _expand_light_groups(self.hass, lights)
return sorted(set(lights))
def _expand_light_groups(self) -> None:
all_lights = self._resolve_lights()
removed = set(self.lights) - set(all_lights)
self.lights = all_lights
@property
def _tracked_lights(self) -> list[str]:
"""Command targets plus native members whose activation we observe."""
return sorted(set(self.lights) | self._group_members.keys())
def _reset_lights(
self,
lights: list[str] | None = None,
*,
reset_manual_control: bool = True,
) -> None:
"""Reset this profile's targets and observed members through the manager."""
self.manager.reset_profile(
self,
lights,
reset_manual_control=reset_manual_control,
)
def _retire_lights(self, removed: set[str]) -> None:
"""Discard tracking only after the last owning profile releases it."""
if removed:
# Other profiles may still own a retired member or group, even when off.
for entry in self.hass.data[DOMAIN].values():
if isinstance(entry, dict) and (switch := entry.get(SWITCH_DOMAIN)):
removed.difference_update(switch._resolve_lights())
# Retire this profile's work even when another profile retains the
# shared tracking records or has its own pending member command.
self.manager.native_groups.invalidate_adaptive(self, removed)
for light in removed:
self.manager.cancel_ongoing_adaptation_calls(light, owner=self)
self.manager.remove_lights(*removed)
self.manager.lights.update(all_lights)
def _expand_light_groups(self) -> None:
if self._removed:
return
previous = set(self._tracked_lights)
self.lights = self._resolve_lights()
self._group_members = _native_group_members(self.hass, self.lights)
tracked = self._tracked_lights
self._retire_lights(previous - set(tracked))
self.manager.lights.update(tracked)
self.manager.set_auto_reset_manual_control_times(
all_lights,
tracked,
self._auto_reset_manual_control_time,
)
@ -1230,26 +1314,29 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
for key in self._settings:
extra_state_attributes[key] = None
return extra_state_attributes
lifecycle_entities = sorted(self.manager.lifecycle_entities(self))
extra_state_attributes["manual_control"] = [
light for light in self.lights if self.manager.manual_control.get(light)
light
for light in lifecycle_entities
if self.manager.get_manual_control_attributes(light)
]
extra_state_attributes["manual_control_brightness"] = [
light
for light in self.lights
if self.manager.manual_control.get(light, LightControlAttributes.NONE)
for light in lifecycle_entities
if self.manager.get_manual_control_attributes(light)
& LightControlAttributes.BRIGHTNESS
]
extra_state_attributes["manual_control_color"] = [
light
for light in self.lights
if self.manager.manual_control.get(light, LightControlAttributes.NONE)
for light in lifecycle_entities
if self.manager.get_manual_control_attributes(light)
& LightControlAttributes.COLOR
]
extra_state_attributes.update(self._settings)
timers = self.manager.auto_reset_manual_control_timers
extra_state_attributes["autoreset_time_remaining"] = {
light: time
for light in self.lights
for light in lifecycle_entities
if (timer := timers.get(light)) and (time := timer.remaining_time()) > 0
}
return extra_state_attributes
@ -1277,7 +1364,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if self.is_on:
return
self._state = True
self.manager.reset(*self.lights)
self._expand_light_groups()
self._reset_lights()
await self._setup_listeners()
if adapt_lights:
await self._update_attrs_and_maybe_adapt_lights(
@ -1292,7 +1380,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
return
self._state = False
self._remove_listeners()
self.manager.reset(*self.lights)
self._reset_lights()
async def _async_update_at_interval_action(
self,
@ -1406,7 +1494,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._send_split_delay / 1000.0,
service_data,
split=self._separate_turn_on_commands,
filter_by_state=self._skip_redundant_commands,
# Forced adaptations must reach the light even when optimistic or
# stale state already matches the requested attributes.
filter_by_state=self._skip_redundant_commands and not force,
force=force,
already_applied=already_applied,
)
@ -1517,8 +1607,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
data,
)
# Execute adaptation calls within a task
task = asyncio.ensure_future(self._execute_adaptation_calls(data))
self.manager.adaptation_task_owners[task] = self
try:
task = asyncio.ensure_future(self._execute_adaptation_calls(data))
if LightControlAttributes.BRIGHTNESS in data.attributes:
self.manager.adaptation_tasks_brightness[data.entity_id] = task
if LightControlAttributes.COLOR in data.attributes:
@ -1531,6 +1622,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
data.entity_id,
data,
)
finally:
self.manager.adaptation_task_owners.pop(task, None)
async def _update_attrs_and_maybe_adapt_lights(
self,
@ -1539,6 +1632,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
lights: list[str] | None = None,
transition: int | None = None,
force: bool = False,
adapt_brightness: bool | None = None,
adapt_color: bool | None = None,
) -> None:
assert context is not None
_LOGGER.debug(
@ -1632,7 +1727,14 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
transition,
context.id,
)
coro = self._adapt_light(light, context, transition, force=force)
coro = self._adapt_light(
light,
context,
transition,
force=force,
adapt_brightness=adapt_brightness,
adapt_color=adapt_color,
)
task = self.hass.async_create_task(
coro,
)
@ -1644,8 +1746,17 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self,
entity_id: str,
event: Event[EventStateChangedData],
*,
activation_claim: ActivationClaim | None = None,
) -> None:
assert not self.manager.is_proactively_adapting(event.context.id)
if self.manager.native_groups.activation_superseded(activation_claim):
return
coverage = self.manager.native_groups.activation_coverage(
entity_id,
activation_claim,
event.context,
)
from_turn_on = self.manager._off_to_on_state_event_is_from_turn_on(
entity_id,
event,
@ -1657,6 +1768,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
or self._manual_control_on_external_turn_on
)
and not from_turn_on
and not coverage.colour
and not coverage.brightness
):
# There is an edge case where 2 switches control the same light, e.g.,
# one for brightness and one for color. Now we will mark both switches
@ -1676,7 +1789,11 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
entity_id,
event.context.id,
)
self.manager.set_manual_control_attributes(entity_id)
# Native group reports need not match individual service contexts.
# The default ignores an observed-only event without pausing the
# parent; the explicit external-turn-on opt-in persists that pause.
if entity_id in self.lights or self._manual_control_on_external_turn_on:
self.manager.set_manual_control_attributes(entity_id)
return
if (
@ -1708,18 +1825,37 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
service_data,
)
# Preserve manual-control bookkeeping above even when the complete
# request already supplied both axes and needs no reactive commands.
if coverage.colour and coverage.brightness:
return
if self._adapt_delay > 0:
await asyncio.sleep(self._adapt_delay)
# Runtime settings may retire this profile's target while the event waits.
if self._removed or entity_id not in self.lights:
if (
self._removed
or not self.is_on
or entity_id not in self._tracked_lights
or self.manager.native_groups.activation_superseded(
activation_claim,
)
):
return
coverage = self.manager.native_groups.activation_coverage(
entity_id,
activation_claim,
event.context,
)
await self._update_attrs_and_maybe_adapt_lights(
context=self.create_context("light_event", parent=event.context),
lights=[entity_id],
transition=self.initial_transition,
force=True,
adapt_brightness=False if coverage.brightness else None,
adapt_color=False if coverage.colour else None,
)
async def _sleep_mode_switch_state_event_action(
@ -1735,8 +1871,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._name,
event,
)
self.manager.reset(
*self.lights,
self._reset_lights(
reset_manual_control=self._reset_manual_control_on_sleep_mode_change,
)
await self._update_attrs_and_maybe_adapt_lights(
@ -1872,8 +2007,9 @@ class AdaptiveLightingManager:
self.sleep_tasks: dict[str, asyncio.Task[None]] = {}
# Locks that prevent light adjusting when waiting for a light to 'turn_off'
self.turn_off_locks: dict[str, asyncio.Lock] = {}
# Tracks which lights are manually controlled
self.manual_control: dict[str, LightControlAttributes] = {}
self._manual_overrides = ManualOverrideState()
# Preserve the raw direct-override dictionary for existing consumers.
self.manual_control = self._manual_overrides.direct
# Track 'state_changed' events of self.lights resulting from this integration
self.our_last_state_on_change: dict[str, list[State]] = {}
# Track last 'service_data' to 'light.turn_on' resulting from this integration
@ -1890,6 +2026,7 @@ class AdaptiveLightingManager:
# Track ongoing split adaptations to be able to cancel them
self.adaptation_tasks_brightness: dict[str, asyncio.Task[None]] = {}
self.adaptation_tasks_color: dict[str, asyncio.Task[None]] = {}
self.adaptation_task_owners: dict[asyncio.Task[None], AdaptiveSwitch] = {}
# Track auto reset of manual_control
self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {}
@ -1916,6 +2053,12 @@ class AdaptiveLightingManager:
self._proactively_adapting_contexts: dict[str, str] = {}
self._context_cnt: int = 0
self.native_groups = NativeGroupPlanner(
self,
self._refresh_native_group_switches,
is_our_context,
)
try:
self.listener_removers.append(
setup_service_call_interceptor(
@ -1923,6 +2066,7 @@ class AdaptiveLightingManager:
LIGHT_DOMAIN,
SERVICE_TURN_ON,
self._service_interceptor_turn_on_handler,
around_func=self.native_groups.around_light,
),
)
@ -1932,6 +2076,7 @@ class AdaptiveLightingManager:
LIGHT_DOMAIN,
SERVICE_TOGGLE,
self._service_interceptor_turn_on_handler,
around_func=self.native_groups.around_light,
),
)
except RuntimeError:
@ -1940,10 +2085,60 @@ class AdaptiveLightingManager:
"falling back to event-reactive mode",
exc_info=True,
)
self.native_groups.setup()
def _refresh_native_group_switches(self) -> AdaptiveSwitches:
"""Refresh membership and retire old ownership before planner selection."""
return _switches_with_lights(
self.hass,
list(self.lights),
expand_light_groups=False,
include_group_members=True,
)
def lifecycle_entities(self, switch: AdaptiveSwitch | None = None) -> set[str]:
"""Read owned event/state targets without changing periodic command selection.
Derived manual sources remain authoritative even for a disabled profile
or when their contribution members have been explicitly reset.
"""
tracked = self.lights if switch is None else set(switch._tracked_lights)
if switch is None:
tracked = tracked | {
entity
for entity, attributes in self.manual_control.items()
if attributes
}
return tracked | self._manual_overrides.owned_entities(
owner=None if switch is None else switch.unique_id,
)
def _unowned_entities(
self,
candidates: Iterable[str],
*,
preserve_manual_control: bool = False,
) -> set[str]:
"""Select tracking that no loaded profile or derived source retains."""
unowned = {
entity
for entity in candidates
if not preserve_manual_control
or not self.get_manual_control_attributes(entity)
}
for entry in self.hass.data[DOMAIN].values():
if (
isinstance(entry, dict)
and (switch := entry.get(SWITCH_DOMAIN))
and not switch._removed
):
unowned.difference_update(self.lifecycle_entities(switch))
return unowned
def disable(self) -> None:
"""Disable listeners and pending manual-reset and transition timers."""
for remove in self.listener_removers:
self.native_groups.disable()
for remove in reversed(self.listener_removers):
remove()
for timer in self.auto_reset_manual_control_timers.values():
timer.cancel()
@ -2004,6 +2199,9 @@ class AdaptiveLightingManager:
switch_to_eids: AdaptiveSwitchMap = {}
skipped: list[str] = []
for entity_id in entity_ids:
if self.native_groups.claimed(entity_id):
skipped.append(entity_id)
continue
try:
switch = _switch_with_lights(
self.hass,
@ -2033,7 +2231,7 @@ class AdaptiveLightingManager:
# Prevent adaptation of TURN_ON calls when light is already on,
# and of TOGGLE calls when toggling off.
or self.hass.states.is_state(entity_id, STATE_ON)
or self.manual_control.get(entity_id, False)
or self.get_manual_control_attributes(entity_id)
or (
switch._take_over_control
and switch._adapt_only_on_bare_turn_on
@ -2050,7 +2248,7 @@ class AdaptiveLightingManager:
skipped,
switch.is_on,
self.hass.states.is_state(entity_id, STATE_ON),
self.manual_control.get(entity_id, False),
self.get_manual_control_attributes(entity_id),
switch._intercept,
)
skipped.append(entity_id)
@ -2411,7 +2609,30 @@ class AdaptiveLightingManager:
light: str,
) -> LightControlAttributes:
"""Get the attributes for a light that are manually controlled."""
return self.manual_control.get(light, LightControlAttributes.NONE)
return self._manual_overrides.effective(light)
def set_group_manual_control_attributes(
self,
group: str,
members: Iterable[str],
attributes: LightControlAttributes,
*,
owner: str,
) -> None:
"""Replace one subgroup's contribution without changing member overrides."""
previously_owned = self._manual_overrides.owned_entities()
affected = self._manual_overrides.replace_source(
group,
members,
attributes,
owner=owner,
)
for member in affected:
self._manual_control_attributes_changed(member)
self.remove_lights(
*(previously_owned - self._manual_overrides.owned_entities()),
preserve_manual_control=True,
)
def set_manual_control_attributes(
self,
@ -2425,8 +2646,14 @@ class AdaptiveLightingManager:
attributes,
self.get_manual_control_attributes(light),
)
self.manual_control[light] = attributes
for affected in self._manual_overrides.set_direct(light, attributes):
self._manual_control_attributes_changed(affected)
def _manual_control_attributes_changed(self, light: str) -> None:
"""Refresh the effective override's timer and publish it to its profiles."""
delay = self.auto_reset_manual_control_times.get(light)
if not self.get_manual_control_attributes(light):
delay = None
async def reset() -> None:
_LOGGER.debug(
@ -2435,11 +2662,12 @@ class AdaptiveLightingManager:
light,
delay,
)
self.reset(light)
self._apply_manual_override_reset(self._manual_overrides.expire(light))
switches = _switches_with_lights(
self.hass,
[light],
expand_light_groups=False,
include_group_members=True,
)
for switch in switches:
if not switch.is_on:
@ -2454,7 +2682,11 @@ class AdaptiveLightingManager:
self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset)
self._schedule_manual_control_state_update(light)
def _schedule_manual_control_state_update(self, *lights: str) -> None:
def _schedule_manual_control_state_update(
self,
*lights: str,
owners: frozenset[str] = frozenset(),
) -> None:
"""Publish shared manual-control state on every affected switch."""
# State publication must not expand groups or change tracked lights.
for entry in self.hass.config_entries.async_entries(DOMAIN):
@ -2462,7 +2694,14 @@ class AdaptiveLightingManager:
if entry_data is None:
continue
switch = entry_data.get(SWITCH_DOMAIN)
if switch is not None and set(lights).intersection(switch.lights):
if (
switch is not None
and not switch._removed
and (
switch.unique_id in owners
or set(lights).intersection(self.lifecycle_entities(switch))
)
):
switch.async_schedule_update_ha_state()
def add_manual_control_attributes(
@ -2471,15 +2710,13 @@ class AdaptiveLightingManager:
attributes: LightControlAttributes,
) -> None:
"""Add attributes to the manual control status of a light."""
current = self.get_manual_control_attributes(light)
_LOGGER.debug(
"Light %s: Adding manual control attributes %s (current: %s).",
"Light %s: Adding direct manual control attributes %s.",
light,
attributes,
current,
)
new = current | attributes
self.set_manual_control_attributes(light, new)
for affected in self._manual_overrides.add_direct(light, attributes):
self._manual_control_attributes_changed(affected)
def invalidate_manual_control_state(
self,
@ -2565,6 +2802,19 @@ class AdaptiveLightingManager:
color is also not adapted.
"""
denied_adaptation_attributes = self.get_manual_control_attributes(light)
# A member repair must respect its parent's manual override. Conversely,
# a native group command cannot exclude a manually controlled member, so
# suppress that axis for the group rather than overwrite the member.
related = set(switch._group_members.get(light, ()))
related.update(
member
for member, parents in switch._group_members.items()
if light in parents
)
for entity_id in related:
denied_adaptation_attributes |= self.get_manual_control_attributes(
entity_id,
)
if (
denied_adaptation_attributes.has_any()
@ -2592,18 +2842,31 @@ class AdaptiveLightingManager:
def cancel_ongoing_adaptation_calls(
self,
light_id: str,
*,
owner: AdaptiveSwitch | None = None,
) -> None:
"""Cancel ongoing adaptation service calls for a specific light entity."""
brightness_task = self.adaptation_tasks_brightness.get(light_id)
color_task = self.adaptation_tasks_color.get(light_id)
if brightness_task is not None and not brightness_task.done():
if (
brightness_task is not None
and not brightness_task.done()
and (
owner is None
or self.adaptation_task_owners.get(brightness_task) is owner
)
):
_LOGGER.debug(
"Cancelled ongoing brightness adaptation calls (%s) for '%s'",
brightness_task,
light_id,
)
brightness_task.cancel()
if color_task is not None and not color_task.done():
if (
color_task is not None
and not color_task.done()
and (owner is None or self.adaptation_task_owners.get(color_task) is owner)
):
_LOGGER.debug(
"Cancelled ongoing color adaptation calls (%s) for '%s'",
color_task,
@ -2612,28 +2875,148 @@ class AdaptiveLightingManager:
# color_task might be the same as brightness_task
color_task.cancel()
def reset(self, *lights: str, reset_manual_control: bool = True) -> None:
"""Reset the 'manual_control' status of the lights."""
def reset_profile(
self,
switch: AdaptiveSwitch,
lights: list[str] | None = None,
*,
reset_manual_control: bool = True,
) -> None:
"""Reset profile intent while retaining the established ownership policy.
Configured or explicitly requested command targets retain the global
cancellation behavior. Observed native members cancel only this
profile's work. Both reset shared history and, when requested, overrides.
"""
targets = switch.lights if lights is None else lights
self.native_groups.invalidate_adaptive(switch, targets)
group_members = set(_native_group_members(self.hass, targets))
observed = group_members - set(targets)
source_owner = switch.unique_id if lights is None or group_members else None
if reset_manual_control:
if lights is None:
self.release_profile_manual_sources(switch)
elif group_members:
self.release_profile_manual_sources(
switch,
within_members=group_members,
)
self.reset(
*targets,
reset_manual_control=reset_manual_control,
source_owner=switch.unique_id if lights is None else None,
)
self.reset(
*observed,
reset_manual_control=reset_manual_control,
cancel_owner=switch,
source_owner=source_owner,
)
def release_profile_manual_sources(
self,
switch: AdaptiveSwitch,
*,
within_members: Iterable[str] | None = None,
) -> None:
"""Release only the derived subgroup pauses created by this profile."""
self._apply_manual_override_reset(
self._manual_overrides.clear_owner(
switch.unique_id,
within_members=within_members,
),
cancel_owner=switch,
)
def reset(
self,
*lights: str,
reset_manual_control: bool = True,
cancel_owner: AdaptiveSwitch | None = None,
source_owner: str | None = None,
) -> None:
"""Reset per-entity adaptation history, pending work and optional overrides."""
if reset_manual_control:
self._apply_manual_override_reset(
self._manual_overrides.clear(lights, source_owner=source_owner),
cancel_owner=cancel_owner,
)
else:
self._reset_tracking(lights, cancel_owner=cancel_owner)
def _reset_tracking(
self,
lights: Iterable[str],
*,
reset_manual_control: bool = False,
cancel_owner: AdaptiveSwitch | None = None,
) -> None:
"""Clear manager-owned history and pending work for reset entities."""
for light in lights:
if reset_manual_control:
_LOGGER.debug(
"Light %s: Clearing manual control attributes.",
light,
)
self.manual_control[light] = LightControlAttributes.NONE
self.last_manual_control_state.pop(light, None)
self.pending_manual_control_state.pop(light, None)
if timer := self.auto_reset_manual_control_timers.pop(light, None):
timer.cancel()
self.our_last_state_on_change.pop(light, None)
self.last_service_data.pop(light, None)
self.cancel_ongoing_adaptation_calls(light)
if reset_manual_control:
self._schedule_manual_control_state_update(*lights)
self.cancel_ongoing_adaptation_calls(light, owner=cancel_owner)
def remove_lights(self, *lights: str) -> None:
def _apply_manual_override_reset(
self,
change: ManualOverrideReset,
*,
cancel_owner: AdaptiveSwitch | None = None,
) -> None:
"""Apply lifecycle effects after the override state has been cleared."""
retired = self._unowned_entities(
change.cleared_entities | change.released_entities,
preserve_manual_control=True,
)
if retired:
# Explicit resets retain their legacy raw NONE entries even when
# no profile needs the entity's event/timer tracking anymore.
forgotten = self._manual_overrides.forget(retired - change.cleared_entities)
change = ManualOverrideReset(
change.cleared_entities | forgotten.cleared_entities,
change.affected_entities | forgotten.affected_entities,
change.affected_owners | forgotten.affected_owners,
change.released_entities | forgotten.released_entities,
)
self._reset_tracking(
change.cleared_entities,
reset_manual_control=True,
cancel_owner=cancel_owner,
)
for light in change.affected_entities:
if not self.get_manual_control_attributes(light) and (
timer := self.auto_reset_manual_control_timers.pop(light, None)
):
timer.cancel()
self._schedule_manual_control_state_update(
*change.affected_entities,
owners=change.affected_owners,
)
self._remove_tracking_records(retired)
def remove_lights(
self,
*lights: str,
preserve_manual_control: bool = False,
) -> None:
"""Retire tracking and pending work for targets no profile owns anymore."""
self.reset(*lights)
retired = self._unowned_entities(
lights,
preserve_manual_control=preserve_manual_control,
)
self._apply_manual_override_reset(self._manual_overrides.forget(retired))
self._remove_tracking_records(retired)
def _remove_tracking_records(self, lights: Iterable[str]) -> None:
"""Discard timers and event records after retiring unowned manual state."""
for light in lights:
self.lights.discard(light)
self.clear_proactively_adapting(light)
@ -2642,7 +3025,6 @@ class AdaptiveLightingManager:
if task := self.sleep_tasks.pop(light, None):
task.cancel()
for records in (
self.manual_control,
self.auto_reset_manual_control_times,
self.turn_on_event,
self.turn_off_event,
@ -2672,7 +3054,7 @@ class AdaptiveLightingManager:
service_data = event.data[ATTR_SERVICE_DATA]
entity_ids = self._get_entity_list(service_data)
if not any(eid in self.lights for eid in entity_ids):
if not self.lifecycle_entities().intersection(entity_ids):
return
def off(eid: str, event: Event) -> None:
@ -2695,6 +3077,7 @@ class AdaptiveLightingManager:
self.hass,
[eid],
expand_light_groups=False,
include_group_members=True,
)
for switch in switches:
# Preserve tracking for a lone profile, including when off.
@ -2758,11 +3141,17 @@ class AdaptiveLightingManager:
) -> None:
"""Track 'state_changed' events."""
entity_id = event.data.get(ATTR_ENTITY_ID, "")
if entity_id not in self.lights:
if entity_id not in self.lifecycle_entities():
return
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
old_members = old_state.attributes.get("group_entities") if old_state else None
new_members = new_state.attributes.get("group_entities") if new_state else None
if old_members != new_members:
# Discovery, registry renames and membership updates can arrive after
# profile setup. Refresh tracking before the next member event.
_switches_with_lights(self.hass, [entity_id], expand_light_groups=False)
new_on = (
new_state if new_state is not None and new_state.state == STATE_ON else None
@ -2831,6 +3220,7 @@ class AdaptiveLightingManager:
)
if old_on and not new_on:
self.native_groups.invalidate([entity_id])
# Availability loss invalidates pending commands, not manual state.
self.cancel_ongoing_adaptation_calls(entity_id)
@ -2844,6 +3234,12 @@ class AdaptiveLightingManager:
event.context.id,
)
elif old_off and new_on:
activation_claim = self.native_groups.take_activation_claim(entity_id)
coverage = self.native_groups.activation_coverage(
entity_id,
activation_claim,
event.context,
)
# Tracks 'off' → 'on' state changes
self.off_to_on_event[entity_id] = event
_LOGGER.debug(
@ -2852,7 +3248,9 @@ class AdaptiveLightingManager:
event.context.id,
)
if self.is_proactively_adapting(event.context.id):
if coverage.aggregate_only or self.is_proactively_adapting(
event.context.id,
):
_LOGGER.debug(
"Skipping responding to 'off' → 'on' event for '%s' with context.id='%s' because"
" we are already proactively adapting",
@ -2877,12 +3275,14 @@ class AdaptiveLightingManager:
self.hass,
[entity_id],
expand_light_groups=False,
include_group_members=True,
)
for switch in switches:
if switch.is_on:
await switch._respond_to_off_to_on_event(
entity_id,
event,
activation_claim=activation_claim,
)
async def update_manually_controlled_from_event(

View file

@ -20,7 +20,8 @@ This feature is available when `take_over_control` is enabled.
Additionally, enabling `detect_non_ha_changes` allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings.
The `adaptive_lighting.manual_control` event is fired when a light is marked as "manually controlled," allowing for integration with automations 🤖.
With `expand_light_groups: false`, manual control belongs to the group. A direct member change cannot pause adaptation for only that member; use group-level manual control or enable expansion for individual tracking.
For Home Assistant light groups with `expand_light_groups: false`, manual control belongs to the group. A direct member change cannot pause adaptation for only that member; use group-level manual control or enable expansion for individual tracking.
Native groups that advertise `group_entities` also track their members. A newly turned-on member receives an individual adaptation, even if the group's cached settings already match; periodic adaptations still target the native group. A member's manual override pauses that attribute for the group because a group command cannot exclude one member. Resetting manual control for the group also clears its member overrides. Explicitly pausing a smaller native scene group carries that pause to its known members, protecting them from individual repairs and broader periodic group updates until reset. Resetting a native parent group, resetting the whole profile, or cycling its switch OFF/ON also releases the smaller-group pauses created through that profile. Turning a paused smaller group OFF releases its pause. Its group ID and affected members appear in the profile's manual-control attributes, including a group pause that remains after individual member resets. Unloading a profile releases only its own smaller-group pauses; pauses owned by another loaded profile remain active unless explicitly reset.
Explicit member targets in Adaptive Lighting services stay individual targets and do not mark or command the whole group.
Changing expansion at runtime discards tracking and pending adaptation for targets no longer used by any profile.

View file

@ -89,10 +89,16 @@ This sends a single broadcast command to adjust all bulbs, improving response ti
As a rule of thumb, if you always control lights together (e.g., bulbs in a ceiling fixture), they should be in a Zigbee group.
Expose only the group (not individual bulbs) in Home Assistant Dashboards and external systems like Google Home or Apple HomeKit.
> :warning: **If you control lights individually, `manual_control` cannot behave correctly! If you need to control lights individually as well, use a [Home Assistant Light Group](https://www.home-assistant.io/integrations/group/).**
When Home Assistant exposes a native group's `group_entities` attribute, Adaptive Lighting observes individual member activations while keeping periodic updates targeted at the group. An isolated member activation receives its normal individual brightness and colour corrections, according to the enabled adaptation switches and `adapt_delay`, even when the group's cached settings already match. Without membership metadata, configure individual lights or a [Home Assistant Light Group](https://www.home-assistant.io/integrations/group/) if member tracking is needed.
A complete `light.turn_on` request, `scene.apply`, or Home Assistant scene activation can consolidate brightness and colour independently. Each shared attribute requires every native-group member to be intended ON, eligible for that attribute, and receive the same compatible value and transition. Already-ON members count towards scene coverage. Matching brightness can use one group command while different colours remain individual; matching colour can use one group command while different brightness levels remain individual. Matching brightness and colour can share a combined group command when the same profile controls both. Separate brightness and colour profiles retain separate group commands, and competing profiles prevent grouping only for the attribute they share. Brightness grouping also works when colour adaptation is disabled. Smaller scene groups are supported when all their advertised members have one unambiguous native-group owner for each grouped attribute.
Explicit scene values keep their requested brightness, colour and transition. When both attributes are computed by Adaptive Lighting, `separate_turn_on_commands` divides the requested transition between their commands. Colour waits for the brightness transition plus `send_split_delay`, including when one attribute uses a group and the other uses individual members. Explicit scene commands retain their requested transition rather than using this adaptive split policy. Partial requests, differing values or transitions, manual overrides and incompatible capabilities prevent grouping for the affected attribute. Unrelated groups proceed concurrently, and explicit values do not wait for unrelated adaptive delays.
Explicit power settings, Home Assistant light-profile defaults and permissions remain applicable, as do `only_once` and `skip_redundant_commands`. Individual corrections follow the existing external-turn-on policy. A delayed report from an accepted command does not duplicate the brightness or colour already supplied; newer requests and OFF events supersede pending work. Integration-owned scenes that do not expose their desired states cannot provide complete coverage, and independent service calls are handled separately even if they share a context or arrive close together.
When mixing group types, avoid nesting: do not add integration-level groups (e.g., Zigbee2MQTT groups) to a [Home Assistant Light Group](https://www.home-assistant.io/integrations/group/) that is managed by Adaptive Lighting, and do not nest Home Assistant Light Groups inside each other.
Adaptive Lighting cannot expand an integration-level group into its member lights, and nested groups make it unpredictable which entity Adaptive Lighting tracks and adapts, which can prevent lights from being adapted at all (see [#1378](https://github.com/basnijholt/adaptive-lighting/issues/1378)).
Native-group member observation does not expand periodic updates into individual commands. Nested groups make it unpredictable which entity Adaptive Lighting tracks and adapts, which can prevent lights from being adapted at all (see [#1378](https://github.com/basnijholt/adaptive-lighting/issues/1378)).
Instead, add the individual light entities or a single Zigbee group directly to the Adaptive Lighting configuration.
Also note that bulbs turned on via a Zigbee group broadcast may briefly flash their last (cached) brightness and color before the adapted values arrive; this happens inside the bulbs and cannot be prevented by Home Assistant or Adaptive Lighting.

View file

@ -11,6 +11,22 @@ Coverage measures executed code, not whether assertions would catch a bug. Add t
The tests in `test_automation_examples.py` load YAML directly from `README.md` and execute it through Home Assistant's automation and script engines. Edit the README source when changing those examples, then run `./scripts/update-generated-content` to update the documentation pages.
## Native-group test support
Native-group tests are organised by behaviour (`test_native_scenes.py`, `test_native_dispatch.py`, `test_native_lifecycle.py`, and the related manual-control, ownership, permission and compatibility modules). `test_native_brightness.py` covers independent brightness and colour grouping, including brightness-only operation, explicit scene values and computed split commands. `test_native_brightness_coverage.py` covers delayed activation reports, attribute coverage and timing when the two attributes use different targets. `test_native_retirement.py` covers profile removal while commands or activation reports are waiting, including preservation of explicit scene attributes and work retained by other owners or members. Keep shared fixtures in `support/` instead of importing them from another test module.
`support/native_lights.py` models physical colour separately from the colour reported to HA. Recording devices add a device-command trace and support delayed reports. Direct HA light-service tests in `test_native_light_model.py` check the model without Adaptive Lighting: bare or transition-only ON and brightness commands power group members, colour-only commands preserve power, and group OFF reaches every member while retaining its attributes. A broadcast records one group command; member reports are independent of that command count. `support/native_scenarios.py` installs these devices and exposes three deliberate starting states:
| Setup helper | Starting state |
| --- | --- |
| `setup_native_group` | One ON member physically matches the target; OFF members have matching caches but stale physical colours. |
| `setup_scene_group` | Mixed member power; every physical colour is stale while all reported caches match the target. |
| `setup_reported_group` | Mixed member power; physical and reported colours agree at a value away from the target. |
Use `prepare_reported_members` to arrange consistent physical/report states while reactive work is disabled. Use the device trace for outgoing command assertions and `support/adaptive.py` for shared profile setup or observations at the HA service boundary. These observations represent different boundaries and are not interchangeable.
The device trace omits the legacy `color_temp` alias only when it exactly matches the mired conversion of the supplied `color_temp_kelvin`. Contradictory aliases and mired-only commands remain visible. The physical device model receives the original parameters unchanged, so this projection only removes duplicate representations across Home Assistant versions.
## Prerequisites
Before running tests with Docker, you need a local Home Assistant core checkout with symlinks:

View file

@ -0,0 +1 @@
"""Reusable fixtures and device models for Adaptive Lighting tests."""

53
tests/support/adaptive.py Normal file
View file

@ -0,0 +1,53 @@
"""Integration setup and observations at the Home Assistant service boundary."""
from homeassistant.components.adaptive_lighting.const import (
CONF_INTERCEPT,
DEFAULT_NAME,
DOMAIN,
)
from homeassistant.components.adaptive_lighting.switch import (
AdaptiveSwitch,
is_our_context,
)
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_NAME, EVENT_CALL_SERVICE, SERVICE_TURN_ON
from homeassistant.core import callback
from tests.common import MockConfigEntry
async def setup_switch(hass, extra_data) -> tuple[MockConfigEntry, AdaptiveSwitch]:
"""Create the switch entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_NAME: DEFAULT_NAME,
CONF_INTERCEPT: False,
**extra_data,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN]
return entry, switch
def track_adaptive_light_calls(hass, *, ours_only=True):
"""Capture commands emitted by Adaptive Lighting at the HA service boundary."""
calls = []
@callback
def track(event):
if (
event.data["domain"] == LIGHT_DOMAIN
and event.data["service"] == SERVICE_TURN_ON
and (not ours_only or is_our_context(event.context))
):
calls.append(event.data["service_data"])
hass.bus.async_listen(EVENT_CALL_SERVICE, track)
return calls

View file

@ -0,0 +1,54 @@
"""Explicit scheduling boundaries for integration race tests."""
import asyncio
import sys
from functools import wraps
class DelayBarrier:
"""Pause one owner's coroutine at its first sleep, without changing asyncio.
Only the selected coroutine's exact task uses this barrier. Other tasks and
all other asyncio operations delegate to the original module. Pausing at the
sleep itself preserves the production checks immediately after the delay.
"""
def __init__(self, monkeypatch, owner, method_name):
"""Install a module-local proxy and track invocations of one bound method."""
self.entered = asyncio.Event()
self.release = asyncio.Event()
self.delay = None
active_tasks = set()
original = getattr(owner, method_name)
module = sys.modules[original.__module__]
original_asyncio = module.asyncio
barrier = self
class ScopedAsyncio:
"""Delegate normally except for the selected task's first delay."""
def __getattr__(self, name):
return getattr(original_asyncio, name)
async def sleep(self, delay, result=None):
if (
asyncio.current_task() not in active_tasks
or barrier.entered.is_set()
):
return await original_asyncio.sleep(delay, result)
barrier.delay = delay
barrier.entered.set()
await asyncio.wait_for(barrier.release.wait(), 5)
return result
@wraps(original)
async def tracked(*args, **kwargs):
task = asyncio.current_task()
active_tasks.add(task)
try:
return await original(*args, **kwargs)
finally:
active_tasks.discard(task)
monkeypatch.setattr(module, "asyncio", ScopedAsyncio())
monkeypatch.setattr(owner, method_name, tracked)

View file

@ -0,0 +1,214 @@
"""Native device models separating physical colour, reports and group traffic."""
# pylint: disable=protected-access
from dataclasses import dataclass
from typing import ClassVar
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_TRANSITION,
ColorMode,
LightEntity,
LightEntityFeature,
)
from homeassistant.util.color import color_temperature_kelvin_to_mired
# Older supported Home Assistant releases do not export this attribute name.
ATTR_GROUP_ENTITIES = "group_entities"
GROUP = "light.native_group"
LEFT = "light.native_left"
RIGHT = "light.native_right"
OFF_MEMBER = "light.native_off_member"
TARGET_KELVIN = 3000
def command_params(params):
"""Omit only a proven redundant legacy alias from the command trace."""
recorded = dict(params)
if (kelvin := recorded.get(ATTR_COLOR_TEMP_KELVIN)) is not None and recorded.get(
"color_temp",
) == color_temperature_kelvin_to_mired(kelvin):
recorded.pop("color_temp", None)
return recorded
class NativeMember(LightEntity):
"""A real HA light whose cached and physical colour may differ.
A native group colour command updates the advertised cache even for OFF
bulbs, while their physical colour remains unchanged. A bare turn-on then
exposes that cache, reproducing the observed MQTT/native-group behaviour.
"""
_attr_should_poll = False
_attr_supported_color_modes: ClassVar = {ColorMode.COLOR_TEMP}
_attr_color_mode = ColorMode.COLOR_TEMP
_attr_min_color_temp_kelvin = 2000
_attr_max_color_temp_kelvin = 6500
_attr_supported_features = LightEntityFeature.TRANSITION
def __init__(self, entity_id):
"""Start with an optimistic target cache and stale physical colour."""
self.entity_id = entity_id
self._attr_name = entity_id
self._attr_is_on = False
self._attr_brightness = 110
self._attr_color_temp_kelvin = TARGET_KELVIN
self.physical_kelvin = 4000
async def async_turn_on(self, **kwargs):
"""Apply explicitly requested attributes and retain cached bare ON."""
self._attr_is_on = True
if ATTR_BRIGHTNESS in kwargs:
self._attr_brightness = kwargs[ATTR_BRIGHTNESS]
if ATTR_COLOR_TEMP_KELVIN in kwargs:
self.physical_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
self._attr_color_temp_kelvin = self.physical_kelvin
self.async_write_ha_state()
async def async_turn_off(self, **kwargs): # noqa: ARG002
"""Switch off without changing the retained physical attributes."""
self._attr_is_on = False
self.async_write_ha_state()
class NativeGroup(NativeMember):
"""A native group keeps one command target and publishes HA membership."""
def __init__(self, members):
"""Publish members independently of the command target."""
super().__init__(GROUP)
self.members = members
self._attr_is_on = True
@property
def extra_state_attributes(self):
"""Expose HA's native integration-group membership attribute."""
return {ATTR_GROUP_ENTITIES: [member.entity_id for member in self.members]}
async def async_turn_on(self, **kwargs):
"""Model group colour broadcasts updating optimistic member caches."""
# With a lit group, a CT-only update leaves OFF members off. Brightness
# or a bare power command can power all members, so tests catch unsafe
# group-wide repair of an individual member's activation.
changes_power = ATTR_BRIGHTNESS in kwargs or kwargs.keys() <= {ATTR_TRANSITION}
if changes_power:
self._attr_is_on = True
if ATTR_BRIGHTNESS in kwargs:
self._attr_brightness = kwargs[ATTR_BRIGHTNESS]
if ATTR_COLOR_TEMP_KELVIN in kwargs:
self._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
self.physical_kelvin = self._attr_color_temp_kelvin
for member in self.members:
if changes_power:
member._attr_is_on = True
if ATTR_BRIGHTNESS in kwargs:
member._attr_brightness = kwargs[ATTR_BRIGHTNESS]
if ATTR_COLOR_TEMP_KELVIN in kwargs:
member._attr_color_temp_kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN]
if member.is_on:
member.physical_kelvin = member._attr_color_temp_kelvin
member.async_write_ha_state()
self.async_write_ha_state()
async def async_turn_off(self, **kwargs): # noqa: ARG002
"""One native OFF broadcast changes every member, with independent reports."""
for member in self.members:
member._attr_is_on = False
member.async_write_ha_state()
self._attr_is_on = False
self.async_write_ha_state()
@dataclass
class NativeSetup:
"""The profile and modeled devices used by each isolated regression."""
entry: object
switch: object
group: NativeGroup
left: NativeMember
right: NativeMember
off_member: NativeMember
class RecordingMember(NativeMember):
"""Record every device command, regardless of who issues the service call."""
def __init__(self, entity_id, commands):
"""Keep one shared device-boundary trace for ordering assertions."""
super().__init__(entity_id)
self.commands = commands
self.attempts = []
self.hold_reports = False
self.before_turn_on = None
async def async_turn_on(self, **kwargs):
"""Distinguish attempted commands from those the device actually applies."""
command = {
"target": self.entity_id,
"service": "turn_on",
**command_params(kwargs),
}
self.attempts.append(command)
if self.before_turn_on is not None:
callback, self.before_turn_on = self.before_turn_on, None
await callback()
await super().async_turn_on(**kwargs)
self.commands.append(command)
async def async_turn_off(self, **kwargs):
"""Record explicit power intent as well as colour and brightness."""
command = {"target": self.entity_id, "service": "turn_off", **kwargs}
self.attempts.append(command)
await super().async_turn_off(**kwargs)
self.commands.append(command)
def async_write_ha_state(self):
"""Optionally delay a real ON report until after scene dispatch finishes."""
if not self.hold_reports:
super().async_write_ha_state()
class RecordingGroup(NativeGroup):
"""Record native broadcasts without miscounting them as member commands."""
def __init__(self, members, commands):
"""Use the same native-group physical and optimistic-cache model."""
super().__init__(members)
self.commands = commands
async def async_turn_on(self, **kwargs):
"""One native group call represents one outgoing group command."""
self.commands.append(
{
"target": self.entity_id,
"service": "turn_on",
**command_params(kwargs),
},
)
await super().async_turn_on(**kwargs)
async def async_turn_off(self, **kwargs):
"""Record one group power command without inventing member unicasts."""
self.commands.append(
{"target": self.entity_id, "service": "turn_off", **kwargs},
)
await super().async_turn_off(**kwargs)
@dataclass
class SceneSetup:
"""The modeled native group and a trace captured after setup traffic."""
switch: object
group: RecordingGroup
members: list[RecordingMember]
commands: list[dict]
@property
def colour_commands(self):
"""Count scene commands and adaptive corrections equally."""
return [call for call in self.commands if ATTR_COLOR_TEMP_KELVIN in call]

View file

@ -0,0 +1,301 @@
"""Named native-group scenarios and their public Home Assistant service helpers."""
# pylint: disable=protected-access
from homeassistant.components.adaptive_lighting.const import (
CONF_EXPAND_LIGHT_GROUPS,
CONF_INITIAL_TRANSITION,
CONF_MANUAL_CONTROL,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_BRIGHTNESS,
CONF_MIN_COLOR_TEMP,
CONF_SKIP_REDUNDANT_COMMANDS,
CONF_SLEEP_COLOR_TEMP,
CONF_TAKE_OVER_CONTROL,
CONF_TRANSITION,
DOMAIN,
SERVICE_SET_MANUAL_CONTROL,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_TRANSITION,
)
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_LIGHTS,
SERVICE_TURN_ON,
STATE_ON,
)
from homeassistant.core import Context
from homeassistant.setup import async_setup_component
from .adaptive import setup_switch
from .native_lights import (
ATTR_GROUP_ENTITIES,
GROUP,
LEFT,
OFF_MEMBER,
RIGHT,
TARGET_KELVIN,
NativeGroup,
NativeMember,
NativeSetup,
RecordingGroup,
RecordingMember,
SceneSetup,
)
async def _install_group(hass, members, group, *, scene_services=False, **options):
"""Register the modeled devices and a native-target adaptation profile."""
assert await async_setup_component(hass, "light", {})
if scene_services:
assert await async_setup_component(hass, "scene", {"scene": []})
await hass.data[DATA_COMPONENT].async_add_entities([*members, group])
return await setup_switch(
hass,
{
CONF_LIGHTS: [GROUP],
CONF_EXPAND_LIGHT_GROUPS: False,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_MIN_COLOR_TEMP: TARGET_KELVIN,
CONF_MAX_COLOR_TEMP: TARGET_KELVIN,
CONF_SLEEP_COLOR_TEMP: TARGET_KELVIN,
CONF_SKIP_REDUNDANT_COMMANDS: True,
CONF_TAKE_OVER_CONTROL: False,
**options,
},
)
async def _prepare_members(
hass,
switch,
group,
members,
*,
on,
physical_kelvin,
cached_kelvin,
group_brightness=None,
brightness_adaptation=False,
):
"""Set independent physical/cache values while reactive adaptation is off."""
await switch.async_turn_off()
if brightness_adaptation:
await switch.adapt_brightness_switch.async_turn_on()
else:
await switch.adapt_brightness_switch.async_turn_off()
for member, enabled, brightness, physical, cached in zip(
members,
on,
(80, 110, 26),
physical_kelvin,
cached_kelvin,
strict=True,
):
member._attr_is_on = enabled
member._attr_brightness = brightness
member.physical_kelvin = physical
member._attr_color_temp_kelvin = cached
member.async_write_ha_state()
group._attr_is_on = any(on)
group._attr_color_temp_kelvin = cached_kelvin[0]
if group_brightness is not None:
group._attr_brightness = group_brightness
group.async_write_ha_state()
await hass.async_block_till_done()
await switch.async_turn_on(adapt_lights=False)
await hass.async_block_till_done()
async def setup_native_group(hass, **options):
"""A lit member matches target; OFF members have optimistic, stale caches."""
left, right, off_member = members = [
NativeMember(entity_id) for entity_id in (LEFT, RIGHT, OFF_MEMBER)
]
group = NativeGroup(members)
entry, switch = await _install_group(
hass,
members,
group,
**{CONF_MIN_BRIGHTNESS: 50, CONF_MAX_BRIGHTNESS: 50, **options},
)
await _prepare_members(
hass,
switch,
group,
members,
on=(True, False, False),
physical_kelvin=(TARGET_KELVIN, 4000, 3600),
cached_kelvin=(TARGET_KELVIN,) * 3,
group_brightness=80,
)
assert switch.lights == [GROUP]
assert ATTR_ENTITY_ID not in hass.states.get(GROUP).attributes
assert hass.states.get(GROUP).attributes[ATTR_GROUP_ENTITIES] == [
LEFT,
RIGHT,
OFF_MEMBER,
]
return NativeSetup(entry, switch, group, left, right, off_member)
async def _recorded_group(hass, **options):
"""Install a native group that records commands at each device boundary."""
commands = []
members = [
RecordingMember(entity_id, commands) for entity_id in (LEFT, RIGHT, OFF_MEMBER)
]
group = RecordingGroup(members, commands)
_, switch = await _install_group(
hass,
members,
group,
scene_services=True,
**options,
)
return SceneSetup(switch, group, members, commands)
async def setup_scene_group(hass):
"""Mixed power: every physical colour is stale but all caches match target."""
setup = await _recorded_group(hass)
await _prepare_members(
hass,
setup.switch,
setup.group,
setup.members,
on=(True, False, False),
physical_kelvin=(4000,) * 3,
cached_kelvin=(TARGET_KELVIN,) * 3,
)
setup.commands.clear()
return setup
async def setup_reported_group(hass, **config):
"""Mixed power: physical and reported colour agree, away from target."""
setup = await _recorded_group(
hass,
**{CONF_MIN_BRIGHTNESS: 50, CONF_MAX_BRIGHTNESS: 50, **config},
)
await prepare_reported_members(hass, setup, on=(True, False, False))
return setup
async def prepare_reported_members(
hass,
setup,
*,
on,
kelvin=4000,
brightness_adaptation=False,
):
"""Arrange matching physical/cache state without synthetic activation work."""
await _prepare_members(
hass,
setup.switch,
setup.group,
setup.members,
on=on,
physical_kelvin=(kelvin,) * 3,
cached_kelvin=(kelvin,) * 3,
group_brightness=80,
brightness_adaptation=brightness_adaptation,
)
setup.commands.clear()
async def turn_on_member(hass, **kwargs):
"""Use the real light service, including event and entity context handling."""
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: RIGHT, **kwargs},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
async def activate_scene(hass, entities, *, service="apply", transition=2):
"""Use real HA scene services, including the stored-scene activation path."""
context = Context()
if service == "apply":
await hass.services.async_call(
"scene",
"apply",
{"entities": entities, ATTR_TRANSITION: transition},
blocking=True,
context=context,
)
else:
await hass.services.async_call(
"scene",
"create",
{"scene_id": "native_scene_intent", "entities": entities},
blocking=True,
)
await hass.async_block_till_done()
await hass.services.async_call(
"scene",
"turn_on",
{
ATTR_ENTITY_ID: "scene.native_scene_intent",
ATTR_TRANSITION: transition,
},
blocking=True,
context=context,
)
await hass.async_block_till_done()
def scene_state(kelvin, brightness):
"""Keep explicit colour separate from scene-specific power and brightness."""
return {
"state": STATE_ON,
ATTR_COLOR_TEMP_KELVIN: kelvin,
ATTR_BRIGHTNESS: brightness,
}
async def prepare_all_off(setup, hass):
"""Avoid manufacturing activation events while setting up manual guards."""
await setup.switch.async_turn_off()
for member in setup.members:
member._attr_is_on = False
member.physical_kelvin = 4000
member.async_write_ha_state()
await hass.async_block_till_done()
await setup.switch.async_turn_on(adapt_lights=False)
await hass.async_block_till_done()
setup.commands.clear()
async def add_subgroup(hass, setup):
"""Add a scene target whose members belong to the configured broad group."""
subgroup = RecordingGroup(setup.members[1:], setup.commands)
subgroup.entity_id = "light.review_safety_subset"
subgroup._attr_is_on = False
await hass.data[DATA_COMPONENT].async_add_entities([subgroup])
await hass.async_block_till_done()
setup.commands.clear()
return subgroup
async def set_manual(hass, switch, light, attribute):
"""Use the public selective-manual-control service."""
await hass.services.async_call(
DOMAIN,
SERVICE_SET_MANUAL_CONTROL,
{
ATTR_ENTITY_ID: switch.entity_id,
CONF_LIGHTS: [light],
CONF_MANUAL_CONTROL: attribute,
},
blocking=True,
)

View file

@ -63,7 +63,7 @@ from homeassistant.util import yaml as yaml_util
from tests.common import async_fire_time_changed
from .test_switch import setup_switch
from .support.adaptive import setup_switch
if TYPE_CHECKING:
from collections.abc import Callable

View file

@ -1,17 +1,27 @@
"""Tests for Adaptive Lighting HASS utils."""
import asyncio
from contextvars import ContextVar
from inspect import signature
from unittest.mock import AsyncMock
import pytest
from homeassistant.components.adaptive_lighting.adaptation_utils import ServiceData
from homeassistant.components.adaptive_lighting.hass_utils import (
setup_service_call_interceptor,
)
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.const import SERVICE_TURN_ON
from homeassistant.core import ServiceCall
from homeassistant.core import ServiceCall, SupportsResponse, callback
from homeassistant.util.read_only_dict import ReadOnlyDict
@pytest.fixture
def ignore_translations_for_mock_domains():
"""Synthetic test services have no integration translation files."""
return ["test"]
async def test_setup_service_call_interceptor(hass):
"""Test setup and removal of service call interceptor."""
service_func_mock = AsyncMock()
@ -78,3 +88,284 @@ async def test_service_call_interceptor_data_manipulation(hass):
(service_call,) = service_func_mock.call_args[0]
assert service_call.data == {"test1": "changed", "test2": "added"}
assert isinstance(service_call.data, ReadOnlyDict)
async def test_service_interceptor_isolates_concurrent_dispatch_calls(hass):
"""Awaited preprocessing cannot exchange data between split target calls."""
entered = asyncio.Event()
observed = []
async def original(call):
observed.append((call.data["target"], call.data["prepared"]))
async def before(call, data):
if data["target"] == "left":
await entered.wait()
else:
entered.set()
data["prepared"] = data["target"]
async def around(call, invoke_original):
await asyncio.gather(
*(
invoke_original(
ServiceCall(
hass,
call.domain,
call.service,
{"target": target},
context=call.context,
),
)
for target in ("left", "right")
),
)
assert call.data == {"target": "both"}
hass.services.async_register("test", "split", original)
setup_service_call_interceptor(
hass,
"test",
"split",
before,
around_func=around,
)
await hass.services.async_call(
"test",
"split",
{"target": "both"},
blocking=True,
)
assert sorted(observed) == [("left", "left"), ("right", "right")]
@pytest.mark.parametrize("failure", [None, "original", "after"])
async def test_service_interceptor_around_order_and_errors(hass, failure):
"""Wrap preprocessing and dispatch without retrying a failed original call."""
events = []
original_error = ValueError("original failure")
after_error = ValueError("after failure")
async def original(call):
events.append(("original", dict(call.data)))
if failure == "original":
raise original_error
return {"result": "original response"}
async def before(call, data):
events.append(("before", dict(call.data)))
data["changed"] = True
async def around(call, invoke_original):
events.append(("enter", dict(call.data)))
try:
result = await invoke_original()
events.append(("after", result))
if failure == "after":
raise after_error
return result
finally:
events.append(("exit", None))
hass.services.async_register(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
original,
supports_response=SupportsResponse.OPTIONAL,
)
setup_service_call_interceptor(
hass,
LIGHT_DOMAIN,
SERVICE_TURN_ON,
before,
around_func=around,
)
if failure:
with pytest.raises(ValueError, match="failure") as exc_info:
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"initial": True},
blocking=True,
return_response=True,
)
assert exc_info.value is (
original_error if failure == "original" else after_error
)
else:
assert await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"initial": True},
blocking=True,
return_response=True,
) == {"result": "original response"}
expected = [
("enter", {"initial": True}),
("before", {"initial": True}),
("original", {"initial": True, "changed": True}),
]
if failure != "original":
expected.append(("after", {"result": "original response"}))
assert events == [*expected, ("exit", None)]
@pytest.mark.parametrize("fail_inner", [False, True])
async def test_service_interceptor_nested_context_cleanup(hass, fail_inner):
"""Nested awaited services inherit their outer operation and clean up errors."""
current_operation = ContextVar("test_operation", default=())
events = []
inner_error = ValueError("inner failure")
async def original(call):
events.append((call.service, current_operation.get()))
if call.service == "outer":
await hass.services.async_call(
"test",
"inner",
{},
blocking=True,
context=call.context,
)
events.append(("outer_resumed", current_operation.get()))
elif fail_inner:
raise inner_error
async def around(call, invoke_original):
token = current_operation.set((*current_operation.get(), call.service))
try:
return await invoke_original()
finally:
current_operation.reset(token)
events.append((f"{call.service}_cleanup", current_operation.get()))
for service in ("outer", "inner"):
hass.services.async_register("test", service, original)
setup_service_call_interceptor(
hass,
"test",
service,
lambda _call, _data: None,
around_func=around,
)
if fail_inner:
with pytest.raises(ValueError, match="inner failure") as exc_info:
await hass.services.async_call("test", "outer", {}, blocking=True)
assert exc_info.value is inner_error
else:
await hass.services.async_call("test", "outer", {}, blocking=True)
expected = [
("outer", ("outer",)),
("inner", ("outer", "inner")),
("inner_cleanup", ("outer",)),
]
if not fail_inner:
expected.append(("outer_resumed", ("outer",)))
assert events == [*expected, ("outer_cleanup", ())]
assert current_operation.get() == ()
async def test_service_interceptor_cancellation_cleans_up_once(hass):
"""Cancellation unwinds the around hook without dispatching a second call."""
entered = asyncio.Event()
events = []
async def original(_call):
events.append("original")
entered.set()
await asyncio.Event().wait()
async def around(_call, invoke_original):
try:
return await invoke_original()
finally:
events.append("cleanup")
hass.services.async_register("test", "cancel", original)
setup_service_call_interceptor(
hass,
"test",
"cancel",
lambda _call, _data: None,
around_func=around,
)
task = asyncio.create_task(
hass.services.async_call("test", "cancel", {}, blocking=True),
)
await entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert events == ["original", "cleanup"]
async def test_service_interceptor_removal_keeps_external_replacement(hass):
"""Unloading must not reinstall an obsolete handler over another owner."""
original, replacement, before = AsyncMock(), AsyncMock(), AsyncMock()
hass.services.async_register(LIGHT_DOMAIN, SERVICE_TURN_ON, original)
remove = setup_service_call_interceptor(
hass,
LIGHT_DOMAIN,
SERVICE_TURN_ON,
before,
)
hass.services.async_register(LIGHT_DOMAIN, SERVICE_TURN_ON, replacement)
remove()
remove()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{},
blocking=True,
)
replacement.assert_awaited_once()
original.assert_not_called()
before.assert_not_called()
async def test_service_interceptor_preserves_metadata_and_sync_response(hass):
"""Installing and removing preserve service metadata and callback responses."""
descriptions = {"name": "Original service"}
metadata = (
{"description_placeholders": descriptions}
if "description_placeholders"
in signature(hass.services.async_register).parameters
else {}
)
observed = []
@callback
def original(call):
observed.append(dict(call.data))
return {"response": call.data["request"]}
hass.services.async_register(
"test",
"respond",
original,
supports_response=SupportsResponse.ONLY,
**metadata,
)
remove = setup_service_call_interceptor(
hass,
"test",
"respond",
lambda _call, _data: None,
)
for remove_first in (False, True):
if remove_first:
remove()
service = hass.services.async_services()["test"]["respond"]
assert service.supports_response is SupportsResponse.ONLY
if metadata:
assert service.description_placeholders == descriptions
assert await hass.services.async_call(
"test",
"respond",
{"request": "unchanged"},
blocking=True,
return_response=True,
) == {"response": "unchanged"}
assert observed == [{"request": "unchanged"}] * 2

View file

@ -177,7 +177,10 @@ async def test_change_switch_settings_requires_entity_target(hass):
with pytest.raises(
voluptuous.error.MultipleInvalid,
match=r"must contain at least one of entity_id.*area_id",
match=(
r"(?:must contain at least one of entity_id.*area_id"
r"|at least one of .*entity_id.*area_id.* is required)"
),
):
await hass.services.async_call(
adaptive_lighting.DOMAIN,

View file

@ -0,0 +1,320 @@
"""Manual override provenance, independent of HA timers and service dispatch."""
import pytest
from homeassistant.components.adaptive_lighting.adaptation_utils import (
LightControlAttributes as Attributes,
)
from homeassistant.components.adaptive_lighting.manual_control import (
ManualOverrideState,
)
@pytest.mark.parametrize("independent", ["none", "before", "during"])
def test_replacing_subgroup_pause_preserves_independent_member_flags(independent):
"""Changing ALL to brightness releases only the subgroup's colour pause."""
state = ManualOverrideState()
if independent == "before":
state.set_direct("light.a", Attributes.COLOR)
state.replace_source(
"light.group",
["light.a", "light.b"],
Attributes.ALL,
owner="profile",
)
if independent == "during":
state.set_direct("light.a", Attributes.COLOR)
affected = state.replace_source(
"light.group",
["light.a", "light.b"],
Attributes.BRIGHTNESS,
owner="profile",
)
assert affected == {"light.group", "light.a", "light.b"}
assert state.effective("light.a") == (
Attributes.BRIGHTNESS if independent == "none" else Attributes.ALL
)
assert state.effective("light.b") == Attributes.BRIGHTNESS
def test_adding_direct_flag_does_not_adopt_inherited_axes():
"""A member's direct brightness change must not retain a released colour pause."""
state = ManualOverrideState()
state.replace_source("light.group", ["light.a"], Attributes.ALL, owner="profile")
state.add_direct("light.a", Attributes.BRIGHTNESS)
state.clear(["light.group"])
assert state.effective("light.a") == Attributes.BRIGHTNESS
assert state.direct["light.a"] == Attributes.BRIGHTNESS
def test_replacing_subgroup_membership_releases_former_members():
"""A changed subgroup affects former and current members, preserving other sources."""
state = ManualOverrideState()
state.replace_source(
"light.group",
["light.a", "light.b"],
Attributes.ALL,
owner="profile",
)
state.replace_source("light.other", ["light.a"], Attributes.COLOR, owner="profile")
affected = state.replace_source(
"light.group",
["light.b", "light.c"],
Attributes.BRIGHTNESS,
owner="profile",
)
assert affected == {"light.group", "light.a", "light.b", "light.c"}
assert state.effective("light.a") == Attributes.COLOR
assert state.effective("light.b") == Attributes.BRIGHTNESS
assert state.effective("light.c") == Attributes.BRIGHTNESS
def test_clearing_subgroup_preserves_other_sources_and_direct_flags():
"""Inherited changes notify members without resetting their independent history."""
state = ManualOverrideState()
state.set_direct("light.group", Attributes.ALL)
state.set_direct("light.a", Attributes.BRIGHTNESS)
state.replace_source(
"light.group",
["light.a", "light.b"],
Attributes.ALL,
owner="profile",
)
state.replace_source("light.other", ["light.a"], Attributes.COLOR, owner="profile")
change = state.clear(["light.group"])
assert change.cleared_entities == {"light.group"}
assert change.affected_entities == {"light.group", "light.a", "light.b"}
assert state.effective("light.group") == Attributes.NONE
assert state.effective("light.a") == Attributes.ALL
assert state.effective("light.b") == Attributes.NONE
def test_clearing_member_does_not_clear_siblings_or_subgroup():
"""An explicit member reset clears its contributions without resetting the source."""
state = ManualOverrideState()
state.set_direct("light.group", Attributes.ALL)
state.set_direct("light.a", Attributes.BRIGHTNESS)
state.replace_source(
"light.group",
["light.a", "light.b"],
Attributes.COLOR,
owner="profile",
)
change = state.clear(["light.a"])
assert change.cleared_entities == change.affected_entities == {"light.a"}
assert state.effective("light.a") == Attributes.NONE
assert state.effective("light.b") == Attributes.COLOR
assert state.effective("light.group") == Attributes.ALL
def test_last_member_expiry_also_releases_subgroup_flag():
"""A scene subgroup without its own timer expires after its final contribution."""
state = ManualOverrideState()
state.replace_source(
"light.group",
["light.a", "light.b"],
Attributes.ALL,
owner="profile",
)
first = state.expire("light.a")
assert first.cleared_entities == first.affected_entities == {"light.a"}
assert first.affected_owners == {"profile"}
assert state.effective("light.group") == Attributes.ALL
assert state.effective("light.b") == Attributes.ALL
last = state.expire("light.b")
assert (
last.cleared_entities
== last.affected_entities
== {
"light.b",
"light.group",
}
)
assert state.effective("light.group") == Attributes.NONE
assert last.affected_owners == {"profile"}
def test_forgetting_source_removes_its_raw_key_and_releases_members():
"""Retired tracking cannot leave inherited pauses on surviving lights."""
state = ManualOverrideState()
state.set_direct("light.group", Attributes.ALL)
state.set_direct("light.a", Attributes.BRIGHTNESS)
state.replace_source("light.group", ["light.a"], Attributes.COLOR, owner="profile")
change = state.forget(["light.group"])
assert "light.group" not in state.direct
assert change.cleared_entities == {"light.group"}
assert change.affected_entities == {"light.group", "light.a"}
assert change.affected_owners == {"profile"}
assert state.effective("light.a") == Attributes.BRIGHTNESS
def test_raw_direct_dictionary_remains_mutable_and_stable_across_resets():
"""Existing manager consumers retain the same raw mapping, without inherited flags."""
state = ManualOverrideState()
direct = state.direct
direct["light.a"] = Attributes.BRIGHTNESS
state.replace_source("light.group", ["light.a"], Attributes.COLOR, owner="profile")
assert state.effective("light.a") == Attributes.ALL
assert direct["light.a"] == Attributes.BRIGHTNESS
state.clear(["light.a"])
assert state.direct is direct
assert direct["light.a"] == Attributes.NONE
assert state.effective("light.a") == Attributes.NONE
@pytest.mark.parametrize("same_group", [False, True])
def test_profile_reset_preserves_foreign_source_and_independent_group_flag(same_group):
"""Overlapping source ownership cannot merge into a single raw group flag."""
state = ManualOverrideState()
other_group = "light.group" if same_group else "light.other"
state.set_direct("light.group", Attributes.BRIGHTNESS)
state.set_direct("light.a", Attributes.BRIGHTNESS)
state.replace_source(
"light.group",
["light.a", "light.b"],
Attributes.ALL,
owner="profile",
)
state.replace_source(
other_group,
["light.b", "light.c"],
Attributes.COLOR,
owner="other",
)
change = state.clear_owner("profile")
assert change.cleared_entities == set()
assert change.affected_entities == {"light.group", "light.a", "light.b"}
assert state.effective("light.group") == (
Attributes.ALL if same_group else Attributes.BRIGHTNESS
)
assert state.effective("light.a") == Attributes.BRIGHTNESS
assert state.effective("light.b") == Attributes.COLOR
assert state.effective("light.c") == Attributes.COLOR
# Whole-profile target reset retains global direct-flag semantics, but
# cannot consume another profile's inherited member pause.
state.clear(["light.a", "light.b"], source_owner="profile")
assert state.effective("light.a") == Attributes.NONE
assert state.effective("light.b") == Attributes.COLOR
assert state.effective(other_group) == (
Attributes.ALL if same_group else Attributes.COLOR
)
def test_replacing_one_owner_preserves_other_owner_on_same_subgroup():
"""Changing one profile's selective pause cannot overwrite a peer's flags."""
state = ManualOverrideState()
state.replace_source("light.group", ["light.a"], Attributes.COLOR, owner="other")
state.replace_source("light.group", ["light.a"], Attributes.ALL, owner="profile")
state.replace_source(
"light.group",
["light.b"],
Attributes.BRIGHTNESS,
owner="profile",
)
assert state.effective("light.group") == Attributes.ALL
assert state.effective("light.a") == Attributes.COLOR
assert state.effective("light.b") == Attributes.BRIGHTNESS
state.clear_owner("other")
assert state.effective("light.group") == Attributes.BRIGHTNESS
assert state.effective("light.a") == Attributes.NONE
def test_source_expiry_preserves_independent_group_flag_and_other_owner():
"""An expired source cannot clear an independent group or sibling pause."""
state = ManualOverrideState()
state.set_direct("light.group", Attributes.BRIGHTNESS)
state.replace_source("light.group", ["light.a"], Attributes.ALL, owner="profile")
state.replace_source("light.group", ["light.b"], Attributes.COLOR, owner="other")
first = state.expire("light.a")
assert first.cleared_entities == {"light.a"}
assert first.affected_entities == {"light.a", "light.group"}
assert state.effective("light.group") == Attributes.ALL
last = state.expire("light.b")
assert last.cleared_entities == {"light.b"}
assert last.affected_entities == {"light.b", "light.group"}
assert state.effective("light.group") == Attributes.BRIGHTNESS
@pytest.mark.parametrize("clear_members_first", [False, True])
def test_parent_reset_uses_original_scope_and_preserves_unrelated_sources(
clear_members_first,
):
"""Empty contributions still belong to their original, distinct parent scope."""
state = ManualOverrideState()
state.replace_source(
"light.subgroup",
["light.a", "light.b"],
Attributes.COLOR,
owner="profile",
)
state.replace_source("light.other", ["light.c"], Attributes.ALL, owner="profile")
state.replace_source("light.peer", ["light.a"], Attributes.ALL, owner="other")
if clear_members_first:
state.clear(["light.a", "light.b", "light.c"])
state.clear_owner("profile", within_members=[])
assert state.effective("light.subgroup") == Attributes.COLOR
state.clear_owner("profile", within_members=["light.a"])
assert state.effective("light.subgroup") == Attributes.COLOR
state.clear_owner("profile", within_members=["light.a", "light.b"])
assert state.effective("light.subgroup") == Attributes.NONE
assert state.effective("light.other") == Attributes.ALL
assert state.effective("light.peer") == Attributes.ALL
def test_source_ownership_follows_surviving_contributions_without_losing_reset_scope():
"""Lifecycle ownership is a view of sources, not another mutable registry."""
state = ManualOverrideState()
state.replace_source(
"light.group",
["light.a", "light.b"],
Attributes.ALL,
owner="profile",
)
state.replace_source("light.group", ["light.c"], Attributes.COLOR, owner="other")
assert state.owned_entities() == {
"light.group",
"light.a",
"light.b",
"light.c",
}
assert state.owned_entities(owner="profile") == {
"light.group",
"light.a",
"light.b",
}
change = state.clear(["light.a", "light.b"], source_owner="profile")
assert change.affected_owners == {"profile"}
assert state.owned_entities(owner="profile") == {"light.group"}
change = state.clear_owner("profile", within_members=["light.a", "light.b"])
assert change.affected_owners == {"profile"}
assert state.owned_entities(owner="profile") == set()
assert state.owned_entities() == {"light.group", "light.c"}
state.replace_source("light.group", ["light.d"], Attributes.COLOR, owner="other")
assert state.owned_entities() == {"light.group", "light.d"}
change = state.expire("light.d")
assert change.affected_owners == {"other"}
assert state.owned_entities() == set()

View file

@ -0,0 +1,531 @@
"""Native scenes consolidate brightness and colour independently."""
import asyncio
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_ADAPT_DELAY,
CONF_SEPARATE_TURN_ON_COMMANDS,
CONF_TAKE_OVER_CONTROL,
CONF_TAKE_OVER_CONTROL_MODE,
TakeOverControlMode,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_TRANSITION,
Profile,
)
from homeassistant.components.light.const import DATA_PROFILES
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import Context
from .support.async_helpers import DelayBarrier
from .support.native_lights import GROUP, LEFT, OFF_MEMBER, RIGHT, TARGET_KELVIN
from .support.native_scenarios import (
activate_scene,
prepare_reported_members,
scene_state,
set_manual,
setup_reported_group,
)
@pytest.mark.parametrize("service", ["apply", "turn_on"])
async def test_native_scene_matching_brightness_groups_with_different_colours(
hass,
service,
):
"""Colour disagreement cannot prevent a shared, explicit brightness command."""
setup = await setup_reported_group(hass)
await prepare_reported_members(
hass,
setup,
on=(True, False, False),
brightness_adaptation=True,
)
colours = {LEFT: 3500, RIGHT: 3000, OFF_MEMBER: 4500}
await activate_scene(
hass,
{entity_id: scene_state(kelvin, 100) for entity_id, kelvin in colours.items()},
service=service,
transition=3,
)
assert all(member.is_on and member.brightness == 100 for member in setup.members)
assert {
member.entity_id: member.physical_kelvin for member in setup.members
} == colours
assert [call for call in setup.commands if ATTR_BRIGHTNESS in call] == [
{
"target": GROUP,
"service": "turn_on",
ATTR_BRIGHTNESS: 100,
ATTR_TRANSITION: 3,
},
]
assert {
call["target"]: call[ATTR_COLOR_TEMP_KELVIN] for call in setup.colour_commands
} == colours
assert len(setup.colour_commands) == 3, setup.commands
assert all(ATTR_BRIGHTNESS not in call for call in setup.colour_commands)
@pytest.mark.parametrize("split", [False, True])
async def test_native_scene_matching_explicit_axes_share_one_group_command(hass, split):
"""The split policy governs computed adaptation, not explicit scene values."""
setup = await setup_reported_group(
hass,
**{CONF_SEPARATE_TURN_ON_COMMANDS: split},
)
await prepare_reported_members(
hass,
setup,
on=(True, False, False),
brightness_adaptation=True,
)
await activate_scene(
hass,
{member.entity_id: scene_state(3500, 100) for member in setup.members},
transition=3,
)
assert all(member.is_on and member.brightness == 100 for member in setup.members)
assert all(member.physical_kelvin == 3500 for member in setup.members)
adjustments = [
call
for call in setup.commands
if ATTR_BRIGHTNESS in call or ATTR_COLOR_TEMP_KELVIN in call
]
assert adjustments == [
{
"target": GROUP,
"service": "turn_on",
ATTR_BRIGHTNESS: 100,
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 3,
},
]
@pytest.mark.parametrize("explicit", [False, True])
async def test_native_scene_brightness_only_groups_with_colour_adaptation_disabled(
hass,
explicit,
):
"""Disabling colour leaves native brightness consolidation available."""
setup = await setup_reported_group(hass)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
await setup.switch.adapt_color_switch.async_turn_off()
setup.commands.clear()
await activate_scene(
hass,
{
member.entity_id: {
"state": STATE_ON,
**({ATTR_BRIGHTNESS: 100} if explicit else {}),
}
for member in setup.members
},
transition=2,
)
expected_brightness = 100 if explicit else 128
assert all(member.is_on for member in setup.members)
assert all(member.brightness == expected_brightness for member in setup.members)
assert all(member.physical_kelvin == 4000 for member in setup.members)
assert not setup.colour_commands
assert [call for call in setup.commands if ATTR_BRIGHTNESS in call] == [
{
"target": GROUP,
"service": "turn_on",
ATTR_BRIGHTNESS: expected_brightness,
ATTR_TRANSITION: 2,
},
]
@pytest.mark.parametrize("split", [False, True])
async def test_native_scene_computed_axes_follow_configured_split_policy(hass, split):
"""A full scene shares computed brightness and colour without member repairs."""
setup = await setup_reported_group(
hass,
**{CONF_SEPARATE_TURN_ON_COMMANDS: split},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
await activate_scene(
hass,
{member.entity_id: {"state": STATE_ON} for member in setup.members},
transition=2,
)
assert all(member.is_on and member.brightness == 128 for member in setup.members)
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members)
adjustments = [
call
for call in setup.commands
if ATTR_BRIGHTNESS in call or ATTR_COLOR_TEMP_KELVIN in call
]
shared = {"target": GROUP, "service": "turn_on"}
if split:
assert adjustments == [
{**shared, ATTR_BRIGHTNESS: 128, ATTR_TRANSITION: 1},
{**shared, ATTR_COLOR_TEMP_KELVIN: TARGET_KELVIN, ATTR_TRANSITION: 1},
]
else:
assert adjustments == [
{
**shared,
ATTR_BRIGHTNESS: 128,
ATTR_COLOR_TEMP_KELVIN: TARGET_KELVIN,
ATTR_TRANSITION: 2,
},
]
async def test_native_scene_different_brightness_keeps_shared_colour(hass):
"""Brightness disagreement leaves each level intact and colour consolidated."""
setup = await setup_reported_group(hass)
await prepare_reported_members(
hass,
setup,
on=(True, False, False),
brightness_adaptation=True,
)
levels = {LEFT: 90, RIGHT: 140, OFF_MEMBER: 35}
await activate_scene(
hass,
{entity_id: scene_state(3500, level) for entity_id, level in levels.items()},
transition=3,
)
assert {member.entity_id: member.brightness for member in setup.members} == levels
assert all(
member.is_on and member.physical_kelvin == 3500 for member in setup.members
)
brightness_commands = [call for call in setup.commands if ATTR_BRIGHTNESS in call]
assert {
call["target"]: call[ATTR_BRIGHTNESS] for call in brightness_commands
} == levels
assert len(brightness_commands) == 3
assert setup.colour_commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 3,
},
]
@pytest.mark.parametrize("excluded", ["omitted", "off", "zero_brightness"])
async def test_native_scene_incomplete_on_coverage_cannot_group_brightness(
hass,
excluded,
):
"""A shared brightness command must never activate an excluded sibling."""
setup = await setup_reported_group(hass)
await prepare_reported_members(
hass,
setup,
on=(True, False, False),
brightness_adaptation=True,
)
entities = {LEFT: scene_state(3500, 100), RIGHT: scene_state(3500, 100)}
if excluded == "off":
entities[OFF_MEMBER] = {"state": STATE_OFF}
elif excluded == "zero_brightness":
entities[OFF_MEMBER] = scene_state(3500, 0)
await activate_scene(hass, entities)
assert all(
member.is_on and member.brightness == 100 for member in setup.members[:2]
)
assert all(member.physical_kelvin == 3500 for member in setup.members[:2])
untouched = setup.members[2]
assert not untouched.is_on
assert untouched.brightness == 26
assert untouched.physical_kelvin == 4000
assert not any(call["target"] == GROUP for call in setup.commands)
assert {
call["target"]: call[ATTR_BRIGHTNESS]
for call in setup.commands
if ATTR_BRIGHTNESS in call
} == {LEFT: 100, RIGHT: 100}
@pytest.mark.parametrize("durations", [(3, 5, 7), (3, 3, 3)])
async def test_native_scene_brightness_uses_effective_member_transitions(
hass,
durations,
):
"""Only matching effective transitions permit shared brightness dispatch."""
setup = await setup_reported_group(hass)
await prepare_reported_members(
hass,
setup,
on=(True, True, True),
brightness_adaptation=True,
)
await setup.switch.adapt_color_switch.async_turn_off()
for member, duration in zip(setup.members, durations, strict=True):
profile = f"{member.entity_id}.default"
hass.data[DATA_PROFILES].data[profile] = Profile(
profile,
None,
None,
None,
duration,
)
setup.commands.clear()
await hass.services.async_call(
"scene",
"apply",
{
"entities": {
member.entity_id: {"state": STATE_ON, ATTR_BRIGHTNESS: 100}
for member in setup.members
},
},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert all(member.is_on and member.brightness == 100 for member in setup.members)
assert all(member.physical_kelvin == 4000 for member in setup.members)
brightness_commands = [call for call in setup.commands if ATTR_BRIGHTNESS in call]
expected = (
{GROUP: 3}
if len(set(durations)) == 1
else {
member.entity_id: duration
for member, duration in zip(setup.members, durations, strict=True)
}
)
assert {
call["target"]: call[ATTR_TRANSITION] for call in brightness_commands
} == expected
assert len(brightness_commands) == len(expected)
@pytest.mark.parametrize("manual_axis", ["brightness", "color"])
async def test_native_scene_manual_axis_does_not_prevent_other_axis_group(
hass,
manual_axis,
):
"""Selective manual control blocks only that member's computed axis."""
setup = await setup_reported_group(
hass,
**{
CONF_TAKE_OVER_CONTROL: True,
CONF_TAKE_OVER_CONTROL_MODE: TakeOverControlMode.PAUSE_CHANGED.value,
},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
await set_manual(hass, setup.switch, RIGHT, manual_axis)
setup.commands.clear()
await activate_scene(
hass,
{member.entity_id: {"state": STATE_ON} for member in setup.members},
)
assert all(member.is_on for member in setup.members)
right = setup.members[1]
assert right.brightness == (110 if manual_axis == "brightness" else 128)
assert right.physical_kelvin == (4000 if manual_axis == "color" else TARGET_KELVIN)
assert all(
member.brightness == 128 for member in (setup.members[0], setup.members[2])
)
assert all(
member.physical_kelvin == TARGET_KELVIN
for member in (setup.members[0], setup.members[2])
)
individual_axis = (
ATTR_BRIGHTNESS if manual_axis == "brightness" else ATTR_COLOR_TEMP_KELVIN
)
grouped_axis = (
ATTR_COLOR_TEMP_KELVIN if manual_axis == "brightness" else ATTR_BRIGHTNESS
)
individual = [call for call in setup.commands if individual_axis in call]
grouped = [call for call in setup.commands if grouped_axis in call]
assert len(individual) == 2, setup.commands
assert {call["target"] for call in individual} == {LEFT, OFF_MEMBER}
assert len(grouped) == 1, setup.commands
assert grouped[0]["target"] == GROUP
assert individual_axis not in grouped[0]
@pytest.mark.parametrize("adapt_colour", [False, True])
async def test_native_scene_late_report_retains_independent_axis_coverage(
hass,
adapt_colour,
):
"""Shared brightness cannot hide missing colour or cause a second correction."""
setup = await setup_reported_group(hass)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
if not adapt_colour:
await setup.switch.adapt_color_switch.async_turn_off()
setup.commands.clear()
right = setup.members[1]
right.hold_reports = True
await activate_scene(
hass,
{
member.entity_id: {"state": STATE_ON, ATTR_BRIGHTNESS: 100}
for member in setup.members
},
transition=2,
)
assert hass.states.is_state(RIGHT, STATE_OFF)
assert all(member.is_on and member.brightness == 100 for member in setup.members)
expected_kelvin = TARGET_KELVIN if adapt_colour else 4000
assert all(member.physical_kelvin == expected_kelvin for member in setup.members)
brightness_commands = [call for call in setup.commands if ATTR_BRIGHTNESS in call]
assert len(brightness_commands) == 1, setup.commands
assert brightness_commands[0]["target"] == GROUP
assert len(setup.colour_commands) == int(adapt_colour), setup.commands
if adapt_colour:
assert setup.colour_commands[0]["target"] == GROUP
before_report = list(setup.commands)
right.hold_reports = False
right.async_write_ha_state()
await hass.async_block_till_done()
assert hass.states.is_state(RIGHT, STATE_ON)
assert setup.commands == before_report
assert all(member.brightness == 100 for member in setup.members)
assert all(member.physical_kelvin == expected_kelvin for member in setup.members)
async def test_native_colour_only_group_call_does_not_gain_brightness(hass):
"""An enabled brightness axis cannot turn OFF siblings on during a colour update."""
setup = await setup_reported_group(hass)
await prepare_reported_members(
hass,
setup,
on=(True, False, False),
brightness_adaptation=True,
)
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: GROUP,
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 3,
},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert setup.commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 3,
},
]
assert [member.is_on for member in setup.members] == [True, False, False]
assert [member.brightness for member in setup.members] == [80, 110, 26]
assert [member.physical_kelvin for member in setup.members] == [3500, 4000, 4000]
@pytest.mark.parametrize("adapt_delay", [0, 1])
async def test_native_scene_explicit_colour_keeps_shared_computed_brightness(
hass,
monkeypatch,
adapt_delay,
):
"""Explicit member colours remain immediate while shared brightness adapts."""
setup = await setup_reported_group(hass, **{CONF_ADAPT_DELAY: adapt_delay})
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
barrier = DelayBarrier(
monkeypatch,
setup.switch.manager.native_groups,
"_execute_dispatch",
)
task = asyncio.create_task(
activate_scene(
hass,
{
member.entity_id: {
"state": STATE_ON,
ATTR_COLOR_TEMP_KELVIN: 3500,
}
for member in setup.members
},
transition=2,
),
)
try:
if adapt_delay:
await asyncio.wait_for(barrier.entered.wait(), 2)
assert barrier.delay == adapt_delay
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == GROUP
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert not any(ATTR_BRIGHTNESS in call for call in setup.commands)
assert not task.done()
finally:
barrier.release.set()
await task
await hass.async_block_till_done()
assert all(member.is_on and member.brightness == 128 for member in setup.members)
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert [call for call in setup.commands if ATTR_BRIGHTNESS in call] == [
{
"target": GROUP,
"service": "turn_on",
ATTR_BRIGHTNESS: 128,
ATTR_TRANSITION: 2,
},
]
assert setup.colour_commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 2,
},
]

View file

@ -0,0 +1,315 @@
"""Brightness coverage survives delayed reports without hiding other work."""
# pylint: disable=protected-access
import sys
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_ADAPT_DELAY,
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
CONF_SEND_SPLIT_DELAY,
CONF_SEPARATE_TURN_ON_COMMANDS,
CONF_TAKE_OVER_CONTROL,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_TRANSITION,
ColorMode,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import Context
from .support.native_lights import GROUP, RIGHT, TARGET_KELVIN
from .support.native_scenarios import (
activate_scene,
prepare_reported_members,
setup_reported_group,
)
async def _brightness_only_setup(hass, **config):
setup = await setup_reported_group(hass, **config)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
await setup.switch.adapt_color_switch.async_turn_off()
await hass.async_block_till_done()
setup.commands.clear()
return setup
async def _activate_brightness_scene(hass, setup):
await activate_scene(
hass,
{
member.entity_id: {"state": STATE_ON, ATTR_BRIGHTNESS: 150}
for member in setup.members
},
)
async def test_delayed_brightness_activation_is_not_external_manual_control(
hass,
freezer,
):
"""A genuine group activation owns its late report with either axis covered."""
setup = await _brightness_only_setup(
hass,
**{
CONF_TAKE_OVER_CONTROL: True,
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON: True,
},
)
right = setup.members[1]
right.hold_reports = True
freezer.tick(11)
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert right.is_on
assert right.brightness == 128
assert hass.states.get(RIGHT).state == STATE_OFF
commands_before_report = list(setup.commands)
freezer.tick(60)
right.hold_reports = False
right.async_set_context(Context())
right.async_write_ha_state()
await hass.async_block_till_done()
assert hass.states.get(RIGHT).state == STATE_ON
assert setup.switch.manager.get_manual_control_attributes(RIGHT).has_none()
assert setup.commands == commands_before_report
async def test_brightness_coverage_does_not_hide_later_supported_colour(hass, freezer):
"""An axis absent from the plan remains eligible when a report adds support."""
setup = await setup_reported_group(hass)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
for light in (*setup.members, setup.group):
light._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
light._attr_color_mode = ColorMode.BRIGHTNESS
light.async_write_ha_state()
await hass.async_block_till_done()
right = setup.members[1]
right.hold_reports = True
setup.commands.clear()
await _activate_brightness_scene(hass, setup)
assert right.is_on
assert right.brightness == 150
assert right.physical_kelvin == 4000
assert hass.states.get(RIGHT).state == STATE_OFF
commands_before_report = len(setup.commands)
freezer.tick(60)
right._attr_supported_color_modes = {ColorMode.COLOR_TEMP}
right._attr_color_mode = ColorMode.COLOR_TEMP
right.hold_reports = False
right.async_set_context(Context())
right.async_write_ha_state()
await hass.async_block_till_done()
assert right.brightness == 150
assert right.physical_kelvin == TARGET_KELVIN
assert setup.commands[commands_before_report:] == [
{
"target": RIGHT,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: TARGET_KELVIN,
},
]
async def test_new_off_retires_brightness_coverage_before_later_activation(hass):
"""A fresh activation can adapt brightness after an older scene claim ends."""
setup = await _brightness_only_setup(hass)
right = setup.members[1]
right.hold_reports = True
await _activate_brightness_scene(hass, setup)
await hass.services.async_call(
"light",
"turn_off",
{ATTR_ENTITY_ID: RIGHT},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert not right.is_on
right.hold_reports = False
setup.commands.clear()
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: RIGHT},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert right.is_on
assert right.brightness == 128
assert all(
member.brightness == 150 for member in (setup.members[0], setup.members[2])
)
assert not setup.colour_commands
assert all(call["target"] == RIGHT for call in setup.commands)
assert len([call for call in setup.commands if ATTR_BRIGHTNESS in call]) == 1
@pytest.mark.parametrize("split", [False, True])
async def test_shared_computed_brightness_retains_member_colour_timing(
hass,
monkeypatch,
split,
):
"""Grouping one axis cannot restart adaptation delay or lose split timing."""
setup = await setup_reported_group(
hass,
**{
CONF_ADAPT_DELAY: 0.2,
CONF_SEPARATE_TURN_ON_COMMANDS: split,
CONF_SEND_SPLIT_DELAY: 50,
},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
setup.members[1]._attr_max_color_temp_kelvin = 2800
setup.members[1].async_write_ha_state()
await hass.async_block_till_done()
module = sys.modules[type(setup.switch.manager.native_groups).__module__]
original_asyncio = module.asyncio
delays = []
class RecordingAsyncio:
"""Observe real planner waits without replacing their scheduling."""
def __getattr__(self, name):
return getattr(original_asyncio, name)
async def sleep(self, delay, result=None):
delays.append(
(delay, any(ATTR_BRIGHTNESS in call for call in setup.commands)),
)
return await original_asyncio.sleep(delay, result)
monkeypatch.setattr(module, "asyncio", RecordingAsyncio())
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP, ATTR_TRANSITION: 2},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert all(member.brightness == 128 for member in setup.members)
assert [member.physical_kelvin for member in setup.members] == [3000, 2800, 3000]
brightness_commands = [call for call in setup.commands if ATTR_BRIGHTNESS in call]
assert brightness_commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_BRIGHTNESS: 128,
ATTR_TRANSITION: 1 if split else 2,
},
]
assert len(setup.colour_commands) == 3
assert all(
call[ATTR_TRANSITION] == (1 if split else 2) for call in setup.colour_commands
)
assert any(not brightness_sent and delay > 0.1 for delay, brightness_sent in delays)
after_brightness = [delay for delay, brightness_sent in delays if brightness_sent]
if split:
assert after_brightness
assert all(delay == pytest.approx(1.05, abs=0.02) for delay in after_brightness)
else:
assert all(delay < 0.1 for delay in after_brightness)
async def test_mixed_explicit_and_computed_commands_keep_individual_timing(hass):
"""Matching values do not erase explicit versus computed split policies."""
setup = await setup_reported_group(
hass,
**{CONF_SEPARATE_TURN_ON_COMMANDS: True},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
explicit = setup.members[0]
await activate_scene(
hass,
{
member.entity_id: {
"state": STATE_ON,
**(
{ATTR_BRIGHTNESS: 128, ATTR_COLOR_TEMP_KELVIN: TARGET_KELVIN}
if member is explicit
else {}
),
}
for member in setup.members
},
transition=2,
)
assert all(member.is_on and member.brightness == 128 for member in setup.members)
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members)
assert not any(call["target"] == GROUP for call in setup.commands)
assert [
call for call in setup.commands if call["target"] == explicit.entity_id
] == [
{
"target": explicit.entity_id,
"service": "turn_on",
ATTR_BRIGHTNESS: 128,
ATTR_COLOR_TEMP_KELVIN: TARGET_KELVIN,
ATTR_TRANSITION: 2,
},
]
for member in setup.members[1:]:
adjustments = [
call
for call in setup.commands
if call["target"] == member.entity_id
and (ATTR_BRIGHTNESS in call or ATTR_COLOR_TEMP_KELVIN in call)
]
assert adjustments == [
{
"target": member.entity_id,
"service": "turn_on",
ATTR_BRIGHTNESS: 128,
ATTR_TRANSITION: 1,
},
{
"target": member.entity_id,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: TARGET_KELVIN,
ATTR_TRANSITION: 1,
},
]

View file

@ -0,0 +1,248 @@
"""Independent brightness ownership preserves native colour consolidation."""
import sys
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_ADAPT_DELAY,
CONF_EXPAND_LIGHT_GROUPS,
CONF_INITIAL_TRANSITION,
CONF_MAX_BRIGHTNESS,
CONF_MIN_BRIGHTNESS,
CONF_TAKE_OVER_CONTROL,
CONF_TRANSITION,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_TRANSITION,
)
from homeassistant.const import CONF_LIGHTS, CONF_NAME, STATE_ON
from .support.adaptive import setup_switch
from .support.native_lights import GROUP, LEFT, OFF_MEMBER, RIGHT, TARGET_KELVIN
from .support.native_scenarios import (
activate_scene,
prepare_reported_members,
scene_state,
setup_reported_group,
)
async def _setup_independent_owners(
hass,
brightness_target=GROUP,
*,
brightness_delay=0,
colour_delay=0,
):
"""Install a colour-only native profile and independent brightness owner."""
setup = await setup_reported_group(hass, **{CONF_ADAPT_DELAY: colour_delay})
_, brightness_owner = await setup_switch(
hass,
{
CONF_NAME: "independent_brightness_owner",
CONF_LIGHTS: [brightness_target],
CONF_EXPAND_LIGHT_GROUPS: False,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_MIN_BRIGHTNESS: 50,
CONF_MAX_BRIGHTNESS: 50,
CONF_TAKE_OVER_CONTROL: False,
CONF_ADAPT_DELAY: brightness_delay,
},
)
await brightness_owner.async_turn_off()
await brightness_owner.adapt_color_switch.async_turn_off()
await brightness_owner.adapt_brightness_switch.async_turn_on()
await prepare_reported_members(hass, setup, on=(True, True, True))
await brightness_owner.async_turn_on(adapt_lights=False)
await hass.async_block_till_done()
assert setup.switch.adapt_color_switch.is_on
assert not setup.switch.adapt_brightness_switch.is_on
assert brightness_owner.is_on
assert brightness_owner.adapt_brightness_switch.is_on
assert not brightness_owner.adapt_color_switch.is_on
setup.commands.clear()
return setup, brightness_owner
@pytest.mark.parametrize("brightness_target", [GROUP, RIGHT])
async def test_noncompeting_brightness_owner_preserves_native_colour_group(
hass,
brightness_target,
):
"""A brightness-only owner does not compete for the group's colour command."""
setup, _ = await _setup_independent_owners(hass, brightness_target)
levels = {LEFT: 90, RIGHT: 140, OFF_MEMBER: 35}
await activate_scene(
hass,
{entity: scene_state(3500, level) for entity, level in levels.items()},
transition=2,
)
assert all(member.is_on for member in setup.members)
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert {member.entity_id: member.brightness for member in setup.members} == levels
assert setup.colour_commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 2,
},
]
assert len([call for call in setup.commands if ATTR_BRIGHTNESS in call]) == 3
@pytest.mark.parametrize("explicit", [False, True])
async def test_independent_group_owners_send_each_attribute_once(hass, explicit):
"""Distinct profiles retain separate broadcasts even when both values match."""
setup, brightness_owner = await _setup_independent_owners(hass)
await brightness_owner.async_turn_off()
await prepare_reported_members(hass, setup, on=(False, False, False))
await brightness_owner.async_turn_on(adapt_lights=False)
await hass.async_block_till_done()
setup.commands.clear()
await activate_scene(
hass,
{
member.entity_id: (
scene_state(3500, 100) if explicit else {"state": STATE_ON}
)
for member in setup.members
},
transition=2,
)
level = 100 if explicit else 128
kelvin = 3500 if explicit else TARGET_KELVIN
assert all(member.is_on and member.brightness == level for member in setup.members)
assert all(member.physical_kelvin == kelvin for member in setup.members)
assert [
call
for call in setup.commands
if ATTR_BRIGHTNESS in call or ATTR_COLOR_TEMP_KELVIN in call
] == [
{
"target": GROUP,
"service": "turn_on",
ATTR_BRIGHTNESS: level,
ATTR_TRANSITION: 2,
},
{
"target": GROUP,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: kelvin,
ATTR_TRANSITION: 2,
},
]
@pytest.mark.parametrize("competing_attribute", ["brightness", "color"])
async def test_competing_member_owner_only_prevents_its_own_group_attribute(
hass,
competing_attribute,
):
"""A direct owner of one attribute cannot block the other native broadcast."""
setup, _ = await _setup_independent_owners(hass)
_, competing = await setup_switch(
hass,
{
CONF_NAME: "competing_direct_owner",
CONF_LIGHTS: [RIGHT],
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_TAKE_OVER_CONTROL: False,
},
)
if competing_attribute == "brightness":
await competing.adapt_color_switch.async_turn_off()
else:
await competing.adapt_brightness_switch.async_turn_off()
await hass.async_block_till_done()
setup.commands.clear()
await activate_scene(
hass,
{member.entity_id: scene_state(3500, 100) for member in setup.members},
transition=2,
)
assert all(member.is_on and member.brightness == 100 for member in setup.members)
assert all(member.physical_kelvin == 3500 for member in setup.members)
grouped, individual = (
(ATTR_COLOR_TEMP_KELVIN, ATTR_BRIGHTNESS)
if competing_attribute == "brightness"
else (ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN)
)
shared = [call for call in setup.commands if grouped in call]
assert len(shared) == 1, setup.commands
assert shared[0]["target"] == GROUP
assert individual not in shared[0]
separate = [call for call in setup.commands if individual in call]
assert len(separate) == len(setup.members), setup.commands
assert {call["target"] for call in separate} == {LEFT, RIGHT, OFF_MEMBER}
@pytest.mark.parametrize("first_attribute", ["brightness", "color"])
async def test_independent_owners_each_receive_their_adaptive_delay(
hass,
monkeypatch,
first_attribute,
):
"""One owner's short wait cannot consume another owner's longer delay."""
setup, brightness_owner = await _setup_independent_owners(
hass,
brightness_delay=0.1 if first_attribute == "brightness" else 1,
colour_delay=0.1 if first_attribute == "color" else 1,
)
await brightness_owner.async_turn_off()
await prepare_reported_members(hass, setup, on=(False, False, False))
await brightness_owner.async_turn_on(adapt_lights=False)
await hass.async_block_till_done()
setup.commands.clear()
planner = setup.switch.manager.native_groups
module = sys.modules[type(planner).__module__]
original_asyncio = module.asyncio
waits = []
class RecordingAsyncio:
"""Record only the planner's waits while leaving HA's scheduler unchanged."""
def __getattr__(self, name):
return getattr(original_asyncio, name)
async def sleep(self, delay, result=None):
waits.append(delay)
return await original_asyncio.sleep(0, result)
monkeypatch.setattr(module, "asyncio", RecordingAsyncio())
await activate_scene(
hass,
{member.entity_id: {"state": STATE_ON} for member in setup.members},
transition=2,
)
assert waits == pytest.approx([0.1, 0.9])
assert all(member.is_on and member.brightness == 128 for member in setup.members)
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members)
first, second = (
(ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN)
if first_attribute == "brightness"
else (ATTR_COLOR_TEMP_KELVIN, ATTR_BRIGHTNESS)
)
commands = [
call
for call in setup.commands
if ATTR_BRIGHTNESS in call or ATTR_COLOR_TEMP_KELVIN in call
]
assert len(commands) == 2, setup.commands
assert all(call["target"] == GROUP for call in commands)
assert first in commands[0]
assert second not in commands[0]
assert second in commands[1]
assert first not in commands[1]

View file

@ -0,0 +1,244 @@
"""Native-group compatibility behavior at HA and device boundaries."""
# pylint: disable=protected-access
import math
from importlib import import_module
import pytest
from homeassistant.components import light
from homeassistant.components.adaptive_lighting import native_groups
from homeassistant.components.light import (
ATTR_COLOR_TEMP_KELVIN,
ATTR_TRANSITION,
ColorMode,
)
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant, State
from .support.native_lights import GROUP, TARGET_KELVIN
from .support.native_scenarios import activate_scene, scene_state, setup_scene_group
@pytest.mark.parametrize("service", ["apply", "turn_on"])
@pytest.mark.parametrize("nullable", ["brightness", "effect"])
async def test_null_scene_attributes_preserve_complete_group_colour(
hass: HomeAssistant,
service: str,
nullable: str,
) -> None:
"""Omitted HA scene attributes cannot split an otherwise complete colour request."""
setup = await setup_scene_group(hass)
levels = [member.brightness for member in setup.members]
await activate_scene(
hass,
{
member.entity_id: {**scene_state(3500, 100), nullable: None}
for member in setup.members
},
service=service,
transition=2,
)
assert all(member.is_on for member in setup.members)
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert [member.brightness for member in setup.members] == (
levels if nullable == "brightness" else [100, 100, 100]
)
assert setup.colour_commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 2,
},
]
@pytest.fixture
def legacy_colour_group(monkeypatch: pytest.MonkeyPatch) -> None:
"""Match HA 2025.9.4's ordered list of attribute names, not pairs."""
monkeypatch.setattr(
native_groups,
"COLOR_GROUP",
[
"hs_color",
"color_temp",
"color_temp_kelvin",
"rgb_color",
"rgbw_color",
"rgbww_color",
"xy_color",
],
)
@pytest.fixture
def current_colour_group(monkeypatch: pytest.MonkeyPatch) -> None:
"""Select current paired descriptors independently of the test runtime."""
monkeypatch.setattr(
native_groups,
"COLOR_GROUP",
[
("hs_color", "hs_color"),
("color_temp_kelvin", "color_temp_kelvin"),
("rgb_color", "rgb_color"),
("rgbw_color", "rgbw_color"),
("rgbww_color", "rgbww_color"),
("xy_color", "xy_color"),
],
)
@pytest.mark.parametrize("service", ["apply", "turn_on"])
@pytest.mark.usefixtures("legacy_colour_group")
async def test_legacy_colour_group_preserves_explicit_scene_ct(
hass: HomeAssistant,
service: str,
) -> None:
"""A scene without color_mode preserves CT and groups one colour command."""
setup = await setup_scene_group(hass)
levels = dict(
zip((member.entity_id for member in setup.members), (90, 140, 35), strict=True),
)
await activate_scene(
hass,
{entity: scene_state(3500, level) for entity, level in levels.items()},
service=service,
transition=2,
)
assert all(member.is_on for member in setup.members)
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert all(
member.brightness == levels[member.entity_id] for member in setup.members
)
assert setup.colour_commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 2,
},
]
@pytest.mark.parametrize("service", ["apply", "turn_on"])
@pytest.mark.usefixtures("legacy_colour_group")
async def test_legacy_colour_group_accepts_brightness_only_scene(
hass: HomeAssistant,
service: str,
) -> None:
"""Missing color_mode and colour attributes never prevent scene dispatch."""
setup = await setup_scene_group(hass)
levels = dict(
zip((member.entity_id for member in setup.members), (90, 140, 35), strict=True),
)
await activate_scene(
hass,
{
entity: {"state": STATE_ON, "brightness": level}
for entity, level in levels.items()
},
service=service,
transition=2,
)
assert all(member.is_on for member in setup.members)
assert all(
member.brightness == levels[member.entity_id] for member in setup.members
)
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members)
@pytest.mark.parametrize("service", ["apply", "turn_on"])
async def test_legacy_child_mired_alias_retains_complete_scene_boundary(
hass: HomeAssistant,
monkeypatch: pytest.MonkeyPatch,
service: str,
) -> None:
"""Old HA's extra normalized mired key cannot split or duplicate colour."""
setup = await setup_scene_group(hass)
original_preprocess = light.preprocess_turn_on_alternatives
def legacy_preprocess(hass: HomeAssistant, params: dict) -> None:
# Old HA gives the mired alias precedence if preprocessing is repeated,
# even when the exact Kelvin value is already present alongside it.
if (mired := params.pop("color_temp", None)) is not None:
params[ATTR_COLOR_TEMP_KELVIN] = math.floor(1_000_000 / mired)
original_preprocess(hass, params)
if (kelvin := params.get(ATTR_COLOR_TEMP_KELVIN)) is not None:
params["color_temp"] = math.floor(1_000_000 / kelvin)
# HA moved the service schema into a separate module in newer releases.
# Patch its actual global, before our interceptor receives the child call.
service_module = (
import_module(light.async_setup_services.__module__)
if hasattr(light, "async_setup_services")
else light
)
monkeypatch.setattr(
service_module,
"preprocess_turn_on_alternatives",
legacy_preprocess,
)
monkeypatch.setattr(
native_groups,
"preprocess_turn_on_alternatives",
legacy_preprocess,
)
levels = dict(
zip((member.entity_id for member in setup.members), (90, 140, 35), strict=True),
)
await activate_scene(
hass,
{entity: scene_state(3500, level) for entity, level in levels.items()},
service=service,
transition=2,
)
assert all(member.is_on for member in setup.members)
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert all(
member.brightness == levels[member.entity_id] for member in setup.members
)
colour_calls = [
call
for call in setup.commands
if ATTR_COLOR_TEMP_KELVIN in call or "color_temp" in call
]
assert len(colour_calls) == 1, setup.commands
assert colour_calls[0]["target"] == GROUP
assert colour_calls[0][ATTR_COLOR_TEMP_KELVIN] == 3500
assert colour_calls[0][ATTR_TRANSITION] == 2
@pytest.mark.usefixtures("legacy_colour_group")
def test_legacy_scene_mired_fallback_preserves_explicit_ct() -> None:
"""A saved legacy CT-mode scene converts mireds exactly as old HA did."""
state = State(
"light.legacy_ct_scene_member",
STATE_ON,
{"color_mode": ColorMode.COLOR_TEMP, "color_temp": 285, "brightness": 90},
)
assert native_groups.NativeGroupPlanner._scene_params(state, 2) == {
"brightness": 90,
ATTR_COLOR_TEMP_KELVIN: 3508,
ATTR_TRANSITION: 2,
}
@pytest.mark.usefixtures("current_colour_group")
def test_current_scene_mired_only_ct_mode_is_invalid() -> None:
"""Current HA descriptors reject CT-mode scenes without their Kelvin field."""
state = State(
"light.current_ct_scene_member",
STATE_ON,
{"color_mode": ColorMode.COLOR_TEMP, "color_temp": 285, "brightness": 90},
)
assert native_groups.NativeGroupPlanner._scene_params(state, 2) is None

View file

@ -0,0 +1,550 @@
"""Native-group dispatch behavior at HA and device boundaries."""
# pylint: disable=protected-access
from contextlib import nullcontext
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_INITIAL_TRANSITION,
CONF_INTERCEPT,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_BRIGHTNESS,
CONF_MIN_COLOR_TEMP,
CONF_ONLY_ONCE,
CONF_SLEEP_COLOR_TEMP,
CONF_TAKE_OVER_CONTROL,
CONF_TRANSITION,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGBWW_COLOR,
ATTR_TRANSITION,
ColorMode,
Profile,
)
from homeassistant.components.light.const import DATA_COMPONENT, DATA_PROFILES
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_LIGHTS,
CONF_NAME,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util.color import color_temperature_to_rgbww
from .support.adaptive import setup_switch
from .support.native_lights import (
GROUP,
LEFT,
OFF_MEMBER,
RIGHT,
TARGET_KELVIN,
RecordingMember,
)
from .support.native_scenarios import (
activate_scene,
prepare_reported_members,
scene_state,
setup_reported_group,
setup_scene_group,
)
async def test_unavailable_native_group_falls_back_to_available_members(hass):
"""An unavailable group cannot silently swallow its available members' colour."""
setup = await setup_scene_group(hass)
setup.group._attr_available = False
setup.group.async_write_ha_state()
await hass.async_block_till_done()
assert hass.states.get(GROUP).state == STATE_UNAVAILABLE
assert all(member.available for member in setup.members)
setup.commands.clear()
await activate_scene(
hass,
{member.entity_id: scene_state(3500, 100) for member in setup.members},
)
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert len(setup.colour_commands) == 3, setup.commands
assert {call["target"] for call in setup.colour_commands} == {
LEFT,
RIGHT,
OFF_MEMBER,
}
assert not any(call["target"] == GROUP for call in setup.commands)
async def test_native_scene_member_ct_range_uses_individual_clamping(hass):
"""Different per-member adaptive clamps prevent an incorrect common colour."""
setup = await setup_scene_group(hass)
setup.members[1]._attr_max_color_temp_kelvin = 2800
setup.members[1].async_write_ha_state()
await hass.async_block_till_done()
setup.commands.clear()
await activate_scene(
hass,
{
member.entity_id: {"state": STATE_ON, "brightness": 100}
for member in setup.members
},
transition=2,
)
assert len(setup.colour_commands) == 3, setup.commands
assert {call["target"] for call in setup.colour_commands} == {
member.entity_id for member in setup.members
}
assert not any(call["target"] == GROUP for call in setup.commands)
assert [member.physical_kelvin for member in setup.members] == [3000, 2800, 3000]
assert all(member.brightness == 100 for member in setup.members)
assert all(call[ATTR_TRANSITION] == 2 for call in setup.colour_commands)
@pytest.mark.parametrize("planner_enabled", [False, True])
@pytest.mark.parametrize("failed_index", [0, 1], ids=["failed-first", "healthy-first"])
async def test_healthy_target_survives_sibling_device_failure(
hass: HomeAssistant,
planner_enabled: bool,
failed_index: int,
):
"""A sibling failure cannot suppress accepted power, brightness, or colour."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(False, False, False))
setup.switch.manager.native_groups.disabled = not planner_enabled
message = "device unreachable"
device_error = HomeAssistantError(message)
async def broken():
raise device_error
setup.members[failed_index].before_turn_on = broken
failed = setup.members[failed_index]
attempts_before = len(failed.attempts)
with pytest.raises(HomeAssistantError, match="device unreachable") as error:
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: [LEFT, RIGHT],
ATTR_BRIGHTNESS: 150,
ATTR_COLOR_TEMP_KELVIN: TARGET_KELVIN,
ATTR_TRANSITION: 3,
},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert error.value is device_error
assert len(failed.attempts) == attempts_before + 1, failed.attempts
assert not failed.is_on
assert failed.brightness == (80, 110)[failed_index]
assert failed.physical_kelvin == 4000
assert not any(call["target"] == failed.entity_id for call in setup.commands)
healthy = setup.members[1 - failed_index]
assert healthy.is_on, setup.commands
assert healthy.brightness == 150, setup.commands
assert healthy.physical_kelvin == TARGET_KELVIN, setup.commands
healthy_commands = [
call for call in setup.commands if call["target"] == healthy.entity_id
]
# Without the planner, the original combined command is followed by the
# existing OFF-to-ON correction. With it, power and colour are split.
assert len(healthy_commands) == 2, setup.commands
assert healthy_commands[0][ATTR_TRANSITION] == 3
assert any(
call.get(ATTR_COLOR_TEMP_KELVIN) == TARGET_KELVIN
and call.get(ATTR_TRANSITION) == 3
for call in healthy_commands
), setup.commands
@pytest.mark.parametrize("planner_enabled", [False, True])
@pytest.mark.parametrize("failed_index", [0, 1], ids=["failed-first", "healthy-first"])
async def test_healthy_colour_survives_sibling_colour_failure(
hass: HomeAssistant,
planner_enabled: bool,
failed_index: int,
):
"""A colour-only device failure cannot discard a healthy target's colour."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(True, True, True))
setup.switch.manager.native_groups.disabled = not planner_enabled
message = "colour device unreachable"
device_error = HomeAssistantError(message)
async def broken():
raise device_error
setup.members[failed_index].before_turn_on = broken
failed = setup.members[failed_index]
attempts_before = len(failed.attempts)
with pytest.raises(HomeAssistantError, match="colour device unreachable") as error:
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: [LEFT, RIGHT],
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 3,
},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert error.value is device_error
assert len(failed.attempts) == attempts_before + 1, failed.attempts
assert failed.is_on
assert failed.brightness == (80, 110)[failed_index]
assert failed.physical_kelvin == 4000
assert not any(call["target"] == failed.entity_id for call in setup.commands)
healthy = setup.members[1 - failed_index]
assert healthy.physical_kelvin == 3500, setup.commands
healthy_commands = [
call for call in setup.colour_commands if call["target"] == healthy.entity_id
]
assert len(healthy_commands) == 1, setup.commands
assert healthy_commands[0][ATTR_TRANSITION] == 3
@pytest.mark.parametrize("planner_enabled", [False, True])
@pytest.mark.parametrize("transition", [None, 4])
async def test_default_colour_is_not_sent_twice(
hass: HomeAssistant,
planner_enabled: bool,
transition: int | None,
):
"""A bare ON must apply each light's default colour and transition once."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(True, True, True))
setup.switch.manager.native_groups.disabled = not planner_enabled
for member in setup.members:
name = f"{member.entity_id}.default"
hass.data[DATA_PROFILES].data[name] = Profile(
name,
0.3,
0.3,
None,
transition,
)
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: [member.entity_id for member in setup.members]},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert len(setup.colour_commands) == 3, setup.commands
assert {call["target"] for call in setup.colour_commands} == {
member.entity_id for member in setup.members
}
assert all(
call.get(ATTR_TRANSITION) == transition for call in setup.colour_commands
), setup.commands
@pytest.mark.parametrize("planner_enabled", [False, True])
@pytest.mark.parametrize("transition", [0, 3])
async def test_nonoptimistic_rgbww_preserves_requested_brightness(
hass: HomeAssistant,
monkeypatch: pytest.MonkeyPatch,
planner_enabled: bool,
transition: int,
):
"""Kelvin conversion uses requested brightness before a device report arrives."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(True, True, True))
setup.switch.manager.native_groups.disabled = not planner_enabled
member = setup.members[0]
member._attr_supported_color_modes = {ColorMode.RGBWW}
member._attr_color_mode = ColorMode.RGBWW
member._attr_rgbww_color = (0, 0, 0, 40, 40)
member.async_write_ha_state()
await hass.async_block_till_done()
device_calls = []
async def record_without_immediate_state_report(**kwargs):
device_calls.append(kwargs)
monkeypatch.setattr(member, "async_turn_on", record_without_immediate_state_report)
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: member.entity_id,
ATTR_BRIGHTNESS: 150,
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: transition,
},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
expected = color_temperature_to_rgbww(3500, 150, 2000, 6500)
colour_calls = [data for data in device_calls if ATTR_RGBWW_COLOR in data]
assert [data[ATTR_RGBWW_COLOR] for data in colour_calls] == [expected], device_calls
assert all(data[ATTR_TRANSITION] == transition for data in colour_calls)
@pytest.mark.parametrize("explicit_colour", [False, True])
async def test_bare_scene_preserves_healthy_colour_when_sibling_fails(
hass,
explicit_colour,
):
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(False, False, False))
error = HomeAssistantError("unreachable member")
async def broken():
raise error
setup.members[0].before_turn_on = broken
failed = setup.members[0]
attempts_before = len(failed.attempts)
entities = {
member.entity_id: {
"state": STATE_ON,
ATTR_BRIGHTNESS: 150,
**({ATTR_COLOR_TEMP_KELVIN: 3000} if explicit_colour else {}),
}
for member in setup.members
}
with pytest.raises(HomeAssistantError, match="unreachable member") as exc_info:
await hass.services.async_call(
"scene",
"apply",
{"entities": entities},
blocking=True,
context=Context(),
)
assert exc_info.value is error
await hass.async_block_till_done()
assert len(failed.attempts) == attempts_before + 1, failed.attempts
assert not failed.is_on
assert failed.brightness == 80
assert failed.physical_kelvin == 4000
assert not any(call["target"] == failed.entity_id for call in setup.commands)
assert [member.physical_kelvin for member in setup.members[1:]] == [
3000,
3000,
], setup.commands
for member in setup.members[1:]:
assert (
len(
[call for call in setup.commands if call["target"] == member.entity_id],
)
== 2
), setup.commands
@pytest.mark.parametrize("unmanaged_fails", [False, True])
async def test_mixed_target_failure_preserves_healthy_adaptive_colour(
hass,
unmanaged_fails,
):
"""An unrelated failing target cannot suppress healthy native-member colour."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(False, False, False))
unmanaged = RecordingMember("light.unmanaged_review", setup.commands)
await hass.data[DATA_COMPONENT].async_add_entities([unmanaged])
error = HomeAssistantError("unmanaged member unreachable")
if unmanaged_fails:
async def broken():
raise error
unmanaged.before_turn_on = broken
entities = [member.entity_id for member in setup.members] + [unmanaged.entity_id]
expectation = (
pytest.raises(HomeAssistantError, match="unmanaged member unreachable")
if unmanaged_fails
else nullcontext()
)
with expectation as exc_info:
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: entities, ATTR_BRIGHTNESS: 150},
blocking=True,
context=Context(),
)
if unmanaged_fails:
assert exc_info.value is error
await hass.async_block_till_done()
assert len(unmanaged.attempts) == 1, unmanaged.attempts
assert unmanaged.is_on == (not unmanaged_fails)
assert unmanaged.brightness == (110 if unmanaged_fails else 150)
assert unmanaged.physical_kelvin == 4000
assert len(
[call for call in setup.commands if call["target"] == unmanaged.entity_id],
) == (0 if unmanaged_fails else 1)
assert [member.physical_kelvin for member in setup.members] == [
3000,
] * 3, setup.commands
assert [member.brightness for member in setup.members] == [150] * 3, setup.commands
assert len(setup.commands) == (4 if unmanaged_fails else 5), setup.commands
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_unrelated_direct_profile_does_not_leak_brightness(hass, planner_enabled):
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(False, False, False))
direct = RecordingMember("light.aaa_direct_review", setup.commands)
await hass.data[DATA_COMPONENT].async_add_entities([direct])
await setup_switch(
hass,
{
CONF_NAME: "direct_review",
CONF_LIGHTS: [direct.entity_id],
CONF_INTERCEPT: True,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_MIN_BRIGHTNESS: 20,
CONF_MAX_BRIGHTNESS: 20,
CONF_MIN_COLOR_TEMP: 5000,
CONF_MAX_COLOR_TEMP: 5000,
CONF_SLEEP_COLOR_TEMP: 5000,
CONF_TAKE_OVER_CONTROL: False,
},
)
setup.switch.manager.native_groups.disabled = not planner_enabled
setup.commands.clear()
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: [
direct.entity_id,
*[member.entity_id for member in setup.members],
],
},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert direct.brightness == 51, setup.commands
assert [member.brightness for member in setup.members] == [
80,
110,
26,
], setup.commands
async def test_deferred_colour_preserves_brightness_with_delayed_entity_report(
hass,
monkeypatch,
):
"""An OFF cache must not reapply default brightness over explicit scene intent."""
setup = await setup_scene_group(hass)
right = setup.members[1]
profile = f"{RIGHT}.default"
hass.data[DATA_PROFILES].data[profile] = Profile(profile, None, None, 200, None)
physical = {"on": False, "brightness": right.brightness, "kelvin": 4000}
async def delayed_turn_on(**kwargs):
# Non-optimistic MQTT entities retain both LightEntity.is_on and the HA
# state until a report arrives, while the physical bulb accepts writes.
setup.commands.append({"target": RIGHT, "service": "turn_on", **kwargs})
physical["on"] = True
if ATTR_BRIGHTNESS in kwargs:
physical["brightness"] = kwargs[ATTR_BRIGHTNESS]
if ATTR_COLOR_TEMP_KELVIN in kwargs:
physical["kelvin"] = kwargs[ATTR_COLOR_TEMP_KELVIN]
monkeypatch.setattr(right, "async_turn_on", delayed_turn_on)
await activate_scene(hass, {RIGHT: scene_state(3500, 80)})
assert not right.is_on
assert hass.states.get(RIGHT).state == STATE_OFF
assert physical == {"on": True, "brightness": 80, "kelvin": 3500}, setup.commands
async def test_native_colour_only_call_preserves_existing_brightness_defaults(hass):
"""Colour splitting must not apply a default brightness to an already-ON bulb."""
setup = await setup_reported_group(hass)
profile = f"{LEFT}.default"
hass.data[DATA_PROFILES].data[profile] = Profile(profile, None, None, 200, None)
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: [LEFT, RIGHT], ATTR_COLOR_TEMP_KELVIN: 3500},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert setup.members[0].brightness == 80, setup.commands
assert all(member.physical_kelvin == 3500 for member in setup.members[:2])
assert setup.members[1].is_on
assert not setup.members[2].is_on
@pytest.mark.parametrize("service", ["group", "scene"])
async def test_native_only_once_brightness_update_keeps_existing_colour(hass, service):
"""Brightness-only requests do not re-adapt lights that were already ON."""
setup = await setup_reported_group(hass, **{CONF_ONLY_ONCE: True})
await prepare_reported_members(hass, setup, on=(True, True, True), kelvin=3500)
if service == "group":
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP, ATTR_BRIGHTNESS: 70},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
else:
await activate_scene(
hass,
{
member.entity_id: {"state": STATE_ON, ATTR_BRIGHTNESS: 70}
for member in setup.members
},
)
assert all(member.brightness == 70 for member in setup.members)
assert [member.physical_kelvin for member in setup.members] == [3500] * 3
assert setup.colour_commands == [], setup.commands
@pytest.mark.parametrize("intercept", [False, True])
async def test_native_whole_group_brightness_adapts_once(hass, freezer, intercept):
"""A whole-group activation sends one brightness update, not one per member."""
setup = await setup_reported_group(
hass,
**{CONF_TAKE_OVER_CONTROL: True, CONF_INTERCEPT: intercept},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
freezer.tick(11)
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert all(member.is_on for member in setup.members)
assert all(member.brightness == 128 for member in setup.members)
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members)
brightness_commands = [call for call in setup.commands if ATTR_BRIGHTNESS in call]
assert len(brightness_commands) == 1, setup.commands
assert brightness_commands[0]["target"] == GROUP

View file

@ -0,0 +1,97 @@
"""Native request snapshots and completed-invocation retention contracts."""
import gc
import weakref
from unittest.mock import Mock
import pytest
from homeassistant.components.adaptive_lighting.adaptation_utils import (
LightControlAttributes,
)
from homeassistant.components.adaptive_lighting.native_group_models import (
AttributePolicy,
BatchExecution,
NativeIntent,
NativePlan,
)
from homeassistant.core import Context
@pytest.mark.parametrize(
("attribute", "vector"),
[
("rgb_color", [255, 56, 0]),
("xy_color", [0.4, 0.3]),
("hs_color", [30, 75]),
("rgbw_color", [255, 56, 0, 80]),
("rgbww_color", [255, 56, 0, 80, 100]),
],
)
def test_colour_intent_detaches_mutable_colour_vectors(attribute, vector):
"""Queued colour is fixed even when the preparation payload changes later."""
expected = tuple(vector)
source = {attribute: vector, "transition": 1}
intent = NativeIntent(
colour=source,
explicit=LightControlAttributes.NONE,
version=0,
source="light.member",
policies={LightControlAttributes.COLOR: AttributePolicy(Mock(), 0)},
)
source["transition"] = 2
vector[0] = 0
dispatch = dict(intent.colour)
dispatch[attribute] = tuple(0 for _ in vector)
assert intent.colour[attribute] == expected
assert intent.colour["transition"] == 1
with pytest.raises(TypeError):
intent.colour[attribute][0] = 0
def test_complete_request_snapshot_detaches_scene_colour_vectors():
"""Later scene payload edits cannot change the request used for child matching."""
colour = [255, 56, 0]
request = {"state": "on", "rgb_color": colour}
requests = {"light.member": request}
plan = NativePlan(Context(), requests, {}, {})
colour[0] = 0
request["state"] = "off"
requests.clear()
assert plan.requests["light.member"] == {
"state": "on",
"rgb_color": (255, 56, 0),
}
def test_closed_execution_releases_error_traceback_payload():
"""A late-report claim retaining outcomes must not retain failed request frames."""
execution = BatchExecution()
class RequestPayload:
"""Stand in for request data reachable through a failed handler frame."""
def fail_request():
raise ValueError
def record_failure():
payload = RequestPayload()
reference = weakref.ref(payload)
try:
fail_request()
except ValueError as error:
execution.errors.append(error)
return reference
reference = record_failure()
gc.collect()
assert reference() is not None
execution.close()
gc.collect()
assert execution.closed
assert reference() is None

View file

@ -0,0 +1,935 @@
"""Native-group lifecycle behavior at HA and device boundaries."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_ADAPT_DELAY,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
)
from homeassistant.components.light import (
reproduce_state as light_reproduce_state,
)
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.components.scene import DATA_COMPONENT as SCENE_COMPONENT
from homeassistant.components.scene import Scene
from homeassistant.const import (
ATTR_ENTITY_ID,
EVENT_CALL_SERVICE,
EVENT_STATE_CHANGED,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import Context, HomeAssistant, State
from .support.async_helpers import DelayBarrier
from .support.native_lights import (
GROUP,
LEFT,
RIGHT,
TARGET_KELVIN,
RecordingGroup,
RecordingMember,
)
from .support.native_scenarios import (
activate_scene,
add_subgroup,
prepare_all_off,
prepare_reported_members,
scene_state,
setup_reported_group,
setup_scene_group,
)
@pytest.mark.parametrize("new_intent", ["off", "colour"])
async def test_newer_member_intent_invalidates_pending_scene_group(hass, new_intent):
"""A newer individual request cannot be overwritten by an older scene batch."""
setup = await setup_scene_group(hass)
entered, release = asyncio.Event(), asyncio.Event()
async def pause_power_dispatch():
entered.set()
await release.wait()
setup.members[2].before_turn_on = pause_power_dispatch
pending_scene = asyncio.create_task(
activate_scene(
hass,
{
member.entity_id: scene_state(TARGET_KELVIN, 100)
for member in setup.members
},
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
assert setup.members[1].is_on
if new_intent == "off":
await hass.services.async_call(
"light",
"turn_off",
{ATTR_ENTITY_ID: RIGHT},
blocking=True,
context=Context(),
)
else:
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: RIGHT, ATTR_COLOR_TEMP_KELVIN: 4500},
blocking=True,
context=Context(),
)
after_new_intent = len(setup.commands)
finally:
release.set()
await pending_scene
assert setup.members[0].physical_kelvin == TARGET_KELVIN
assert setup.members[2].physical_kelvin == TARGET_KELVIN
if new_intent == "off":
assert not setup.members[1].is_on
else:
assert setup.members[1].is_on
assert setup.members[1].physical_kelvin == 4500
# Even a CT-only broadcast would adjust a member with a newer colour
# request. A safety assertion therefore counts the commands, not just OFF
# physical behaviour of the simulated Zigbee devices.
assert not any(
ATTR_COLOR_TEMP_KELVIN in call and call["target"] in {GROUP, RIGHT}
for call in setup.commands[after_new_intent:]
), setup.commands
async def test_new_off_then_on_invalidates_prior_scene_coverage(hass):
"""Previous scene coverage cannot suppress a later, distinct activation."""
setup = await setup_scene_group(hass)
right = setup.members[1]
right.hold_reports = True
await activate_scene(
hass,
{member.entity_id: scene_state(TARGET_KELVIN, 100) for member in setup.members},
)
assert hass.states.get(RIGHT).state == STATE_OFF
assert right.is_on
assert right.physical_kelvin == TARGET_KELVIN
# HA still thinks the member is OFF, so there is no state transition for
# this service. Its explicit OFF intent must nevertheless retire coverage.
await hass.services.async_call(
"light",
"turn_off",
{ATTR_ENTITY_ID: RIGHT},
blocking=True,
context=Context(),
)
assert not right.is_on
right.hold_reports = False
right.physical_kelvin = 4000
right.async_write_ha_state()
await hass.async_block_till_done()
setup.commands.clear()
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: RIGHT},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert right.is_on
assert right.physical_kelvin == TARGET_KELVIN
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == RIGHT
assert not any(call["target"] == GROUP for call in setup.commands)
async def test_newer_scene_colour_wins_when_older_scene_finishes_last(hass):
"""An older operation cannot broadcast its colour after a newer whole scene."""
setup = await setup_scene_group(hass)
entered, release = asyncio.Event(), asyncio.Event()
async def pause_old_power_dispatch():
entered.set()
await release.wait()
setup.members[2].before_turn_on = pause_old_power_dispatch
older_scene = asyncio.create_task(
activate_scene(
hass,
{
member.entity_id: scene_state(TARGET_KELVIN, 100)
for member in setup.members
},
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
# The newer request supplies colour only. The older command already in
# flight may finish power/brightness, but its deferred colour must not
# overwrite any member covered by this newer complete operation.
await asyncio.wait_for(
hass.services.async_call(
"scene",
"apply",
{
"entities": {
member.entity_id: {
"state": STATE_ON,
ATTR_COLOR_TEMP_KELVIN: 4500,
}
for member in setup.members
},
},
blocking=True,
context=Context(),
),
2,
)
assert all(member.physical_kelvin == 4500 for member in setup.members)
after_new_scene = len(setup.commands)
finally:
release.set()
await older_scene
assert all(member.physical_kelvin == 4500 for member in setup.members)
assert not any(
ATTR_COLOR_TEMP_KELVIN in call for call in setup.commands[after_new_scene:]
), setup.commands
async def test_group_flush_revalidates_members_after_another_awaited_group_send(
hass,
monkeypatch,
):
"""Newer member intent between broadcasts invalidates the next group's plan."""
setup = await setup_scene_group(hass)
second_members = [
RecordingMember(f"light.native_z_member_{index}", setup.commands)
for index in range(3)
]
for member in second_members:
member._attr_is_on = True
second_group = RecordingGroup(second_members, setup.commands)
second_group.entity_id = "light.native_z_group"
await hass.data[DATA_COMPONENT].async_add_entities([*second_members, second_group])
setup.switch._configured_lights.append(second_group.entity_id)
setup.switch._expand_light_groups()
await hass.async_block_till_done()
setup.commands.clear()
original_group_turn_on = setup.group.async_turn_on
newer_request_sent = False
async def first_group_turn_on(**kwargs):
nonlocal newer_request_sent
if ATTR_COLOR_TEMP_KELVIN in kwargs and not newer_request_sent:
newer_request_sent = True
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: second_members[0].entity_id,
ATTR_COLOR_TEMP_KELVIN: 4500,
},
blocking=True,
context=Context(),
)
await original_group_turn_on(**kwargs)
monkeypatch.setattr(setup.group, "async_turn_on", first_group_turn_on)
await activate_scene(
hass,
{
member.entity_id: scene_state(3500, 100)
for member in [*setup.members, *second_members]
},
)
assert newer_request_sent
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert second_members[0].physical_kelvin == 4500
assert all(member.physical_kelvin == 3500 for member in second_members[1:])
assert not any(
call["target"] == second_group.entity_id for call in setup.colour_commands
), setup.commands
async def test_native_group_coverage_does_not_require_individual_turn_on_events(
hass,
caplog,
):
"""Group-originated reports work with takeover and bare-only adaptation."""
setup = await setup_scene_group(hass)
await prepare_all_off(setup, hass)
setup.switch._take_over_control = True
setup.switch._adapt_only_on_bare_turn_on = True
setup.switch._detect_non_ha_changes = False
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert all(member.is_on for member in setup.members)
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members)
assert all(
setup.switch.manager.get_manual_control_attributes(member.entity_id).has_none()
for member in setup.members
)
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == GROUP
assert not [
record
for record in caplog.records
if record.exc_info and isinstance(record.exc_info[1], KeyError)
]
async def test_group_off_invalidates_pending_scene_after_profile_is_disabled(
hass,
):
"""A later group OFF remains authoritative after its adaptive profile stops."""
setup = await setup_scene_group(hass)
entered, release = asyncio.Event(), asyncio.Event()
async def pause_power_dispatch():
entered.set()
await release.wait()
setup.members[2].before_turn_on = pause_power_dispatch
pending_scene = asyncio.create_task(
activate_scene(
hass,
{
member.entity_id: scene_state(TARGET_KELVIN, 100)
for member in setup.members
},
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
assert setup.members[0].is_on
assert setup.members[1].is_on
await setup.switch.async_turn_off()
# The group OFF command is immediate; its individual MQTT reports lag.
# Retained HA member states therefore cannot substitute for respecting
# the newer group request, even after the profile is disabled.
setup.members[0].hold_reports = True
setup.members[1].hold_reports = True
await hass.services.async_call(
"light",
"turn_off",
{ATTR_ENTITY_ID: GROUP},
blocking=True,
context=Context(),
)
assert not setup.members[0].is_on
assert not setup.members[1].is_on
after_group_off = len(setup.commands)
finally:
release.set()
await pending_scene
assert not setup.members[0].is_on
assert not setup.members[1].is_on
assert not any(
ATTR_COLOR_TEMP_KELVIN in call and call["target"] in {GROUP, LEFT, RIGHT}
for call in setup.commands[after_group_off:]
), setup.commands
async def test_disabling_profile_during_group_flush_cancels_remaining_automatic_colour(
hass,
monkeypatch,
):
"""Automatic fallback must recheck profile guards after an awaited group send."""
setup = await setup_scene_group(hass)
second_members = [
RecordingMember(f"light.native_z_member_{index}", setup.commands)
for index in range(3)
]
second_group = RecordingGroup(second_members, setup.commands)
second_group.entity_id = "light.native_z_group"
await hass.data[DATA_COMPONENT].async_add_entities([*second_members, second_group])
setup.switch._configured_lights.append(second_group.entity_id)
setup.switch._expand_light_groups()
await prepare_all_off(setup, hass)
original_group_turn_on = setup.group.async_turn_on
disabled_during_colour_send = False
async def first_group_turn_on(**kwargs):
nonlocal disabled_during_colour_send
if ATTR_COLOR_TEMP_KELVIN in kwargs and not disabled_during_colour_send:
disabled_during_colour_send = True
await setup.switch.async_turn_off()
await original_group_turn_on(**kwargs)
monkeypatch.setattr(setup.group, "async_turn_on", first_group_turn_on)
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: [GROUP, second_group.entity_id]},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert disabled_during_colour_send
assert not setup.switch.is_on
assert all(member.is_on for member in [*setup.members, *second_members])
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members)
assert all(member.physical_kelvin == 4000 for member in second_members)
remaining_targets = {
second_group.entity_id,
*(member.entity_id for member in second_members),
}
assert not any(
call["target"] in remaining_targets for call in setup.colour_commands
), setup.commands
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_undispatched_split_power_respects_newer_off(
hass: HomeAssistant,
planner_enabled: bool,
):
"""A newer OFF cannot be undone by another target's delayed split request."""
setup = await setup_scene_group(hass)
setup.switch.manager.native_groups.disabled = not planner_enabled
entered, release = asyncio.Event(), asyncio.Event()
async def hold_left():
entered.set()
await release.wait()
setup.members[0].before_turn_on = hold_left
older = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: [LEFT, RIGHT],
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_BRIGHTNESS: 100,
},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
await hass.services.async_call(
"light",
"turn_off",
{ATTR_ENTITY_ID: RIGHT},
blocking=True,
context=Context(),
)
after_off = len(setup.commands)
assert not setup.members[1].is_on
finally:
release.set()
await older
await hass.async_block_till_done()
assert not setup.members[1].is_on, setup.commands[after_off:]
assert not any(
call["target"] == RIGHT and call["service"] == "turn_on"
for call in setup.commands[after_off:]
), setup.commands[after_off:]
async def test_derived_group_membership_growth_does_not_broaden_pending_repair(
hass,
monkeypatch,
):
"""A newly joined sibling is outside the original complete activation intent."""
setup = await setup_reported_group(hass, **{CONF_ADAPT_DELAY: 1})
subgroup = await add_subgroup(hass, setup)
delay = DelayBarrier(
monkeypatch,
setup.switch.manager.native_groups,
"_execute_dispatch",
)
turning_on = hass.async_create_task(
hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: subgroup.entity_id},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1
subgroup.members.append(setup.members[0])
subgroup.async_write_ha_state()
finally:
delay.release.set()
await turning_on
await hass.async_block_till_done()
assert setup.members[0].physical_kelvin == 4000
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members[1:])
assert not any(
call["target"] in (GROUP, subgroup.entity_id) for call in setup.colour_commands
), setup.commands
@pytest.mark.parametrize("retire", ["profile_off", "colour_off", "member_removed"])
async def test_newer_colour_invalidates_old_scene_after_target_is_retired(hass, retire):
"""Deferred explicit colour cannot overwrite a newer unmanaged light request."""
setup = await setup_scene_group(hass)
right = setup.members[1]
entered, release = asyncio.Event(), asyncio.Event()
async def pause_power_dispatch():
entered.set()
await release.wait()
setup.members[2].before_turn_on = pause_power_dispatch
older_scene = asyncio.create_task(
activate_scene(
hass,
{member.entity_id: scene_state(3500, 100) for member in setup.members},
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
if retire == "profile_off":
await setup.switch.async_turn_off()
elif retire == "colour_off":
await setup.switch.adapt_color_switch.async_turn_off()
else:
setup.group.members.remove(right)
setup.group.async_write_ha_state()
setup.switch._expand_light_groups()
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: RIGHT, ATTR_COLOR_TEMP_KELVIN: 4500},
blocking=True,
context=Context(),
)
assert right.physical_kelvin == 4500
after_newer_colour = len(setup.commands)
finally:
release.set()
await older_scene
assert right.physical_kelvin == 4500, setup.commands
assert not any(
call["target"] == RIGHT and ATTR_COLOR_TEMP_KELVIN in call
for call in setup.commands[after_newer_colour:]
), setup.commands
async def test_group_colour_for_off_member_does_not_cover_later_external_activation(
hass,
freezer,
):
"""An ignored colour-only broadcast cannot explain a future physical turn-on."""
setup = await setup_scene_group(hass)
right = setup.members[1]
assert not right.is_on
assert right.physical_kelvin == 4000
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP, ATTR_COLOR_TEMP_KELVIN: 3500},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert not right.is_on
assert right.physical_kelvin == 4000
setup.commands.clear()
# A bound Zigbee remote later activates the bulb without an HA service call.
# Fresh context and elapsed time rule out HA's recent-OFF artifact guard.
freezer.tick(60)
right.async_set_context(Context())
right._attr_is_on = True
right.async_write_ha_state()
await hass.async_block_till_done()
assert right.physical_kelvin == TARGET_KELVIN, setup.commands
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == RIGHT
async def test_group_colour_for_off_member_does_not_cover_later_ha_activation(hass):
"""An explicit newer member activation is corrected independently of the group."""
setup = await setup_scene_group(hass)
right = setup.members[1]
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP, ATTR_COLOR_TEMP_KELVIN: 3500},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert not right.is_on
assert right.physical_kelvin == 4000
setup.commands.clear()
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: RIGHT},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert right.physical_kelvin == TARGET_KELVIN, setup.commands
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == RIGHT
async def test_real_group_power_command_keeps_coverage_for_delayed_member_report(
hass,
freezer,
):
"""An actual group power command still covers a genuinely delayed ON report."""
setup = await setup_scene_group(hass)
await prepare_all_off(setup, hass)
right = setup.members[1]
right.hold_reports = True
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert right.is_on
assert right.physical_kelvin == TARGET_KELVIN
assert hass.states.get(RIGHT).state == STATE_OFF
assert len(setup.colour_commands) == 1
assert setup.colour_commands[0]["target"] == GROUP
commands_before_report = list(setup.commands)
freezer.tick(60)
right.hold_reports = False
right.async_set_context(Context())
right.async_write_ha_state()
await hass.async_block_till_done()
assert hass.states.get(RIGHT).state == STATE_ON
assert right.physical_kelvin == TARGET_KELVIN
assert setup.commands == commands_before_report
@pytest.mark.parametrize("trigger", [EVENT_STATE_CHANGED, EVENT_CALL_SERVICE])
@pytest.mark.parametrize("planner_enabled", [False, True])
@pytest.mark.parametrize("reuse_context", [False, True])
async def test_event_triggered_request_finishes_before_originating_scene_cancel(
hass,
planner_enabled,
reuse_context,
trigger,
):
"""An event-triggered service is independent even with the scene's Context."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(False, False, False))
setup.switch.manager.native_groups.disabled = not planner_enabled
entered, release = asyncio.Event(), asyncio.Event()
action_done = asyncio.Event()
completed_colours = []
scene_context = Context()
action_started = False
async def hold_left():
entered.set()
await release.wait()
setup.members[0].before_turn_on = hold_left
async def state_triggered_action(event):
nonlocal action_started
if action_started:
return
if trigger == EVENT_STATE_CHANGED:
if (
event.data[ATTR_ENTITY_ID] != RIGHT
or event.data["old_state"].state != STATE_OFF
or event.data["new_state"].state != STATE_ON
):
return
elif (
event.data["domain"] != "light"
or event.data["service"] != "turn_on"
or event.data["service_data"].get(ATTR_ENTITY_ID) != RIGHT
):
return
action_started = True
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: RIGHT,
ATTR_BRIGHTNESS: 100,
ATTR_COLOR_TEMP_KELVIN: 3000,
},
blocking=True,
context=event.context if reuse_context else Context(),
)
completed_colours.append(setup.members[1].physical_kelvin)
action_done.set()
remove_listener = hass.bus.async_listen(trigger, state_triggered_action)
older_scene = asyncio.create_task(
hass.services.async_call(
"scene",
"apply",
{
"entities": {
member.entity_id: scene_state(3000, 100) for member in setup.members
},
},
blocking=True,
context=scene_context,
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
await asyncio.wait_for(action_done.wait(), 2)
older_scene.cancel()
with pytest.raises(asyncio.CancelledError):
await older_scene
finally:
remove_listener()
release.set()
await asyncio.gather(older_scene, return_exceptions=True)
await hass.async_block_till_done()
assert completed_colours == [3000], setup.commands
assert setup.members[1].physical_kelvin == 3000, setup.commands
assert not any(call["target"] == GROUP for call in setup.colour_commands)
async def test_scene_state_triggered_request_does_not_wait_for_scene_flush(
hass,
monkeypatch,
):
"""A stored scene's final state event also starts an independent operation."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(False, False, False))
scene = "scene.native_boundary"
await hass.services.async_call(
"scene",
"create",
{
"scene_id": "native_boundary",
"entities": {
member.entity_id: scene_state(3000, 100) for member in setup.members
},
},
blocking=True,
)
await hass.async_block_till_done()
entered, release, action_done = asyncio.Event(), asyncio.Event(), asyncio.Event()
planner = setup.switch.manager.native_groups
original_flush = planner._flush
completed_colours = []
async def hold_scene_flush(batch):
if len(batch.plan.requests) == len(setup.members):
entered.set()
await release.wait()
await original_flush(batch)
monkeypatch.setattr(planner, "_flush", hold_scene_flush)
async def scene_state_action(event):
if event.data[ATTR_ENTITY_ID] != scene:
return
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: RIGHT,
ATTR_BRIGHTNESS: 100,
ATTR_COLOR_TEMP_KELVIN: 3000,
},
blocking=True,
context=event.context,
)
completed_colours.append(setup.members[1].physical_kelvin)
action_done.set()
remove_listener = hass.bus.async_listen(EVENT_STATE_CHANGED, scene_state_action)
older_scene = asyncio.create_task(
hass.services.async_call(
"scene",
"turn_on",
{ATTR_ENTITY_ID: scene},
blocking=True,
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
await asyncio.wait_for(action_done.wait(), 2)
older_scene.cancel()
with pytest.raises(asyncio.CancelledError):
await older_scene
finally:
remove_listener()
release.set()
await asyncio.gather(older_scene, return_exceptions=True)
await hass.async_block_till_done()
assert completed_colours == [3000], setup.commands
assert setup.members[1].physical_kelvin == 3000, setup.commands
assert not any(call["target"] == GROUP for call in setup.colour_commands)
@pytest.mark.parametrize("worker_identity_available", [False, True])
@pytest.mark.parametrize("service", ["apply", "turn_on"])
async def test_scene_worker_identity_is_an_optional_grouping_optimization(
hass,
monkeypatch,
worker_identity_available,
service,
):
"""An unfamiliar HA worker keeps correct colour through individual dispatch."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(False, False, False))
if not worker_identity_available:
original_worker = light_reproduce_state._async_reproduce_state
class UnidentifiedWorker:
"""Preserve HA reproduction but expose no coroutine code identity."""
def __call__(self, *args, **kwargs):
return original_worker(*args, **kwargs)
monkeypatch.setattr(
light_reproduce_state,
"_async_reproduce_state",
UnidentifiedWorker(),
)
await activate_scene(
hass,
{member.entity_id: scene_state(3000, 100) for member in setup.members},
service=service,
)
assert all(member.physical_kelvin == 3000 for member in setup.members)
assert all(member.is_on and member.brightness == 100 for member in setup.members)
assert sorted(command["target"] for command in setup.colour_commands) == (
[GROUP]
if worker_identity_available
else sorted(member.entity_id for member in setup.members)
), setup.commands
assert setup.switch.manager.native_groups.active.get() is None
@pytest.mark.parametrize("planner_enabled", [False, True])
@pytest.mark.parametrize("reuse_context", [False, True])
async def test_independent_opaque_scene_cannot_join_older_scene(
hass,
planner_enabled,
reuse_context,
):
"""An opaque scene owns its reproduction even when it inherits a scene context."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(False, False, False))
setup.switch.manager.native_groups.disabled = not planner_enabled
entered, release, action_done = asyncio.Event(), asyncio.Event(), asyncio.Event()
completed_colours = []
action_started = False
class OpaqueScene(Scene):
"""An integration scene with no readable scene_config for advance planning."""
entity_id = "scene.opaque_independent"
_attr_name = "Opaque independent"
async def async_activate(self, **_kwargs):
await light_reproduce_state.async_reproduce_states(
hass,
[
State(
LEFT,
STATE_ON,
{ATTR_BRIGHTNESS: 100, ATTR_COLOR_TEMP_KELVIN: 3000},
),
],
context=self._context,
)
await hass.data[SCENE_COMPONENT].async_add_entities([OpaqueScene()])
async def hold_left():
entered.set()
await release.wait()
setup.members[0].before_turn_on = hold_left
async def triggered_scene(event):
nonlocal action_started
if (
action_started
or event.data[ATTR_ENTITY_ID] != RIGHT
or event.data["old_state"].state != STATE_OFF
or event.data["new_state"].state != STATE_ON
):
return
action_started = True
await hass.services.async_call(
"scene",
"turn_on",
{ATTR_ENTITY_ID: "scene.opaque_independent"},
blocking=True,
context=event.context if reuse_context else Context(),
)
completed_colours.append(setup.members[0].physical_kelvin)
action_done.set()
remove_listener = hass.bus.async_listen(EVENT_STATE_CHANGED, triggered_scene)
older_scene = asyncio.create_task(
hass.services.async_call(
"scene",
"apply",
{
"entities": {
member.entity_id: scene_state(3000, 100) for member in setup.members
},
},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
await asyncio.wait_for(action_done.wait(), 2)
older_scene.cancel()
with pytest.raises(asyncio.CancelledError):
await older_scene
finally:
remove_listener()
release.set()
await asyncio.gather(older_scene, return_exceptions=True)
await hass.async_block_till_done()
assert completed_colours == [3000], setup.commands
assert setup.members[0].physical_kelvin == 3000, setup.commands
assert not any(call["target"] == GROUP for call in setup.colour_commands)

View file

@ -0,0 +1,194 @@
"""The shared native-light model obeys power and report contracts without AL."""
# pylint: disable=protected-access
import pytest
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_TRANSITION,
)
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import Context
from homeassistant.setup import async_setup_component
from .support.native_lights import (
GROUP,
LEFT,
OFF_MEMBER,
RIGHT,
NativeGroup,
NativeMember,
RecordingGroup,
RecordingMember,
)
@pytest.fixture
async def native_devices(hass):
"""Install real HA light services with no Adaptive Lighting profile."""
assert await async_setup_component(hass, "light", {})
commands = []
members = [
RecordingMember(entity_id, commands) for entity_id in (LEFT, RIGHT, OFF_MEMBER)
]
group = RecordingGroup(members, commands)
group._attr_is_on = False
await hass.data[DATA_COMPONENT].async_add_entities([*members, group])
return group, members, commands
@pytest.mark.parametrize("initially_lit", [False, True])
@pytest.mark.parametrize(
("params", "powers_members"),
[
({}, True),
({ATTR_TRANSITION: 2}, True),
({ATTR_BRIGHTNESS: 73}, True),
({ATTR_BRIGHTNESS: 73, ATTR_TRANSITION: 2}, True),
({ATTR_COLOR_TEMP_KELVIN: 3500}, False),
({ATTR_COLOR_TEMP_KELVIN: 3500, ATTR_TRANSITION: 2}, False),
],
)
async def test_native_model_group_on_distinguishes_power_from_colour(
hass,
native_devices,
initially_lit,
params,
powers_members,
):
"""Only power intent wakes OFF members; colour still updates optimistic caches."""
group, members, commands = native_devices
group._attr_is_on = members[0]._attr_is_on = initially_lit
members[0].async_write_ha_state()
group.async_write_ha_state()
await hass.async_block_till_done()
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP, **params},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
expected_power = [initially_lit or powers_members, powers_members, powers_members]
assert group.is_on == any(expected_power)
assert hass.states.get(GROUP).state == (
STATE_ON if any(expected_power) else STATE_OFF
)
assert [member.is_on for member in members] == expected_power
assert [hass.states.get(member.entity_id).state for member in members] == [
STATE_ON if enabled else STATE_OFF for enabled in expected_power
]
assert [member.brightness for member in members] == [
params.get(ATTR_BRIGHTNESS, 110),
] * 3
assert [member.physical_kelvin for member in members] == [
params.get(ATTR_COLOR_TEMP_KELVIN, 4000) if enabled else 4000
for enabled in expected_power
]
assert [member.color_temp_kelvin for member in members] == [
params.get(ATTR_COLOR_TEMP_KELVIN, 3000),
] * 3
assert commands == [{"target": GROUP, "service": "turn_on", **params}]
assert all(not member.attempts for member in members)
@pytest.mark.parametrize("hold_reports", [False, True])
async def test_native_model_group_off_preserves_attributes_and_independent_reports(
hass,
native_devices,
hold_reports,
):
"""OFF is one broadcast, retaining member attributes even while reports lag."""
group, members, commands = native_devices
group._attr_is_on = True
for index, member in enumerate(members):
member._attr_is_on = index < 2
member._attr_brightness = 50 + index
member.physical_kelvin = 4100 + index * 100
member._attr_color_temp_kelvin = 3100 + index * 100
member.async_write_ha_state()
group.async_write_ha_state()
await hass.async_block_till_done()
members[0].hold_reports = hold_reports
await hass.services.async_call(
"light",
"turn_off",
{ATTR_ENTITY_ID: GROUP, ATTR_TRANSITION: 2},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert not group.is_on
assert all(not member.is_on for member in members)
assert hass.states.get(GROUP).state == STATE_OFF
assert [hass.states.get(member.entity_id).state for member in members] == [
STATE_ON if hold_reports else STATE_OFF,
STATE_OFF,
STATE_OFF,
]
assert [member.brightness for member in members] == [50, 51, 52]
assert [member.physical_kelvin for member in members] == [4100, 4200, 4300]
assert [member.color_temp_kelvin for member in members] == [3100, 3200, 3300]
assert commands == [
{"target": GROUP, "service": "turn_off", ATTR_TRANSITION: 2},
]
assert all(not member.attempts for member in members)
members[0].hold_reports = False
members[0].async_write_ha_state()
assert hass.states.get(LEFT).state == STATE_OFF
@pytest.mark.parametrize("device_kind", ["member", "group"])
@pytest.mark.parametrize(
("params", "recorded"),
[
(
{ATTR_COLOR_TEMP_KELVIN: 3500, "color_temp": 285},
{ATTR_COLOR_TEMP_KELVIN: 3500},
),
(
{ATTR_COLOR_TEMP_KELVIN: 3500, "color_temp": 286},
{ATTR_COLOR_TEMP_KELVIN: 3500, "color_temp": 286},
),
({"color_temp": 285}, {"color_temp": 285}),
],
ids=["matching-alias", "contradictory-alias", "alias-only"],
)
async def test_native_model_trace_preserves_distinct_colour_parameters(
native_devices,
monkeypatch,
device_kind,
params,
recorded,
):
"""Trace projection preserves raw device input and exposes nonredundant aliases."""
group, members, commands = native_devices
group._attr_is_on = True
for member in members:
member._attr_is_on = True
device = members[0] if device_kind == "member" else group
model = NativeMember if device_kind == "member" else NativeGroup
original = model.async_turn_on
received = []
async def observe_device(self, **kwargs):
received.append(kwargs)
await original(self, **kwargs)
monkeypatch.setattr(model, "async_turn_on", observe_device)
# Exercise the device boundary directly: HA schemas forbid callers sending
# both aliases, but older HA supplies both to devices after preprocessing.
await device.async_turn_on(**params)
assert received == [params]
assert commands == [
{"target": device.entity_id, "service": "turn_on", **recorded},
]
assert members[0].physical_kelvin == params.get(ATTR_COLOR_TEMP_KELVIN, 4000)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,234 @@
"""Native-group members behavior at HA and device boundaries."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_ADAPT_DELAY,
CONF_DETECT_NON_HA_CHANGES,
CONF_TAKE_OVER_CONTROL,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
)
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import Context
from .support.adaptive import track_adaptive_light_calls
from .support.async_helpers import DelayBarrier
from .support.native_lights import GROUP, RIGHT, TARGET_KELVIN, NativeMember
from .support.native_scenarios import setup_native_group, turn_on_member
async def test_native_group_member_activation_repairs_stale_colour(hass):
"""A newly ON member is corrected even though its ON group cache matches."""
setup = await setup_native_group(hass)
calls = track_adaptive_light_calls(hass)
assert setup.right.physical_kelvin == 4000
assert hass.states.get(GROUP).attributes[ATTR_COLOR_TEMP_KELVIN] == TARGET_KELVIN
await turn_on_member(hass)
assert hass.states.get(GROUP).state == STATE_ON
assert hass.states.get(RIGHT).attributes[ATTR_COLOR_TEMP_KELVIN] == TARGET_KELVIN
assert setup.right.physical_kelvin == TARGET_KELVIN
assert len(calls) == 1
assert calls[0][ATTR_ENTITY_ID] == RIGHT
assert calls[0][ATTR_COLOR_TEMP_KELVIN] == TARGET_KELVIN
assert ATTR_BRIGHTNESS not in calls[0]
assert setup.left.brightness == 80
assert setup.right.brightness == 110
assert setup.off_member.is_on is False
assert setup.off_member.physical_kelvin == 3600
assert setup.switch.lights == [GROUP]
calls.clear()
await setup.switch._async_update_at_interval_action()
await hass.async_block_till_done()
assert calls == []
setup.group._attr_color_temp_kelvin = 4000
setup.group.async_write_ha_state()
await hass.async_block_till_done()
await setup.switch._async_update_at_interval_action()
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0][ATTR_ENTITY_ID] == GROUP
async def test_native_group_member_brightness_correction_does_not_wake_siblings(hass):
"""Brightness correction targets only the member which has turned on."""
setup = await setup_native_group(hass)
await setup.switch.adapt_brightness_switch.async_turn_on()
calls = track_adaptive_light_calls(hass)
await turn_on_member(hass)
assert setup.right.physical_kelvin == TARGET_KELVIN
assert setup.right.brightness == 128
assert setup.left.brightness == 80
assert setup.off_member.is_on is False
assert len(calls) == 1
assert calls[0][ATTR_ENTITY_ID] == RIGHT
@pytest.mark.parametrize(
"guard",
["profile_off", "color_off", "group_manual", "removed"],
)
async def test_native_group_member_activation_respects_guards(hass, guard):
"""Disabled, manually controlled, or retired targets receive no repair."""
setup = await setup_native_group(hass)
if guard == "profile_off":
await setup.switch.async_turn_off()
elif guard == "color_off":
await setup.switch.adapt_color_switch.async_turn_off()
elif guard == "group_manual":
setup.switch.manager.set_manual_control_attributes(GROUP)
else:
setup.group.members.remove(setup.right)
setup.group.async_write_ha_state()
await hass.async_block_till_done()
calls = track_adaptive_light_calls(hass)
await turn_on_member(hass)
assert calls == []
assert setup.right.physical_kelvin == 4000
assert setup.off_member.is_on is False
async def test_native_group_newly_discovered_member_is_observed(hass):
"""Updated HA membership starts tracking a new member without reloading."""
setup = await setup_native_group(hass)
new_member = NativeMember("light.new_native_member")
await hass.data[DATA_COMPONENT].async_add_entities([new_member])
setup.group.members.append(new_member)
setup.group.async_write_ha_state()
await hass.async_block_till_done()
calls = track_adaptive_light_calls(hass)
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: new_member.entity_id},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert new_member.physical_kelvin == TARGET_KELVIN
assert len(calls) == 1
assert calls[0][ATTR_ENTITY_ID] == new_member.entity_id
assert setup.switch.lights == [GROUP]
assert setup.right.is_on is False
assert setup.off_member.is_on is False
async def test_native_group_member_cache_reports_do_not_trigger_correction(hass):
"""An ON-to-ON report is not an activation and must not add traffic."""
setup = await setup_native_group(hass)
setup.right._attr_is_on = True
await setup.switch.async_turn_off()
setup.right.async_write_ha_state()
await hass.async_block_till_done()
await setup.switch.async_turn_on(adapt_lights=False)
calls = track_adaptive_light_calls(hass)
setup.right._attr_brightness = 111
setup.right.async_write_ha_state()
await hass.async_block_till_done()
assert calls == []
assert setup.right.physical_kelvin == 4000
async def test_native_group_activation_does_not_misclassify_member_reports(
hass,
freezer,
):
"""Group service activation must not mark its independent member reports manual."""
setup = await setup_native_group(
hass,
**{
CONF_TAKE_OVER_CONTROL: True,
CONF_DETECT_NON_HA_CHANGES: False,
},
)
await setup.switch.async_turn_off()
for member in (setup.left, setup.right, setup.off_member):
member._attr_is_on = False
member.physical_kelvin = 4000
member._attr_color_temp_kelvin = TARGET_KELVIN
member.async_write_ha_state()
setup.group._attr_is_on = False
setup.group.async_write_ha_state()
await hass.async_block_till_done()
await setup.switch.async_turn_on(adapt_lights=False)
# The group has been off longer than the turn-off artifact debounce window.
freezer.tick(11)
calls = track_adaptive_light_calls(hass)
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: GROUP},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert hass.states.get(GROUP).state == STATE_ON
for member in (setup.left, setup.right, setup.off_member):
assert member.is_on is True
assert member.physical_kelvin == TARGET_KELVIN
assert setup.switch.manager.get_manual_control_attributes(
member.entity_id,
).has_none()
assert any(
call[ATTR_ENTITY_ID] == GROUP
and call.get(ATTR_COLOR_TEMP_KELVIN) == TARGET_KELVIN
for call in calls
)
@pytest.mark.parametrize(
"cancel",
["turn_off", "disable_profile", "unload", "remove_member"],
)
async def test_native_group_member_activation_rechecks_after_delay(
hass,
monkeypatch,
cancel,
):
"""A queued repair may not outlive its member or owning profile."""
setup = await setup_native_group(hass, **{CONF_ADAPT_DELAY: 1})
delay = DelayBarrier(monkeypatch, setup.switch, "_respond_to_off_to_on_event")
calls = track_adaptive_light_calls(hass)
turning_on = hass.async_create_task(turn_on_member(hass))
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1
if cancel == "turn_off":
await hass.services.async_call(
"light",
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: RIGHT},
blocking=True,
)
elif cancel == "disable_profile":
await setup.switch.async_turn_off()
elif cancel == "unload":
assert await hass.config_entries.async_unload(setup.entry.entry_id)
else:
setup.group.members.remove(setup.right)
setup.group.async_write_ha_state()
finally:
delay.release.set()
await turning_on
await hass.async_block_till_done()
assert calls == []
assert setup.right.physical_kelvin == 4000
if cancel == "turn_off":
assert hass.states.get(RIGHT).state == STATE_OFF

View file

@ -0,0 +1,406 @@
"""Native-group ownership behavior at HA and device boundaries."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.adaptive_lighting.adaptation_utils import (
LightControlAttributes,
)
from homeassistant.components.adaptive_lighting.const import (
CONF_AUTORESET_CONTROL,
CONF_EXPAND_LIGHT_GROUPS,
CONF_INITIAL_TRANSITION,
CONF_MANUAL_CONTROL,
CONF_MAX_COLOR_TEMP,
CONF_MIN_COLOR_TEMP,
CONF_ONLY_ONCE,
CONF_SEND_SPLIT_DELAY,
CONF_SEPARATE_TURN_ON_COMMANDS,
CONF_SKIP_REDUNDANT_COMMANDS,
CONF_SLEEP_COLOR_TEMP,
CONF_TAKE_OVER_CONTROL,
CONF_TRANSITION,
DOMAIN,
SERVICE_SET_MANUAL_CONTROL,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
)
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_LIGHTS,
CONF_NAME,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import Context, Event, HomeAssistant, State
from .support.adaptive import setup_switch, track_adaptive_light_calls
from .support.async_helpers import DelayBarrier
from .support.native_lights import (
GROUP,
LEFT,
RIGHT,
TARGET_KELVIN,
RecordingGroup,
RecordingMember,
)
from .support.native_scenarios import (
set_manual,
setup_native_group,
setup_reported_group,
setup_scene_group,
turn_on_member,
)
@pytest.mark.parametrize("unload", [False, True])
@pytest.mark.parametrize("owner_enabled", [False, True])
async def test_retiring_command_group_preserves_foreign_derived_pause(
hass,
unload,
owner_enabled,
):
"""Retiring a configured group cannot erase a surviving profile's source."""
setup = await setup_reported_group(hass)
foreign = RecordingMember("light.foreign_member", setup.commands)
broad = RecordingGroup([*setup.members, foreign], setup.commands)
broad.entity_id = "light.broader_group"
await hass.data[DATA_COMPONENT].async_add_entities([foreign, broad])
_, other = await setup_switch(
hass,
{
CONF_NAME: "broader_owner",
CONF_LIGHTS: [broad.entity_id],
CONF_EXPAND_LIGHT_GROUPS: False,
CONF_INITIAL_TRANSITION: 0,
CONF_ONLY_ONCE: True,
CONF_TAKE_OVER_CONTROL: False,
CONF_AUTORESET_CONTROL: 60,
},
)
if not owner_enabled:
await other.async_turn_off()
await set_manual(hass, other, GROUP, "color")
manager = setup.switch.manager
retained = [GROUP, *(member.entity_id for member in setup.members)]
assert all(
manager.get_manual_control_attributes(entity) == LightControlAttributes.COLOR
for entity in retained
)
timers = {
member.entity_id: manager.auto_reset_manual_control_timers[member.entity_id]
for member in setup.members
}
if unload:
entry = next(
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if hass.data[DOMAIN][entry.entry_id]["switch"] is setup.switch
)
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert other.is_on is owner_enabled
assert all(
manager.get_manual_control_attributes(entity) == LightControlAttributes.COLOR
for entity in retained
)
for member, timer in timers.items():
assert manager.auto_reset_manual_control_timers[member] is timer
assert timer.is_running()
async def test_native_group_member_removal_preserves_other_profile(hass):
"""Retiring group membership cannot retire another profile's direct light."""
setup = await setup_native_group(hass)
_, other_switch = await setup_switch(
hass,
{
CONF_NAME: "direct_member_owner",
CONF_LIGHTS: [RIGHT],
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_MIN_COLOR_TEMP: TARGET_KELVIN,
CONF_MAX_COLOR_TEMP: TARGET_KELVIN,
CONF_SLEEP_COLOR_TEMP: TARGET_KELVIN,
CONF_TAKE_OVER_CONTROL: False,
CONF_SKIP_REDUNDANT_COMMANDS: True,
},
)
await other_switch.adapt_brightness_switch.async_turn_off()
setup.group.members.remove(setup.right)
setup.group.async_write_ha_state()
await hass.async_block_till_done()
calls = track_adaptive_light_calls(hass)
await turn_on_member(hass)
assert setup.right.physical_kelvin == TARGET_KELVIN
assert len(calls) == 1
assert calls[0][ATTR_ENTITY_ID] == RIGHT
assert setup.switch.lights == [GROUP]
assert other_switch.lights == [RIGHT]
assert setup.off_member.is_on is False
async def test_native_group_with_two_colour_owners_does_not_choose_one(hass):
"""Competing profiles cannot be resolved by arbitrary group-owner iteration."""
setup = await setup_scene_group(hass)
_, other = await setup_switch(
hass,
{
CONF_NAME: "conflicting_native_colour_owner",
CONF_LIGHTS: [GROUP],
CONF_EXPAND_LIGHT_GROUPS: False,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_MIN_COLOR_TEMP: 4500,
CONF_MAX_COLOR_TEMP: 4500,
CONF_SLEEP_COLOR_TEMP: 4500,
CONF_TAKE_OVER_CONTROL: False,
},
)
await other.adapt_brightness_switch.async_turn_off()
await hass.async_block_till_done()
assert setup.switch.is_on
assert other.is_on
assert setup.switch.adapt_color_switch.is_on
assert other.adapt_color_switch.is_on
setup.commands.clear()
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: [member.entity_id for member in setup.members]},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert all(member.is_on for member in setup.members)
# Existing per-member conflict handling is outside this optimization. Only
# assert that it does not invent one arbitrarily owned group correction.
assert not any(
call["target"] == GROUP for call in setup.colour_commands
), setup.commands
@pytest.mark.parametrize("reset_action", ["disable", "enable", "sleep", "manual_reset"])
async def test_native_profile_reset_preserves_another_owner_member_split(
hass: HomeAssistant,
monkeypatch: pytest.MonkeyPatch,
reset_action: str,
) -> None:
"""An observed member's colour phase belongs to its still-enabled owner."""
setup = await setup_native_group(hass)
if reset_action == "enable":
await setup.switch.async_turn_off()
_, other = await setup_switch(
hass,
{
CONF_NAME: "direct_member_owner",
CONF_LIGHTS: [LEFT],
CONF_TAKE_OVER_CONTROL: False,
CONF_ONLY_ONCE: True,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 1234,
CONF_INITIAL_TRANSITION: 0,
},
)
delay = DelayBarrier(monkeypatch, other, "_execute_adaptation_calls")
calls = track_adaptive_light_calls(hass)
adapting = hass.async_create_task(
other._adapt_light(LEFT, other.create_context("test"), 0, force=True),
)
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1.234
assert len(calls) == 1
assert ATTR_BRIGHTNESS in calls[0]
try:
if reset_action == "disable":
await setup.switch.async_turn_off()
elif reset_action == "enable":
await setup.switch.async_turn_on(adapt_lights=False)
elif reset_action == "sleep":
await setup.switch._sleep_mode_switch_state_event_action(
Event(
"state_changed",
{
"entity_id": setup.switch.sleep_mode_switch.entity_id,
"old_state": State(
setup.switch.sleep_mode_switch.entity_id,
STATE_OFF,
),
"new_state": State(
setup.switch.sleep_mode_switch.entity_id,
STATE_ON,
),
},
),
)
else:
await hass.services.async_call(
DOMAIN,
SERVICE_SET_MANUAL_CONTROL,
{
ATTR_ENTITY_ID: setup.switch.entity_id,
CONF_LIGHTS: [GROUP],
CONF_MANUAL_CONTROL: False,
},
blocking=True,
)
finally:
delay.release.set()
await adapting
await hass.async_block_till_done()
member_calls = [call for call in calls if call[ATTR_ENTITY_ID] == LEFT]
assert other.is_on
assert len(member_calls) == 2, calls
assert ATTR_COLOR_TEMP_KELVIN in member_calls[-1]
assert not setup.switch.manager.adaptation_task_owners
async def test_disabling_native_profile_still_cancels_its_own_member_split(
hass: HomeAssistant,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Preserving foreign tasks must not let the disabled profile finish its own."""
setup = await setup_native_group(
hass,
**{CONF_SEPARATE_TURN_ON_COMMANDS: True, CONF_SEND_SPLIT_DELAY: 1234},
)
_, other = await setup_switch(
hass,
{CONF_NAME: "other_member_owner", CONF_LIGHTS: [LEFT], CONF_ONLY_ONCE: True},
)
await setup.switch.adapt_brightness_switch.async_turn_on()
delay = DelayBarrier(monkeypatch, setup.switch, "_execute_adaptation_calls")
calls = track_adaptive_light_calls(hass)
adapting = hass.async_create_task(
setup.switch._adapt_light(
LEFT,
setup.switch.create_context("test"),
0,
force=True,
),
)
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1.234
try:
await setup.switch.async_turn_off()
finally:
delay.release.set()
await adapting
await hass.async_block_till_done()
assert other.is_on
assert len(calls) == 1, calls
assert ATTR_BRIGHTNESS in calls[0]
assert not setup.switch.manager.adaptation_task_owners
@pytest.mark.parametrize("other_owner", [False, True])
async def test_member_retirement_cancels_only_retiring_owner_split(
hass,
monkeypatch,
other_owner,
):
"""Another profile retaining the member cannot keep the retired colour task alive."""
setup = await setup_native_group(
hass,
**{CONF_SEPARATE_TURN_ON_COMMANDS: True, CONF_SEND_SPLIT_DELAY: 1234},
)
if other_owner:
_, other = await setup_switch(
hass,
{
CONF_NAME: "other_member_owner",
CONF_LIGHTS: [RIGHT],
CONF_ONLY_ONCE: True,
},
)
await other.async_turn_off()
await setup.switch.adapt_brightness_switch.async_turn_on()
delay = DelayBarrier(monkeypatch, setup.switch, "_execute_adaptation_calls")
calls = track_adaptive_light_calls(hass)
turning_on = hass.async_create_task(turn_on_member(hass))
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1.234
retired = asyncio.Event()
original_expand = setup.switch._expand_light_groups
def observe_retirement():
original_expand()
if RIGHT not in setup.switch._tracked_lights:
retired.set()
monkeypatch.setattr(setup.switch, "_expand_light_groups", observe_retirement)
setup.group.members.remove(setup.right)
setup.group.async_write_ha_state()
await asyncio.wait_for(retired.wait(), 2)
assert RIGHT not in setup.switch._tracked_lights
if other_owner:
# Shared records are still useful to the remaining owner.
assert RIGHT in other._tracked_lights
assert RIGHT in setup.switch.manager.lights
assert RIGHT in setup.switch.manager.last_service_data
finally:
delay.release.set()
await turning_on
await hass.async_block_till_done()
assert len(calls) == 1, calls
assert ATTR_BRIGHTNESS in calls[0]
assert not setup.switch.manager.adaptation_task_owners
async def test_member_retirement_preserves_remaining_owner_split(hass, monkeypatch):
"""Retiring the observer leaves an independent active owner's delayed task intact."""
setup = await setup_native_group(hass)
await setup.switch.async_turn_off()
_, other = await setup_switch(
hass,
{
CONF_NAME: "remaining_member_owner",
CONF_LIGHTS: [RIGHT],
CONF_ONLY_ONCE: True,
CONF_TAKE_OVER_CONTROL: False,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 1234,
CONF_INITIAL_TRANSITION: 0,
},
)
delay = DelayBarrier(monkeypatch, other, "_execute_adaptation_calls")
calls = track_adaptive_light_calls(hass)
turning_on = hass.async_create_task(turn_on_member(hass))
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1.234
retired = asyncio.Event()
original_expand = setup.switch._expand_light_groups
def observe_retirement():
original_expand()
if RIGHT not in setup.switch._tracked_lights:
retired.set()
monkeypatch.setattr(setup.switch, "_expand_light_groups", observe_retirement)
setup.group.members.remove(setup.right)
setup.group.async_write_ha_state()
await asyncio.wait_for(retired.wait(), 2)
assert RIGHT not in setup.switch._tracked_lights
assert RIGHT in other._tracked_lights
assert other in setup.switch.manager.adaptation_task_owners.values()
finally:
delay.release.set()
await turning_on
await hass.async_block_till_done()
assert len(calls) == 2, calls
assert ATTR_BRIGHTNESS in calls[0]
assert ATTR_COLOR_TEMP_KELVIN in calls[1]
assert not setup.switch.manager.adaptation_task_owners

View file

@ -0,0 +1,238 @@
"""Native-group permissions behavior at HA and device boundaries."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.light import (
ATTR_COLOR_TEMP_KELVIN,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON
from homeassistant.core import Context
from homeassistant.exceptions import Unauthorized
from .support.native_lights import GROUP, LEFT, RIGHT
from .support.native_scenarios import (
activate_scene,
prepare_reported_members,
scene_state,
setup_reported_group,
setup_scene_group,
)
@pytest.mark.parametrize("newer_target", ["all", "helper_group"])
async def test_expanded_targets_preserve_implicit_and_explicit_permissions(
hass,
hass_read_only_user,
monkeypatch,
newer_target,
):
"""All filters denied entities; an explicit helper group rejects all targets."""
setup = await setup_reported_group(hass)
# Keep aggregate power stable to isolate supersession of pending colour
# from the separate reactive path triggered by a new aggregate ON report.
await prepare_reported_members(hass, setup, on=(True, True, True))
hass_read_only_user.mock_policy({"entities": {"entity_ids": {LEFT: True}}})
planner = setup.switch.manager.native_groups
entered, release = asyncio.Event(), asyncio.Event()
original_flush = planner._flush
first = True
async def held_flush(batch):
nonlocal first
if first:
first = False
entered.set()
await release.wait()
await original_flush(batch)
monkeypatch.setattr(planner, "_flush", held_flush)
entities = [member.entity_id for member in setup.members]
hass.states.async_set("group.review_all", STATE_ON, {ATTR_ENTITY_ID: entities})
pending = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: entities},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
data = {
ATTR_ENTITY_ID: "all" if newer_target == "all" else "group.review_all",
ATTR_COLOR_TEMP_KELVIN: 5000,
}
if newer_target == "all":
await hass.services.async_call(
"light",
"turn_on",
data,
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
else:
with pytest.raises(Unauthorized):
await hass.services.async_call(
"light",
"turn_on",
data,
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
finally:
release.set()
await pending
await hass.async_block_till_done()
expected = [5000, 3000, 3000] if newer_target == "all" else [3000] * 3
assert [
member.physical_kelvin for member in setup.members
] == expected, setup.commands
@pytest.mark.parametrize("service", ["turn_on", "turn_off"])
async def test_rejected_light_request_does_not_cancel_authorized_pending_scene(
hass,
hass_read_only_user,
service,
):
"""A caller without control permission cannot invalidate accepted colour work."""
setup = await setup_scene_group(hass)
entered, release = asyncio.Event(), asyncio.Event()
async def pause_power_dispatch():
entered.set()
await release.wait()
setup.members[2].before_turn_on = pause_power_dispatch
older_scene = asyncio.create_task(
activate_scene(
hass,
{member.entity_id: scene_state(3500, 100) for member in setup.members},
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
commands_before_denied_call = list(setup.commands)
params = {ATTR_ENTITY_ID: RIGHT}
if service == "turn_on":
params[ATTR_COLOR_TEMP_KELVIN] = 4500
with pytest.raises(Unauthorized):
await hass.services.async_call(
"light",
service,
params,
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
assert setup.commands == commands_before_denied_call
finally:
release.set()
await older_scene
assert [member.physical_kelvin for member in setup.members] == [3500] * 3
assert all(member.is_on for member in setup.members)
async def test_native_member_colour_requires_control_permission(
hass,
hass_read_only_user,
):
"""Skipping an already-ON power call cannot bypass HA entity authorization."""
setup = await setup_reported_group(hass)
with pytest.raises(Unauthorized):
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: LEFT, ATTR_COLOR_TEMP_KELVIN: 3500},
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
await hass.async_block_till_done()
assert setup.commands == []
assert [member.physical_kelvin for member in setup.members] == [4000] * 3
async def test_native_colour_rejects_mixed_authorized_and_unauthorized_targets(
hass,
hass_read_only_user,
):
"""An allowed member cannot grant control of another explicitly named member."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(True, True, True))
hass_read_only_user.mock_policy({"entities": {"entity_ids": {LEFT: True}}})
assert hass_read_only_user.permissions.check_entity(LEFT, "control")
assert not hass_read_only_user.permissions.check_entity(RIGHT, "control")
with pytest.raises(Unauthorized):
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: [LEFT, RIGHT], ATTR_COLOR_TEMP_KELVIN: 3500},
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
await hass.async_block_till_done()
assert setup.commands == []
assert [member.physical_kelvin for member in setup.members] == [4000] * 3
async def test_native_member_colour_allows_authorized_subset(hass, hass_read_only_user):
"""A non-admin allowed to control one member can still change that member."""
setup = await setup_reported_group(hass)
hass_read_only_user.mock_policy({"entities": {"entity_ids": {LEFT: True}}})
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: LEFT, ATTR_COLOR_TEMP_KELVIN: 3500},
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
await hass.async_block_till_done()
assert setup.members[0].physical_kelvin == 3500
assert len(setup.colour_commands) == 1
assert setup.colour_commands[0]["target"] == LEFT
assert not any(member.is_on for member in setup.members[1:])
@pytest.mark.parametrize("group_authorized", [False, True])
async def test_native_colour_allows_members_without_requiring_group_permission(
hass,
hass_read_only_user,
group_authorized,
):
"""Group optimization must retain the user's actual entity permissions."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(True, True, True))
member_ids = [member.entity_id for member in setup.members]
hass_read_only_user.mock_policy(
{
"entities": {
"entity_ids": {
**dict.fromkeys(member_ids, True),
GROUP: group_authorized,
},
},
},
)
assert all(
hass_read_only_user.permissions.check_entity(member, "control")
for member in member_ids
)
assert (
hass_read_only_user.permissions.check_entity(GROUP, "control")
is group_authorized
)
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: member_ids, ATTR_COLOR_TEMP_KELVIN: 3500},
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
await hass.async_block_till_done()
assert all(member.physical_kelvin == 3500 for member in setup.members)
expected_targets = {GROUP} if group_authorized else set(member_ids)
assert {call["target"] for call in setup.colour_commands} == expected_targets
assert len(setup.colour_commands) == len(expected_targets)

View file

@ -0,0 +1,307 @@
"""Native-group profile updates behavior at HA and device boundaries."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_ADAPT_DELAY,
CONF_MAX_COLOR_TEMP,
CONF_MIN_COLOR_TEMP,
CONF_SEND_SPLIT_DELAY,
CONF_SEPARATE_TURN_ON_COMMANDS,
CONF_SLEEP_COLOR_TEMP,
DOMAIN,
SERVICE_CHANGE_SWITCH_SETTINGS,
)
from homeassistant.components.light import ATTR_COLOR_TEMP_KELVIN
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import Context, HomeAssistant
from .support.adaptive import track_adaptive_light_calls
from .support.async_helpers import DelayBarrier
from .support.native_lights import GROUP, RIGHT, RecordingGroup, RecordingMember
from .support.native_scenarios import (
prepare_reported_members,
setup_native_group,
setup_reported_group,
turn_on_member,
)
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_sleep_mode_change_supersedes_pending_adaptive_colour(
hass: HomeAssistant,
monkeypatch: pytest.MonkeyPatch,
planner_enabled: bool,
):
"""A delayed adaptive colour cannot overwrite the newer sleep temperature."""
setup = await setup_reported_group(
hass,
**{CONF_ADAPT_DELAY: 1, CONF_SLEEP_COLOR_TEMP: 2200},
)
await prepare_reported_members(hass, setup, on=(False, False, False))
setup.switch.manager.native_groups.disabled = not planner_enabled
delay = DelayBarrier(
monkeypatch,
setup.switch.manager.native_groups if planner_enabled else setup.switch,
"_execute_dispatch" if planner_enabled else "_respond_to_off_to_on_event",
)
replacement_applied = asyncio.Event()
original_turn_on = setup.group.async_turn_on
async def observe_replacement(**kwargs):
await original_turn_on(**kwargs)
if all(member.physical_kelvin == 2200 for member in setup.members):
replacement_applied.set()
monkeypatch.setattr(setup.group, "async_turn_on", observe_replacement)
older = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1
await hass.services.async_call(
"switch",
"turn_on",
{ATTR_ENTITY_ID: setup.switch.sleep_mode_switch.entity_id},
blocking=True,
)
await asyncio.wait_for(replacement_applied.wait(), 2)
assert all(member.physical_kelvin == 2200 for member in setup.members)
after_sleep = len(setup.commands)
finally:
delay.release.set()
await older
await hass.async_block_till_done()
assert all(
member.physical_kelvin == 2200 for member in setup.members
), setup.commands[after_sleep:]
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_sleep_mode_change_preserves_pending_explicit_colour(
hass: HomeAssistant,
monkeypatch: pytest.MonkeyPatch,
planner_enabled: bool,
):
"""Changing adaptive settings cannot discard a user's accepted colour request."""
setup = await setup_reported_group(hass, **{CONF_SLEEP_COLOR_TEMP: 2200})
await prepare_reported_members(hass, setup, on=(False, False, False))
setup.switch.manager.native_groups.disabled = not planner_enabled
entered, release = asyncio.Event(), asyncio.Event()
async def hold_power():
entered.set()
await release.wait()
setup.members[1].before_turn_on = hold_power
# Isolate pending explicit intent from the separate later ON-report reaction.
setup.members[1].hold_reports = True
older = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: RIGHT, ATTR_COLOR_TEMP_KELVIN: 3500},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
reset_completed = asyncio.Event()
original_reset = setup.switch._reset_lights
def observe_reset(*args, **kwargs):
original_reset(*args, **kwargs)
reset_completed.set()
monkeypatch.setattr(setup.switch, "_reset_lights", observe_reset)
await hass.services.async_call(
"switch",
"turn_on",
{ATTR_ENTITY_ID: setup.switch.sleep_mode_switch.entity_id},
blocking=True,
)
await asyncio.wait_for(reset_completed.wait(), 2)
assert setup.switch.sleep_mode_switch.is_on
finally:
release.set()
await older
await hass.async_block_till_done()
assert setup.members[1].is_on
assert setup.members[1].physical_kelvin == 3500, setup.commands
async def change_temperature(hass, switch):
"""Set a different daytime target through the public settings service."""
await hass.services.async_call(
DOMAIN,
SERVICE_CHANGE_SWITCH_SETTINGS,
{
ATTR_ENTITY_ID: switch.entity_id,
CONF_MIN_COLOR_TEMP: 2200,
CONF_MAX_COLOR_TEMP: 2200,
},
blocking=True,
)
@pytest.mark.parametrize("explicit_member", [False, True])
async def test_public_settings_change_cancels_old_reactive_member_split(
hass,
monkeypatch,
explicit_member,
):
"""Observed native members retire old split commands like direct targets."""
setup = await setup_native_group(
hass,
**{CONF_SEPARATE_TURN_ON_COMMANDS: True, CONF_SEND_SPLIT_DELAY: 1234},
)
if explicit_member:
setup.switch._configured_lights.append(RIGHT)
setup.switch._expand_light_groups()
await setup.switch.adapt_brightness_switch.async_turn_on()
delay = DelayBarrier(monkeypatch, setup.switch, "_execute_adaptation_calls")
calls = track_adaptive_light_calls(hass)
turning_on = hass.async_create_task(turn_on_member(hass))
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1.234
await change_temperature(hass, setup.switch)
assert setup.right.physical_kelvin == 2200, calls
after_update = len(calls)
finally:
delay.release.set()
await turning_on
await hass.async_block_till_done()
assert setup.right.physical_kelvin == 2200, calls
assert not any(
call.get(ATTR_COLOR_TEMP_KELVIN) == 3000 for call in calls[after_update:]
), calls
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_public_settings_change_retires_delayed_native_colour(
hass,
monkeypatch,
planner_enabled,
):
"""An old computed colour cannot restore settings replaced while it waited."""
setup = await setup_reported_group(hass, **{CONF_ADAPT_DELAY: 1})
await prepare_reported_members(hass, setup, on=(False, False, False))
setup.switch.manager.native_groups.disabled = not planner_enabled
delay = DelayBarrier(
monkeypatch,
setup.switch.manager.native_groups if planner_enabled else setup.switch,
"_execute_dispatch" if planner_enabled else "_respond_to_off_to_on_event",
)
older = hass.async_create_task(
hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1
await change_temperature(hass, setup.switch)
assert all(member.physical_kelvin == 2200 for member in setup.members)
after_update = len(setup.commands)
finally:
delay.release.set()
await older
await hass.async_block_till_done()
assert all(
member.physical_kelvin == 2200 for member in setup.members
), setup.commands[after_update:]
@pytest.mark.parametrize("change", ["sleep", "settings"])
async def test_second_group_rechecks_profile_after_first_group_dispatch(
hass,
monkeypatch,
change,
):
"""Recheck computed intent after a previous broadcast awaited newer settings."""
setup = await setup_reported_group(hass, **{CONF_SLEEP_COLOR_TEMP: 2200})
await prepare_reported_members(hass, setup, on=(False, False, False))
second_members = [
RecordingMember(f"light.native_z_member_{index}", setup.commands)
for index in range(3)
]
second_group = RecordingGroup(second_members, setup.commands)
second_group.entity_id = "light.native_z_group"
await hass.data[DATA_COMPONENT].async_add_entities([*second_members, second_group])
setup.switch._configured_lights.append(second_group.entity_id)
setup.switch._expand_light_groups()
await hass.async_block_till_done()
setup.commands.clear()
original_first = setup.group.async_turn_on
original_second = second_group.async_turn_on
changed = False
second_power_ready = asyncio.Event()
replacement_applied = asyncio.Event()
async def second_turn_on(**kwargs):
await original_second(**kwargs)
if kwargs.get(ATTR_COLOR_TEMP_KELVIN) == 2200:
replacement_applied.set()
elif ATTR_COLOR_TEMP_KELVIN not in kwargs:
# Independent groups may progress concurrently. Hold this original
# power call after the bulbs turn ON, before its old colour can run.
second_power_ready.set()
await asyncio.wait_for(replacement_applied.wait(), 2)
async def first_turn_on(**kwargs):
nonlocal changed
await original_first(**kwargs)
if ATTR_COLOR_TEMP_KELVIN in kwargs and not changed:
changed = True
await asyncio.wait_for(second_power_ready.wait(), 2)
if change == "settings":
await change_temperature(hass, setup.switch)
else:
await hass.services.async_call(
"switch",
"turn_on",
{ATTR_ENTITY_ID: setup.switch.sleep_mode_switch.entity_id},
blocking=True,
)
await asyncio.wait_for(replacement_applied.wait(), 2)
monkeypatch.setattr(setup.group, "async_turn_on", first_turn_on)
monkeypatch.setattr(second_group, "async_turn_on", second_turn_on)
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: [GROUP, second_group.entity_id]},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert changed
assert all(
member.physical_kelvin == 2200 for member in [*setup.members, *second_members]
), setup.commands
assert [
call[ATTR_COLOR_TEMP_KELVIN]
for call in setup.colour_commands
if call["target"] == second_group.entity_id
] == [2200], setup.commands

View file

@ -0,0 +1,485 @@
"""Profile retirement releases computed coverage without discarding other intent."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_EXPAND_LIGHT_GROUPS,
CONF_INITIAL_TRANSITION,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_BRIGHTNESS,
CONF_MIN_COLOR_TEMP,
CONF_SKIP_REDUNDANT_COMMANDS,
CONF_SLEEP_COLOR_TEMP,
CONF_TAKE_OVER_CONTROL,
CONF_TRANSITION,
DOMAIN,
)
from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_LIGHTS,
CONF_NAME,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import Context
from .support.adaptive import setup_switch
from .support.async_helpers import DelayBarrier
from .support.native_lights import GROUP, RIGHT, TARGET_KELVIN
from .support.native_scenarios import (
activate_scene,
prepare_reported_members,
setup_reported_group,
)
async def _unload_profile(hass, switch):
entry = next(
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if hass.data[DOMAIN][entry.entry_id].get("switch") is switch
)
assert await hass.config_entries.async_unload(entry.entry_id)
async def _replacement_profile(hass, target=RIGHT):
"""Keep a distinct brightness/colour profile ready to take over a target."""
_, replacement = await setup_switch(
hass,
{
CONF_NAME: "replacement",
CONF_LIGHTS: [target],
CONF_EXPAND_LIGHT_GROUPS: False,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_MIN_BRIGHTNESS: 100,
CONF_MAX_BRIGHTNESS: 100,
CONF_MIN_COLOR_TEMP: 3500,
CONF_MAX_COLOR_TEMP: 3500,
CONF_SLEEP_COLOR_TEMP: 3500,
CONF_TAKE_OVER_CONTROL: False,
},
)
await replacement.async_turn_off()
return replacement
async def _release_report(hass, member, freezer):
"""Publish the delayed real activation after the recent-OFF debounce expires."""
freezer.tick(11)
member.hold_reports = False
member.async_set_context(Context())
member.async_write_ha_state()
await hass.async_block_till_done()
@pytest.mark.parametrize(
("retirement", "explicit_brightness"),
[
("unload", False),
("target_removed", False),
("member_removed", False),
("unload", True),
],
)
async def test_retired_computed_claim_does_not_suppress_replacement(
hass,
monkeypatch,
freezer,
retirement,
explicit_brightness,
):
"""Late reports adapt both computed axes, preserving explicit scene brightness."""
setup = await setup_reported_group(hass, **{CONF_SKIP_REDUNDANT_COMMANDS: False})
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
members = tuple(setup.members)
left, right, _ = members
replacement = await _replacement_profile(hass)
freezer.tick(11)
for member in members:
member.hold_reports = True
setup.switch._adapt_delay = 1
delay = DelayBarrier(
monkeypatch,
setup.switch.manager.native_groups,
"_execute_dispatch",
)
scene = {member.entity_id: {"state": STATE_ON} for member in members}
if explicit_brightness:
for state in scene.values():
state[ATTR_BRIGHTNESS] = 150
activating = asyncio.create_task(activate_scene(hass, scene))
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1
assert right.is_on
assert right.physical_kelvin == 4000
assert right.brightness == (150 if explicit_brightness else 110)
if retirement == "unload":
await _unload_profile(hass, setup.switch)
elif retirement == "target_removed":
setup.switch._configured_lights.clear()
setup.switch._expand_light_groups()
else:
setup.group.members.remove(right)
setup.group.async_write_ha_state()
setup.switch._expand_light_groups()
assert RIGHT not in setup.switch._tracked_lights or retirement == "unload"
await replacement.async_turn_on()
finally:
delay.release.set()
await activating
assert right.physical_kelvin == 4000
assert right.brightness == (150 if explicit_brightness else 110)
assert hass.states.get(RIGHT).state == STATE_OFF
setup.commands.clear()
await _release_report(hass, right, freezer)
assert (right.physical_kelvin, right.brightness) == (
3500,
150 if explicit_brightness else 255,
), setup.commands
assert len(setup.commands) == 1, setup.commands
assert setup.commands[0]["target"] == RIGHT
assert setup.commands[0][ATTR_COLOR_TEMP_KELVIN] == 3500
if explicit_brightness:
assert ATTR_BRIGHTNESS not in setup.commands[0]
else:
assert setup.commands[0][ATTR_BRIGHTNESS] == 255
if retirement == "member_removed":
# The old profile retains the other members and their completed claims.
assert left.physical_kelvin == TARGET_KELVIN
assert left.brightness == 128
setup.commands.clear()
await _release_report(hass, left, freezer)
assert not setup.commands
async def test_retirement_preserves_other_owners_pending_claim(
hass,
monkeypatch,
freezer,
):
"""Unloading colour ownership retains another owner's pending brightness work."""
setup = await setup_reported_group(hass, **{CONF_SKIP_REDUNDANT_COMMANDS: False})
await prepare_reported_members(hass, setup, on=(False, False, False))
_, brightness_owner = await setup_switch(
hass,
{
CONF_NAME: "remaining_brightness_owner",
CONF_LIGHTS: [GROUP],
CONF_EXPAND_LIGHT_GROUPS: False,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_MIN_BRIGHTNESS: 50,
CONF_MAX_BRIGHTNESS: 50,
CONF_TAKE_OVER_CONTROL: False,
},
)
await brightness_owner.async_turn_off()
await brightness_owner.adapt_color_switch.async_turn_off()
await brightness_owner.async_turn_on(adapt_lights=False)
freezer.tick(11)
for member in setup.members:
member.hold_reports = True
brightness_owner._adapt_delay = 1
setup.switch._adapt_delay = 1
setup.commands.clear()
delay = DelayBarrier(
monkeypatch,
setup.switch.manager.native_groups,
"_execute_dispatch",
)
activating = asyncio.create_task(
activate_scene(
hass,
{member.entity_id: {"state": STATE_ON} for member in setup.members},
),
)
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert not any(ATTR_BRIGHTNESS in call for call in setup.commands)
await _unload_profile(hass, setup.switch)
finally:
delay.release.set()
await activating
assert all(member.brightness == 128 for member in setup.members)
assert all(member.physical_kelvin == 4000 for member in setup.members)
brightness_commands = [call for call in setup.commands if ATTR_BRIGHTNESS in call]
assert len(brightness_commands) == 1, setup.commands
assert brightness_commands[0]["target"] == GROUP
setup.commands.clear()
# The scene delay has already been exercised. Keep the late report's
# no-duplicate assertion independent of a new reactive delay under freezer.
brightness_owner._adapt_delay = 0
await _release_report(hass, setup.members[1], freezer)
assert not setup.commands
class _ReportLock(asyncio.Lock):
"""Observe a report waiting for the recent-OFF lock without bypassing it."""
def __init__(self):
super().__init__()
self.waiting = asyncio.Event()
async def acquire(self):
if self.locked():
self.waiting.set()
return await super().acquire()
@pytest.mark.parametrize("explicit_brightness", [False, True])
async def test_retirement_while_report_waits_at_off_lock(
hass,
monkeypatch,
freezer,
explicit_brightness,
):
"""A report already being handled must release the retired owner's coverage."""
setup = await setup_reported_group(hass, **{CONF_SKIP_REDUNDANT_COMMANDS: False})
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
right = setup.members[1]
replacement = await _replacement_profile(hass)
freezer.tick(11)
for member in setup.members:
member.hold_reports = True
setup.switch._adapt_delay = 1
manager = setup.switch.manager
delay = DelayBarrier(monkeypatch, manager.native_groups, "_execute_dispatch")
scene = {member.entity_id: {"state": STATE_ON} for member in setup.members}
if explicit_brightness:
for state in scene.values():
state[ATTR_BRIGHTNESS] = 150
lock = manager.turn_off_locks[RIGHT] = _ReportLock()
await lock.acquire()
activating = asyncio.create_task(activate_scene(hass, scene))
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert right.is_on
assert hass.states.get(RIGHT).state == STATE_OFF
right.hold_reports = False
right.async_set_context(Context())
right.async_write_ha_state()
await asyncio.wait_for(lock.waiting.wait(), 2)
assert right.physical_kelvin == 4000
await _unload_profile(hass, setup.switch)
await replacement.async_turn_on(adapt_lights=False)
finally:
lock.release()
delay.release.set()
await activating
await hass.async_block_till_done()
assert (right.physical_kelvin, right.brightness) == (
3500,
150 if explicit_brightness else 255,
), setup.commands
corrections = [
call for call in setup.commands if call.get(ATTR_COLOR_TEMP_KELVIN) == 3500
]
assert len(corrections) == 1, setup.commands
assert corrections[0]["target"] == RIGHT
if explicit_brightness:
assert ATTR_BRIGHTNESS not in corrections[0]
@pytest.mark.parametrize("newer_request", [False, True])
async def test_replacement_revalidates_claim_after_adapt_delay(
hass,
monkeypatch,
freezer,
newer_request,
):
"""Settings retirement and newer intent remain authoritative after a wait."""
setup = await setup_reported_group(hass, **{CONF_SKIP_REDUNDANT_COMMANDS: False})
await prepare_reported_members(hass, setup, on=(False, False, False))
right = setup.members[1]
replacement = await _replacement_profile(hass)
freezer.tick(11)
for member in setup.members:
member.hold_reports = True
setup.switch._adapt_delay = 1
dispatch = DelayBarrier(
monkeypatch,
setup.switch.manager.native_groups,
"_execute_dispatch",
)
reacting = DelayBarrier(monkeypatch, replacement, "_respond_to_off_to_on_event")
replacement._adapt_delay = 1
activating = asyncio.create_task(
activate_scene(
hass,
{member.entity_id: {"state": STATE_ON} for member in setup.members},
),
)
try:
await asyncio.wait_for(dispatch.entered.wait(), 2)
assert right.is_on
assert hass.states.get(RIGHT).state == STATE_OFF
setup.switch._adapt_delay = 0
await replacement.async_turn_on(adapt_lights=False)
right.hold_reports = False
right.async_set_context(Context())
right.async_write_ha_state()
await asyncio.wait_for(reacting.entered.wait(), 2)
assert reacting.delay == 1
assert right.physical_kelvin == 4000
if newer_request:
await _set_newer_attributes(hass)
else:
await _unload_profile(hass, setup.switch)
finally:
dispatch.release.set()
reacting.release.set()
await activating
await hass.async_block_till_done()
if newer_request:
assert (right.physical_kelvin, right.brightness) == (4500, 150), setup.commands
assert not any(
call.get(ATTR_COLOR_TEMP_KELVIN) == 3500 for call in setup.commands
)
else:
assert (right.physical_kelvin, right.brightness) == (3500, 255), setup.commands
corrections = [
call for call in setup.commands if call.get(ATTR_COLOR_TEMP_KELVIN) == 3500
]
assert len(corrections) == 1, setup.commands
assert corrections[0]["target"] == RIGHT
assert corrections[0][ATTR_BRIGHTNESS] == 255
async def _set_newer_attributes(hass):
"""Submit new explicit intent without draining the intentionally waiting task."""
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: RIGHT,
ATTR_COLOR_TEMP_KELVIN: 4500,
ATTR_BRIGHTNESS: 150,
},
blocking=True,
context=Context(),
)
async def test_newer_request_supersedes_report_waiting_at_off_lock(
hass,
monkeypatch,
freezer,
):
"""A newer explicit command wins before the old report reaches the profile."""
setup = await setup_reported_group(hass, **{CONF_SKIP_REDUNDANT_COMMANDS: False})
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
right = setup.members[1]
manager = setup.switch.manager
freezer.tick(11)
for member in setup.members:
member.hold_reports = True
setup.switch._adapt_delay = 1
dispatch = DelayBarrier(monkeypatch, manager.native_groups, "_execute_dispatch")
lock = manager.turn_off_locks[RIGHT] = _ReportLock()
await lock.acquire()
activating = asyncio.create_task(
activate_scene(
hass,
{member.entity_id: {"state": STATE_ON} for member in setup.members},
),
)
try:
await asyncio.wait_for(dispatch.entered.wait(), 2)
assert right.is_on
assert hass.states.get(RIGHT).state == STATE_OFF
right.hold_reports = False
right.async_set_context(Context())
right.async_write_ha_state()
await asyncio.wait_for(lock.waiting.wait(), 2)
setup.switch._adapt_delay = 0
await _set_newer_attributes(hass)
before_release = len(setup.commands)
finally:
lock.release()
dispatch.release.set()
await activating
await hass.async_block_till_done()
assert (right.physical_kelvin, right.brightness) == (4500, 150), setup.commands
assert not any(
call["target"] == RIGHT for call in setup.commands[before_release:]
), setup.commands[before_release:]
async def test_retired_group_report_respects_newer_member_request(
hass,
monkeypatch,
freezer,
):
"""A waiting group report must not overwrite newer intent for one member."""
setup = await setup_reported_group(hass, **{CONF_SKIP_REDUNDANT_COMMANDS: False})
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
replacement = await _replacement_profile(hass, GROUP)
manager = setup.switch.manager
right = setup.members[1]
freezer.tick(11)
for member in setup.members:
member.hold_reports = True
# Model a native group whose own state report is delayed independently.
original_write = setup.group.async_write_ha_state
monkeypatch.setattr(setup.group, "async_write_ha_state", lambda: None)
setup.switch._adapt_delay = 1
dispatch = DelayBarrier(monkeypatch, manager.native_groups, "_execute_dispatch")
lock = manager.turn_off_locks[GROUP] = _ReportLock()
await lock.acquire()
activating = asyncio.create_task(activate_scene(hass, {GROUP: {"state": STATE_ON}}))
try:
await asyncio.wait_for(dispatch.entered.wait(), 2)
assert right.is_on
assert hass.states.get(GROUP).state == STATE_OFF
setup.group.async_set_context(Context())
original_write()
await asyncio.wait_for(lock.waiting.wait(), 2)
await _set_newer_attributes(hass)
assert (right.physical_kelvin, right.brightness) == (4500, 150)
await _unload_profile(hass, setup.switch)
await replacement.async_turn_on(adapt_lights=False)
after_newer = len(setup.commands)
finally:
lock.release()
dispatch.release.set()
await activating
await hass.async_block_till_done()
assert (right.physical_kelvin, right.brightness) == (
4500,
150,
), setup.commands[after_newer:]
assert not setup.commands[after_newer:]

View file

@ -0,0 +1,297 @@
"""Native-group scene errors behavior at HA and device boundaries."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.adaptive_lighting.const import TURNING_OFF_DELAY
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
)
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.components.switch import (
DATA_COMPONENT as SWITCH_COMPONENT,
)
from homeassistant.components.switch import (
SwitchEntity,
)
from homeassistant.const import (
STATE_OFF,
STATE_ON,
)
from homeassistant.core import Context
from homeassistant.exceptions import HomeAssistantError
from .support.native_lights import GROUP, RecordingMember
from .support.native_scenarios import (
prepare_reported_members,
scene_state,
setup_reported_group,
setup_scene_group,
)
@pytest.mark.parametrize("outcome", ["failure", "cancellation"])
async def test_native_scene_original_failure_retires_transaction(hass, outcome):
"""Failure before dispatch cannot flush deferred colour or retain valid claims."""
setup = await setup_scene_group(hass)
planner = setup.switch.manager.native_groups
registered = hass.services.async_services()["scene"]["apply"]
entered = asyncio.Event()
transactions = []
failure = RuntimeError("scene handler failed")
async def original(_call):
transactions.append(planner.active.get())
entered.set()
if outcome == "failure":
raise failure
await asyncio.Event().wait()
hass.services.async_register("scene", "apply", original, registered.schema)
await hass.async_block_till_done()
task = asyncio.create_task(
hass.services.async_call(
"scene",
"apply",
{
"entities": {
member.entity_id: scene_state(3500, 100) for member in setup.members
},
},
blocking=True,
context=Context(),
),
)
await asyncio.wait_for(entered.wait(), 2)
if outcome == "failure":
with pytest.raises(RuntimeError, match="scene handler failed") as exc_info:
await task
assert exc_info.value is failure
else:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await hass.async_block_till_done()
assert len(transactions) == 1
assert transactions[0] is not None
assert transactions[0].execution.aborted
assert transactions[0].execution.closed
assert planner.active.get() is None
assert setup.commands == []
assert all(member.physical_kelvin == 4000 for member in setup.members)
for target in [GROUP, *(member.entity_id for member in setup.members)]:
claim = planner.take_activation_claim(target)
assert planner.activation_coverage(target, claim) == (False, False, False)
class BrokenSceneSwitch(SwitchEntity):
"""Expose a real non-light scene target that reports an original error."""
_attr_should_poll = False
_attr_is_on = False
_attr_name = "Broken scene review switch"
entity_id = "switch.broken_scene_review"
def __init__(self, error, before=None):
"""Retain the original exception and optional dispatch rendezvous."""
self.error = error
self.before = before
async def async_turn_on(self, **kwargs): # noqa: ARG002
"""Fail without changing the switch state."""
if self.before is not None:
await self.before()
raise self.error
def _desired_members(setup, brightness=None):
return {
member.entity_id: {
"state": STATE_ON,
ATTR_COLOR_TEMP_KELVIN: 3500,
**({ATTR_BRIGHTNESS: brightness} if brightness is not None else {}),
}
for member in setup.members
}
@pytest.mark.parametrize("planner_enabled", [False, True])
@pytest.mark.parametrize("failure_kind", ["light_off", "switch"])
async def test_healthy_scene_colour_survives_unrelated_failure(
hass,
monkeypatch,
planner_enabled,
failure_kind,
):
"""Ordinary scene errors preserve independent healthy colour and identity."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(True, True, True))
setup.switch.manager.native_groups.disabled = not planner_enabled
error = HomeAssistantError("unrelated scene device unreachable")
if failure_kind == "light_off":
other = RecordingMember("light.other_scene_review", setup.commands)
other._attr_is_on = True
await hass.data[DATA_COMPONENT].async_add_entities([other])
async def broken(**kwargs):
raise error
monkeypatch.setattr(other, "async_turn_off", broken)
other_state = STATE_OFF
else:
other = BrokenSceneSwitch(error)
await hass.data[SWITCH_COMPONENT].async_add_entities([other])
other_state = STATE_ON
with pytest.raises(
HomeAssistantError,
match="unrelated scene device unreachable",
) as exc_info:
await hass.services.async_call(
"scene",
"apply",
{
"entities": {
**_desired_members(setup),
other.entity_id: {"state": other_state},
},
},
blocking=True,
context=Context(),
)
assert exc_info.value is error
await hass.async_block_till_done()
assert [member.physical_kelvin for member in setup.members] == [
3500,
] * 3, setup.commands
@pytest.mark.parametrize("cancel", [False, True])
async def test_partial_scene_error_settles_power_before_colour_or_cancellation(
hass,
monkeypatch,
cancel,
):
"""Parent errors wait for accepted power; cancellation never flushes colour."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(False, False, False))
entered, release, failed = asyncio.Event(), asyncio.Event(), asyncio.Event()
error = HomeAssistantError("unrelated scene device unreachable")
async def hold_power():
entered.set()
await release.wait()
async def fail_after_power_starts():
await asyncio.wait_for(entered.wait(), 2)
setup.members[0].before_turn_on = hold_power
other = BrokenSceneSwitch(error, fail_after_power_starts)
await hass.data[SWITCH_COMPONENT].async_add_entities([other])
planner = setup.switch.manager.native_groups
original_flush = planner._flush
original_run = planner._run
flush_states = []
async def observe_flush(batch):
flush_states.append([member.is_on for member in setup.members])
await original_flush(batch)
async def observe_invocation_error(batch, invoke):
async def observed_invoke():
try:
return await invoke()
except Exception:
# Setting the event does not yield: _run receives the exception
# before this test resumes and inspects the held power task.
failed.set()
raise
return await original_run(batch, observed_invoke)
monkeypatch.setattr(planner, "_flush", observe_flush)
monkeypatch.setattr(planner, "_run", observe_invocation_error)
task = asyncio.create_task(
hass.services.async_call(
"scene",
"apply",
{
"entities": {
**_desired_members(setup, 100),
other.entity_id: {"state": STATE_ON},
},
},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(failed.wait(), 2)
assert not task.done()
assert not flush_states
if cancel:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
if cancel:
await asyncio.gather(task, return_exceptions=True)
else:
with pytest.raises(HomeAssistantError) as exc_info:
await task
assert exc_info.value is error
await hass.async_block_till_done()
if cancel:
assert not flush_states
assert not any(
call.get(ATTR_COLOR_TEMP_KELVIN) == 3500 for call in setup.commands
)
else:
assert flush_states == [[True, True, True]]
assert [member.physical_kelvin for member in setup.members] == [
3500,
] * 3, setup.commands
async def test_partial_scene_error_preserves_successful_late_report_coverage(
hass,
freezer,
):
"""An unrelated error does not turn a late successful ON report into new work."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(False, False, False))
member = setup.members[0]
member.hold_reports = True
error = HomeAssistantError("unrelated scene device unreachable")
other = BrokenSceneSwitch(error)
await hass.data[SWITCH_COMPONENT].async_add_entities([other])
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
"scene",
"apply",
{
"entities": {
**_desired_members(setup, 100),
other.entity_id: {"state": STATE_ON},
},
},
blocking=True,
context=Context(),
)
assert exc_info.value is error
await hass.async_block_till_done()
assert member.physical_kelvin == 3500
assert hass.states.is_state(member.entity_id, STATE_OFF)
commands = list(setup.commands)
# The delayed report arrives after the recent-OFF polling guard. Advance the
# clock so the test exercises retained scene coverage without real sleeps.
freezer.tick(TURNING_OFF_DELAY + 1)
member.hold_reports = False
member.async_set_context(Context())
member.async_write_ha_state()
await hass.async_block_till_done()
assert hass.states.is_state(member.entity_id, STATE_ON)
assert setup.commands == commands

709
tests/test_native_scenes.py Normal file
View file

@ -0,0 +1,709 @@
"""Native-group scenes behavior at HA and device boundaries."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_EXPAND_LIGHT_GROUPS,
CONF_INITIAL_TRANSITION,
CONF_MAX_COLOR_TEMP,
CONF_MIN_COLOR_TEMP,
CONF_ONLY_ONCE,
CONF_SLEEP_COLOR_TEMP,
CONF_TAKE_OVER_CONTROL,
CONF_TRANSITION,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_TRANSITION,
ColorMode,
Profile,
)
from homeassistant.components.light.const import DATA_COMPONENT, DATA_PROFILES
from homeassistant.components.light.const import DATA_COMPONENT as LIGHT_COMPONENT
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_LIGHTS,
CONF_NAME,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import Context
from homeassistant.setup import async_setup_component
from .support.adaptive import setup_switch
from .support.native_lights import (
GROUP,
LEFT,
OFF_MEMBER,
RIGHT,
TARGET_KELVIN,
RecordingGroup,
RecordingMember,
)
from .support.native_scenarios import (
activate_scene,
prepare_reported_members,
scene_state,
setup_reported_group,
setup_scene_group,
)
async def test_explicit_scene_brightness_survives_enabled_brightness_adaptation(hass):
"""Covered activations retain each requested scene level without extra repairs."""
setup = await setup_scene_group(hass)
await setup.switch.adapt_brightness_switch.async_turn_on()
setup.commands.clear()
brightness = {LEFT: 90, RIGHT: 140, OFF_MEMBER: 35}
await activate_scene(
hass,
{eid: scene_state(3500, level) for eid, level in brightness.items()},
)
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert {
member.entity_id: member.brightness for member in setup.members
} == brightness
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == GROUP
assert ATTR_BRIGHTNESS not in setup.colour_commands[0]
assert len([call for call in setup.commands if ATTR_BRIGHTNESS in call]) == 3
async def test_scene_zero_brightness_is_off_intent_not_colour_coverage(hass):
"""A zero-brightness turn-on must stay OFF and receive no colour correction."""
setup = await setup_scene_group(hass)
await activate_scene(
hass,
{
LEFT: scene_state(3500, 100),
RIGHT: scene_state(3500, 120),
OFF_MEMBER: scene_state(3500, 0),
},
)
assert setup.members[0].physical_kelvin == 3500
assert setup.members[1].physical_kelvin == 3500
assert not setup.members[2].is_on
assert setup.members[2].physical_kelvin == 4000
assert len(setup.colour_commands) == 2, setup.commands
assert {call["target"] for call in setup.colour_commands} == {LEFT, RIGHT}
assert not any(call["target"] == GROUP for call in setup.commands)
async def test_native_scene_preserves_unrelated_light_and_nonlight_targets(hass):
"""A mixed scene groups eligible colour without taking over other targets."""
setup = await setup_scene_group(hass)
outside = RecordingMember("light.outside_native_group", setup.commands)
await hass.data[LIGHT_COMPONENT].async_add_entities([outside])
assert await async_setup_component(
hass,
"input_boolean",
{"input_boolean": {"native_scene_unrelated": {"initial": False}}},
)
entities = {member.entity_id: scene_state(3500, 100) for member in setup.members}
entities[outside.entity_id] = scene_state(4500, 77)
entities["input_boolean.native_scene_unrelated"] = {"state": STATE_ON}
await activate_scene(hass, entities, transition=2)
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert outside.is_on
assert outside.physical_kelvin == 4500
assert outside.brightness == 77
assert hass.states.is_state("input_boolean.native_scene_unrelated", STATE_ON)
colours = {call["target"]: call for call in setup.colour_commands}
assert set(colours) == {GROUP, outside.entity_id}, setup.commands
assert colours[outside.entity_id][ATTR_COLOR_TEMP_KELVIN] == 4500
assert colours[outside.entity_id]["brightness"] == 77
assert colours[outside.entity_id][ATTR_TRANSITION] == 2
async def test_native_group_explicit_colour_preserves_off_members(hass):
"""An existing CT-only native broadcast must never gain a bare power command."""
setup = await setup_scene_group(hass)
original_brightness = [member.brightness for member in setup.members]
assert [member.is_on for member in setup.members] == [True, False, False]
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: GROUP,
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 3,
},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert setup.commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 3,
},
]
assert [member.is_on for member in setup.members] == [True, False, False]
assert [member.brightness for member in setup.members] == original_brightness
assert [member.physical_kelvin for member in setup.members] == [3500, 4000, 4000]
assert hass.states.is_state(RIGHT, STATE_OFF)
@pytest.mark.parametrize("rgb_input", ["tuple", "list"])
async def test_native_scene_matching_rgb_is_one_group_command(hass, rgb_input):
"""Compatible RGB requests preserve their colour representation at dispatch."""
setup = await setup_scene_group(hass)
for entity in [setup.group, *setup.members]:
entity._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.RGB}
entity._attr_color_mode = ColorMode.RGB
entity._attr_rgb_color = (10, 20, 30)
entity.async_write_ha_state()
await hass.async_block_till_done()
setup.commands.clear()
requested_rgb = (40, 80, 160)
levels = [80, 110, 26]
await activate_scene(
hass,
{
member.entity_id: {
"state": STATE_ON,
"color_mode": ColorMode.RGB,
ATTR_RGB_COLOR: (
requested_rgb if rgb_input == "tuple" else list(requested_rgb)
),
"brightness": level,
}
for member, level in zip(setup.members, levels, strict=True)
},
transition=3,
)
colour_commands = [
call
for call in setup.commands
if ATTR_RGB_COLOR in call or ATTR_COLOR_TEMP_KELVIN in call
]
assert len(colour_commands) == 1, setup.commands
command = colour_commands[0]
assert command["target"] == GROUP
assert tuple(command[ATTR_RGB_COLOR]) == requested_rgb
assert command[ATTR_TRANSITION] == 3
assert "brightness" not in command
assert ATTR_COLOR_TEMP_KELVIN not in command
assert all(member.is_on for member in setup.members)
assert [member.brightness for member in setup.members] == levels
async def test_native_scene_uses_fully_covered_existing_subgroup(hass):
"""A complete smaller group can serve an incomplete larger-group request."""
setup = await setup_scene_group(hass)
subset = RecordingGroup(setup.members[:2], setup.commands)
subset.entity_id = "light.native_front_subset"
await hass.data[LIGHT_COMPONENT].async_add_entities([subset])
await setup.switch.async_turn_off()
_, combined = await setup_switch(
hass,
{
CONF_NAME: "native_group_and_subgroup",
CONF_LIGHTS: [GROUP, subset.entity_id],
CONF_EXPAND_LIGHT_GROUPS: False,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_MIN_COLOR_TEMP: 3000,
CONF_MAX_COLOR_TEMP: 3000,
CONF_SLEEP_COLOR_TEMP: 3000,
CONF_TAKE_OVER_CONTROL: False,
},
)
await combined.adapt_brightness_switch.async_turn_off()
await hass.async_block_till_done()
untouched = setup.members[2]
original = (untouched.is_on, untouched.brightness, untouched.physical_kelvin)
setup.commands.clear()
await activate_scene(
hass,
{
setup.members[0].entity_id: scene_state(3500, 81),
setup.members[1].entity_id: scene_state(3500, 111),
},
transition=2,
)
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == subset.entity_id
assert setup.colour_commands[0][ATTR_COLOR_TEMP_KELVIN] == 3500
assert setup.colour_commands[0][ATTR_TRANSITION] == 2
assert "brightness" not in setup.colour_commands[0]
assert [member.physical_kelvin for member in setup.members[:2]] == [3500, 3500]
assert [member.brightness for member in setup.members[:2]] == [81, 111]
assert (
untouched.is_on,
untouched.brightness,
untouched.physical_kelvin,
) == original
assert not any(
call["target"] in {GROUP, untouched.entity_id} for call in setup.commands
)
@pytest.mark.parametrize("durations", [(3, 5, 7), (3, 3, 3)])
async def test_native_scene_preserves_effective_default_transitions(hass, durations):
"""Only matching effective transitions permit a shared scene colour command."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(True, True, True))
for member, duration in zip(setup.members, durations, strict=True):
profile = f"{member.entity_id}.default"
hass.data[DATA_PROFILES].data[profile] = Profile(
profile,
None,
None,
None,
duration,
)
await hass.services.async_call(
"scene",
"apply",
{
"entities": {
member.entity_id: {"state": STATE_ON, ATTR_COLOR_TEMP_KELVIN: 3500}
for member in setup.members
},
},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert all(member.physical_kelvin == 3500 for member in setup.members)
if len(set(durations)) == 1:
expected = {GROUP: durations[0]}
else:
expected = {
member.entity_id: duration
for member, duration in zip(setup.members, durations, strict=True)
}
assert {
call["target"]: call.get(ATTR_TRANSITION) for call in setup.colour_commands
} == expected, setup.commands
assert len(setup.colour_commands) == len(expected)
async def test_native_only_once_scene_repairs_only_newly_on_member(hass):
"""An existing colour on lit siblings survives one member joining a scene."""
setup = await setup_reported_group(hass, **{CONF_ONLY_ONCE: True})
await prepare_reported_members(hass, setup, on=(True, False, True), kelvin=3500)
await activate_scene(
hass,
{
member.entity_id: {"state": STATE_ON, ATTR_BRIGHTNESS: 70}
for member in setup.members
},
transition=0,
)
assert all(member.is_on for member in setup.members)
assert [member.physical_kelvin for member in setup.members] == [3500, 3000, 3500]
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == RIGHT
async def test_native_only_once_allows_explicit_scene_colour(hass):
"""Only-once adaptation must not block the user's explicit scene colour."""
setup = await setup_reported_group(hass, **{CONF_ONLY_ONCE: True})
await prepare_reported_members(hass, setup, on=(True, True, True), kelvin=3500)
await activate_scene(
hass,
{
member.entity_id: {"state": STATE_ON, ATTR_COLOR_TEMP_KELVIN: 4500}
for member in setup.members
},
transition=0,
)
assert [member.physical_kelvin for member in setup.members] == [4500] * 3
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == GROUP
async def test_native_scene_subgroup_repairs_only_its_members(hass, freezer):
"""A native subgroup activation inherits its configured parent adaptation."""
setup = await setup_reported_group(hass, **{CONF_TAKE_OVER_CONTROL: True})
subgroup = RecordingGroup(setup.members[1:], setup.commands)
subgroup.entity_id = "light.review_scene_subset"
subgroup._attr_is_on = False
await hass.data[DATA_COMPONENT].async_add_entities([subgroup])
await hass.async_block_till_done()
setup.commands.clear()
freezer.tick(11)
await activate_scene(
hass,
{subgroup.entity_id: {"state": STATE_ON, ATTR_BRIGHTNESS: 90}},
transition=0,
)
assert all(member.is_on for member in setup.members[1:])
assert all(member.brightness == 90 for member in setup.members[1:])
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members[1:])
assert setup.members[0].brightness == 80
assert setup.members[0].physical_kelvin == 4000
assert not any(call["target"] == GROUP for call in setup.commands)
@pytest.mark.parametrize(
("default_brightness", "default_transition", "group_on"),
[(None, 7, True), (200, None, False)],
)
async def test_native_scene_ignores_unrequested_group_defaults(
hass,
default_brightness,
default_transition,
group_on,
):
"""Broadcasting cannot add the native target's own brightness or transition."""
setup = await setup_reported_group(hass)
await prepare_reported_members(hass, setup, on=(True, True, True))
setup.group._attr_is_on = group_on
setup.group.async_write_ha_state()
await hass.async_block_till_done()
setup.commands.clear()
profile = f"{GROUP}.default"
hass.data[DATA_PROFILES].data[profile] = Profile(
profile,
None,
None,
default_brightness,
default_transition,
)
levels = (90, 140, 35)
await hass.services.async_call(
"scene",
"apply",
{
"entities": {
member.entity_id: {
"state": STATE_ON,
ATTR_BRIGHTNESS: level,
ATTR_COLOR_TEMP_KELVIN: 3500,
}
for member, level in zip(setup.members, levels, strict=True)
},
},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert tuple(member.brightness for member in setup.members) == levels
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert all(ATTR_TRANSITION not in call for call in setup.commands)
assert {call["target"] for call in setup.colour_commands} == {
member.entity_id for member in setup.members
}
assert len(setup.colour_commands) == len(setup.members)
@pytest.mark.parametrize("service", ["apply", "turn_on"])
@pytest.mark.parametrize("kelvin", [TARGET_KELVIN, 3500])
async def test_scene_complete_matching_colour_uses_one_native_group_command(
hass,
service,
kelvin,
):
"""Already-ON and newly-ON members share one complete scene colour command."""
setup = await setup_scene_group(hass)
brightness = {LEFT: 90, RIGHT: 140, OFF_MEMBER: 35}
await activate_scene(
hass,
{eid: scene_state(kelvin, level) for eid, level in brightness.items()},
service=service,
transition=2,
)
assert all(member.is_on for member in setup.members)
assert all(member.physical_kelvin == kelvin for member in setup.members)
assert all(
member.brightness == brightness[member.entity_id] for member in setup.members
)
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0] == {
"target": GROUP,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: kelvin,
ATTR_TRANSITION: 2,
}
assert setup.switch.lights == [GROUP]
@pytest.mark.parametrize("service", ["apply", "turn_on"])
async def test_scene_partial_colour_targets_only_included_member(hass, service):
"""A partial scene cannot broadcast over a lit or excluded OFF sibling."""
setup = await setup_scene_group(hass)
left, right, off_member = setup.members
await activate_scene(
hass,
{RIGHT: scene_state(3500, 37), OFF_MEMBER: {"state": STATE_OFF}},
service=service,
transition=3,
)
assert right.is_on
assert right.physical_kelvin == 3500
assert right.brightness == 37
assert left.is_on
assert left.physical_kelvin == 4000
assert left.brightness == 80
assert not off_member.is_on
assert off_member.physical_kelvin == 4000
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == RIGHT
assert setup.colour_commands[0][ATTR_COLOR_TEMP_KELVIN] == 3500
assert setup.colour_commands[0][ATTR_TRANSITION] == 3
assert not any(call["target"] == GROUP for call in setup.commands)
async def test_scene_different_colours_remain_individual(hass):
"""Complete membership alone is insufficient if requested colours differ."""
setup = await setup_scene_group(hass)
colours = {LEFT: 3500, RIGHT: 3000, OFF_MEMBER: 4500}
await activate_scene(
hass,
{eid: scene_state(kelvin, 100) for eid, kelvin in colours.items()},
)
assert all(
member.physical_kelvin == colours[member.entity_id] for member in setup.members
)
assert len(setup.colour_commands) == len(setup.members), setup.commands
assert {
call["target"]: call[ATTR_COLOR_TEMP_KELVIN] for call in setup.colour_commands
} == colours
assert not any(call["target"] == GROUP for call in setup.commands)
async def test_scene_native_group_colour_already_covers_member_activation(hass):
"""A scene's own native broadcast must not be followed by member repairs."""
setup = await setup_scene_group(hass)
await activate_scene(hass, {GROUP: scene_state(3500, 100)}, transition=3)
assert all(
member.is_on and member.physical_kelvin == 3500 for member in setup.members
)
assert all(member.brightness == 100 for member in setup.members)
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == GROUP
assert setup.colour_commands[0][ATTR_COLOR_TEMP_KELVIN] == 3500
assert setup.colour_commands[0][ATTR_TRANSITION] == 3
async def test_scene_covered_late_member_report_does_not_duplicate_colour(hass):
"""A real activation report arriving after scene completion remains covered."""
setup = await setup_scene_group(hass)
setup.members[1].hold_reports = True
await activate_scene(
hass,
{member.entity_id: scene_state(3500, 100) for member in setup.members},
)
assert hass.states.get(RIGHT).state == STATE_OFF
assert all(member.physical_kelvin == 3500 for member in setup.members)
before_report = list(setup.commands)
# The device has already accepted power and colour. Only its report arrives
# late, without reissuing the scene or inventing another user activation.
setup.members[1].hold_reports = False
setup.members[1].async_write_ha_state()
await hass.async_block_till_done()
assert hass.states.get(RIGHT).state == STATE_ON
assert setup.commands == before_report
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == GROUP
@pytest.mark.parametrize("service", ["apply", "turn_on"])
async def test_scene_complete_colour_intent_survives_fully_matching_ha_cache(
hass,
service,
):
"""HA's scene no-op optimization cannot discard complete colour intent."""
setup = await setup_scene_group(hass)
await setup.switch.async_turn_off()
for member in setup.members:
member._attr_is_on = True
member.async_write_ha_state()
await hass.async_block_till_done()
await setup.switch.async_turn_on(adapt_lights=False)
setup.commands.clear()
# Every desired attribute equals HA's optimistic cache, including power and
# brightness. Only the complete scene request exposes colour intent; there
# are no downstream light calls from HA's default reproduction path.
await activate_scene(
hass,
{
member.entity_id: {
"state": STATE_ON,
**hass.states.get(member.entity_id).attributes,
}
for member in setup.members
},
service=service,
)
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members)
assert [member.brightness for member in setup.members] == [80, 110, 26]
assert setup.colour_commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: TARGET_KELVIN,
ATTR_TRANSITION: 2,
},
]
async def test_scene_partial_colour_intent_survives_matching_ha_cache(hass):
"""An already-ON individual target still receives its explicit scene colour."""
setup = await setup_scene_group(hass)
await activate_scene(
hass,
{LEFT: {"state": STATE_ON, **hass.states.get(LEFT).attributes}},
)
left, right, off_member = setup.members
assert left.physical_kelvin == TARGET_KELVIN
assert left.brightness == 80
assert not right.is_on
assert not off_member.is_on
assert right.physical_kelvin == 4000
assert off_member.physical_kelvin == 4000
assert len(setup.colour_commands) == 1, setup.commands
assert setup.colour_commands[0]["target"] == LEFT
assert setup.colour_commands[0][ATTR_COLOR_TEMP_KELVIN] == TARGET_KELVIN
assert not any(call["target"] == GROUP for call in setup.commands)
@pytest.mark.parametrize("change", ["membership", "disable_profile"])
async def test_scene_revalidates_group_before_sending_broadcast(hass, change):
"""A changed group or disabled profile cannot receive a planned broadcast."""
setup = await setup_scene_group(hass)
outside = RecordingMember("light.scene_outside_control", setup.commands)
await hass.data[DATA_COMPONENT].async_add_entities([outside])
async def change_during_dispatch():
if change == "membership":
setup.group.members.append(outside)
setup.group.async_write_ha_state()
else:
await setup.switch.async_turn_off()
# A scene with newly ON lights must dispatch their power before a colour-
# only native broadcast can reach them. Change the plan's preconditions at
# that real device boundary, without relying on a future private API.
setup.members[0].before_turn_on = change_during_dispatch
await activate_scene(
hass,
{member.entity_id: scene_state(3500, 100) for member in setup.members},
)
assert all(member.is_on for member in setup.members[:3])
assert all(member.physical_kelvin == 3500 for member in setup.members[:3])
assert not outside.is_on
assert outside.physical_kelvin == 4000
assert not any(call["target"] == GROUP for call in setup.commands)
assert {call["target"] for call in setup.colour_commands} == {
LEFT,
RIGHT,
OFF_MEMBER,
}
async def test_scene_off_member_colour_attribute_is_not_broadcast_coverage(hass):
"""A stored colour on an OFF scene target does not request a colour update."""
setup = await setup_scene_group(hass)
await activate_scene(
hass,
{
LEFT: scene_state(3500, 100),
RIGHT: scene_state(3500, 120),
OFF_MEMBER: {"state": STATE_OFF, ATTR_COLOR_TEMP_KELVIN: 3500},
},
)
left, right, off_member = setup.members
assert left.physical_kelvin == 3500
assert right.physical_kelvin == 3500
assert not off_member.is_on
assert off_member.physical_kelvin == 4000
assert len(setup.colour_commands) == 2, setup.commands
assert {call["target"] for call in setup.colour_commands} == {LEFT, RIGHT}
assert not any(call["target"] == GROUP for call in setup.commands)
@pytest.mark.parametrize("capability", ["colour_mode", "temperature_range"])
async def test_scene_incompatible_group_capability_uses_individual_targets(
hass,
capability,
):
"""Group coverage does not guarantee it supports the members' colour request."""
setup = await setup_scene_group(hass)
if capability == "colour_mode":
setup.group._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
setup.group._attr_color_mode = ColorMode.BRIGHTNESS
else:
setup.group._attr_max_color_temp_kelvin = 3200
setup.group.async_write_ha_state()
await hass.async_block_till_done()
setup.commands.clear()
await activate_scene(
hass,
{member.entity_id: scene_state(3500, 100) for member in setup.members},
)
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert len(setup.colour_commands) == 3, setup.commands
assert {call["target"] for call in setup.colour_commands} == {
LEFT,
RIGHT,
OFF_MEMBER,
}
assert not any(call["target"] == GROUP for call in setup.commands)
async def test_independent_colour_calls_with_different_transitions_are_not_grouped(
hass,
):
"""A shared context cannot erase different transition intent across calls."""
setup = await setup_scene_group(hass)
transitions = {LEFT: 1, RIGHT: 3, OFF_MEMBER: 5}
context = Context()
await asyncio.gather(
*(
hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: member.entity_id,
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: transitions[member.entity_id],
},
blocking=True,
context=context,
)
for member in setup.members
),
)
await hass.async_block_till_done()
assert all(member.physical_kelvin == 3500 for member in setup.members)
assert len(setup.colour_commands) == 3, setup.commands
assert {
call["target"]: call[ATTR_TRANSITION] for call in setup.colour_commands
} == transitions
assert not any(call["target"] == GROUP for call in setup.commands)

View file

@ -0,0 +1,641 @@
"""Independent native requests retain the established adaptation timing policy."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_ADAPT_DELAY,
CONF_INTERCEPT,
CONF_SEND_SPLIT_DELAY,
CONF_SEPARATE_TURN_ON_COMMANDS,
)
from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.const import STATE_ON
from homeassistant.core import Context
from homeassistant.exceptions import HomeAssistantError
from .support.async_helpers import DelayBarrier
from .support.native_lights import GROUP, RIGHT, RecordingGroup, RecordingMember
from .support.native_scenarios import (
prepare_reported_members,
setup_reported_group,
)
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_split_transition_duration_is_preserved(hass, planner_enabled):
setup = await setup_reported_group(
hass,
**{
CONF_INTERCEPT: True,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 0,
},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
setup.switch.manager.native_groups.disabled = not planner_enabled
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": GROUP, "transition": 2.5},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
adaptation = [
call
for call in setup.commands
if "brightness" in call or "color_temp_kelvin" in call
]
assert adaptation, setup.commands
assert all(call.get("transition") == 1.25 for call in adaptation), setup.commands
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_split_delay_includes_first_axis_transition(
hass,
monkeypatch,
planner_enabled,
):
setup = await setup_reported_group(
hass,
**{
CONF_INTERCEPT: True,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 1234,
},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
planner = setup.switch.manager.native_groups
planner.disabled = not planner_enabled
barrier = DelayBarrier(
monkeypatch,
planner if planner_enabled else setup.switch,
"_execute_dispatch" if planner_enabled else "_execute_adaptation_calls",
)
task = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": GROUP, "transition": 2.5},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(barrier.entered.wait(), 2)
assert any("brightness" in call for call in setup.commands), setup.commands
assert not setup.colour_commands, setup.commands
actual_delay = barrier.delay
finally:
barrier.release.set()
await task
await hass.async_block_till_done()
assert actual_delay == 2.484, (actual_delay, setup.commands)
adaptation = [
call
for call in setup.commands
if "brightness" in call or "color_temp_kelvin" in call
]
assert any("color_temp_kelvin" in call for call in adaptation), setup.commands
assert all(call.get("transition") == 1.25 for call in adaptation), setup.commands
async def _setup_independent_groups(hass):
"""Install disjoint native targets under one adaptive profile."""
setup = await setup_reported_group(
hass,
**{
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 1234,
},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
await setup.switch.async_turn_off()
second_members = [
RecordingMember(f"light.native_z_member_{index}", setup.commands)
for index in range(3)
]
second_group = RecordingGroup(second_members, setup.commands)
second_group.entity_id = "light.native_z_group"
second_group._attr_is_on = False
for member in second_members:
member._attr_color_temp_kelvin = 4000
await hass.data[DATA_COMPONENT].async_add_entities([*second_members, second_group])
setup.switch._configured_lights.append(second_group.entity_id)
setup.switch._expand_light_groups()
await setup.switch.async_turn_on(adapt_lights=False)
await hass.async_block_till_done()
setup.commands.clear()
return setup, second_group
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_split_group_does_not_delay_independent_group_brightness(
hass,
monkeypatch,
planner_enabled,
):
setup, second_group = await _setup_independent_groups(hass)
second_brightness = asyncio.Event()
original = second_group.async_turn_on
async def observed(**kwargs):
await original(**kwargs)
if "brightness" in kwargs:
second_brightness.set()
monkeypatch.setattr(second_group, "async_turn_on", observed)
planner = setup.switch.manager.native_groups
planner.disabled = not planner_enabled
barrier = DelayBarrier(
monkeypatch,
planner if planner_enabled else setup.switch,
"_execute_dispatch" if planner_enabled else "_execute_adaptation_calls",
)
task = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second_group.entity_id]},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(barrier.entered.wait(), 2)
assert barrier.delay == 1.234
try:
await asyncio.wait_for(second_brightness.wait(), 0.5)
other_group_started = True
except TimeoutError:
other_group_started = False
paused_commands = list(setup.commands)
finally:
barrier.release.set()
await task
await hass.async_block_till_done()
assert other_group_started, paused_commands
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_explicit_scene_colour_not_held_by_other_members_adapt_delay(
hass,
monkeypatch,
planner_enabled,
):
setup = await setup_reported_group(hass, **{CONF_ADAPT_DELAY: 1})
planner = setup.switch.manager.native_groups
planner.disabled = not planner_enabled
barrier = DelayBarrier(
monkeypatch,
planner if planner_enabled else setup.switch,
"_execute_dispatch" if planner_enabled else "_respond_to_off_to_on_event",
)
entities = {
m.entity_id: {"state": STATE_ON, ATTR_BRIGHTNESS: 100} for m in setup.members
}
member = setup.members[0]
entities[member.entity_id][ATTR_COLOR_TEMP_KELVIN] = 3500
explicit_colour = asyncio.Event()
original_turn_on = member.async_turn_on
async def observe_colour(**kwargs):
await original_turn_on(**kwargs)
if kwargs.get(ATTR_COLOR_TEMP_KELVIN) == 3500:
explicit_colour.set()
monkeypatch.setattr(member, "async_turn_on", observe_colour)
task = asyncio.create_task(
hass.services.async_call(
"scene",
"apply",
{"entities": entities},
blocking=True,
),
)
try:
await asyncio.wait_for(barrier.entered.wait(), 2)
assert barrier.delay == 1
try:
await asyncio.wait_for(explicit_colour.wait(), 0.5)
explicit_completed = True
except TimeoutError:
explicit_completed = False
paused_commands = list(setup.commands)
finally:
barrier.release.set()
await task
await hass.async_block_till_done()
assert explicit_completed, paused_commands
assert member.physical_kelvin == 3500, setup.commands
@pytest.mark.parametrize(
"failed_axis",
["power", ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN],
)
async def test_independent_native_group_error_keeps_other_group(
hass,
monkeypatch,
failed_axis,
):
"""One failed sequence neither cancels a disjoint group nor retries its axis."""
setup, second_group = await _setup_independent_groups(hass)
error = HomeAssistantError("first native group unavailable")
original = setup.group.async_turn_on
attempts = []
async def fail_axis(**kwargs):
attempts.append(dict(kwargs))
if (failed_axis == "power" and not kwargs) or failed_axis in kwargs:
raise error
await original(**kwargs)
monkeypatch.setattr(setup.group, "async_turn_on", fail_axis)
with pytest.raises(
HomeAssistantError,
match="first native group unavailable",
) as exc:
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second_group.entity_id]},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert exc.value is error
assert (
sum(
(not command if failed_axis == "power" else failed_axis in command)
for command in attempts
)
== 1
)
assert all(member.physical_kelvin == 4000 for member in setup.members)
assert all(member.physical_kelvin == 3000 for member in second_group.members)
assert all(member.brightness == 128 for member in second_group.members)
assert all(
call["target"] in (GROUP, second_group.entity_id) for call in setup.commands
), setup.commands
@pytest.mark.parametrize("brightness_adaptation", [False, True])
async def test_member_fallback_preserves_single_and_split_axis_transitions(
hass,
monkeypatch,
brightness_adaptation,
):
"""Colour fallback retains split timing and independently compatible brightness."""
setup = await setup_reported_group(
hass,
**{CONF_SEPARATE_TURN_ON_COMMANDS: True, CONF_SEND_SPLIT_DELAY: 0},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=brightness_adaptation,
)
original = setup.group.async_turn_on
async def restrict_group_colour(**kwargs):
await original(**kwargs)
setup.group._attr_max_color_temp_kelvin = 2500
setup.group.async_write_ha_state()
monkeypatch.setattr(setup.group, "async_turn_on", restrict_group_colour)
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": GROUP, "transition": 2.5},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert all(member.is_on for member in setup.members)
assert all(member.physical_kelvin == 3000 for member in setup.members)
assert setup.commands[0] == {
"target": GROUP,
"service": "turn_on",
"transition": 2.5,
}
expected_duration = 1.25 if brightness_adaptation else 2.5
assert len(setup.commands) == (5 if brightness_adaptation else 4), setup.commands
brightness_commands = [
command for command in setup.commands if ATTR_BRIGHTNESS in command
]
if brightness_adaptation:
assert brightness_commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_BRIGHTNESS: 128,
"transition": expected_duration,
},
]
assert setup.commands[1] == brightness_commands[0]
assert all(member.brightness == 128 for member in setup.members)
else:
assert not brightness_commands
assert [member.brightness for member in setup.members] == [80, 110, 26]
assert len(setup.colour_commands) == 3
assert {command["target"] for command in setup.colour_commands} == {
member.entity_id for member in setup.members
}
for command in setup.colour_commands:
assert command["transition"] == expected_duration
assert ATTR_BRIGHTNESS not in command
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_independent_group_adapts_while_other_original_power_handler_waits(
hass,
monkeypatch,
planner_enabled,
):
setup, second = await _setup_independent_groups(hass)
setup.switch.manager.native_groups.disabled = not planner_enabled
setup.switch._send_split_delay = 0
entered, release, second_ready = asyncio.Event(), asyncio.Event(), asyncio.Event()
first_on, second_on = setup.group.async_turn_on, second.async_turn_on
async def first(**kwargs):
if not kwargs:
entered.set()
await asyncio.wait_for(release.wait(), 5)
await first_on(**kwargs)
async def other(**kwargs):
await second_on(**kwargs)
if ATTR_COLOR_TEMP_KELVIN in kwargs:
second_ready.set()
monkeypatch.setattr(setup.group, "async_turn_on", first)
monkeypatch.setattr(second, "async_turn_on", other)
task = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second.entity_id]},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
try:
await asyncio.wait_for(second_ready.wait(), 0.5)
other_completed = True
except TimeoutError:
other_completed = False
paused_commands = list(setup.commands)
paused_colour = [member.physical_kelvin for member in second.members]
finally:
release.set()
await task
await hass.async_block_till_done()
assert other_completed, (paused_commands, paused_colour)
assert all(member.physical_kelvin == 3000 for member in second.members)
@pytest.mark.parametrize("partial_parent", [False, True])
async def test_shared_ancestor_keeps_grouping_and_late_aggregate_coverage(
hass,
partial_parent,
):
"""Shared full/partial native ancestors keep one owner for aggregate reports."""
setup, second = await _setup_independent_groups(hass)
setup.switch._send_split_delay = 0
await setup.switch.async_turn_off()
selected_members = [*setup.members, *second.members]
extra = RecordingMember("light.native_unselected", setup.commands)
parent = RecordingGroup(
[*selected_members, *([extra] if partial_parent else [])],
setup.commands,
)
parent.entity_id = "light.native_parent"
parent._attr_is_on = False
await hass.data[DATA_COMPONENT].async_add_entities([extra, parent])
setup.switch._configured_lights.append(parent.entity_id)
setup.switch._expand_light_groups()
await setup.switch.async_turn_on(adapt_lights=False)
await hass.async_block_till_done()
setup.commands.clear()
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second.entity_id]},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert all(member.physical_kelvin == 3000 for member in selected_members)
assert all(member.brightness == 128 for member in selected_members)
assert not extra.is_on
assert extra.physical_kelvin == 4000
assert {command["target"] for command in setup.colour_commands} == (
{GROUP, second.entity_id} if partial_parent else {parent.entity_id}
), setup.commands
before_report = list(setup.commands)
# A separate aggregate ON report may arrive after the complete service.
parent.async_set_context(Context())
parent._attr_is_on = True
parent.async_write_ha_state()
await hass.async_block_till_done()
assert setup.commands == before_report
assert not extra.is_on
async def test_unsupported_overlapping_native_target_keeps_shared_dispatch_boundary(
hass,
monkeypatch,
):
"""An ineligible native target still shares physical members with its peer."""
setup, second = await _setup_independent_groups(hass)
setup.switch._send_split_delay = 0
extra = RecordingMember("light.native_foreign_member", setup.commands)
overlapping = RecordingGroup([*second.members, extra], setup.commands)
overlapping.entity_id = "light.native_unowned_overlap"
overlapping._attr_is_on = False
await hass.data[DATA_COMPONENT].async_add_entities([extra, overlapping])
entered, release = asyncio.Event(), asyncio.Event()
peer_power, peer_colour = asyncio.Event(), asyncio.Event()
original_overlap, original_peer = overlapping.async_turn_on, second.async_turn_on
async def hold_overlap(**kwargs):
entered.set()
await asyncio.wait_for(release.wait(), 5)
await original_overlap(**kwargs)
async def observe_peer(**kwargs):
await original_peer(**kwargs)
if not kwargs:
peer_power.set()
if ATTR_COLOR_TEMP_KELVIN in kwargs:
peer_colour.set()
monkeypatch.setattr(overlapping, "async_turn_on", hold_overlap)
monkeypatch.setattr(second, "async_turn_on", observe_peer)
task = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": [overlapping.entity_id, second.entity_id]},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
await asyncio.wait_for(peer_power.wait(), 2)
try:
await asyncio.wait_for(peer_colour.wait(), 0.5)
correction_before_shared_power = True
except TimeoutError:
correction_before_shared_power = False
finally:
release.set()
await task
await hass.async_block_till_done()
assert not correction_before_shared_power, setup.commands
assert all(member.physical_kelvin == 3000 for member in second.members)
assert extra.is_on
assert extra.physical_kelvin == 4000
@pytest.mark.parametrize("action", ["cancel", "new_colour"])
async def test_parallel_split_dispatch_respects_parent_cancel_and_new_member_intent(
hass,
monkeypatch,
action,
):
setup, second = await _setup_independent_groups(hass)
planner = setup.switch.manager.native_groups
barrier = DelayBarrier(monkeypatch, planner, "_execute_dispatch")
other_brightness = asyncio.Event()
original = second.async_turn_on
async def observe(**kwargs):
await original(**kwargs)
if ATTR_BRIGHTNESS in kwargs:
other_brightness.set()
monkeypatch.setattr(second, "async_turn_on", observe)
parent = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second.entity_id]},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(barrier.entered.wait(), 2)
assert barrier.delay == 1.234
await asyncio.wait_for(other_brightness.wait(), 2)
assert not setup.colour_commands
if action == "cancel":
parent.cancel()
with pytest.raises(asyncio.CancelledError):
await parent
else:
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": RIGHT, ATTR_COLOR_TEMP_KELVIN: 4500},
blocking=True,
context=Context(),
)
finally:
barrier.release.set()
await asyncio.gather(parent, return_exceptions=True)
await hass.async_block_till_done()
if action == "cancel":
assert not setup.colour_commands, setup.commands
assert all(m.physical_kelvin == 4000 for m in [*setup.members, *second.members])
else:
assert [m.physical_kelvin for m in setup.members] == [3000, 4500, 3000]
assert all(m.physical_kelvin == 3000 for m in second.members)
assert not any(c["target"] == GROUP for c in setup.colour_commands)
assert all(m.is_on for m in [*setup.members, *second.members])
async def test_completed_partition_retains_late_report_claim_after_peer_cancel(
hass,
monkeypatch,
):
setup, second = await _setup_independent_groups(hass)
setup.switch._send_split_delay = 0
held_member = second.members[0]
held_member.hold_reports = True
entered, release, partition_done = asyncio.Event(), asyncio.Event(), asyncio.Event()
first_on = setup.group.async_turn_on
planner = setup.switch.manager.native_groups
original_run = planner._run
async def observe_partition(batch, invoke):
result = await original_run(batch, invoke)
if second.entity_id in batch.plan.requests:
partition_done.set()
return result
monkeypatch.setattr(planner, "_run", observe_partition)
async def first(**kwargs):
if not kwargs:
entered.set()
await asyncio.wait_for(release.wait(), 5)
await first_on(**kwargs)
monkeypatch.setattr(setup.group, "async_turn_on", first)
parent = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second.entity_id]},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
await asyncio.wait_for(partition_done.wait(), 2)
assert held_member.physical_kelvin == 3000
assert hass.states.is_state(held_member.entity_id, "off")
parent.cancel()
with pytest.raises(asyncio.CancelledError):
await parent
finally:
release.set()
await asyncio.gather(parent, return_exceptions=True)
await hass.async_block_till_done()
before_report = list(setup.commands)
held_member.hold_reports = False
held_member.async_set_context(Context())
held_member.async_write_ha_state()
await hass.async_block_till_done()
assert hass.states.is_state(held_member.entity_id, "on")
assert setup.commands == before_report
assert all(m.physical_kelvin == 3000 for m in second.members)

View file

@ -0,0 +1,259 @@
"""Native-group services behavior at HA and device boundaries."""
# pylint: disable=protected-access
from homeassistant.components.adaptive_lighting import native_groups
from homeassistant.components.light import (
ATTR_COLOR_TEMP_KELVIN,
ATTR_TRANSITION,
)
from homeassistant.components.scene import DATA_COMPONENT as SCENE_COMPONENT
from homeassistant.components.scene import Scene
from homeassistant.const import (
ATTR_ENTITY_ID,
EVENT_SERVICE_REGISTERED,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import Context, EventOrigin
from homeassistant.setup import async_setup_component
from .support.native_lights import GROUP, command_params
from .support.native_scenarios import (
activate_scene,
scene_state,
setup_native_group,
setup_scene_group,
)
async def test_native_planning_installs_scene_services_registered_later(
hass,
monkeypatch,
):
"""Scenes loaded after Adaptive Lighting still expose complete intent."""
setup = await setup_native_group(hass)
assert not hass.services.has_service("scene", "apply")
calls = []
original = setup.group.async_turn_on
async def record_group(**kwargs):
calls.append(command_params(kwargs))
await original(**kwargs)
monkeypatch.setattr(setup.group, "async_turn_on", record_group)
assert await async_setup_component(hass, "scene", {"scene": []})
await hass.async_block_till_done()
members = [setup.left, setup.right, setup.off_member]
await activate_scene(
hass,
{member.entity_id: scene_state(3500, 100) for member in members},
service="turn_on",
transition=3,
)
assert all(member.is_on and member.physical_kelvin == 3500 for member in members)
assert calls == [{ATTR_COLOR_TEMP_KELVIN: 3500, ATTR_TRANSITION: 3}]
assert setup.switch.manager.native_groups.active.get() is None
async def test_native_planning_reinstalls_replaced_scene_handler(hass):
"""A later service replacement is wrapped once and retained on unload."""
setup = await setup_scene_group(hass)
planner = setup.switch.manager.native_groups
registered = hass.services.async_services()["scene"]["apply"]
received = []
async def replacement(call):
# This replacement intentionally handles no devices; the planner must
# still preserve and invoke the actual newly registered handler once.
received.append((call, planner.active.get()))
hass.services.async_register(
"scene",
"apply",
replacement,
registered.schema,
)
await hass.async_block_till_done()
context = Context()
await hass.services.async_call(
"scene",
"apply",
{"entities": {"switch.unrelated": {"state": STATE_ON}}},
blocking=True,
context=context,
)
assert len(received) == 1
assert received[0][0].context is context
assert received[0][0].data["entities"]["switch.unrelated"].state == STATE_ON
assert received[0][1] is None
assert setup.commands == []
# A request affecting a configured member proves the replacement actually
# acquired the planner, rather than merely remaining callable unwrapped.
left = setup.members[0]
await hass.services.async_call(
"scene",
"apply",
{
"entities": {
left.entity_id: {"state": STATE_ON, ATTR_COLOR_TEMP_KELVIN: 3500},
},
},
blocking=True,
)
assert len(received) == 2
assert received[1][1] is not None
assert received[1][1].execution.closed
assert not received[1][1].execution.aborted
assert left.physical_kelvin == 3500
assert len(setup.colour_commands) == 1
planner.disable()
assert hass.services.async_services()["scene"]["apply"].job.target is replacement
await hass.services.async_call(
"scene",
"apply",
{"entities": {"switch.unrelated": {"state": STATE_OFF}}},
blocking=True,
)
assert len(received) == 3
assert received[2][1] is None
async def test_native_planning_passes_opaque_integration_scene_through(hass):
"""A vendor scene has no HA state definition and keeps its original call."""
setup = await setup_scene_group(hass)
activations = []
planner = setup.switch.manager.native_groups
class OpaqueScene(Scene):
"""An integration scene whose intended device state is not exposed."""
entity_id = "scene.opaque_integration"
_attr_name = "Opaque integration"
async def async_activate(self, **kwargs):
activations.append((kwargs, self._context, planner.active.get()))
await hass.data[SCENE_COMPONENT].async_add_entities([OpaqueScene()])
context = Context()
await hass.services.async_call(
"scene",
"turn_on",
{ATTR_ENTITY_ID: "scene.opaque_integration", ATTR_TRANSITION: 4},
blocking=True,
context=context,
)
await hass.async_block_till_done()
assert activations == [({ATTR_TRANSITION: 4}, context, None)]
assert setup.commands == []
assert hass.states.get("scene.opaque_integration").state is not None
async def test_native_planning_handles_synchronous_service_registration(
hass,
monkeypatch,
):
"""Legacy HA registration events cannot recursively wrap the same handler."""
bus_type = type(hass.bus)
original_fire = bus_type.async_fire_internal
def fire_registration_immediately(
bus,
event_type,
event_data=None,
origin=EventOrigin.local,
context=None,
time_fired=None,
):
# New HA queues nested bus events. Exercise the older synchronous
# contract on new HA too; old HA already dispatches this way.
if event_type == EVENT_SERVICE_REGISTERED and hasattr(bus, "_async_dispatch"):
return bus._async_dispatch(
event_type,
event_data,
origin,
context,
time_fired,
)
return original_fire(bus, event_type, event_data, origin, context, time_fired)
monkeypatch.setattr(bus_type, "async_fire_internal", fire_registration_immediately)
assert await async_setup_component(hass, "scene", {"scene": []})
originals = {
service: registered.job.target
for service, registered in hass.services.async_services()["scene"].items()
if service in {"apply", "turn_on"}
}
setup = await setup_scene_group(hass)
await activate_scene(
hass,
{member.entity_id: scene_state(3500, 100) for member in setup.members},
transition=2,
)
assert all(
member.is_on and member.physical_kelvin == 3500 for member in setup.members
)
assert setup.colour_commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: 3500,
ATTR_TRANSITION: 2,
},
]
setup.switch.manager.native_groups.disable()
assert {
service: hass.services.async_services()["scene"][service].job.target
for service in originals
} == originals
async def test_native_planning_retries_after_failed_handler_install(
hass,
monkeypatch,
):
"""An installation failure cannot block a later legitimate registration."""
setup = await setup_scene_group(hass)
planner = setup.switch.manager.native_groups
registered = hass.services.async_services()["scene"]["apply"]
original_interceptor = native_groups.setup_service_call_interceptor
attempts = []
received = []
def fail_once(*args, **kwargs):
attempts.append(args[1:3])
if len(attempts) == 1:
message = "test interceptor installation failure"
raise RuntimeError(message)
return original_interceptor(*args, **kwargs)
async def replacement(call):
received.append(call)
monkeypatch.setattr(native_groups, "setup_service_call_interceptor", fail_once)
hass.services.async_register("scene", "apply", replacement, registered.schema)
await hass.async_block_till_done()
assert hass.services.async_services()["scene"]["apply"].job.target is replacement
hass.services.async_register("scene", "apply", replacement, registered.schema)
await hass.async_block_till_done()
assert attempts == [("scene", "apply"), ("scene", "apply")]
assert (
hass.services.async_services()["scene"]["apply"].job.target is not replacement
)
await hass.services.async_call(
"scene",
"apply",
{"entities": {"switch.unrelated": {"state": STATE_ON}}},
blocking=True,
)
assert len(received) == 1
planner.disable()
assert hass.services.async_services()["scene"]["apply"].job.target is replacement

View file

@ -0,0 +1,318 @@
"""Native broadcasts preserve the configured brightness/colour split policy."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_SEND_SPLIT_DELAY,
CONF_SEPARATE_TURN_ON_COMMANDS,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_TRANSITION,
)
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import Context
from homeassistant.exceptions import HomeAssistantError
from .support.async_helpers import DelayBarrier
from .support.native_lights import GROUP, RIGHT, TARGET_KELVIN, RecordingMember
from .support.native_scenarios import (
prepare_reported_members,
set_manual,
setup_reported_group,
)
async def _setup_activation(hass, *, split=True, delay=0):
setup = await setup_reported_group(
hass,
**{CONF_SEPARATE_TURN_ON_COMMANDS: split, CONF_SEND_SPLIT_DELAY: delay},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
return setup
async def _activate_group(hass, transition=None):
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: GROUP,
**({ATTR_TRANSITION: transition} if transition is not None else {}),
},
blocking=True,
context=Context(),
)
@pytest.mark.parametrize("planner_enabled", [False, True])
@pytest.mark.parametrize("split", [False, True])
@pytest.mark.parametrize("transition", [None, 2.5])
async def test_native_activation_preserves_configured_command_split(
hass,
planner_enabled,
split,
transition,
):
"""Splitting retains one broadcast per axis; the default remains combined."""
setup = await _setup_activation(hass, split=split)
setup.switch.manager.native_groups.disabled = not planner_enabled
await _activate_group(hass, transition)
await hass.async_block_till_done()
assert all(member.is_on for member in setup.members)
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members)
combined = [
call
for call in setup.commands
if ATTR_BRIGHTNESS in call and ATTR_COLOR_TEMP_KELVIN in call
]
assert bool(combined) is not split, setup.commands
if planner_enabled:
assert len(setup.commands) == (3 if split else 2), setup.commands
assert all(call["target"] == GROUP for call in setup.commands)
assert len(setup.colour_commands) == 1
if transition is not None:
# The original bare power request retains the user's duration;
# computed brightness/colour divide it according to the split policy.
assert setup.commands[0][ATTR_TRANSITION] == transition
assert all(
call[ATTR_TRANSITION] == (transition / 2 if split else transition)
for call in setup.commands[1:]
)
async def test_native_split_waits_between_brightness_and_colour(hass, monkeypatch):
"""The configured millisecond delay starts after brightness has reached devices."""
setup = await _setup_activation(hass, delay=1234)
delay = DelayBarrier(
monkeypatch,
setup.switch.manager.native_groups,
"_execute_dispatch",
)
task = asyncio.create_task(_activate_group(hass))
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1.234
assert len(setup.commands) == 2, setup.commands
assert setup.commands[-1]["target"] == GROUP
assert ATTR_BRIGHTNESS in setup.commands[-1]
assert not setup.colour_commands
assert all(member.brightness == 128 for member in setup.members)
assert all(member.physical_kelvin == 4000 for member in setup.members)
assert not task.done()
finally:
delay.release.set()
await task
await hass.async_block_till_done()
assert len(setup.commands) == 3
assert setup.commands[-1]["target"] == GROUP
assert ATTR_BRIGHTNESS not in setup.commands[-1]
assert setup.commands[-1][ATTR_COLOR_TEMP_KELVIN] == TARGET_KELVIN
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members)
@pytest.mark.parametrize(
"change",
["off", "colour", "manual", "membership", "growth", "profile"],
)
async def test_native_split_revalidates_colour_after_delay(hass, monkeypatch, change):
"""A split command may only colour members whose original intent is still valid."""
setup = await _setup_activation(hass, delay=1234)
original_members = tuple(setup.members)
right = original_members[1]
if change == "growth":
extra = RecordingMember("light.native_added_later", setup.commands)
await hass.data[DATA_COMPONENT].async_add_entities([extra])
delay = DelayBarrier(
monkeypatch,
setup.switch.manager.native_groups,
"_execute_dispatch",
)
task = asyncio.create_task(_activate_group(hass))
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1.234
assert not setup.colour_commands
if change == "off":
await hass.services.async_call(
"light",
"turn_off",
{ATTR_ENTITY_ID: RIGHT},
blocking=True,
)
elif change == "colour":
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: RIGHT, ATTR_COLOR_TEMP_KELVIN: 4500},
blocking=True,
context=Context(),
)
elif change == "manual":
await set_manual(hass, setup.switch, RIGHT, "color")
elif change == "membership":
setup.group.members.remove(right)
setup.group.async_write_ha_state()
elif change == "growth":
setup.group.members.append(extra)
setup.group.async_write_ha_state()
else:
await setup.switch.async_turn_off()
after_change = len(setup.commands)
finally:
delay.release.set()
await task
await hass.async_block_till_done()
remaining = setup.commands[after_change:]
assert not any(call["target"] == GROUP for call in remaining), remaining
assert not any(ATTR_BRIGHTNESS in call for call in remaining), remaining
if change in {"profile", "manual"}:
# A native-group source cannot adapt colour when one of its members
# is manually controlled, matching the existing group-source guard.
assert remaining == []
assert all(member.physical_kelvin == 4000 for member in original_members)
elif change == "growth":
assert len(remaining) == 3, remaining
assert {call["target"] for call in remaining} == {
member.entity_id for member in original_members
}
assert all(
member.physical_kelvin == TARGET_KELVIN for member in original_members
)
assert not extra.is_on
assert extra.physical_kelvin == 4000
else:
assert len(remaining) == 2, remaining
assert not any(call["target"] == RIGHT for call in remaining)
assert all(call[ATTR_COLOR_TEMP_KELVIN] == TARGET_KELVIN for call in remaining)
assert right.physical_kelvin == (4500 if change == "colour" else 4000)
assert right.is_on is (change != "off")
async def test_native_split_cancellation_does_not_flush_colour(hass, monkeypatch):
"""Cancellation between axes never becomes a colour recovery or a retry."""
setup = await _setup_activation(hass, delay=1234)
delay = DelayBarrier(
monkeypatch,
setup.switch.manager.native_groups,
"_execute_dispatch",
)
task = asyncio.create_task(_activate_group(hass))
try:
await asyncio.wait_for(delay.entered.wait(), 2)
assert delay.delay == 1.234
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
finally:
delay.release.set()
if not task.done():
await task
await hass.async_block_till_done()
assert len(setup.commands) == 2, setup.commands
assert not setup.colour_commands
assert all(member.physical_kelvin == 4000 for member in setup.members)
@pytest.mark.parametrize("failed_axis", [ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN])
async def test_native_split_does_not_retry_failed_axis(hass, monkeypatch, failed_axis):
"""A failed group axis preserves error identity and never becomes member retries."""
setup = await _setup_activation(hass)
original_turn_on = setup.group.async_turn_on
error = HomeAssistantError("native group unavailable")
attempts = []
async def fail_axis(**kwargs):
attempts.append(dict(kwargs))
if failed_axis in kwargs:
raise error
await original_turn_on(**kwargs)
monkeypatch.setattr(setup.group, "async_turn_on", fail_axis)
with pytest.raises(HomeAssistantError, match="native group unavailable") as exc:
await _activate_group(hass)
assert exc.value is error
await hass.async_block_till_done()
assert len(attempts) == (2 if failed_axis == ATTR_BRIGHTNESS else 3)
assert sum(failed_axis in data for data in attempts) == 1
assert all(call["target"] == GROUP for call in setup.commands)
assert len(setup.commands) == (1 if failed_axis == ATTR_BRIGHTNESS else 2)
assert all(member.is_on for member in setup.members)
assert all(member.physical_kelvin == 4000 for member in setup.members)
assert [member.brightness for member in setup.members] == (
[80, 110, 26] if failed_axis == ATTR_BRIGHTNESS else [128, 128, 128]
)
async def test_native_split_preserves_explicit_combined_group_request(hass):
"""The compatibility option changes computed adaptation, not explicit user data."""
setup = await _setup_activation(hass)
await hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: GROUP, ATTR_BRIGHTNESS: 160, ATTR_COLOR_TEMP_KELVIN: 3500},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert setup.commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_BRIGHTNESS: 160,
ATTR_COLOR_TEMP_KELVIN: 3500,
},
]
assert all(member.brightness == 160 for member in setup.members)
assert all(member.physical_kelvin == 3500 for member in setup.members)
async def test_native_member_fallback_preserves_command_split(hass, monkeypatch):
"""An incompatible group colour leaves the compatible brightness broadcast intact."""
setup = await _setup_activation(hass)
original_turn_on = setup.group.async_turn_on
async def update_group_capability(**kwargs):
await original_turn_on(**kwargs)
setup.group._attr_max_color_temp_kelvin = 2500
setup.group.async_write_ha_state()
monkeypatch.setattr(setup.group, "async_turn_on", update_group_capability)
await _activate_group(hass)
await hass.async_block_till_done()
assert len(setup.commands) == 5, setup.commands
assert setup.commands[:2] == [
{"target": GROUP, "service": "turn_on"},
{"target": GROUP, "service": "turn_on", ATTR_BRIGHTNESS: 128},
]
assert all(member.is_on for member in setup.members)
assert all(member.brightness == 128 for member in setup.members)
assert all(member.physical_kelvin == TARGET_KELVIN for member in setup.members)
for member in setup.members:
commands = [
call for call in setup.commands if call["target"] == member.entity_id
]
assert commands == [
{
"target": member.entity_id,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: TARGET_KELVIN,
},
]

View file

@ -0,0 +1,152 @@
"""Native-group targets behavior at HA and device boundaries."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.light import (
ATTR_COLOR_TEMP_KELVIN,
)
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.const import ATTR_ENTITY_ID, CONF_LIGHTS, STATE_ON
from homeassistant.core import Context
from homeassistant.setup import async_setup_component
from .support.native_lights import (
GROUP,
RecordingGroup,
RecordingMember,
SceneSetup,
)
from .support.native_scenarios import (
prepare_reported_members,
setup_reported_group,
)
@pytest.mark.parametrize("newer_target", ["all", "members", "helper_group"])
@pytest.mark.parametrize("service", ["turn_on", "turn_off"])
async def test_expanded_targets_supersede_pending_adaptive_colour(
hass,
monkeypatch,
newer_target,
service,
):
"""An accepted newer request cancels colour for its actual light targets."""
setup = await setup_reported_group(hass)
# Keep aggregate power stable to isolate supersession of pending colour
# from the separate reactive path triggered by a new aggregate ON report.
await prepare_reported_members(hass, setup, on=(True, True, True))
planner = setup.switch.manager.native_groups
entered, release = asyncio.Event(), asyncio.Event()
original_flush = planner._flush
first = True
async def held_flush(batch):
nonlocal first
if first:
first = False
entered.set()
await release.wait()
await original_flush(batch)
monkeypatch.setattr(planner, "_flush", held_flush)
entities = [member.entity_id for member in setup.members]
hass.states.async_set("group.review_all", STATE_ON, {ATTR_ENTITY_ID: entities})
target = {
"all": "all",
"members": entities,
"helper_group": "group.review_all",
}[newer_target]
pending = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{ATTR_ENTITY_ID: entities},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
await hass.services.async_call(
"light",
service,
{
ATTR_ENTITY_ID: target,
**({ATTR_COLOR_TEMP_KELVIN: 5000} if service == "turn_on" else {}),
},
blocking=True,
context=Context(),
)
finally:
release.set()
await pending
await hass.async_block_till_done()
if service == "turn_on":
assert [member.physical_kelvin for member in setup.members] == [
5000,
] * 3, setup.commands
else:
assert all(not member.is_on for member in setup.members), setup.commands
@pytest.mark.parametrize("brightness_adaptation", [False, True])
async def test_native_colour_call_combines_group_and_unrelated_member(
hass,
brightness_adaptation,
):
"""Mixed targets retain one original group command and one member command."""
assert await async_setup_component(hass, "light", {})
other_commands = []
other_members = [
RecordingMember(f"light.other_native_{index}", other_commands)
for index in range(3)
]
other_group = RecordingGroup(other_members, other_commands)
other_group.entity_id = "light.other_native_group"
await hass.data[DATA_COMPONENT].async_add_entities([*other_members, other_group])
setup = await setup_reported_group(
hass,
**{CONF_LIGHTS: [GROUP, other_group.entity_id]},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=brightness_adaptation,
)
other_setup = SceneSetup(setup.switch, other_group, other_members, other_commands)
await prepare_reported_members(
hass,
other_setup,
on=(True, False, False),
brightness_adaptation=brightness_adaptation,
)
setup.commands.clear()
await hass.services.async_call(
"light",
"turn_on",
{
ATTR_ENTITY_ID: [GROUP, other_members[0].entity_id],
ATTR_COLOR_TEMP_KELVIN: 3500,
},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert setup.commands == [
{"target": GROUP, "service": "turn_on", ATTR_COLOR_TEMP_KELVIN: 3500},
]
assert other_commands == [
{
"target": other_members[0].entity_id,
"service": "turn_on",
ATTR_COLOR_TEMP_KELVIN: 3500,
},
]
assert other_members[0].physical_kelvin == 3500
assert all(not member.is_on for member in setup.members + other_members[1:])
assert all(
member.physical_kelvin == 4000 for member in setup.members + other_members[1:]
)

View file

@ -0,0 +1,129 @@
"""Native-group traffic behavior at HA and device boundaries."""
# pylint: disable=protected-access
import json
from collections import Counter
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_EXPAND_LIGHT_GROUPS,
CONF_INITIAL_TRANSITION,
CONF_MAX_COLOR_TEMP,
CONF_MIN_COLOR_TEMP,
CONF_SKIP_REDUNDANT_COMMANDS,
CONF_SLEEP_COLOR_TEMP,
CONF_TAKE_OVER_CONTROL,
CONF_TRANSITION,
)
from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.const import ATTR_ENTITY_ID, CONF_LIGHTS, SERVICE_TURN_ON, STATE_ON
from homeassistant.core import Context
from homeassistant.setup import async_setup_component
from .support.adaptive import setup_switch, track_adaptive_light_calls
from .support.native_lights import TARGET_KELVIN, NativeGroup, NativeMember
@pytest.mark.parametrize(
"group_sizes",
[(3,), (14,), (3, 14)],
ids=["one_group_3_bulbs", "one_group_14_bulbs", "living_shape_2_groups_17_bulbs"],
)
async def test_native_whole_area_activation_keeps_colour_corrections_grouped(
hass,
record_property,
group_sizes,
):
"""Correct every lit bulb with one colour adaptation per native group."""
assert await async_setup_component(hass, "light", {})
groups, members = [], []
for group_index, size in enumerate(group_sizes):
group_members = [
NativeMember(f"light.area_{group_index}_member_{member_index}")
for member_index in range(size)
]
for member_index, member in enumerate(group_members):
member._attr_brightness = 30 + member_index * 7
group = NativeGroup(group_members)
group.entity_id = f"light.area_group_{group_index}"
group._attr_is_on = False
groups.append(group)
members.extend(group_members)
# A separate OFF light proves that the original area target is respected.
control = NativeMember("light.outside_area_control")
await hass.data[DATA_COMPONENT].async_add_entities([*members, *groups, control])
group_ids = [group.entity_id for group in groups]
member_ids = {member.entity_id for member in members}
_, switch = await setup_switch(
hass,
{
CONF_LIGHTS: group_ids,
CONF_EXPAND_LIGHT_GROUPS: False,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_MIN_COLOR_TEMP: TARGET_KELVIN,
CONF_MAX_COLOR_TEMP: TARGET_KELVIN,
CONF_SLEEP_COLOR_TEMP: TARGET_KELVIN,
CONF_SKIP_REDUNDANT_COMMANDS: True,
CONF_TAKE_OVER_CONTROL: False,
},
)
await switch.adapt_brightness_switch.async_turn_off()
await hass.async_block_till_done()
original_brightness = {m.entity_id: m.brightness for m in members}
assert all(
not member.is_on and member.physical_kelvin == 4000 for member in members
)
calls = track_adaptive_light_calls(hass)
# One area action invokes the real light service on each native group.
# Group and member reports use the existing native entity model, including
# independent member contexts and matching optimistic colour caches.
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: group_ids},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
activation_calls = list(calls)
calls.clear()
await switch._async_update_at_interval_action()
await hass.async_block_till_done()
periodic_calls = list(calls)
counts = Counter(call[ATTR_ENTITY_ID] for call in activation_calls)
evidence = {
"group_sizes": list(group_sizes),
"bulb_count": len(members),
"expected_correction_count": len(groups),
"actual_correction_count": len(activation_calls),
"group_corrections": sum(counts[group_id] for group_id in group_ids),
"member_corrections": sum(counts[member_id] for member_id in member_ids),
"activation_calls": activation_calls,
"unchanged_periodic_calls": periodic_calls,
"all_physical_colours_correct": all(
m.physical_kelvin == TARGET_KELVIN for m in members
),
"brightness_preserved": all(
m.brightness == original_brightness[m.entity_id] for m in members
),
"outside_control_still_off": not control.is_on,
}
record_property("traffic_evidence", json.dumps(evidence, sort_keys=True))
# Verify useful adaptation and safety before checking the traffic budget:
# simply suppressing all corrections must not satisfy this regression.
assert evidence["all_physical_colours_correct"], evidence
assert evidence["brightness_preserved"], evidence
assert evidence["outside_control_still_off"], evidence
assert all(hass.states.get(group_id).state == STATE_ON for group_id in group_ids)
assert all(ATTR_BRIGHTNESS not in call for call in activation_calls)
assert all(
call.get(ATTR_COLOR_TEMP_KELVIN) == TARGET_KELVIN for call in activation_calls
)
assert switch.lights == sorted(group_ids)
assert periodic_calls == [], evidence
assert counts == Counter(dict.fromkeys(group_ids, 1)), evidence

View file

@ -106,7 +106,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.template import light as template_light
from homeassistant.components.template.light import StateLightEntity as LightTemplate
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigEntryState
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_DEVICE_ID,
@ -140,6 +140,9 @@ from homeassistant.util.color import (
from tests.common import MockConfigEntry
from tests.common import mock_area_registry as mock_ha_area_registry
from .support.adaptive import setup_switch
from .support.adaptive import track_adaptive_light_calls as _track_adaptive_light_calls
# HA 2026.6 removed the legacy `light: platform: template` YAML format
# (home-assistant/core#169615); use the modern `template:` format there.
LEGACY_TEMPLATE_LIGHTS = hasattr(template_light, "PLATFORM_SCHEMA")
@ -170,6 +173,8 @@ LAT_LONG_TZS = [
ENTITY_LIGHT_1 = "light.light_1"
ENTITY_LIGHT_2 = "light.light_2"
ENTITY_LIGHT_3 = "light.light_3"
# Legacy template lights store integer mireds; this target round-trips exactly.
MATCHING_COLOR_TEMP_KELVIN = 4000
_SWITCH_FMT = f"{SWITCH_DOMAIN}.{DOMAIN}"
ENTITY_SWITCH = f"{_SWITCH_FMT}_{DEFAULT_NAME}"
ENTITY_SLEEP_MODE_SWITCH = f"{_SWITCH_FMT}_{DEFAULT_NAME}_sleep_mode"
@ -202,24 +207,6 @@ async def cleanup(hass):
task.cancel()
async def setup_switch(hass, extra_data) -> tuple[MockConfigEntry, AdaptiveSwitch]:
"""Create the switch entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_NAME: DEFAULT_NAME,
CONF_INTERCEPT: False,
**extra_data,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.LOADED
switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN]
return entry, switch
async def setup_lights(hass: HomeAssistant, with_group: bool = False):
"""Set up 3 light entities using the 'template' platform."""
n = 3 if not with_group else 5 # last 2 will be put in a group
@ -2138,6 +2125,222 @@ async def test_apply_service(hass):
assert old_state[ATTR_COLOR_TEMP_KELVIN] == new_state[ATTR_COLOR_TEMP_KELVIN]
async def _setup_matching_adaptation(hass, **settings):
"""Set up lights whose recorded attributes already match a fixed target."""
switch, _ = await setup_lights_and_switch(
hass,
{
CONF_MIN_BRIGHTNESS: 50,
CONF_MAX_BRIGHTNESS: 50,
CONF_MIN_COLOR_TEMP: MATCHING_COLOR_TEMP_KELVIN,
CONF_MAX_COLOR_TEMP: MATCHING_COLOR_TEMP_KELVIN,
CONF_DETECT_NON_HA_CHANGES: False,
CONF_SKIP_REDUNDANT_COMMANDS: True,
**settings,
},
)
state = hass.states.get(ENTITY_LIGHT_1)
assert state.state == STATE_ON
assert state.attributes[ATTR_BRIGHTNESS] == 128
assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == MATCHING_COLOR_TEMP_KELVIN
return switch
@pytest.mark.parametrize("skip_redundant", [False, True])
@pytest.mark.parametrize("force", [False, True])
async def test_forced_adaptation_bypasses_redundancy(
hass,
skip_redundant,
force,
cleanup,
):
"""Only unforced adaptations may trust matching recorded attributes."""
switch = await _setup_matching_adaptation(
hass,
**{CONF_SKIP_REDUNDANT_COMMANDS: skip_redundant},
)
calls = _track_adaptive_light_calls(hass)
await switch._adapt_light(
ENTITY_LIGHT_1,
context=switch.create_context("test"),
transition=0,
force=force,
)
await hass.async_block_till_done()
expected = {
ATTR_ENTITY_ID: ENTITY_LIGHT_1,
ATTR_BRIGHTNESS: 128,
ATTR_COLOR_TEMP_KELVIN: MATCHING_COLOR_TEMP_KELVIN,
}
assert calls == ([] if skip_redundant and not force else [expected])
@pytest.mark.parametrize("split", [False, True])
@pytest.mark.parametrize(
("adapt_brightness", "adapt_color"),
[(False, True), (True, False), (True, True), (False, False)],
)
async def test_apply_reapplies_matching_attributes(
hass,
split,
adapt_brightness,
adapt_color,
cleanup,
):
"""An explicit reapply sends requested attributes despite optimistic state."""
switch = await _setup_matching_adaptation(
hass,
**{CONF_SEPARATE_TURN_ON_COMMANDS: split},
)
other_state = hass.states.get(ENTITY_LIGHT_2)
calls = _track_adaptive_light_calls(hass)
attributes = {}
if adapt_brightness:
attributes[ATTR_BRIGHTNESS] = 128
if adapt_color:
attributes[ATTR_COLOR_TEMP_KELVIN] = MATCHING_COLOR_TEMP_KELVIN
target = {ATTR_ENTITY_ID: ENTITY_LIGHT_1}
expected = [target | attributes] if attributes else []
if split:
expected = [target | {key: value} for key, value in attributes.items()]
# Repeated requests must each reach the light: matching HA state does not
# prove that a bulb received an earlier command (e.g. while it was off).
for _ in range(2):
calls.clear()
await hass.services.async_call(
DOMAIN,
SERVICE_APPLY,
{
ATTR_ENTITY_ID: switch.entity_id,
CONF_LIGHTS: [ENTITY_LIGHT_1],
ATTR_ADAPT_BRIGHTNESS: adapt_brightness,
ATTR_ADAPT_COLOR: adapt_color,
CONF_TURN_ON_LIGHTS: False,
},
blocking=True,
)
await hass.async_block_till_done()
assert calls == expected
assert hass.states.get(ENTITY_LIGHT_2) == other_state
assert hass.states.get(ENTITY_LIGHT_3).state == STATE_OFF
@pytest.mark.parametrize("turn_on_lights", [False, True])
async def test_apply_matching_attributes_respects_turn_on_lights(
hass,
turn_on_lights,
cleanup,
):
"""Bypassing redundancy must preserve the apply service's power opt-in."""
switch = await _setup_matching_adaptation(hass)
# Isolate the explicit service from the profile's separate OFF-to-ON response.
# Apply remains available while automatic adaptation is disabled.
await switch.async_turn_off()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: ENTITY_LIGHT_1},
blocking=True,
)
await hass.async_block_till_done()
calls = _track_adaptive_light_calls(hass)
await hass.services.async_call(
DOMAIN,
SERVICE_APPLY,
{
ATTR_ENTITY_ID: switch.entity_id,
CONF_LIGHTS: [ENTITY_LIGHT_1],
ATTR_ADAPT_BRIGHTNESS: False,
ATTR_ADAPT_COLOR: True,
CONF_TURN_ON_LIGHTS: turn_on_lights,
},
blocking=True,
)
await hass.async_block_till_done()
assert calls == (
[
{
ATTR_ENTITY_ID: ENTITY_LIGHT_1,
ATTR_COLOR_TEMP_KELVIN: MATCHING_COLOR_TEMP_KELVIN,
},
]
if turn_on_lights
else []
)
assert hass.states.get(ENTITY_LIGHT_1).state == (
STATE_ON if turn_on_lights else STATE_OFF
)
async def test_turn_on_reapplies_matching_attributes_then_suppresses_interval(
hass,
monkeypatch,
cleanup,
):
"""Activation corrects potentially stale state; unchanged intervals stay quiet."""
switch = await _setup_matching_adaptation(hass)
state = hass.states.get(ENTITY_LIGHT_1)
hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes)
await hass.async_block_till_done()
# Isolate redundant-command handling from the separate turn-off debounce.
monkeypatch.setattr(
switch.manager,
"just_turned_off",
AsyncMock(return_value=False),
)
calls = _track_adaptive_light_calls(hass)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ENTITY_LIGHT_1},
blocking=True,
)
await hass.async_block_till_done()
await asyncio.gather(*switch.manager.adaptation_tasks)
assert calls == [
{
ATTR_ENTITY_ID: ENTITY_LIGHT_1,
ATTR_BRIGHTNESS: 128,
ATTR_COLOR_TEMP_KELVIN: MATCHING_COLOR_TEMP_KELVIN,
},
]
calls.clear()
await switch._async_update_at_interval_action()
await hass.async_block_till_done()
assert calls == []
async def test_forced_adaptation_preserves_manual_control_with_redundancy(
hass,
cleanup,
):
"""A forced automatic update must not reclaim manually controlled lights."""
switch = await _setup_matching_adaptation(hass)
switch.manager.set_manual_control_attributes(ENTITY_LIGHT_1)
calls = _track_adaptive_light_calls(hass)
await switch._update_attrs_and_maybe_adapt_lights(
context=switch.create_context("test"),
lights=[ENTITY_LIGHT_1],
transition=0,
force=True,
)
await hass.async_block_till_done()
assert calls == []
assert (
switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1)
== LightControlAttributes.ALL
)
async def test_apply_service_uses_each_switch_transition(hass):
"""Test global apply resolves omitted transition for each profile."""
await setup_lights(hass)
@ -3837,22 +4040,6 @@ async def test_light_group(
assert len(events) == 3
def _track_adaptive_light_calls(hass, *, ours_only=True):
"""Capture commands emitted by Adaptive Lighting at the HA service boundary."""
calls = []
def track(event):
if (
event.data["domain"] == LIGHT_DOMAIN
and event.data["service"] == SERVICE_TURN_ON
and (not ours_only or is_our_context(event.context))
):
calls.append(event.data["service_data"])
hass.bus.async_listen(EVENT_CALL_SERVICE, track)
return calls
async def _setup_group_switch(hass, **settings):
return await setup_switch(
hass,