mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-10-30 15:07:53 +01:00
227 lines
7.4 KiB
Python
227 lines
7.4 KiB
Python
"""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
|