mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-10-28 14:17:07 +01:00
feat: improve native group brightness and colour adaptation
This commit is contained in:
parent
283fec08ae
commit
4726f328bf
42 changed files with 12609 additions and 147 deletions
13
README.md
13
README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
199
custom_components/adaptive_lighting/manual_control.py
Normal file
199
custom_components/adaptive_lighting/manual_control.py
Normal 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
|
||||
227
custom_components/adaptive_lighting/native_group_models.py
Normal file
227
custom_components/adaptive_lighting/native_group_models.py
Normal 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
|
||||
1706
custom_components/adaptive_lighting/native_groups.py
Normal file
1706
custom_components/adaptive_lighting/native_groups.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
1
tests/support/__init__.py
Normal file
1
tests/support/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Reusable fixtures and device models for Adaptive Lighting tests."""
|
||||
53
tests/support/adaptive.py
Normal file
53
tests/support/adaptive.py
Normal 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
|
||||
54
tests/support/async_helpers.py
Normal file
54
tests/support/async_helpers.py
Normal 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)
|
||||
214
tests/support/native_lights.py
Normal file
214
tests/support/native_lights.py
Normal 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]
|
||||
301
tests/support/native_scenarios.py
Normal file
301
tests/support/native_scenarios.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
320
tests/test_manual_control_state.py
Normal file
320
tests/test_manual_control_state.py
Normal 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()
|
||||
531
tests/test_native_brightness.py
Normal file
531
tests/test_native_brightness.py
Normal 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,
|
||||
},
|
||||
]
|
||||
315
tests/test_native_brightness_coverage.py
Normal file
315
tests/test_native_brightness_coverage.py
Normal 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,
|
||||
},
|
||||
]
|
||||
248
tests/test_native_brightness_ownership.py
Normal file
248
tests/test_native_brightness_ownership.py
Normal 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]
|
||||
244
tests/test_native_compatibility.py
Normal file
244
tests/test_native_compatibility.py
Normal 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
|
||||
550
tests/test_native_dispatch.py
Normal file
550
tests/test_native_dispatch.py
Normal 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
|
||||
97
tests/test_native_group_models.py
Normal file
97
tests/test_native_group_models.py
Normal 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
|
||||
935
tests/test_native_lifecycle.py
Normal file
935
tests/test_native_lifecycle.py
Normal 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)
|
||||
194
tests/test_native_light_model.py
Normal file
194
tests/test_native_light_model.py
Normal 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)
|
||||
1134
tests/test_native_manual_control.py
Normal file
1134
tests/test_native_manual_control.py
Normal file
File diff suppressed because it is too large
Load diff
234
tests/test_native_members.py
Normal file
234
tests/test_native_members.py
Normal 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
|
||||
406
tests/test_native_ownership.py
Normal file
406
tests/test_native_ownership.py
Normal 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
|
||||
238
tests/test_native_permissions.py
Normal file
238
tests/test_native_permissions.py
Normal 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)
|
||||
307
tests/test_native_profile_updates.py
Normal file
307
tests/test_native_profile_updates.py
Normal 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
|
||||
485
tests/test_native_retirement.py
Normal file
485
tests/test_native_retirement.py
Normal 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:]
|
||||
297
tests/test_native_scene_errors.py
Normal file
297
tests/test_native_scene_errors.py
Normal 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
709
tests/test_native_scenes.py
Normal 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)
|
||||
641
tests/test_native_scheduling.py
Normal file
641
tests/test_native_scheduling.py
Normal 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)
|
||||
259
tests/test_native_services.py
Normal file
259
tests/test_native_services.py
Normal 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
|
||||
318
tests/test_native_split_commands.py
Normal file
318
tests/test_native_split_commands.py
Normal 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,
|
||||
},
|
||||
]
|
||||
152
tests/test_native_targets.py
Normal file
152
tests/test_native_targets.py
Normal 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:]
|
||||
)
|
||||
129
tests/test_native_traffic.py
Normal file
129
tests/test_native_traffic.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue