mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-10-29 14:37:47 +01:00
Keep computed brightness in the scene plan when group brightness cannot cover every member. Validate group targets against the actual split phases and order brightness before dependent colour, using one shared execution path. Add regression coverage for mixed-power scenes, cancellation, profile shutdown, and default-profile interactions across supported Home Assistant versions.
1675 lines
64 KiB
Python
1675 lines
64 KiB
Python
"""Plan native-group attributes at a complete service-request boundary.
|
|
|
|
Original handlers retain power and attributes that cannot safely be consolidated. A
|
|
scene supplies its complete intended states before HA's cached-state filtering;
|
|
independent service calls never acquire coverage by sharing a context or timer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from contextlib import contextmanager
|
|
from contextvars import ContextVar
|
|
from dataclasses import replace
|
|
from typing import TYPE_CHECKING, Any
|
|
from weakref import WeakKeyDictionary
|
|
|
|
from homeassistant.auth.permissions.const import POLICY_CONTROL
|
|
from homeassistant.components.light import (
|
|
ATTR_COLOR_TEMP_KELVIN,
|
|
ATTR_SUPPORTED_COLOR_MODES,
|
|
ATTR_TRANSITION,
|
|
ColorMode,
|
|
LightEntityFeature,
|
|
brightness_supported,
|
|
preprocess_turn_on_alternatives,
|
|
)
|
|
from homeassistant.components.light import (
|
|
reproduce_state as light_reproduce_state,
|
|
)
|
|
from homeassistant.components.light.const import DATA_COMPONENT, DATA_PROFILES
|
|
from homeassistant.components.light.reproduce_state import (
|
|
COLOR_GROUP,
|
|
COLOR_MODE_TO_ATTRIBUTE,
|
|
)
|
|
from homeassistant.const import (
|
|
ATTR_ENTITY_ID,
|
|
ATTR_SUPPORTED_FEATURES,
|
|
CONF_PARAMS,
|
|
ENTITY_MATCH_ALL,
|
|
EVENT_SERVICE_REGISTERED,
|
|
SERVICE_TOGGLE,
|
|
SERVICE_TURN_OFF,
|
|
SERVICE_TURN_ON,
|
|
STATE_OFF,
|
|
STATE_ON,
|
|
)
|
|
from homeassistant.core import (
|
|
Context,
|
|
Event,
|
|
ServiceCall,
|
|
ServiceResponse,
|
|
State,
|
|
callback,
|
|
)
|
|
from homeassistant.helpers import config_validation as cv
|
|
from homeassistant.util.color import color_temperature_mired_to_kelvin
|
|
|
|
from .adaptation_utils import (
|
|
BRIGHTNESS_ATTRS,
|
|
COLOR_ATTRS,
|
|
LightControlAttributes,
|
|
adaptation_command_sequence,
|
|
has_effect_attribute,
|
|
)
|
|
from .hass_utils import (
|
|
ServiceContinuation,
|
|
setup_service_call_interceptor,
|
|
target_entities,
|
|
)
|
|
from .native_group_models import (
|
|
ActivationClaim,
|
|
ActivationCoverage,
|
|
AdaptiveRevision,
|
|
AttributePolicy,
|
|
DispatchDecision,
|
|
LightRequests,
|
|
NativeBatch,
|
|
NativeGroup,
|
|
NativeIntent,
|
|
NativePlan,
|
|
Parameters,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import (
|
|
Awaitable,
|
|
Callable,
|
|
Iterable,
|
|
Iterator,
|
|
Mapping,
|
|
Sequence,
|
|
)
|
|
|
|
from .switch import AdaptiveLightingManager, AdaptiveSwitch
|
|
|
|
# Older HA schemas add the deprecated mired alias alongside Kelvin. It must
|
|
# travel with the same colour intent, never leak into the power-only dispatch.
|
|
_COLOUR_PARAMS = COLOR_ATTRS | {"color_temp", "kelvin"}
|
|
|
|
|
|
class NativeGroupPlanner:
|
|
"""Consolidate compatible attributes while retaining individual scene intent."""
|
|
|
|
def __init__(
|
|
self,
|
|
manager: AdaptiveLightingManager,
|
|
refresh_switches: Callable[[], list[AdaptiveSwitch]],
|
|
is_ours: Callable[[Context], bool],
|
|
) -> None:
|
|
"""Keep planning state scoped to the manager, including hook ownership."""
|
|
self.manager = manager
|
|
self.hass = manager.hass
|
|
self.refresh_switches = refresh_switches
|
|
self.is_ours = is_ours
|
|
self.active: ContextVar[NativeBatch | None] = ContextVar(
|
|
"adaptive_lighting_native_batch",
|
|
default=None,
|
|
)
|
|
self.versions: dict[str, int] = {}
|
|
self.adaptive_versions: WeakKeyDictionary[AdaptiveSwitch, dict[str, int]] = (
|
|
WeakKeyDictionary()
|
|
)
|
|
self.coverage: dict[str, ActivationClaim] = {}
|
|
self.removers: list[Callable[[], None]] = []
|
|
self.handlers: dict[tuple[str, str], Any] = {}
|
|
self.installing: set[tuple[str, str]] = set()
|
|
self.disabled = False
|
|
|
|
def setup(self) -> None:
|
|
"""Hook native HA scenes, including services registered after startup."""
|
|
self.remove_registration_listener = self.hass.bus.async_listen(
|
|
EVENT_SERVICE_REGISTERED,
|
|
self._registered,
|
|
)
|
|
for domain, service in (
|
|
("scene", "apply"),
|
|
("scene", SERVICE_TURN_ON),
|
|
("light", SERVICE_TURN_OFF),
|
|
):
|
|
self._install(domain, service)
|
|
|
|
@callback
|
|
def _registered(self, event: Event) -> None:
|
|
self._install(event.data["domain"], event.data["service"])
|
|
|
|
def _install(self, domain: str, service: str) -> None:
|
|
if self.disabled or (domain, service) not in {
|
|
("scene", "apply"),
|
|
("scene", SERVICE_TURN_ON),
|
|
("light", SERVICE_TURN_OFF),
|
|
}:
|
|
return
|
|
key = (domain, service)
|
|
if key in self.installing:
|
|
return
|
|
registered = self.hass.services._services.get(domain, {}).get(service)
|
|
if registered is None or registered.job.target is self.handlers.get(
|
|
(domain, service),
|
|
):
|
|
return
|
|
# Older HA dispatches registration events synchronously. A wrapper's
|
|
# own registration must not install another wrapper before this one
|
|
# has been recorded; other services remain eligible during the call.
|
|
self.installing.add(key)
|
|
try:
|
|
self.removers.append(
|
|
setup_service_call_interceptor(
|
|
self.hass,
|
|
domain,
|
|
service,
|
|
self._before_off,
|
|
around_func=self.around_scene if domain == "scene" else None,
|
|
),
|
|
)
|
|
self.handlers[key] = self.hass.services._services[domain][
|
|
service
|
|
].job.target
|
|
finally:
|
|
self.installing.remove(key)
|
|
|
|
def disable(self) -> None:
|
|
"""Retire pending plans and restore only service handlers we still own."""
|
|
self.disabled = True
|
|
self.remove_registration_listener()
|
|
for remove in reversed(self.removers):
|
|
remove()
|
|
self.coverage.clear()
|
|
self.versions.clear()
|
|
self.adaptive_versions.clear()
|
|
|
|
def _refresh_groups(self, requested: Iterable[str] = ()) -> dict[str, NativeGroup]:
|
|
"""Refresh profile ownership explicitly before taking a group snapshot."""
|
|
return self._select_groups(self.refresh_switches(), requested)
|
|
|
|
def _select_groups(
|
|
self,
|
|
profiles: Sequence[AdaptiveSwitch],
|
|
requested: Iterable[str],
|
|
) -> dict[str, NativeGroup]:
|
|
"""Resolve competing profiles independently for brightness and colour."""
|
|
switches = [switch for switch in profiles if switch.is_on]
|
|
memberships = {
|
|
group: frozenset(
|
|
member
|
|
for member, parents in switch._group_members.items()
|
|
if group in parents
|
|
)
|
|
for switch in switches
|
|
for group in switch.lights
|
|
if any(group in parents for parents in switch._group_members.values())
|
|
}
|
|
for group in requested:
|
|
state = self.hass.states.get(group)
|
|
members = state.attributes.get("group_entities", ()) if state else ()
|
|
if (
|
|
isinstance(members, (list, tuple))
|
|
and members
|
|
and all(
|
|
isinstance(member, str) and member != group for member in members
|
|
)
|
|
):
|
|
memberships[group] = frozenset(members)
|
|
groups = {}
|
|
for group, members in memberships.items():
|
|
owners = {}
|
|
for attribute in (
|
|
LightControlAttributes.BRIGHTNESS,
|
|
LightControlAttributes.COLOR,
|
|
):
|
|
enabled = [
|
|
switch
|
|
for switch in switches
|
|
if (
|
|
switch.adapt_brightness_switch.is_on
|
|
if attribute == LightControlAttributes.BRIGHTNESS
|
|
else switch.adapt_color_switch.is_on
|
|
)
|
|
]
|
|
per_member = [
|
|
{switch for switch in enabled if member in switch._tracked_lights}
|
|
for member in members
|
|
]
|
|
shared = set.intersection(*per_member)
|
|
if len(shared) == 1 and all(
|
|
len(candidates) == 1 for candidates in per_member
|
|
):
|
|
owner = next(iter(shared))
|
|
# Derived groups are eligible only inside a profile's known
|
|
# native membership, not merely a list of unrelated lights.
|
|
if members <= owner._group_members.keys():
|
|
owners[attribute] = owner
|
|
if owners:
|
|
groups[group] = NativeGroup(owners, members)
|
|
return groups
|
|
|
|
async def _before_off(self, call: ServiceCall, _data: dict) -> None:
|
|
if call.domain == "light" and not self.is_ours(call.context):
|
|
targets = await self._light_targets(call)
|
|
if targets is not None:
|
|
self.invalidate(targets)
|
|
|
|
async def _light_targets(self, call: ServiceCall) -> set[str] | None:
|
|
"""Match HA's light targets and implicit-all permission filtering."""
|
|
component = self.hass.data[DATA_COMPONENT]
|
|
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL:
|
|
targets = {
|
|
entity.entity_id for entity in component.entities if entity.available
|
|
}
|
|
if call.context.user_id is None:
|
|
return targets
|
|
user = await self.hass.auth.async_get_user(call.context.user_id)
|
|
if user is None:
|
|
return None
|
|
return {
|
|
entity
|
|
for entity in targets
|
|
if user.is_admin
|
|
or user.permissions.check_entity(entity, POLICY_CONTROL)
|
|
}
|
|
targets = target_entities(self.hass, dict(call.data), expand_group=True)
|
|
if not await self._authorized(call.context, targets):
|
|
return None
|
|
return {
|
|
target
|
|
for target in targets
|
|
if (entity := component.get_entity(target)) is not None and entity.available
|
|
}
|
|
|
|
def invalidate(self, entities: Iterable[str]) -> None:
|
|
"""A newer request or real OFF invalidates old activation coverage."""
|
|
targets = set(entities)
|
|
# OFF remains authoritative after a profile is disabled. Resolve the
|
|
# device's current membership independently of enabled AL profiles.
|
|
for entity in tuple(targets):
|
|
state = self.hass.states.get(entity)
|
|
members = state.attributes.get("group_entities", ()) if state else ()
|
|
if isinstance(members, (list, tuple)):
|
|
targets.update(
|
|
member
|
|
for member in members
|
|
if isinstance(member, str) and member.startswith("light.")
|
|
)
|
|
for entity in targets:
|
|
self.versions[entity] = self.versions.get(entity, 0) + 1
|
|
self.coverage.pop(entity, None)
|
|
|
|
def invalidate_adaptive(
|
|
self,
|
|
switch: AdaptiveSwitch,
|
|
entities: Iterable[str] | None = None,
|
|
) -> None:
|
|
"""Retire an owner's computed settings while preserving explicit intent."""
|
|
targets = set(switch._tracked_lights if entities is None else entities)
|
|
for entity in tuple(targets):
|
|
state = self.hass.states.get(entity)
|
|
members = state.attributes.get("group_entities", ()) if state else ()
|
|
if isinstance(members, (list, tuple)):
|
|
targets.update(member for member in members if isinstance(member, str))
|
|
versions = self.adaptive_versions.setdefault(switch, {})
|
|
for entity in targets:
|
|
versions[entity] = versions.get(entity, 0) + 1
|
|
|
|
def take_activation_claim(self, entity: str) -> ActivationClaim | None:
|
|
"""Assign an expected report to one handler without freezing its validity."""
|
|
return self.coverage.pop(entity, None)
|
|
|
|
def activation_superseded(
|
|
self,
|
|
claim: ActivationClaim | None,
|
|
) -> bool:
|
|
"""A newer request retires this report, independently of owner changes."""
|
|
return claim is not None and any(
|
|
version != self.versions.get(entity, 0)
|
|
for entity, version in claim.request_versions
|
|
)
|
|
|
|
def activation_coverage(
|
|
self,
|
|
entity: str,
|
|
claim: ActivationClaim | None,
|
|
context: Context | None = None,
|
|
) -> ActivationCoverage:
|
|
"""Revalidate captured report coverage after lifecycle and settings changes."""
|
|
if claim is None:
|
|
return ActivationCoverage()
|
|
valid = (
|
|
not claim.execution.aborted
|
|
and entity not in claim.execution.failed_members
|
|
and not self.activation_superseded(claim)
|
|
and (
|
|
claim.required_context_id is None
|
|
or (context is not None and context.id == claim.required_context_id)
|
|
)
|
|
)
|
|
attributes = claim.attributes if valid else LightControlAttributes.NONE
|
|
for revision in claim.adaptive_revisions:
|
|
if revision.version != self.adaptive_versions.get(revision.owner, {}).get(
|
|
revision.member,
|
|
0,
|
|
):
|
|
attributes &= ~revision.attributes
|
|
return ActivationCoverage(
|
|
LightControlAttributes.COLOR in attributes,
|
|
LightControlAttributes.BRIGHTNESS in attributes,
|
|
bool(attributes) and claim.aggregate_only,
|
|
)
|
|
|
|
def claimed(self, entity: str) -> bool:
|
|
"""Keep the original interceptor from adapting a planned target twice."""
|
|
batch = self.active.get()
|
|
return bool(
|
|
batch and not batch.execution.closed and entity in batch.plan.requests,
|
|
)
|
|
|
|
@staticmethod
|
|
def _scene_params(state: State, transition: float | None) -> dict[str, Any] | None:
|
|
attrs = state.attributes
|
|
params = {
|
|
key: attrs[key]
|
|
for key in ("brightness", "effect")
|
|
if attrs.get(key) is not None
|
|
}
|
|
mode = attrs.get("color_mode", ColorMode.UNKNOWN)
|
|
if mode != ColorMode.UNKNOWN:
|
|
pair = COLOR_MODE_TO_ATTRIBUTE.get(mode)
|
|
if pair:
|
|
value = attrs.get(pair.state_attr)
|
|
if value is None:
|
|
if (
|
|
mode == ColorMode.COLOR_TEMP
|
|
and "color_temp" in COLOR_GROUP
|
|
and attrs.get("color_temp")
|
|
):
|
|
value = color_temperature_mired_to_kelvin(attrs["color_temp"])
|
|
else:
|
|
# HA rejects this malformed desired state as well.
|
|
return None
|
|
params[pair.parameter] = value
|
|
else:
|
|
for entry in COLOR_GROUP:
|
|
# HA 2025 uses attribute strings; newer releases pair state
|
|
# attributes with service parameters.
|
|
key, param = (entry, entry) if isinstance(entry, str) else entry
|
|
if attrs.get(key) is not None:
|
|
params[param] = attrs[key]
|
|
break
|
|
if transition is not None:
|
|
params[ATTR_TRANSITION] = transition
|
|
return params
|
|
|
|
def _normalized_params(self, params: Parameters) -> dict[str, Any]:
|
|
params = dict(params)
|
|
if ATTR_COLOR_TEMP_KELVIN in params:
|
|
# Legacy HA also supplies a rounded mired alias. Reprocessing it
|
|
# must not overwrite the exact Kelvin value or change scene intent.
|
|
params.pop("color_temp", None)
|
|
params.pop("kelvin", None)
|
|
preprocess_turn_on_alternatives(self.hass, params)
|
|
if ATTR_COLOR_TEMP_KELVIN not in params and params.get("color_temp"):
|
|
params[ATTR_COLOR_TEMP_KELVIN] = color_temperature_mired_to_kelvin(
|
|
params["color_temp"],
|
|
)
|
|
if ATTR_COLOR_TEMP_KELVIN in params:
|
|
params.pop("color_temp", None)
|
|
params.pop("kelvin", None)
|
|
return {
|
|
key: tuple(value) if isinstance(value, (list, tuple)) else value
|
|
for key, value in params.items()
|
|
}
|
|
|
|
def _effective_params(self, entity: str, params: Parameters) -> dict[str, Any]:
|
|
"""Resolve HA light-profile defaults before comparing colour intents."""
|
|
params = self._normalized_params(params)
|
|
light = self.hass.data[DATA_COMPONENT].get_entity(entity)
|
|
is_on = (
|
|
light.is_on
|
|
if light is not None
|
|
else self.hass.states.is_state(entity, STATE_ON)
|
|
)
|
|
if not params or not is_on or ATTR_TRANSITION not in params:
|
|
self.hass.data[DATA_PROFILES].apply_default(entity, is_on, params)
|
|
return params
|
|
|
|
async def _authorized(self, context: Context, entities: Iterable[str]) -> bool:
|
|
"""Only replace dispatch when the original targets are controllable."""
|
|
if context.user_id is None:
|
|
return True
|
|
user = await self.hass.auth.async_get_user(context.user_id)
|
|
return user is not None and (
|
|
user.is_admin
|
|
or all(
|
|
user.permissions.check_entity(entity, POLICY_CONTROL)
|
|
for entity in entities
|
|
)
|
|
)
|
|
|
|
def _scene_requests(self, call: ServiceCall) -> dict[str, dict[str, Any]]:
|
|
if call.service == "apply":
|
|
states = list(call.data["entities"].values())
|
|
else:
|
|
platform = self.hass.data.get("homeassistant_scene")
|
|
if platform is None:
|
|
return {}
|
|
states = []
|
|
for target in sorted(target_entities(self.hass, dict(call.data))):
|
|
scene = platform.entities.get(target)
|
|
config = getattr(scene, "scene_config", None)
|
|
if config is None:
|
|
# Opaque integration scenes cannot provide full intent.
|
|
return {}
|
|
states.extend(config.states.values())
|
|
requests = {}
|
|
for state in states:
|
|
if not isinstance(state, State) or state.domain != "light":
|
|
continue
|
|
if state.entity_id in requests:
|
|
# Multiple scene targets can race; do not invent an ordering.
|
|
return {}
|
|
params = self._scene_params(state, call.data.get(ATTR_TRANSITION))
|
|
if params is None:
|
|
continue
|
|
requests[state.entity_id] = {
|
|
"state": state.state,
|
|
**self._normalized_params(params),
|
|
}
|
|
return requests
|
|
|
|
async def around_scene(
|
|
self,
|
|
call: ServiceCall,
|
|
invoke: ServiceContinuation,
|
|
) -> ServiceResponse:
|
|
"""Capture desired states before HA filters or fans them out."""
|
|
# Every scene invocation owns its light reproduction, including opaque
|
|
# scenes that cannot provide a plan before their handler runs.
|
|
with self._suspend_batch():
|
|
if self.disabled or self.is_ours(call.context):
|
|
return await invoke()
|
|
if call.service == SERVICE_TURN_ON and not await self._authorized(
|
|
call.context,
|
|
target_entities(self.hass, dict(call.data)),
|
|
):
|
|
return await invoke()
|
|
requests = self._scene_requests(call)
|
|
batch = await self._prepare(call.context, requests)
|
|
if batch is None:
|
|
return await invoke()
|
|
return await self._run(batch, invoke)
|
|
|
|
async def around_light(
|
|
self,
|
|
call: ServiceCall,
|
|
invoke: ServiceContinuation,
|
|
) -> ServiceResponse:
|
|
"""Only HA's actual light reproduction task may join a scene batch."""
|
|
if self.disabled or self.is_ours(call.context):
|
|
with self._suspend_batch():
|
|
return await invoke()
|
|
targets = await self._light_targets(call)
|
|
if targets is None:
|
|
with self._suspend_batch():
|
|
return await invoke()
|
|
params = self._normalized_params(call.data[CONF_PARAMS])
|
|
batch = self.active.get()
|
|
if (
|
|
batch
|
|
and not batch.execution.closed
|
|
and self._is_scene_reproduction()
|
|
and call.context.id == batch.plan.context.id
|
|
and all(
|
|
target in batch.plan.requests
|
|
and not (
|
|
batch.plan.groups[target].members
|
|
if target in batch.plan.groups
|
|
else {target}
|
|
).intersection(batch.execution.accepted_members)
|
|
and batch.plan.requests[target] == {"state": STATE_ON, **params}
|
|
for target in targets
|
|
)
|
|
):
|
|
return await self._dispatch_light(batch, call, invoke, targets)
|
|
# Events and nested integration calls can inherit a scene's ContextVar
|
|
# and even reuse its HA Context. Their preprocessing and fallback must
|
|
# not see the older operation's claimed targets.
|
|
with self._suspend_batch():
|
|
requests = {
|
|
target: {
|
|
"state": (
|
|
STATE_OFF
|
|
if call.service == SERVICE_TOGGLE
|
|
and self.hass.states.is_state(target, STATE_ON)
|
|
else STATE_ON
|
|
),
|
|
**params,
|
|
}
|
|
for target in targets
|
|
}
|
|
batch = await self._prepare(call.context, requests)
|
|
if batch is None:
|
|
return await invoke()
|
|
|
|
return await self._run_light(batch, call, invoke, targets)
|
|
|
|
def _light_partitions(
|
|
self,
|
|
batch: NativeBatch,
|
|
targets: set[str],
|
|
) -> list[tuple[NativeBatch, set[str]]]:
|
|
"""Keep shared native broadcasts/reports together; separate unrelated targets.
|
|
|
|
Even a partially covered ancestor connects its requested descendants:
|
|
its aggregate activation claim must belong to one execution, never be
|
|
overwritten by two independently completing batches. Scenes retain their
|
|
original complete-request boundary and do not use this partitioning.
|
|
"""
|
|
plan = batch.plan
|
|
target_members = {}
|
|
for target in targets:
|
|
if candidate := plan.groups.get(target):
|
|
target_members[target] = candidate.members
|
|
continue
|
|
# An unowned/unsupported native target still broadcasts to its
|
|
# advertised members. This establishes connectivity only; it never
|
|
# grants adaptation eligibility or extends command/claim coverage.
|
|
state = self.hass.states.get(target)
|
|
members = state.attributes.get("group_entities", ()) if state else ()
|
|
target_members[target] = (
|
|
frozenset(members)
|
|
if isinstance(members, (list, tuple))
|
|
and members
|
|
and all(
|
|
isinstance(member, str) and member != target for member in members
|
|
)
|
|
else {target}
|
|
)
|
|
components = [{target} for target in sorted(targets)]
|
|
for group in plan.groups.values():
|
|
related = [
|
|
component
|
|
for component in components
|
|
if any(target_members[target] & group.members for target in component)
|
|
]
|
|
if len(related) > 1:
|
|
components = [c for c in components if c not in related]
|
|
components.append(set().union(*related))
|
|
if len(components) == 1:
|
|
return [(batch, targets)]
|
|
partitions = []
|
|
for component in sorted(components, key=min):
|
|
members = set().union(*(target_members[target] for target in component))
|
|
partitions.append(
|
|
(
|
|
NativeBatch(
|
|
NativePlan(
|
|
plan.context,
|
|
{
|
|
target: plan.requests[target]
|
|
for target in component
|
|
if target in plan.requests
|
|
},
|
|
{
|
|
name: group
|
|
for name, group in plan.groups.items()
|
|
if group.members & members
|
|
},
|
|
{
|
|
member: intent
|
|
for member, intent in plan.intents.items()
|
|
if member in members
|
|
},
|
|
),
|
|
),
|
|
component,
|
|
),
|
|
)
|
|
return partitions
|
|
|
|
async def _run_light(
|
|
self,
|
|
batch: NativeBatch,
|
|
call: ServiceCall,
|
|
invoke: ServiceContinuation,
|
|
targets: set[str],
|
|
) -> ServiceResponse:
|
|
"""A slow original handler cannot hold an unrelated group's adaptation."""
|
|
|
|
async def run(partition: NativeBatch, selected: set[str]) -> ServiceResponse:
|
|
async def dispatch() -> None:
|
|
await self._dispatch_light(partition, call, invoke, selected)
|
|
|
|
return await self._run(partition, dispatch)
|
|
|
|
partitions = self._light_partitions(batch, targets)
|
|
if len(partitions) == 1:
|
|
return await run(*partitions[0])
|
|
results = await asyncio.gather(
|
|
*(run(partition, selected) for partition, selected in partitions),
|
|
return_exceptions=True,
|
|
)
|
|
for result in results:
|
|
if isinstance(result, BaseException):
|
|
raise result
|
|
return None
|
|
|
|
@staticmethod
|
|
def _is_scene_reproduction() -> bool:
|
|
"""Recognize HA's direct light reproduction worker, not inherited events.
|
|
|
|
This private HA function is an optimization boundary only. If HA changes
|
|
its worker or task shape, independent light dispatch preserves behaviour
|
|
without grouping the scene. Neither shared Context nor timing is proof
|
|
that another service call belongs to the scene.
|
|
"""
|
|
task = asyncio.current_task()
|
|
worker = getattr(light_reproduce_state, "_async_reproduce_state", None)
|
|
code = getattr(worker, "__code__", None)
|
|
return bool(
|
|
code is not None
|
|
and task is not None
|
|
and getattr(task.get_coro(), "cr_code", None) is code,
|
|
)
|
|
|
|
@contextmanager
|
|
def _suspend_batch(self) -> Iterator[None]:
|
|
"""Run an independent request without inheriting an older scene plan."""
|
|
token = self.active.set(None)
|
|
try:
|
|
yield
|
|
finally:
|
|
self.active.reset(token)
|
|
|
|
async def _prepare(
|
|
self,
|
|
context: Context,
|
|
requests: LightRequests,
|
|
) -> NativeBatch | None:
|
|
if not await self._authorized(context, requests):
|
|
return None
|
|
# New intent supersedes pending work even after its profile or colour
|
|
# switch is disabled, or the target is removed from the native group.
|
|
self.invalidate(requests)
|
|
groups = self._refresh_groups(requests)
|
|
relevant = set(groups)
|
|
relevant.update(member for group in groups.values() for member in group.members)
|
|
requests = {key: value for key, value in requests.items() if key in relevant}
|
|
# HA resolves relative brightness using the live entity before applying
|
|
# defaults. Leave those commands in that handler rather than guessing.
|
|
if not requests or any(
|
|
{"brightness_step", "brightness_step_pct"}.intersection(request)
|
|
for request in requests.values()
|
|
):
|
|
return None
|
|
authorized_targets = set(requests)
|
|
authorized_targets.update(
|
|
member
|
|
for target in requests
|
|
if target in groups
|
|
for member in groups[target].members
|
|
)
|
|
if not await self._authorized(context, authorized_targets):
|
|
# Preserve HA's own permission errors and target-selection rules.
|
|
return None
|
|
intents: dict[str, NativeIntent] = {}
|
|
for target, request in requests.items():
|
|
if (
|
|
request["state"] != STATE_ON
|
|
or has_effect_attribute(request)
|
|
or "white" in request
|
|
):
|
|
continue
|
|
members = groups[target].members if target in groups else {target}
|
|
owners = {
|
|
attribute: owner
|
|
for group in groups.values()
|
|
if members <= group.members
|
|
for attribute, owner in group.owners.items()
|
|
}
|
|
for member in members:
|
|
effective_request = {
|
|
**request,
|
|
**self._effective_params(
|
|
target,
|
|
{
|
|
key: value
|
|
for key, value in request.items()
|
|
if key != "state"
|
|
},
|
|
),
|
|
}
|
|
intent = await self._member_intent(
|
|
member,
|
|
effective_request,
|
|
owners,
|
|
context,
|
|
target,
|
|
)
|
|
if intent is None:
|
|
continue
|
|
if member in intents:
|
|
# Overlapping group/member requests have no single colour intent.
|
|
return None
|
|
intents[member] = intent
|
|
on_members = {
|
|
member
|
|
for target, request in requests.items()
|
|
if request["state"] == STATE_ON
|
|
and request.get("brightness") != 0
|
|
and request.get("brightness_pct") != 0
|
|
for member in (groups[target].members if target in groups else {target})
|
|
}
|
|
if not intents or (
|
|
not any(intent.explicit for intent in intents.values())
|
|
and not any(
|
|
candidate.members <= on_members for candidate in groups.values()
|
|
)
|
|
):
|
|
# An isolated bare activation retains the established reactive path,
|
|
# including its combined axes and cancellation during adapt_delay.
|
|
return None
|
|
await self._plan_group_brightness(context, groups, intents)
|
|
intents = {
|
|
member: intent for member, intent in intents.items() if intent.attributes
|
|
}
|
|
return (
|
|
NativeBatch(NativePlan(context, requests, groups, intents))
|
|
if intents
|
|
else None
|
|
)
|
|
|
|
def _cover_activations(self, batch: NativeBatch) -> None:
|
|
"""Cover only reports of power changes this request actually intends."""
|
|
requests, groups = batch.plan.requests, batch.plan.groups
|
|
expected_activations = {
|
|
member
|
|
for target, request in requests.items()
|
|
if request["state"] == STATE_ON
|
|
and request.get("brightness") != 0
|
|
and (
|
|
target not in groups
|
|
or BRIGHTNESS_ATTRS.intersection(request)
|
|
or not _COLOUR_PARAMS.intersection(request)
|
|
)
|
|
for member in (groups[target].members if target in groups else {target})
|
|
if self.hass.states.is_state(member, STATE_OFF)
|
|
}
|
|
for member in batch.plan.intents.keys() & expected_activations:
|
|
self.coverage[member] = self._activation_claim(batch, member)
|
|
for group, candidate in groups.items():
|
|
members = candidate.members
|
|
if (
|
|
members & batch.plan.intents.keys() & expected_activations
|
|
or self._colour_only(requests.get(group, {}))
|
|
):
|
|
self.coverage[group] = self._activation_claim(batch, group)
|
|
|
|
def _activation_claim(self, batch: NativeBatch, entity: str) -> ActivationClaim:
|
|
"""Snapshot report semantics while the plan is available, before dispatch."""
|
|
plan = batch.plan
|
|
group = plan.groups.get(entity)
|
|
intents = (
|
|
[(entity, plan.intents[entity])]
|
|
if entity in plan.intents
|
|
else [
|
|
(member, plan.intents[member])
|
|
for member in (group.members if group else ())
|
|
if member in plan.intents
|
|
]
|
|
)
|
|
colour_only_group = group is not None and self._colour_only(
|
|
plan.requests.get(entity, {}),
|
|
)
|
|
brightness_supplied = (
|
|
bool(BRIGHTNESS_ATTRS.intersection(plan.requests.get(entity, {})))
|
|
or bool(plan.intents.get(entity) and plan.intents[entity].brightness)
|
|
or any(
|
|
entity in candidate.members
|
|
and BRIGHTNESS_ATTRS.intersection(plan.requests.get(target, {}))
|
|
for target, candidate in plan.groups.items()
|
|
)
|
|
or any(
|
|
entity == target
|
|
and candidate.members <= plan.intents.keys()
|
|
and all(plan.intents[member].brightness for member in candidate.members)
|
|
for target, candidate in plan.groups.items()
|
|
)
|
|
)
|
|
return ActivationClaim(
|
|
execution=batch.execution,
|
|
request_versions=tuple(
|
|
(target, self.versions.get(target, 0))
|
|
for target in sorted(
|
|
{entity} | (set(group.members) if group else set()),
|
|
)
|
|
),
|
|
adaptive_revisions=tuple(
|
|
AdaptiveRevision(policy.owner, member, policy.version, attribute)
|
|
for member, intent in intents
|
|
for attribute, policy in intent.policies.items()
|
|
if attribute not in intent.explicit
|
|
),
|
|
attributes=(
|
|
(
|
|
LightControlAttributes.BRIGHTNESS
|
|
if brightness_supplied
|
|
else LightControlAttributes.NONE
|
|
)
|
|
| (
|
|
LightControlAttributes.COLOR
|
|
if intents and all(intent.colour for _, intent in intents)
|
|
else LightControlAttributes.NONE
|
|
)
|
|
),
|
|
aggregate_only=(group is not None and entity not in plan.requests)
|
|
or colour_only_group,
|
|
required_context_id=plan.context.id if colour_only_group else None,
|
|
)
|
|
|
|
@staticmethod
|
|
def _colour_only(request: Parameters) -> bool:
|
|
"""A native colour update does not promise to power its OFF members."""
|
|
return bool(
|
|
_COLOUR_PARAMS.intersection(request),
|
|
) and not BRIGHTNESS_ATTRS.intersection(request)
|
|
|
|
async def _plan_group_brightness(
|
|
self,
|
|
context: Context,
|
|
groups: Mapping[str, NativeGroup],
|
|
intents: dict[str, NativeIntent],
|
|
) -> None:
|
|
"""Defer explicit brightness only when a complete group can carry it.
|
|
|
|
Other explicit brightness stays in HA's original payload. Computed
|
|
brightness remains in the plan so member fallbacks retain request timing.
|
|
"""
|
|
covered = set()
|
|
for group, candidate in groups.items():
|
|
members = candidate.members
|
|
if not members <= intents.keys():
|
|
continue
|
|
intent = intents[next(iter(members))].select(
|
|
LightControlAttributes.BRIGHTNESS,
|
|
)
|
|
owner = candidate.owners.get(LightControlAttributes.BRIGHTNESS)
|
|
if (
|
|
not intent.brightness
|
|
or owner is None
|
|
or not self._group_permitted(
|
|
owner,
|
|
group,
|
|
members,
|
|
LightControlAttributes.BRIGHTNESS,
|
|
)
|
|
):
|
|
continue
|
|
if not await self._authorized(context, [group]):
|
|
continue
|
|
if (
|
|
not self._compatible(group, intent.brightness)
|
|
or self._effective_params(group, intent.brightness) != intent.brightness
|
|
):
|
|
continue
|
|
if all(
|
|
intents[member].brightness == intent.brightness
|
|
and intents[member].policies[LightControlAttributes.BRIGHTNESS].owner
|
|
is owner
|
|
and self._ready_delay(
|
|
intents[member].select(LightControlAttributes.BRIGHTNESS),
|
|
)
|
|
== self._ready_delay(intent)
|
|
and self._compatible(member, intent.brightness)
|
|
for member in members
|
|
):
|
|
covered.update(members)
|
|
for member in intents.keys() - covered:
|
|
if LightControlAttributes.BRIGHTNESS in intents[member].explicit:
|
|
intents[member] = intents[member].select(LightControlAttributes.COLOR)
|
|
|
|
async def _member_intent(
|
|
self,
|
|
member: str,
|
|
request: Parameters,
|
|
owners: Mapping[LightControlAttributes, AdaptiveSwitch],
|
|
context: Context,
|
|
source: str,
|
|
) -> NativeIntent | None:
|
|
params = self._normalized_params(
|
|
{key: value for key, value in request.items() if key != "state"},
|
|
)
|
|
if params.get("brightness") == 0:
|
|
return None
|
|
explicit = LightControlAttributes.NONE
|
|
payloads = {}
|
|
policies = {}
|
|
for attribute, keys in (
|
|
(LightControlAttributes.COLOR, COLOR_ATTRS),
|
|
(LightControlAttributes.BRIGHTNESS, BRIGHTNESS_ATTRS),
|
|
):
|
|
switch = owners.get(attribute)
|
|
if switch is None or not self._permitted(switch, member, attribute):
|
|
continue
|
|
payload = {key: value for key, value in params.items() if key in keys}
|
|
if payload:
|
|
explicit |= attribute
|
|
if ATTR_TRANSITION in params:
|
|
payload[ATTR_TRANSITION] = params[ATTR_TRANSITION]
|
|
else:
|
|
if not self._can_compute_attribute(
|
|
switch,
|
|
member,
|
|
source,
|
|
attribute,
|
|
params,
|
|
):
|
|
continue
|
|
if switch._take_over_control and switch._adapt_only_on_bare_turn_on:
|
|
self.manager._mark_manual_control_if_non_bare_turn_on(
|
|
member,
|
|
params,
|
|
)
|
|
if not self._permitted(
|
|
switch,
|
|
member,
|
|
attribute,
|
|
) or attribute not in self.manager.get_adaption_control_attributes(
|
|
switch,
|
|
source,
|
|
):
|
|
continue
|
|
data = await switch.prepare_adaptation_data(
|
|
member,
|
|
params.get(ATTR_TRANSITION, switch.initial_transition),
|
|
adapt_brightness=attribute == LightControlAttributes.BRIGHTNESS,
|
|
adapt_color=attribute == LightControlAttributes.COLOR,
|
|
force=True,
|
|
context=context,
|
|
)
|
|
if data is None:
|
|
continue
|
|
payload = await data.next_service_call_data()
|
|
if not payload:
|
|
continue
|
|
payload.pop(ATTR_ENTITY_ID, None)
|
|
payloads[attribute] = payload
|
|
policies[attribute] = AttributePolicy(
|
|
switch,
|
|
self.adaptive_versions.get(switch, {}).get(member, 0),
|
|
)
|
|
if not payloads:
|
|
return None
|
|
return NativeIntent(
|
|
payloads.get(LightControlAttributes.COLOR, {}),
|
|
explicit,
|
|
self.versions.get(member, 0),
|
|
source,
|
|
policies,
|
|
payloads.get(LightControlAttributes.BRIGHTNESS, {}),
|
|
)
|
|
|
|
def _can_compute_attribute(
|
|
self,
|
|
switch: AdaptiveSwitch,
|
|
member: str,
|
|
source: str,
|
|
attribute: LightControlAttributes,
|
|
params: Parameters,
|
|
) -> bool:
|
|
"""Missing scene brightness does not change already-ON members implicitly."""
|
|
member_on = self.hass.states.is_state(member, STATE_ON)
|
|
if switch._only_once and member_on:
|
|
return False
|
|
if attribute != LightControlAttributes.BRIGHTNESS:
|
|
return True
|
|
state = self.hass.states.get(source)
|
|
activating_group = state is not None and any(
|
|
self.hass.states.is_state(target, STATE_OFF)
|
|
for target in state.attributes.get("group_entities", ())
|
|
)
|
|
colour_only_group = bool(
|
|
state
|
|
and state.attributes.get("group_entities")
|
|
and COLOR_ATTRS.intersection(params),
|
|
)
|
|
return not colour_only_group and (not member_on or activating_group)
|
|
|
|
def _permitted(
|
|
self,
|
|
switch: AdaptiveSwitch,
|
|
member: str,
|
|
attributes: LightControlAttributes,
|
|
) -> bool:
|
|
state = self.hass.states.get(member)
|
|
return (
|
|
not self.disabled
|
|
and not switch._removed
|
|
and switch.is_on
|
|
and member in switch._tracked_lights
|
|
and state is not None
|
|
and state.state in (STATE_ON, STATE_OFF)
|
|
and attributes
|
|
in self.manager.get_adaption_control_attributes(switch, member)
|
|
)
|
|
|
|
def _group_permitted(
|
|
self,
|
|
switch: AdaptiveSwitch,
|
|
group: str,
|
|
members: frozenset[str],
|
|
attributes: LightControlAttributes,
|
|
) -> bool:
|
|
"""Apply both group and member guards to configured or derived groups."""
|
|
return all(
|
|
self._permitted(switch, member, attributes) for member in members
|
|
) and attributes in self.manager.get_adaption_control_attributes(
|
|
switch,
|
|
group,
|
|
)
|
|
|
|
async def _dispatch_light(
|
|
self,
|
|
batch: NativeBatch,
|
|
call: ServiceCall,
|
|
invoke: ServiceContinuation,
|
|
targets: set[str],
|
|
) -> None:
|
|
"""Keep scene error recovery behind every observed child dispatch."""
|
|
completion = self.hass.loop.create_future()
|
|
batch.execution.children.add(completion)
|
|
try:
|
|
await self._dispatch_light_targets(batch, call, invoke, targets)
|
|
finally:
|
|
batch.execution.children.discard(completion)
|
|
if not completion.done():
|
|
completion.set_result(None)
|
|
|
|
async def _dispatch_light_targets(
|
|
self,
|
|
batch: NativeBatch,
|
|
call: ServiceCall,
|
|
invoke: ServiceContinuation,
|
|
targets: set[str],
|
|
) -> None:
|
|
# Keep HA's independent-target concurrency, including unmanaged targets.
|
|
# Only covered members may defer attributes; each other original payload is
|
|
# dispatched intact and is never retried after an error.
|
|
covered = set(batch.plan.intents)
|
|
covered.update(
|
|
group
|
|
for group, candidate in batch.plan.groups.items()
|
|
if candidate.members <= covered
|
|
)
|
|
params = dict(call.data[CONF_PARAMS])
|
|
ordered_targets = sorted(targets)
|
|
|
|
async def dispatch(target: str) -> None:
|
|
members = (
|
|
batch.plan.groups[target].members
|
|
if target in batch.plan.groups
|
|
else {target}
|
|
)
|
|
if any(
|
|
batch.plan.intents[member].version != self.versions.get(member, 0)
|
|
for member in members & batch.plan.intents.keys()
|
|
):
|
|
return
|
|
decision = self._original_dispatch_decision(
|
|
batch.plan,
|
|
target,
|
|
params,
|
|
covered,
|
|
)
|
|
batch.execution.forward(members, decision.forwarded)
|
|
if decision.params is None:
|
|
batch.execution.accept(members & batch.plan.intents.keys())
|
|
return
|
|
await invoke(
|
|
ServiceCall(
|
|
self.hass,
|
|
call.domain,
|
|
call.service,
|
|
{
|
|
**{
|
|
key: value
|
|
for key, value in call.data.items()
|
|
if key not in cv.ENTITY_SERVICE_FIELDS
|
|
},
|
|
ATTR_ENTITY_ID: [target],
|
|
CONF_PARAMS: dict(decision.params),
|
|
},
|
|
context=call.context,
|
|
return_response=call.return_response,
|
|
),
|
|
)
|
|
batch.execution.accept(members & batch.plan.intents.keys())
|
|
|
|
results = await asyncio.gather(
|
|
*(dispatch(target) for target in ordered_targets),
|
|
return_exceptions=True,
|
|
)
|
|
for target, result in zip(ordered_targets, results, strict=True):
|
|
if isinstance(result, BaseException):
|
|
members = (
|
|
batch.plan.groups[target].members
|
|
if target in batch.plan.groups
|
|
else {target}
|
|
)
|
|
self._dispatch_failed(batch, members, result)
|
|
|
|
def _dispatch_failed(
|
|
self,
|
|
batch: NativeBatch,
|
|
members: Iterable[str],
|
|
error: BaseException,
|
|
) -> None:
|
|
"""Keep independent targets eligible without retrying failed commands."""
|
|
batch.execution.errors.append(error)
|
|
self._retire_members(batch, members)
|
|
|
|
def _retire_members(self, batch: NativeBatch, members: Iterable[str]) -> None:
|
|
"""Discard pending attributes and activation claims only for unsuccessful members."""
|
|
members = set(members)
|
|
batch.execution.retire(members)
|
|
groups = {
|
|
group
|
|
for group, candidate in batch.plan.groups.items()
|
|
if candidate.members.intersection(members)
|
|
}
|
|
for entity in (*members, *groups):
|
|
claim = self.coverage.get(entity)
|
|
if claim is not None and claim.execution is batch.execution:
|
|
self.coverage.pop(entity)
|
|
|
|
def _original_dispatch_decision(
|
|
self,
|
|
plan: NativePlan,
|
|
target: str,
|
|
params: Parameters,
|
|
covered: set[str],
|
|
) -> DispatchDecision:
|
|
"""Defer only planned attributes, retaining native commands and HA defaults."""
|
|
effective = self._effective_params(target, params)
|
|
supplied = (
|
|
LightControlAttributes.COLOR
|
|
if _COLOUR_PARAMS.intersection(effective)
|
|
else LightControlAttributes.NONE
|
|
) | (
|
|
LightControlAttributes.BRIGHTNESS
|
|
if BRIGHTNESS_ATTRS.intersection(effective)
|
|
else LightControlAttributes.NONE
|
|
)
|
|
if target not in covered or target in plan.groups:
|
|
return DispatchDecision(params, supplied)
|
|
intent = plan.intents[target]
|
|
deferred = intent.attributes & supplied
|
|
state = self.hass.states.get(target)
|
|
modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, ()) if state else ()
|
|
if (
|
|
ATTR_COLOR_TEMP_KELVIN in effective
|
|
and "brightness" in effective
|
|
and ColorMode.COLOR_TEMP not in modes
|
|
and ColorMode.RGBWW in modes
|
|
):
|
|
return DispatchDecision(params, supplied)
|
|
keys = set()
|
|
if LightControlAttributes.COLOR in deferred:
|
|
keys.update(_COLOUR_PARAMS)
|
|
if LightControlAttributes.BRIGHTNESS in deferred:
|
|
keys.update(BRIGHTNESS_ATTRS)
|
|
remaining = {key: value for key, value in effective.items() if key not in keys}
|
|
if not deferred or self._effective_params(target, remaining) != remaining:
|
|
return DispatchDecision(params, supplied)
|
|
for attribute in (
|
|
LightControlAttributes.BRIGHTNESS,
|
|
LightControlAttributes.COLOR,
|
|
):
|
|
selected = intent.select(attribute)
|
|
if (
|
|
attribute in deferred
|
|
and self._effective_params(target, selected.parameters)
|
|
!= selected.parameters
|
|
):
|
|
return DispatchDecision(params, supplied)
|
|
# Deferred brightness supplies power itself, including for OFF members.
|
|
if set(remaining) <= {ATTR_TRANSITION} and (
|
|
LightControlAttributes.BRIGHTNESS in deferred
|
|
or self.hass.states.is_state(target, STATE_ON)
|
|
):
|
|
return DispatchDecision(None, supplied & ~deferred)
|
|
return DispatchDecision(remaining, supplied & ~deferred)
|
|
|
|
async def _run(
|
|
self,
|
|
batch: NativeBatch,
|
|
invoke: Callable[[], Awaitable[ServiceResponse]],
|
|
) -> ServiceResponse:
|
|
self._cover_activations(batch)
|
|
token = self.active.set(batch)
|
|
invocation_error = None
|
|
try:
|
|
try:
|
|
result = await invoke()
|
|
except Exception as error:
|
|
# HA scene reproduction may raise for an unrelated entity while
|
|
# healthy light handlers are still running. Settle those accepted
|
|
# commands before flushing their deferred attributes; never retry a
|
|
# failed or unobserved target. Cancellation bypasses this recovery.
|
|
invocation_error = error
|
|
while batch.execution.children:
|
|
await asyncio.gather(*tuple(batch.execution.children))
|
|
if not batch.execution.accepted_members:
|
|
raise
|
|
self._retire_members(
|
|
batch,
|
|
batch.plan.intents.keys() - batch.execution.accepted_members,
|
|
)
|
|
await self._flush(batch)
|
|
except BaseException:
|
|
batch.execution.abort()
|
|
raise
|
|
else:
|
|
if invocation_error is not None:
|
|
raise invocation_error
|
|
if batch.execution.errors:
|
|
raise batch.execution.errors[0]
|
|
return result
|
|
finally:
|
|
batch.execution.close()
|
|
self.active.reset(token)
|
|
|
|
def _compatible(self, entity: str, parameters: Parameters) -> bool:
|
|
state = self.hass.states.get(entity)
|
|
if state is None or state.state not in (STATE_ON, STATE_OFF):
|
|
return False
|
|
modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, [])
|
|
for mode, pair in COLOR_MODE_TO_ATTRIBUTE.items():
|
|
if pair.parameter in parameters and mode not in modes:
|
|
return False
|
|
if "brightness" in parameters and not brightness_supported(set(modes)):
|
|
return False
|
|
kelvin = parameters.get(ATTR_COLOR_TEMP_KELVIN)
|
|
if kelvin is not None and not (
|
|
state.attributes.get("min_color_temp_kelvin", kelvin)
|
|
<= kelvin
|
|
<= state.attributes.get("max_color_temp_kelvin", kelvin)
|
|
):
|
|
return False
|
|
return not parameters.get(ATTR_TRANSITION) or bool(
|
|
state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
|
& LightEntityFeature.TRANSITION,
|
|
)
|
|
|
|
async def _flush(self, batch: NativeBatch) -> None:
|
|
await self._dispatch_pending(batch, batch.plan.intents, initial_delay=True)
|
|
|
|
def _pending_intents(
|
|
self,
|
|
batch: NativeBatch,
|
|
intents: Mapping[str, NativeIntent],
|
|
) -> dict[str, NativeIntent]:
|
|
"""Recheck request identity and each attribute's independent ownership."""
|
|
self._refresh_groups(batch.plan.groups)
|
|
pending = {}
|
|
for member, intent in intents.items():
|
|
if (
|
|
intent.version != self.versions.get(member, 0)
|
|
or member in batch.execution.failed_members
|
|
):
|
|
continue
|
|
attributes = intent.attributes & ~batch.execution.forwarded.get(
|
|
member,
|
|
LightControlAttributes.NONE,
|
|
)
|
|
for attribute in (
|
|
LightControlAttributes.BRIGHTNESS,
|
|
LightControlAttributes.COLOR,
|
|
):
|
|
if (
|
|
attribute in attributes
|
|
and attribute not in intent.explicit
|
|
and not self._intent_permitted(member, intent, attribute)
|
|
):
|
|
attributes &= ~attribute
|
|
if attributes:
|
|
pending[member] = intent.select(attributes)
|
|
return pending
|
|
|
|
@staticmethod
|
|
def _ready_delay(intent: NativeIntent) -> float:
|
|
"""Only computed attributes wait for adaptive delay."""
|
|
return max(
|
|
(
|
|
policy.owner._adapt_delay
|
|
for attribute, policy in intent.policies.items()
|
|
if attribute not in intent.explicit
|
|
),
|
|
default=0,
|
|
)
|
|
|
|
@staticmethod
|
|
def _combinable(intent: NativeIntent) -> bool:
|
|
"""A combined command must have one transition and one timing policy."""
|
|
return not (intent.colour and intent.brightness) or (
|
|
intent.brightness_wait is None
|
|
and len({policy.owner for policy in intent.policies.values()}) == 1
|
|
and intent.colour.get(ATTR_TRANSITION)
|
|
== intent.brightness.get(ATTR_TRANSITION)
|
|
and intent.explicit in (LightControlAttributes.NONE, intent.attributes)
|
|
)
|
|
|
|
@staticmethod
|
|
def _split_intent(intent: NativeIntent) -> NativeIntent:
|
|
"""Retain the configured sequence even when its attributes use different targets."""
|
|
if (
|
|
intent.attributes != LightControlAttributes.ALL
|
|
or intent.explicit
|
|
or not NativeGroupPlanner._combinable(intent)
|
|
or not intent.switch._separate_turn_on_commands
|
|
):
|
|
return intent
|
|
commands, wait = adaptation_command_sequence(
|
|
dict(intent.parameters),
|
|
intent.colour.get(ATTR_TRANSITION),
|
|
intent.switch._send_split_delay / 1000.0,
|
|
split=True,
|
|
)
|
|
return replace(
|
|
intent,
|
|
brightness=commands[0],
|
|
colour=commands[1],
|
|
brightness_wait=wait,
|
|
)
|
|
|
|
async def _reserve_dispatches(
|
|
self,
|
|
batch: NativeBatch,
|
|
intents: Mapping[str, NativeIntent],
|
|
) -> list[tuple[str, dict[str, NativeIntent]]]:
|
|
"""Reserve shared attributes once, retaining each member's other attributes."""
|
|
# Validate group eligibility against the actual split-phase payloads.
|
|
pending = {
|
|
member: self._split_intent(intent)
|
|
for member, intent in self._pending_intents(batch, intents).items()
|
|
}
|
|
dispatches = []
|
|
groups = sorted(
|
|
batch.plan.groups.items(),
|
|
key=lambda item: (-len(item[1].members), item[0]),
|
|
)
|
|
for attributes in (
|
|
LightControlAttributes.ALL,
|
|
LightControlAttributes.BRIGHTNESS,
|
|
LightControlAttributes.COLOR,
|
|
):
|
|
for group, candidate in groups:
|
|
selected = {
|
|
member: intent.select(attributes)
|
|
for member, intent in pending.items()
|
|
if attributes in intent.attributes
|
|
}
|
|
if await self._can_send_group(batch, selected, group, candidate):
|
|
dispatches.append(
|
|
(
|
|
group,
|
|
{member: selected[member] for member in candidate.members},
|
|
),
|
|
)
|
|
for member in candidate.members:
|
|
pending[member] = pending[member].select(
|
|
pending[member].attributes & ~attributes,
|
|
)
|
|
for member, intent in pending.items():
|
|
if not intent.attributes:
|
|
continue
|
|
if self._combinable(intent):
|
|
dispatches.append((member, {member: intent}))
|
|
else:
|
|
dispatches.extend(
|
|
(member, {member: intent.select(attribute)})
|
|
for attribute in (
|
|
LightControlAttributes.BRIGHTNESS,
|
|
LightControlAttributes.COLOR,
|
|
)
|
|
if attribute in intent.attributes
|
|
)
|
|
|
|
# Explicit attributes are due immediately. A computed overlapping
|
|
# attribute must not delay them; equal-time phases retain brightness first.
|
|
def dispatch_order(item):
|
|
intent = next(iter(item[1].values()))
|
|
return self._ready_delay(intent), not intent.brightness
|
|
|
|
dispatches.sort(key=dispatch_order)
|
|
return dispatches
|
|
|
|
async def _dispatch_pending(
|
|
self,
|
|
batch: NativeBatch,
|
|
intents: Mapping[str, NativeIntent],
|
|
*,
|
|
initial_delay: bool,
|
|
) -> None:
|
|
"""Sequence overlapping attributes while unrelated groups proceed concurrently."""
|
|
dispatches = await self._reserve_dispatches(batch, intents)
|
|
predecessors = {}
|
|
tasks = []
|
|
|
|
async def execute(target, members, dependencies, adaptation_delay):
|
|
if dependencies:
|
|
await asyncio.gather(*(task for task, _ in dependencies))
|
|
results = await asyncio.gather(
|
|
self._execute_dispatch(batch, target, members, adaptation_delay),
|
|
return_exceptions=True,
|
|
)
|
|
if isinstance(results[0], BaseException):
|
|
self._dispatch_failed(batch, members, results[0])
|
|
|
|
for target, members in dispatches:
|
|
dependencies = {predecessors[m] for m in members if m in predecessors}
|
|
already_waited = min(
|
|
(predecessors[m][1] if m in predecessors else 0 for m in members),
|
|
default=0,
|
|
)
|
|
intent = next(iter(members.values()))
|
|
required_delay = self._ready_delay(intent)
|
|
adaptation_delay = (
|
|
max(0, required_delay - already_waited) if initial_delay else 0
|
|
)
|
|
task = self.hass.async_create_task(
|
|
execute(target, members, dependencies, adaptation_delay),
|
|
)
|
|
tasks.append(task)
|
|
waited = max(already_waited, required_delay)
|
|
predecessors.update(dict.fromkeys(members, (task, waited)))
|
|
await asyncio.gather(*tasks)
|
|
|
|
async def _execute_dispatch(
|
|
self,
|
|
batch: NativeBatch,
|
|
target: str,
|
|
intents: Mapping[str, NativeIntent],
|
|
adaptation_delay: float,
|
|
) -> None:
|
|
"""Execute one sequence; replan only its reserved members after each wait."""
|
|
intent = next(iter(intents.values()))
|
|
delay = (
|
|
intent.brightness_wait
|
|
if intent.brightness_wait is not None
|
|
else adaptation_delay
|
|
)
|
|
if delay:
|
|
await asyncio.sleep(delay)
|
|
pending = self._pending_intents(
|
|
batch,
|
|
{
|
|
member: replace(value, brightness_wait=None)
|
|
for member, value in intents.items()
|
|
},
|
|
)
|
|
if not pending:
|
|
return
|
|
candidate = batch.plan.groups.get(target)
|
|
if candidate is not None and not await self._can_send_group(
|
|
batch,
|
|
pending,
|
|
target,
|
|
candidate,
|
|
):
|
|
# Membership, ownership or permissions may have changed while waiting.
|
|
# Reuse the same reservation policy for safe subgroups/member fallbacks.
|
|
await self._dispatch_pending(batch, pending, initial_delay=False)
|
|
return
|
|
intent = next(iter(pending.values()))
|
|
members = candidate.members if candidate is not None else ()
|
|
await self._send(target, intent, batch.plan.context, members)
|
|
|
|
async def _can_send_group(
|
|
self,
|
|
batch: NativeBatch,
|
|
pending: Mapping[str, NativeIntent],
|
|
group: str,
|
|
candidate: NativeGroup,
|
|
) -> bool:
|
|
"""Revalidate the complete broadcast before dispatch and after split delays."""
|
|
members = candidate.members
|
|
current_groups = self._refresh_groups(batch.plan.groups)
|
|
state = self.hass.states.get(group)
|
|
current = current_groups.get(group)
|
|
if (
|
|
current is None
|
|
or current.members != members
|
|
or not members <= pending.keys()
|
|
or state is None
|
|
):
|
|
return False
|
|
intent = pending[next(iter(members))]
|
|
if any(
|
|
current.owners.get(attribute) is not candidate.owners.get(attribute)
|
|
for attribute in intent.policies
|
|
):
|
|
return False
|
|
return (
|
|
set(state.attributes.get("group_entities", [])) == members
|
|
and all(
|
|
attribute in candidate.owners
|
|
and self._group_permitted(
|
|
candidate.owners[attribute],
|
|
group,
|
|
members,
|
|
attribute,
|
|
)
|
|
for attribute in intent.policies
|
|
)
|
|
and await self._authorized(batch.plan.context, [group])
|
|
and self._combinable(intent)
|
|
and self._compatible(group, intent.parameters)
|
|
and self._effective_params(group, intent.parameters) == intent.parameters
|
|
and all(
|
|
pending[member].version == self.versions.get(member, 0)
|
|
and pending[member].explicit == intent.explicit
|
|
and pending[member].brightness_wait == intent.brightness_wait
|
|
and pending[member].colour == intent.colour
|
|
and self._ready_delay(pending[member]) == self._ready_delay(intent)
|
|
and pending[member].brightness == intent.brightness
|
|
and all(
|
|
pending[member].policies[attribute].owner
|
|
is candidate.owners.get(attribute)
|
|
and self._permitted(
|
|
pending[member].policies[attribute].owner,
|
|
member,
|
|
attribute,
|
|
)
|
|
for attribute in intent.policies
|
|
)
|
|
and all(
|
|
attribute in pending[member].explicit
|
|
or self._intent_permitted(member, pending[member], attribute)
|
|
for attribute in (
|
|
LightControlAttributes.BRIGHTNESS,
|
|
LightControlAttributes.COLOR,
|
|
)
|
|
if attribute in intent.attributes
|
|
)
|
|
and self._combinable(pending[member])
|
|
and self._compatible(member, intent.parameters)
|
|
for member in members
|
|
)
|
|
)
|
|
|
|
def _intent_permitted(
|
|
self,
|
|
member: str,
|
|
intent: NativeIntent,
|
|
attribute: LightControlAttributes,
|
|
) -> bool:
|
|
"""Computed fallback retains source and member guards for its own attribute."""
|
|
policy = intent.policies[attribute]
|
|
return (
|
|
policy.version
|
|
== self.adaptive_versions.get(policy.owner, {}).get(member, 0)
|
|
and self._permitted(policy.owner, member, attribute)
|
|
and attribute
|
|
in self.manager.get_adaption_control_attributes(
|
|
policy.owner,
|
|
intent.source,
|
|
)
|
|
)
|
|
|
|
async def _send(
|
|
self,
|
|
entity: str,
|
|
intent: NativeIntent,
|
|
parent: Context,
|
|
members: Iterable[str] = (),
|
|
) -> None:
|
|
context = self.manager.create_context("native_group", parent=parent)
|
|
context.user_id = parent.user_id
|
|
policy = intent.policies.get(LightControlAttributes.BRIGHTNESS)
|
|
brightness = (
|
|
intent.brightness
|
|
if (
|
|
LightControlAttributes.BRIGHTNESS in intent.explicit
|
|
or (
|
|
policy is not None
|
|
and policy.owner.is_on
|
|
and LightControlAttributes.BRIGHTNESS
|
|
in self.manager.get_adaption_control_attributes(
|
|
policy.owner,
|
|
entity,
|
|
)
|
|
and all(
|
|
LightControlAttributes.BRIGHTNESS
|
|
in self.manager.get_adaption_control_attributes(
|
|
policy.owner,
|
|
member,
|
|
)
|
|
for member in (*members, intent.source)
|
|
)
|
|
)
|
|
)
|
|
else {}
|
|
)
|
|
if not brightness and not intent.colour:
|
|
return
|
|
data = {ATTR_ENTITY_ID: entity, **brightness, **intent.colour}
|
|
self.manager.last_service_data[entity] = {
|
|
**self.manager.last_service_data.get(entity, {}),
|
|
**data,
|
|
}
|
|
await self.hass.services.async_call(
|
|
"light",
|
|
SERVICE_TURN_ON,
|
|
data,
|
|
blocking=True,
|
|
context=context,
|
|
)
|