chore: initial baseline before refactoring

Initial snapshot of Home Assistant config (HAOS 2026.4.4)
prior to dashboard restructuring and helper YAML migration.

Pre-commit cleanup applied:
- Removed blueprints/switch_manager/ (9.4 MB stale, component already gone)
- Removed .storage/switch_manager
- Removed home-assistant.log.{1,old,fault}
- Removed zigbee2mqtt/configuration_backup_v{1..4}.yaml
- Removed zigbee2mqtt/database.db.tmp.2024-09-27 (1.5y old)
- Created empty themes/ to satisfy configuration.yaml include

.gitignore uses allowlist strategy for .storage/ to keep
all tokens, keys, and PINs out of version control.
This commit is contained in:
2026-05-02 14:24:53 +02:00
commit 5f01411780
149 changed files with 47886 additions and 0 deletions
@@ -0,0 +1,18 @@
from cx_core.controller import Controller, action
from cx_core.release_hold_controller import ReleaseHoldController
from cx_core.type.cover_controller import CoverController
from cx_core.type.light_controller import LightController
from cx_core.type.media_player_controller import MediaPlayerController
from cx_core.type.switch_controller import SwitchController
from cx_core.type.z2m_light_controller import Z2MLightController
__all__ = [
"Controller",
"ReleaseHoldController",
"LightController",
"Z2MLightController",
"MediaPlayerController",
"SwitchController",
"CoverController",
"action",
]
@@ -0,0 +1,46 @@
from typing import TYPE_CHECKING
from cx_const import ActionEvent, CustomAction, CustomActions
from cx_core.action_type.base import ActionType
from cx_core.action_type.call_service_action_type import CallServiceActionType
from cx_core.action_type.delay_action_type import DelayActionType
from cx_core.action_type.predefined_action_type import PredefinedActionType
from cx_core.action_type.scene_action_type import SceneActionType
if TYPE_CHECKING:
from cx_core import Controller
ActionsMapping = dict[ActionEvent, list[ActionType]]
action_type_mapping: dict[str, type[ActionType]] = {
"action": PredefinedActionType,
"service": CallServiceActionType,
"scene": SceneActionType,
"delay": DelayActionType,
}
def parse_actions(controller: "Controller", data: CustomActions) -> list[ActionType]:
actions: CustomActions
if isinstance(data, (list, tuple)):
actions = list(data)
else:
actions = [data]
return [_parse_action(controller, action) for action in actions]
def _parse_action(controller: "Controller", action: CustomAction) -> ActionType:
if isinstance(action, str):
return PredefinedActionType(controller, {"action": action})
try:
return next(
action_type(controller, action)
for key in action
for action_type_key, action_type in action_type_mapping.items()
if key == action_type_key
)
except StopIteration:
raise ValueError(
f"Not able to parse `{action}`. Available keys are: {list(action_type_mapping.keys())}"
)
@@ -0,0 +1,25 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from cx_core.integration import EventData
if TYPE_CHECKING:
from cx_core import Controller
class ActionType(ABC):
controller: "Controller"
def __init__(self, controller: "Controller", action: dict[str, Any]) -> None:
self.controller = controller
self.initialize(**action)
def initialize(self, **kwargs: Any) -> None:
pass
@abstractmethod
async def run(self, extra: EventData | None = None) -> None:
raise NotImplementedError
def __str__(self) -> str:
return f"{self.__class__.__name__}"
@@ -0,0 +1,52 @@
from typing import TYPE_CHECKING, Any, cast
from cx_core.action_type.base import ActionType
from cx_core.integration import EventData
if TYPE_CHECKING:
from cx_core.type_controller import Entity, TypeController
class CallServiceActionType(ActionType):
service: str
# Priority order for entity_id:
# - Inside data
# - In the same level as "service"
# - From the main config if the domain matches
entity_id: str | None
data: dict[str, Any]
def initialize(self, **kwargs: Any) -> None:
self.service = kwargs["service"]
self.data = kwargs.get("data", {})
self.entity_id = self.data.get("entity_id") or kwargs.get("entity_id")
if (
self.entity_id is None
and self._check_controller_isinstance_type_controller()
):
type_controller = cast("TypeController[Entity]", self.controller)
if self._get_service_domain(self.service) in type_controller.domains:
self.entity_id = type_controller.entity.name
if "entity_id" in self.data:
del self.data["entity_id"]
def _check_controller_isinstance_type_controller(self) -> bool:
# This is checked dynamically without the isinstance to avoid
# circular dependency
class_names = [c.__name__ for c in type(self.controller).mro()]
return "TypeController" in class_names
def _get_service_domain(self, service: str) -> str:
return service.replace(".", "/").split("/")[0]
async def run(self, extra: EventData | None = None) -> None:
if self.entity_id:
await self.controller.call_service(
self.service, entity_id=self.entity_id, **self.data
)
else:
await self.controller.call_service(self.service, **self.data)
def __str__(self) -> str:
return f"Service ({self.service})"
@@ -0,0 +1,17 @@
from typing import Any
from cx_core.action_type.base import ActionType
from cx_core.integration import EventData
class DelayActionType(ActionType):
delay: int
def initialize(self, **kwargs: Any) -> None:
self.delay = kwargs["delay"]
async def run(self, extra: EventData | None = None) -> None:
await self.controller.sleep(self.delay)
def __str__(self) -> str:
return f"Delay ({self.delay} seconds)"
@@ -0,0 +1,121 @@
import inspect
from typing import Any
from cx_const import (
ActionFunction,
ActionFunctionWithParams,
ActionParams,
PredefinedActionsMapping,
TypeAction,
)
from cx_core.action_type.base import ActionType
from cx_core.integration import EventData
def _get_action(action_value: TypeAction) -> ActionFunctionWithParams:
if isinstance(action_value, tuple):
return action_value
else:
return (action_value, tuple())
def _get_arguments(
action: ActionFunction,
args: ActionParams,
predefined_action_kwargs: dict[str, Any],
extra: EventData | None,
) -> tuple[ActionParams, dict[str, Any]]:
action_parameters = inspect.signature(action).parameters
action_parameters_without_extra = {
key: param for key, param in action_parameters.items() if key != "extra"
}
action_parameters_without_default = {
key: param
for key, param in action_parameters.items()
if param.default is inspect.Signature.empty
}
action_args: dict[str, Any] = dict(
zip(action_parameters_without_extra.keys(), args)
) # ControllerX args
action_positional_args = set(action_args.keys())
action_args.update(predefined_action_kwargs) # User args
action_args.update({"extra": extra} if "extra" in action_parameters else {})
action_args = {
key: value for key, value in action_args.items() if key in action_parameters
}
if len(set(action_parameters_without_default).difference(action_args)) != 0:
error_msg = [
f"`{action.__name__}` action is missing some parameters. Parameters available:"
]
for key, param in action_parameters_without_extra.items():
if hasattr(param.annotation, "__name__"):
attr_msg = f" {key}: {param.annotation.__name__}"
else:
attr_msg = f" {key}:"
if param.default is not inspect.Signature.empty:
attr_msg += f" [default: {param.default}]"
if key in action_args:
attr_msg += f" (value given: {action_args[key]})"
elif param.default is inspect.Signature.empty:
attr_msg += " (missing)"
error_msg.append(attr_msg)
raise ValueError("\n".join(error_msg))
positional = tuple(
value for key, value in action_args.items() if key in action_positional_args
)
action_args = {
key: value
for key, value in action_args.items()
if key not in action_positional_args
}
return positional, action_args
class PredefinedActionType(ActionType):
predefined_action_key: str
predefined_action_kwargs: dict[str, Any]
predefined_actions_mapping: PredefinedActionsMapping
def _raise_action_key_not_found(
self, predefined_action_key: str, predefined_actions: PredefinedActionsMapping
) -> None:
raise ValueError(
f"`{predefined_action_key}` is not one of the predefined actions. "
f"Available actions are: {list(predefined_actions.keys())}."
"See more in: https://xaviml.github.io/controllerx/advanced/custom-controllers"
)
def initialize(self, **kwargs: Any) -> None:
self.predefined_action_key = kwargs.pop("action")
self.predefined_action_kwargs = kwargs
self.predefined_actions_mapping = (
self.controller.get_predefined_actions_mapping()
)
if not self.predefined_actions_mapping:
raise ValueError(
f"Cannot use predefined actions for `{self.controller.__class__.__name__}` class."
)
if (
not self.controller.contains_templating(self.predefined_action_key)
and self.predefined_action_key not in self.predefined_actions_mapping
):
self._raise_action_key_not_found(
self.predefined_action_key, self.predefined_actions_mapping
)
async def run(self, extra: EventData | None = None) -> None:
action_key = await self.controller.render_value(self.predefined_action_key)
if action_key not in self.predefined_actions_mapping:
self._raise_action_key_not_found(
action_key, self.predefined_actions_mapping
)
action, args = _get_action(self.predefined_actions_mapping[action_key])
positional, action_args = _get_arguments(
action, args, self.predefined_action_kwargs, extra
)
await action(*positional, **action_args)
def __str__(self) -> str:
return f"Predefined ({self.predefined_action_key})"
@@ -0,0 +1,17 @@
from typing import Any
from cx_core.action_type.base import ActionType
from cx_core.integration import EventData
class SceneActionType(ActionType):
scene: str
def initialize(self, **kwargs: Any) -> None:
self.scene = kwargs["scene"]
async def run(self, extra: EventData | None = None) -> None:
await self.controller.call_service("scene/turn_on", entity_id=self.scene)
def __str__(self) -> str:
return f"Scene ({self.scene})"
@@ -0,0 +1,81 @@
Color = tuple[float, float]
Colors = list[Color]
# These are the 24 colors that appear in the circle color of home assistant
default_color_wheel = [
(0.701, 0.299),
(0.667, 0.284),
(0.581, 0.245),
(0.477, 0.196),
(0.385, 0.155),
(0.301, 0.116),
(0.217, 0.077),
(0.157, 0.05),
(0.136, 0.04),
(0.137, 0.065),
(0.141, 0.137),
(0.146, 0.238),
(0.323, 0.329), # 12; white color middle
(0.151, 0.343),
(0.157, 0.457),
(0.164, 0.591),
(0.17, 0.703),
(0.172, 0.747),
(0.199, 0.724),
(0.269, 0.665),
(0.36, 0.588),
(0.444, 0.517),
(0.527, 0.447),
(0.612, 0.374),
(0.677, 0.319),
]
# These are the xy colors translated from color temperature (2000K to 6488K)
# They were extracted from here https://www.waveformlighting.com/files/blackBodyLocus_1.txt
color_temp_wheel = [
(0.527, 0.413),
(0.507, 0.415),
(0.489, 0.415),
(0.472, 0.413),
(0.456, 0.41),
(0.442, 0.406),
(0.428, 0.401),
(0.416, 0.396),
(0.406, 0.391),
(0.396, 0.386),
(0.386, 0.38),
(0.378, 0.375),
(0.37, 0.37),
(0.363, 0.365),
(0.357, 0.361),
(0.351, 0.358),
(0.345, 0.355),
(0.34, 0.352),
(0.336, 0.349),
(0.331, 0.346),
(0.327, 0.343),
(0.323, 0.339),
(0.32, 0.336),
(0.317, 0.333),
(0.314, 0.33),
]
COLOR_WHEELS = {
"default_color_wheel": default_color_wheel,
"color_temp_wheel": color_temp_wheel,
}
def get_color_wheel(colors: str | Colors) -> Colors:
if isinstance(colors, str):
if colors not in COLOR_WHEELS:
raise ValueError(
f"`{colors}` is not an option for `color_wheel`. Options are: {list(COLOR_WHEELS.keys())}"
)
return COLOR_WHEELS[colors]
elif isinstance(colors, (list, tuple)):
return colors
else:
raise ValueError(
f"Type {type(colors)} is not supported for `color_wheel` attribute"
)
@@ -0,0 +1,611 @@
import asyncio
import re
import time
from ast import literal_eval
from asyncio import CancelledError, Task
from collections import Counter, defaultdict
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import (
Any,
DefaultDict,
Literal,
Optional,
TypeVar,
overload,
)
import appdaemon.utils as utils
import cx_core.integration as integration_module
import cx_version
from appdaemon.adapi import ADAPI
from appdaemon.plugins.hass.hassapi import Hass
from appdaemon.plugins.mqtt.mqttapi import Mqtt
from cx_const import (
ActionEvent,
ActionFunction,
CustomActionsMapping,
DefaultActionsMapping,
PredefinedActionsMapping,
)
from cx_core.action_type import ActionsMapping, parse_actions
from cx_core.action_type.base import ActionType
from cx_core.integration import EventData, Integration
DEFAULT_ACTION_DELTA = 300 # In milliseconds
DEFAULT_MULTIPLE_CLICK_DELAY = 500 # In milliseconds
MULTIPLE_CLICK_TOKEN = "$"
MODE_SINGLE = "single"
MODE_RESTART = "restart"
MODE_QUEUED = "queued"
MODE_PARALLEL = "parallel"
T = TypeVar("T")
def action(method: Callable[..., Awaitable[Any]]) -> ActionFunction:
@wraps(method)
async def _action_impl(controller: "Controller", *args: Any, **kwargs: Any) -> None:
continue_call = await controller.before_action(method.__name__, *args, **kwargs)
if continue_call:
await method(controller, *args, **kwargs)
return _action_impl
def run_in(fn: Callable[..., Any], delay: float, **kwargs: Any) -> "Task[None]":
"""
It runs the function (fn) in running event loop in `delay` seconds.
This function has been created because the default run_in function
from AppDaemon does not accept microseconds.
"""
async def inner() -> None:
await asyncio.sleep(delay)
await fn(kwargs)
task = asyncio.create_task(inner())
return task
class Controller(Hass, Mqtt): # type: ignore[misc]
"""
This is the parent Controller, all controllers must extend from this class.
"""
args: dict[str, Any]
integration: Integration
actions_mapping: ActionsMapping
action_handles: DefaultDict[ActionEvent, Optional["Task[None]"]]
action_delay_handles: dict[ActionEvent, str | None]
multiple_click_actions: set[ActionEvent]
action_delay: dict[ActionEvent, int]
action_delta: dict[ActionEvent, int]
action_times: dict[str, float]
previous_states: dict[ActionEvent, str | None]
multiple_click_action_times: dict[str, float]
click_counter: Counter[ActionEvent]
multiple_click_action_delay_tasks: DefaultDict[ActionEvent, Optional["Task[None]"]]
multiple_click_delay: int
async def initialize(self) -> None:
self.log(f"🎮 ControllerX {cx_version.__version__}", ascii_encode=False)
await self.init()
async def init(self) -> None:
controllers_ids: list[str] = self.get_list(self.args["controller"])
self.integration = self.get_integration(self.args["integration"])
if "mapping" in self.args and "merge_mapping" in self.args:
raise ValueError("`mapping` and `merge_mapping` cannot be used together")
custom_mapping: CustomActionsMapping | None = self.args.get("mapping", None)
merge_mapping: CustomActionsMapping | None = self.args.get(
"merge_mapping", None
)
if custom_mapping is None:
default_actions_mapping = self.get_default_actions_mapping(self.integration)
self.actions_mapping = self.parse_action_mapping(default_actions_mapping) # type: ignore[arg-type]
else:
self.actions_mapping = self.parse_action_mapping(custom_mapping)
if merge_mapping is not None:
self.actions_mapping.update(self.parse_action_mapping(merge_mapping))
# Filter actions with include and exclude
if "actions" in self.args and "excluded_actions" in self.args:
raise ValueError("`actions` and `excluded_actions` cannot be used together")
include: list[ActionEvent] = self.get_list(
self.args.get("actions", list(self.actions_mapping.keys()))
)
exclude: list[ActionEvent] = self.get_list(
self.args.get("excluded_actions", [])
)
self.actions_mapping = self.filter_actions(
self.actions_mapping, set(include), set(exclude)
)
# Action delay
self.action_delay = self.get_mapping_per_action(
self.actions_mapping,
custom=self.args.get("action_delay"),
default=0,
)
self.action_delay_handles = defaultdict(lambda: None)
self.action_handles = defaultdict(lambda: None)
# Action delta
self.action_delta = self.get_mapping_per_action(
self.actions_mapping,
custom=self.args.get("action_delta"),
default=DEFAULT_ACTION_DELTA,
)
self.action_times = defaultdict(float)
# Previous state
self.previous_states = self.get_mapping_per_action(
self.actions_mapping,
custom=self.args.get("previous_state"),
default=None,
)
# Multiple click
self.multiple_click_actions = self.get_multiple_click_actions(
self.actions_mapping
)
self.multiple_click_delay = self.args.get(
"multiple_click_delay", DEFAULT_MULTIPLE_CLICK_DELAY
)
self.multiple_click_action_times = defaultdict(float)
self.click_counter = Counter()
self.multiple_click_action_delay_tasks = defaultdict(lambda: None)
# Mode
self.mode = self.get_mapping_per_action(
self.actions_mapping, custom=self.args.get("mode"), default=MODE_SINGLE
)
# Listen for device changes
for controller_id in controllers_ids:
await self.integration.listen_changes(controller_id)
def filter_actions(
self,
actions_mapping: ActionsMapping,
include: set[ActionEvent],
exclude: set[ActionEvent],
) -> ActionsMapping:
allowed_actions = include - exclude
return {
key: value
for key, value in actions_mapping.items()
if key in allowed_actions
}
@staticmethod
def get_option(value: str, options: list[str], ctx: str | None = None) -> str:
if value in options:
return value
else:
raise ValueError(
f"{f'{ctx} - ' if ctx is not None else ''}`{value}` is not an option. "
f"The options are {options}"
)
def parse_integration(
self, integration: str | dict[str, Any] | Any
) -> dict[str, str]:
if isinstance(integration, str):
return {"name": integration}
elif isinstance(integration, dict):
if "name" in integration:
return integration
else:
raise ValueError("'name' attribute is mandatory")
else:
raise ValueError(
f"Type {type(integration)} is not supported for `integration` attribute"
)
def get_integration(self, integration: str | dict[str, Any]) -> Integration:
parsed_integration = self.parse_integration(integration)
kwargs = {k: v for k, v in parsed_integration.items() if k != "name"}
integrations = integration_module.get_integrations(self, kwargs)
integration_argument = self.get_option(
parsed_integration["name"], [i.name for i in integrations]
)
return next(i for i in integrations if i.name == integration_argument)
def get_default_actions_mapping(
self, integration: Integration
) -> DefaultActionsMapping:
actions_mapping = integration.get_default_actions_mapping()
if actions_mapping is None:
raise ValueError(
f"This controller does not support {integration.name}. Use `mapping` to define the actions."
)
return actions_mapping
@overload
def get_list(self, entities: list[T]) -> list[T]:
pass
@overload
def get_list(self, entities: T) -> list[T]:
pass
def get_list(self, entities: list[T] | T) -> list[T]:
if isinstance(entities, (list, tuple)):
return list(entities)
return [entities]
@overload
def get_mapping_per_action(
self,
actions_mapping: ActionsMapping,
*,
custom: T | dict[ActionEvent, T] | None,
default: None,
) -> dict[ActionEvent, T | None]:
pass
@overload
def get_mapping_per_action(
self,
actions_mapping: ActionsMapping,
*,
custom: T | dict[ActionEvent, T] | None,
default: T,
) -> dict[ActionEvent, T]:
pass
def get_mapping_per_action(
self,
actions_mapping: ActionsMapping,
*,
custom: T | dict[ActionEvent, T] | None,
default: None | T,
) -> dict[ActionEvent, T | None] | dict[ActionEvent, T]:
if custom is not None and not isinstance(custom, dict):
default = custom
mapping = {action: default for action in actions_mapping}
if custom is not None and isinstance(custom, dict):
mapping.update(custom)
return mapping
def parse_action_mapping(self, mapping: CustomActionsMapping) -> ActionsMapping:
return {
event: parse_actions(self, action)
for event, action in mapping.items()
if action is not None
}
def get_multiple_click_actions(self, mapping: ActionsMapping) -> set[ActionEvent]:
to_return: set[ActionEvent] = set()
for key in mapping.keys():
if not isinstance(key, str) or MULTIPLE_CLICK_TOKEN not in key:
continue
splitted = key.split(MULTIPLE_CLICK_TOKEN)
assert 1 <= len(splitted) <= 2
action_key, _ = splitted
try:
to_return.add(int(action_key))
except ValueError:
to_return.add(action_key)
return to_return
def format_multiple_click_action(
self, action_key: ActionEvent, click_count: int
) -> str:
return (
str(action_key) + MULTIPLE_CLICK_TOKEN + str(click_count)
) # e.g. toggle$2
async def _render_template(self, template: str) -> Any:
result = await self.render_template(template)
if result is None:
raise ValueError(f"Template {template} returned None")
try:
return literal_eval(result)
except (SyntaxError, ValueError):
return result
_TEMPLATE_RE = re.compile(r"\s*\{\{.*\}\}")
def contains_templating(self, template: str) -> bool:
is_template = self._TEMPLATE_RE.search(template) is not None
if not is_template:
self.log(f"`{template}` is not recognized as a template", level="DEBUG")
return is_template
async def render_value(self, value: Any) -> Any:
if isinstance(value, str) and self.contains_templating(value):
return await self._render_template(value)
else:
return value
async def render_attributes(self, attributes: dict[str, Any]) -> dict[str, Any]:
new_attributes: dict[str, Any] = {}
for key, value in attributes.items():
new_value = await self.render_value(value)
if isinstance(value, dict):
new_value = await self.render_attributes(value)
new_attributes[key] = new_value
return new_attributes
async def call_service(
self, service: str, render_template: bool = True, **attributes: Any
) -> Any | None:
service = service.replace(".", "/")
to_log = ["\n", f"🤖 Service: \033[1m{service.replace('/', '.')}\033[0m"]
if service != "template/render" and render_template:
attributes = await self.render_attributes(attributes)
for attribute, value in attributes.items():
if isinstance(value, float):
value = f"{value:.2f}"
to_log.append(f" - {attribute}: {value}")
self.log("\n".join(to_log), level="INFO", ascii_encode=False)
return await ADAPI.call_service(self, service, **attributes)
@utils.sync_decorator # type: ignore[untyped-decorator]
async def get_state(
self,
entity_id: str | None = None,
attribute: str | Literal["all"] | None = None,
default: Any | None = None,
namespace: str | None = None,
copy: bool = True,
**kwargs: dict[str, Any], # left in intentionally for compatibility
) -> Any | dict[str, Any] | None:
rendered_entity_id = await self.render_value(entity_id)
return await super().get_state(
rendered_entity_id, attribute, default=default, copy=copy
)
async def handle_action(
self,
action_key: str,
previous_state: str | None = None,
extra: EventData | None = None,
) -> None:
if (
action_key in self.actions_mapping
and self.previous_states[action_key] is not None
and previous_state != self.previous_states[action_key]
):
self.log(
f"🎮 `{action_key}` not triggered because previous action was `{previous_state}`",
level="DEBUG",
ascii_encode=False,
)
return
if (
action_key in self.actions_mapping
and action_key not in self.multiple_click_actions
):
previous_call_time = self.action_times[action_key]
now = time.time() * 1000
self.action_times[action_key] = now
if now - previous_call_time > self.action_delta[action_key]:
await self.call_action(action_key, extra=extra)
elif action_key in self.multiple_click_actions:
now = time.time() * 1000
previous_call_time = self.multiple_click_action_times.get(action_key, now)
self.multiple_click_action_times[action_key] = now
if now - previous_call_time > self.multiple_click_delay:
pass
previous_task = self.multiple_click_action_delay_tasks[action_key]
if previous_task is not None:
previous_task.cancel()
self.click_counter[action_key] += 1
click_count = self.click_counter[action_key]
new_task = run_in(
self.multiple_click_call_action,
self.multiple_click_delay / 1000,
action_key=action_key,
extra=extra,
click_count=click_count,
)
self.multiple_click_action_delay_tasks[action_key] = new_task
else:
self.log(
f"🎮 Button event triggered, but not registered: `{action_key}`",
level="DEBUG",
ascii_encode=False,
)
async def multiple_click_call_action(self, kwargs: dict[str, Any]) -> None:
action_key: ActionEvent = kwargs["action_key"]
extra: EventData = kwargs["extra"]
click_count: int = kwargs["click_count"]
self.multiple_click_action_delay_tasks[action_key] = None
self.log(
f"🎮 {action_key} clicked `{click_count}` time(s)",
level="DEBUG",
ascii_encode=False,
)
self.click_counter[action_key] = 0
click_action_key = self.format_multiple_click_action(action_key, click_count)
if click_action_key in self.actions_mapping:
await self.call_action(click_action_key, extra=extra)
elif action_key in self.actions_mapping and click_count == 1:
await self.call_action(action_key, extra=extra)
async def call_action(
self, action_key: ActionEvent, extra: EventData | None = None
) -> None:
self.log(
f"🎮 Button event triggered: `{action_key}`",
level="INFO",
ascii_encode=False,
)
self.log(
f"Extra:\n{extra}",
level="DEBUG",
)
delay = self.action_delay[action_key]
if delay > 0:
handle = self.action_delay_handles[action_key]
if handle is not None:
await self.cancel_timer(handle)
self.log(
f"🕒 Running action(s) from `{action_key}` in {delay} seconds",
level="INFO",
ascii_encode=False,
)
new_handle = await self.run_in(
self.action_timer_callback, delay, action_key=action_key, extra=extra
)
self.action_delay_handles[action_key] = new_handle
else:
await self.action_timer_callback({"action_key": action_key, "extra": extra})
async def _apply_mode_strategy(self, action_key: ActionEvent) -> bool:
previous_task = self.action_handles[action_key]
if previous_task is None or previous_task.done():
return False
if self.mode[action_key] == MODE_SINGLE:
self.log(
f"There is already an action executing for `{action_key}`. "
"If you want a different behaviour change `mode` parameter, "
"the default value is `single`.",
level="WARNING",
)
return True
elif self.mode[action_key] == MODE_RESTART:
previous_task.cancel()
elif self.mode[action_key] == MODE_QUEUED:
await previous_task
elif self.mode[action_key] == MODE_PARALLEL:
pass
else:
raise ValueError(
f"`{self.mode[action_key]}` is not a possible value for `mode` parameter."
"Possible values: `single`, `restart`, `queued` and `parallel`."
)
return False
async def action_timer_callback(self, kwargs: dict[str, Any]) -> None:
action_key: ActionEvent = kwargs["action_key"]
extra: EventData = kwargs["extra"]
self.action_delay_handles[action_key] = None
skip = await self._apply_mode_strategy(action_key)
if skip:
return
action_types = self.actions_mapping[action_key]
task = asyncio.create_task(self.call_action_types(action_types, extra))
self.action_handles[action_key] = task
try:
await task
except CancelledError:
self.log(
f"Task(s) from `{action_key}` was/were canceled and executed again",
level="DEBUG",
)
async def call_action_types(
self, action_types: list[ActionType], extra: EventData | None = None
) -> None:
for action_type in action_types:
self.log(
f"🏃 Running `{action_type}` now",
level="INFO",
ascii_encode=False,
)
await action_type.run(extra=extra)
async def before_action(self, action: str, *args: str, **kwargs: Any) -> bool:
"""
Controllers have the option to implement this function, which is called
everytime before an action is called and it has the check_before_action decorator.
It should return True if the action shoul be called.
Otherwise it should return False.
"""
return True
def get_z2m_actions_mapping(self) -> DefaultActionsMapping | None:
"""
Controllers can implement this function. It should return a dict
with the states that a controller can take and the functions as values.
This is used for zigbee2mqtt support.
"""
return None
def get_deconz_actions_mapping(self) -> DefaultActionsMapping | None:
"""
Controllers can implement this function. It should return a dict
with the event id that a controller can take and the functions as values.
This is used for deCONZ support.
"""
return None
def get_zha_actions_mapping(self) -> DefaultActionsMapping | None:
"""
Controllers can implement this function. It should return a dict
with the command that a controller can take and the functions as values.
This is used for ZHA support.
"""
return None
def get_zha_action(self, data: EventData) -> str | None:
"""
This method can be override for controllers that do not support
the standard extraction of the actions on cx_core/integration/zha.py
"""
return None
def get_lutron_caseta_actions_mapping(self) -> DefaultActionsMapping | None:
"""
Controllers can implement this function. It should return a dict
with the command that a controller can take and the functions as values.
This is used for Lutron support.
"""
return None
def get_state_actions_mapping(self) -> DefaultActionsMapping | None:
"""
Controllers can implement this function. It should return a dict
with the command that a controller can take and the functions as values.
This is used for State integration support.
"""
return None
def get_homematic_actions_mapping(self) -> DefaultActionsMapping | None:
"""
Controllers can implement this function. It should return a dict
with the command that a controller can take and the functions as values.
This is used for Homematic support.
"""
return None
def get_shelly_actions_mapping(self) -> DefaultActionsMapping | None:
"""
Controllers can implement this function. It should return a dict
with the command that a controller can take and the functions as values.
This is used for Shelly support.
"""
return None
def get_shellyforhass_actions_mapping(self) -> DefaultActionsMapping | None:
"""
Controllers can implement this function. It should return a dict
with the command that a controller can take and the functions as values.
This is used for Shelly for HASS support.
"""
return None
def get_tasmota_actions_mapping(self) -> DefaultActionsMapping | None:
"""
Controllers can implement this function. It should return a dict
with the command that a controller can take and the functions as values.
This is used for Tasmota support.
"""
return None
def get_predefined_actions_mapping(self) -> PredefinedActionsMapping:
return {}
@@ -0,0 +1,40 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from cx_core.type_controller import Entity, TypeController
class FeatureSupport:
controller: "TypeController[Entity]"
update_supported_features: bool
_supported_features: int | None
def __init__(
self,
controller: "TypeController[Entity]",
supported_features: int | None = None,
update_supported_features: bool = False,
) -> None:
self.controller = controller
self._supported_features = supported_features
self.update_supported_features = update_supported_features
@property
async def supported_features(self) -> int:
if self._supported_features is None or self.update_supported_features:
bitfield: str = await self.controller.get_entity_state(
attribute="supported_features"
)
if bitfield is not None:
self._supported_features = int(bitfield)
else:
raise ValueError(
f"`supported_features` could not be read from `{self.controller.entity}`. Entity might not be available."
)
return self._supported_features
async def is_supported(self, feature: int) -> bool:
return feature & await self.supported_features != 0
async def not_supported(self, feature: int) -> bool:
return not await self.is_supported(feature)
@@ -0,0 +1,9 @@
class CoverSupport:
OPEN = 1
CLOSE = 2
SET_COVER_POSITION = 4
STOP = 8
OPEN_TILT = 16
CLOSE_TILT = 32
STOP_TILT = 64
SET_TILT_POSITION = 128
@@ -0,0 +1,4 @@
class LightSupport:
EFFECT = 4
FLASH = 8
TRANSITION = 32
@@ -0,0 +1,20 @@
class MediaPlayerSupport:
PAUSE = 1
SEEK = 2
VOLUME_SET = 4
VOLUME_MUTE = 8
PREVIOUS_TRACK = 16
NEXT_TRACK = 32
TURN_ON = 128
TURN_OFF = 256
PLAY_MEDIA = 512
VOLUME_STEP = 1024
SELECT_SOURCE = 2048
STOP = 4096
CLEAR_PLAYLIST = 8192
PLAY = 16384
SHUFFLE_SET = 32768
SELECT_SOUND_MODE = 65536
SUPPORT_BROWSE_MEDIA = 131072
SUPPORT_REPEAT_SET = 262144
SUPPORT_GROUPING = 524288
@@ -0,0 +1,35 @@
import abc
from typing import TYPE_CHECKING, Any
from cx_const import DefaultActionsMapping
from cx_helper import get_classes
if TYPE_CHECKING:
from cx_core.controller import Controller
EventData = dict[str, Any]
class Integration(abc.ABC):
name: str
controller: "Controller"
kwargs: dict[str, Any]
def __init__(self, controller: "Controller", kwargs: dict[str, Any]):
self.controller = controller
self.kwargs = kwargs
def get_default_actions_mapping(self) -> DefaultActionsMapping | None:
return None
@abc.abstractmethod
async def listen_changes(self, controller_id: str) -> None:
raise NotImplementedError
def get_integrations(
controller: "Controller", kwargs: dict[str, Any]
) -> list[Integration]:
integration_classes = get_classes(__file__, __package__, Integration)
integrations = [cls_(controller, kwargs) for cls_ in integration_classes]
return integrations
@@ -0,0 +1,34 @@
from typing import Any
from appdaemon.plugins.hass.hassapi import Hass
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
LISTENS_TO_ID = "id"
LISTENS_TO_UNIQUE_ID = "unique_id"
class DeCONZIntegration(Integration):
name = "deconz"
def get_default_actions_mapping(self) -> DefaultActionsMapping | None:
return self.controller.get_deconz_actions_mapping()
async def listen_changes(self, controller_id: str) -> None:
listens_to = self.kwargs.get("listen_to", LISTENS_TO_ID)
if listens_to not in (LISTENS_TO_ID, LISTENS_TO_UNIQUE_ID):
raise ValueError(
"`listens_to` for deCONZ integration should either be `id` or `unique_id`"
)
await Hass.listen_event(
self.controller,
self.event_callback,
"deconz_event",
**{listens_to: controller_id}
)
async def event_callback(
self, event_name: str, data: EventData, kwargs: dict[str, Any]
) -> None:
type_ = self.kwargs.get("type", "event")
await self.controller.handle_action(data[type_], extra=data)
@@ -0,0 +1,41 @@
from typing import Any
from appdaemon.plugins.hass.hassapi import Hass
from cx_core.integration import EventData, Integration
class EventIntegration(Integration):
name = "event"
def get_arg(self, arg: str) -> Any:
try:
return self.kwargs[arg]
except KeyError:
raise ValueError(f"{arg} is a mandatory field for event integration.")
async def listen_changes(self, controller_id: str) -> None:
event_type: str = self.get_arg("event_type")
controller_key: str = self.get_arg("controller_key")
self.controller.log(
f"Listening to `{event_type}` events for controller `{controller_key}={controller_id}`"
)
await Hass.listen_event(
self.controller,
self.event_callback,
event_type,
**{controller_key: controller_id},
)
async def event_callback(
self, event_name: str, data: EventData, kwargs: dict[str, Any]
) -> None:
action_template: str = self.get_arg("action_template")
try:
action = action_template.format(**data)
except Exception:
self.controller.log(
f"Template `{action_template}` could not be rendered with data={data}",
level="WARNING",
)
return
await self.controller.handle_action(action)
@@ -0,0 +1,36 @@
from typing import TYPE_CHECKING, Any
from appdaemon.plugins.hass.hassapi import Hass
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
if TYPE_CHECKING:
from cx_core.controller import Controller
class HomematicIntegration(Integration):
name = "homematic"
_registered_controller_ids: set[str]
def __init__(self, controller: "Controller", kwargs: dict[str, Any]):
self._registered_controller_ids = set()
super().__init__(controller, kwargs)
def get_default_actions_mapping(self) -> DefaultActionsMapping | None:
return self.controller.get_homematic_actions_mapping()
async def listen_changes(self, controller_id: str) -> None:
self._registered_controller_ids.add(controller_id)
await Hass.listen_event(
self.controller, self.event_callback, "homematic.keypress"
)
async def event_callback(
self, event_name: str, data: EventData, kwargs: dict[str, Any]
) -> None:
if data["name"] not in self._registered_controller_ids:
return
param = data["param"]
channel = data["channel"]
action = f"{param}_{channel}"
await self.controller.handle_action(action, extra=data)
@@ -0,0 +1,28 @@
from typing import Any
from appdaemon.plugins.hass.hassapi import Hass
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
class LutronIntegration(Integration):
name = "lutron_caseta"
def get_default_actions_mapping(self) -> DefaultActionsMapping | None:
return self.controller.get_lutron_caseta_actions_mapping()
async def listen_changes(self, controller_id: str) -> None:
await Hass.listen_event(
self.controller,
self.event_callback,
"lutron_caseta_button_event",
serial=controller_id,
)
async def event_callback(
self, event_name: str, data: EventData, kwargs: dict[str, Any]
) -> None:
button = data["button_number"]
action_type = data["action"]
action = f"button_{button}_{action_type}"
await self.controller.handle_action(action, extra=data)
@@ -0,0 +1,43 @@
import json
from typing import Any
from appdaemon.plugins.mqtt.mqttapi import Mqtt
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
class MQTTIntegration(Integration):
name = "mqtt"
def get_default_actions_mapping(self) -> DefaultActionsMapping | None:
return self.controller.get_z2m_actions_mapping()
async def listen_changes(self, controller_id: str) -> None:
await Mqtt.listen_event(
self.controller, self.event_callback, topic=controller_id, namespace="mqtt"
)
async def event_callback(
self, event_name: str, data: EventData, kwargs: dict[str, Any]
) -> None:
self.controller.log(f"MQTT data event: {data}", level="DEBUG")
payload_key = self.kwargs.get("key")
if "payload" not in data:
return
payload = data["payload"]
action_key: str
if payload_key is None:
action_key = payload
else:
try:
action_key = str(json.loads(payload)[payload_key]).lower()
except json.decoder.JSONDecodeError:
raise ValueError(
f"`key` is being used ({payload_key}). "
f"Following payload is not a valid JSON: {payload}"
)
except KeyError:
raise ValueError(
f"Following payload does not contain `{payload_key}`: {payload}"
)
await self.controller.handle_action(action_key)
@@ -0,0 +1,25 @@
from typing import Any
from appdaemon.plugins.hass.hassapi import Hass
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
class ShellyIntegration(Integration):
name = "shelly"
def get_default_actions_mapping(self) -> DefaultActionsMapping | None:
return self.controller.get_shelly_actions_mapping()
async def listen_changes(self, controller_id: str) -> None:
await Hass.listen_event(
self.controller, self.event_callback, "shelly.click", device=controller_id
)
async def event_callback(
self, event_name: str, data: EventData, kwargs: dict[str, Any]
) -> None:
click_type = data["click_type"]
channel = data["channel"]
action = f"{click_type}_{channel}"
await self.controller.handle_action(action, extra=data)
@@ -0,0 +1,27 @@
from typing import Any
from appdaemon.plugins.hass.hassapi import Hass
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
class ShellyForHASSIntegration(Integration):
name = "shellyforhass"
def get_default_actions_mapping(self) -> DefaultActionsMapping | None:
return self.controller.get_shellyforhass_actions_mapping()
async def listen_changes(self, controller_id: str) -> None:
await Hass.listen_event(
self.controller,
self.event_callback,
"shellyforhass.click",
entity_id=controller_id,
)
async def event_callback(
self, event_name: str, data: EventData, kwargs: dict[str, Any]
) -> None:
click_type = data["click_type"]
action = f"{click_type}"
await self.controller.handle_action(action, extra=data)
@@ -0,0 +1,28 @@
from typing import Any
from appdaemon.plugins.hass.hassapi import Hass
from cx_const import DefaultActionsMapping
from cx_core.integration import Integration
class StateIntegration(Integration):
name = "state"
def get_default_actions_mapping(self) -> DefaultActionsMapping | None:
return self.controller.get_state_actions_mapping()
async def listen_changes(self, controller_id: str) -> None:
attribute = self.kwargs.get("attribute", None)
await Hass.listen_state(
self.controller, self.state_callback, controller_id, attribute=attribute
)
async def state_callback(
self,
entity: str | None,
attribute: str | None,
old: str | None,
new: str,
kwargs: dict[str, Any],
) -> None:
await self.controller.handle_action(new, previous_state=old)
@@ -0,0 +1,51 @@
import json
from typing import Any
from appdaemon.plugins.mqtt.mqttapi import Mqtt
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
class TasmotaIntegration(Integration):
name = "tasmota"
def get_default_actions_mapping(self) -> DefaultActionsMapping | None:
return self.controller.get_tasmota_actions_mapping()
async def listen_changes(self, controller_id: str) -> None:
component_key = self.kwargs.get("component")
if component_key is None:
raise ValueError(
"`component` attribute is mandatory. "
"Check example from https://xaviml.github.io/controllerx/start/integrations/tasmota"
)
await Mqtt.listen_event(
self.controller, self.event_callback, topic=controller_id, namespace="mqtt"
)
async def event_callback(
self, event_name: str, data: EventData, kwargs: dict[str, Any]
) -> None:
self.controller.log(f"MQTT data event: {data}", level="DEBUG")
component_key: str = self.kwargs["component"]
payload_key: str = self.kwargs.get("key", "Action")
if "payload" not in data:
return
payload: str = data["payload"]
# Even though this checks if "compoenent_key" is in the payload
# (as string, not dictionary), it is preferred for its performance
if component_key not in payload:
return
try:
action_key = str(json.loads(payload)[component_key][payload_key])
except json.decoder.JSONDecodeError:
raise ValueError(
f"`key` is being used ({payload_key}). "
f"Following payload is not a valid JSON: {payload}"
)
except KeyError:
raise ValueError(
"Following payload does not contain "
f"payload_key=`{payload_key}`: {payload}"
)
await self.controller.handle_action(action_key)
@@ -0,0 +1,85 @@
import json
from typing import Any
from appdaemon.plugins.hass.hassapi import Hass
from appdaemon.plugins.mqtt.mqttapi import Mqtt
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
LISTENS_TO_HA = "ha"
LISTENS_TO_MQTT = "mqtt"
LISTENS_TO_EVENT = "event"
class Z2MIntegration(Integration):
name = "z2m"
def get_default_actions_mapping(self) -> DefaultActionsMapping | None:
return self.controller.get_z2m_actions_mapping()
async def listen_changes(self, controller_id: str) -> None:
listens_to = self.kwargs.get("listen_to", LISTENS_TO_HA)
if listens_to == LISTENS_TO_HA:
self.controller.log(
"⚠️ Listening to HA sensor actions is now deprecated and will be removed in the future. Use `listen_to: mqtt` or `listen_to: event` instead."
" Read more about it here: https://xaviml.github.io/controllerx/others/z2m-ha-sensor-deprecated",
level="WARNING",
ascii_encode=False,
)
await Hass.listen_state(self.controller, self.state_callback, controller_id)
elif listens_to == LISTENS_TO_MQTT:
topic_prefix = self.kwargs.get("topic_prefix", "zigbee2mqtt")
await Mqtt.listen_event(
self.controller,
self.event_callback,
topic=f"{topic_prefix}/{controller_id}",
namespace="mqtt",
)
elif listens_to == LISTENS_TO_EVENT:
await Hass.listen_state(
self.controller,
self.state_callback,
f"event.{controller_id}",
attribute="event_type",
)
else:
raise ValueError(
"`listen_to` has to be either `ha`, `mqtt` or `event`. Default is `ha`."
)
async def event_callback(
self, event_name: str, data: EventData, kwargs: dict[str, Any]
) -> None:
self.controller.log(f"MQTT data event: {data}", level="DEBUG")
action_key = self.kwargs.get("action_key", "action")
action_group_key = self.kwargs.get("action_group_key", "action_group")
if "payload" not in data:
return
payload = json.loads(data["payload"])
if action_key not in payload:
self.controller.log(
f"⚠️ There is no `{action_key}` in the MQTT topic payload",
level="WARNING",
ascii_encode=False,
)
return
if action_group_key in payload and "action_group" in self.kwargs:
action_group = self.controller.get_list(self.kwargs["action_group"])
if payload["action_group"] not in action_group:
self.controller.log(
f"Action group {payload['action_group']} not found in "
f"action groups: {action_group}",
level="DEBUG",
)
return
await self.controller.handle_action(payload[action_key], extra=payload)
async def state_callback(
self,
entity: str | None,
attribute: str | None,
old: str | None,
new: str,
kwargs: dict[str, Any],
) -> None:
await self.controller.handle_action(new, previous_state=old)
@@ -0,0 +1,46 @@
from typing import Any
from appdaemon.plugins.hass.hassapi import Hass
from cx_const import DefaultActionsMapping
from cx_core.integration import EventData, Integration
class ZHAIntegration(Integration):
name = "zha"
def get_default_actions_mapping(self) -> DefaultActionsMapping | None:
return self.controller.get_zha_actions_mapping()
async def listen_changes(self, controller_id: str) -> None:
await Hass.listen_event(
self.controller, self.event_callback, "zha_event", device_ieee=controller_id
)
def get_action(self, data: EventData) -> str:
command = data["command"]
args = data["args"]
if isinstance(args, dict):
args = args["args"]
args = list(map(str, args))
action = command
if not (command == "stop" or command == "release"):
if len(args) > 0:
action += "_" + "_".join(args)
return action
async def event_callback(
self, event_name: str, data: EventData, kwargs: dict[str, Any]
) -> None:
action = self.controller.get_zha_action(data)
if action is None:
# If there is no action extracted from the controller then
# we extract with the standard function
try:
action = self.get_action(data)
except Exception:
self.controller.log(
f"The following event could not be parsed: {data}", level="WARNING"
)
return
await self.controller.handle_action(action, extra=data)
@@ -0,0 +1,69 @@
import abc
from typing import Any
from cx_core.controller import Controller, action
DEFAULT_DELAY = 350 # In milliseconds
DEFAULT_RELEASE_DELAY = 0 # In seconds
class ReleaseHoldController(Controller, abc.ABC):
DEFAULT_MAX_LOOPS = 50
on_hold: bool
delay: float
max_loops: int
hold_release_toggle: bool
release_delay: int
async def init(self) -> None:
self.on_hold = False
self.delay = self.args.get("delay", self.default_delay())
self.max_loops = self.args.get(
"max_loops", ReleaseHoldController.DEFAULT_MAX_LOOPS
)
self.hold_release_toggle = self.args.get("hold_release_toggle", False)
self.release_delay = self.args.get("release_delay", DEFAULT_RELEASE_DELAY)
await super().init()
@action
async def release(self) -> None:
if self.release_delay > 0:
await self.sleep(self.release_delay)
self.on_hold = False
async def hold(self, *args: Any) -> None:
loops = 0
self.on_hold = True
stop = False
while self.on_hold and not stop:
stop = await self.hold_loop(*args)
# Stop the iteration if we either stop from the hold_loop
# or we reached the max loop number
stop = stop or loops >= self.max_loops
await self.sleep(self.delay / 1000)
loops += 1
self.on_hold = False
async def before_action(self, action: str, *args: Any, **kwargs: Any) -> bool:
super_before_action = await super().before_action(action, *args, **kwargs)
to_return = not (action == "hold" and self.on_hold)
if action == "hold" and self.on_hold and self.hold_release_toggle:
self.on_hold = False
return super_before_action and to_return
@abc.abstractmethod
async def hold_loop(self, *args: Any) -> bool:
"""
This function is called by the ReleaseHoldController depending on the settings.
It stops calling the function once release action is called or when this function
returns True.
"""
raise NotImplementedError
def default_delay(self) -> int:
"""
This function can be overwritten for each device to indeicate the delay
for the specific device, by default it returns the default delay from the app
"""
return DEFAULT_DELAY
@@ -0,0 +1,112 @@
import abc
from dataclasses import dataclass
from cx_const import Number, StepperDir
class MinMax:
def __init__(self, min: Number, max: Number, margin: float = 0.05) -> None:
self._min = min
self._max = max
self.margin_dist = (max - min) * margin
@property
def min(self) -> Number:
return self._min
@property
def max(self) -> Number:
return self._max
def is_min(self, value: Number) -> bool:
return self._min == value
def is_max(self, value: Number) -> bool:
return self._max == value
def is_between(self, value: Number) -> bool:
return self._min < value < self._max
def in_min_boundaries(self, value: Number) -> bool:
return self._min <= value <= (self._min + self.margin_dist)
def in_max_boundaries(self, value: Number) -> bool:
return (self._max - self.margin_dist) <= value <= self._max
def clip(self, value: Number) -> Number:
return max(self._min, min(value, self._max))
def __repr__(self) -> str:
return f"MinMax({self.min}, {self.max})"
@dataclass
class StepperOutput:
next_value: Number
next_direction: str | None
@property
def exceeded(self) -> bool:
return self.next_direction is None
class Stepper(abc.ABC):
sign_mapping = {StepperDir.UP: 1, StepperDir.DOWN: -1}
min_max: MinMax
steps: Number
previous_direction: str
relative_steps: bool
@staticmethod
def invert_direction(direction: str) -> str:
return StepperDir.UP if direction == StepperDir.DOWN else StepperDir.DOWN
@staticmethod
def sign(direction: str) -> int:
return Stepper.sign_mapping[direction]
@staticmethod
def apply_sign(value: Number, direction: str) -> Number:
return Stepper.sign(direction) * value
def __init__(
self,
min_max: MinMax,
steps: Number,
previous_direction: str = StepperDir.DOWN,
relative_steps: bool = True,
) -> None:
self.min_max = min_max
self.steps = steps
self.previous_direction = previous_direction
self.relative_steps = relative_steps
def _compute_step(self) -> float:
if self.relative_steps:
max_ = self.min_max.max
min_ = self.min_max.min
return (max_ - min_) / self.steps
else:
return self.steps
def get_direction(self, value: Number, direction: str) -> str:
if direction == StepperDir.TOGGLE:
direction = Stepper.invert_direction(self.previous_direction)
self.previous_direction = direction
return direction
@abc.abstractmethod
def step(self, value: Number, direction: str) -> StepperOutput:
"""
This function updates the value according to the steps
that needs to take and returns the new value together with
the new direction it will need to go. If next_direction is
None, the loop will stop executing.
"""
raise NotImplementedError
class InvertStepper(Stepper):
def step(self, value: Number, direction: str) -> StepperOutput:
return StepperOutput(self.apply_sign(value, direction), next_direction=None)
@@ -0,0 +1,17 @@
from cx_const import Number
from cx_core.stepper import Stepper, StepperOutput
class BounceStepper(Stepper):
def step(self, value: Number, direction: str) -> StepperOutput:
value = self.min_max.clip(value)
step = self._compute_step()
new_value = value + Stepper.apply_sign(step, direction)
if self.min_max.is_between(new_value):
return StepperOutput(round(new_value, 3), next_direction=direction)
else:
new_value = 2 * self.min_max.clip(new_value) - new_value
return StepperOutput(
round(new_value, 3), next_direction=Stepper.invert_direction(direction)
)
@@ -0,0 +1,31 @@
from cx_const import Number, StepperDir
from cx_core.stepper import MinMax, Stepper, StepperOutput
class IndexLoopStepper(Stepper):
def __init__(
self,
size: int,
previous_direction: str = StepperDir.DOWN,
relative_steps: bool = True,
) -> None:
super().__init__(
MinMax(0, size - 1),
size,
previous_direction,
relative_steps,
)
def step(self, value: Number, direction: str) -> StepperOutput:
value = self.min_max.clip(value)
sign = self.sign(direction)
# We add +1 to make the max be included
max_ = int(self.min_max.max) + 1
min_ = int(self.min_max.min)
if self.relative_steps:
step = (max_ - min_) // self.steps
else:
step = self.steps
new_value = (int(value) + step * sign) % (max_ - min_) + min_
return StepperOutput(new_value, next_direction=direction)
@@ -0,0 +1,16 @@
from cx_const import Number
from cx_core.stepper import Stepper, StepperOutput
class LoopStepper(Stepper):
def step(self, value: Number, direction: str) -> StepperOutput:
value = self.min_max.clip(value)
max_ = self.min_max.max
min_ = self.min_max.min
step = self._compute_step()
new_value = (
((value + Stepper.apply_sign(step, direction)) - min_) % (max_ - min_)
) + min_
new_value = round(new_value, 3)
return StepperOutput(new_value, next_direction=direction)
@@ -0,0 +1,26 @@
from cx_const import Number, StepperDir
from cx_core.stepper import Stepper, StepperOutput
class StopStepper(Stepper):
def get_direction(self, value: Number, direction: str) -> str:
value = self.min_max.clip(value)
if direction == StepperDir.TOGGLE and self.min_max.in_min_boundaries(value):
self.previous_direction = StepperDir.UP
return self.previous_direction
if direction == StepperDir.TOGGLE and self.min_max.in_max_boundaries(value):
self.previous_direction = StepperDir.DOWN
return self.previous_direction
return super().get_direction(value, direction)
def step(self, value: Number, direction: str) -> StepperOutput:
value = self.min_max.clip(value)
step = self._compute_step()
new_value = value + Stepper.apply_sign(step, direction)
new_value = round(new_value, 3)
if self.min_max.is_between(new_value):
return StepperOutput(new_value, next_direction=direction)
else:
new_value = self.min_max.clip(new_value)
return StepperOutput(new_value, next_direction=None)
@@ -0,0 +1,125 @@
from collections.abc import Awaitable, Callable
from typing import Any
from cx_const import Cover, PredefinedActionsMapping
from cx_core.controller import action
from cx_core.feature_support.cover import CoverSupport
from cx_core.type_controller import Entity, TypeController
class CoverController(TypeController[Entity]):
"""
This is the main class that controls the coveres for different devices.
Type of actions:
- Open
- Close
Parameters taken:
- controller (required): Inherited from Controller
- cover (required): cover entity name
- open_position (optional): The open position. Default is 100
- close_position (optional): The close position. Default is 0
"""
domains = ["cover"]
entity_arg = "cover"
open_position: int
close_position: int
cover_duration: int | None
is_supposedly_moving: bool = False
stop_timer_handle: str | None = None
async def init(self) -> None:
self.open_position = self.args.get("open_position", 100)
self.close_position = self.args.get("close_position", 0)
self.cover_duration = self.args.get("cover_duration")
if self.open_position < self.close_position:
raise ValueError("`open_position` must be higher than `close_position`")
await super().init()
def _get_entity_type(self) -> type[Entity]:
return Entity
def get_predefined_actions_mapping(self) -> PredefinedActionsMapping:
return {
Cover.OPEN: self.open,
Cover.CLOSE: self.close,
Cover.STOP: self.stop,
Cover.TOGGLE_OPEN: (self.toggle, (self.open,)),
Cover.TOGGLE_CLOSE: (self.toggle, (self.close,)),
}
async def cover_stopped_cb(self, kwargs: dict[str, Any]) -> None:
self.is_supposedly_moving = False
self.stop_timer_handle = None
async def start_timer(self) -> None:
if self.cover_duration is None:
return
await self.stop_timer()
self.is_supposedly_moving = True
self.stop_timer_handle = await self.run_in(
self.cover_stopped_cb, self.cover_duration
)
async def stop_timer(self) -> None:
if self.stop_timer_handle is not None:
self.is_supposedly_moving = False
await self.cancel_timer(self.stop_timer_handle)
@action
async def open(self) -> None:
if await self.feature_support.is_supported(CoverSupport.SET_COVER_POSITION):
await self.call_service(
"cover/set_cover_position",
entity_id=self.entity.name,
position=self.open_position,
)
elif await self.feature_support.is_supported(CoverSupport.OPEN):
await self.call_service("cover/open_cover", entity_id=self.entity.name)
else:
self.log(
f"⚠️ `{self.entity}` does not support SET_COVER_POSITION or OPEN",
level="WARNING",
ascii_encode=False,
)
return
await self.start_timer()
@action
async def close(self) -> None:
if await self.feature_support.is_supported(CoverSupport.SET_COVER_POSITION):
await self.call_service(
"cover/set_cover_position",
entity_id=self.entity.name,
position=self.close_position,
)
elif await self.feature_support.is_supported(CoverSupport.CLOSE):
await self.call_service("cover/close_cover", entity_id=self.entity.name)
else:
self.log(
f"⚠️ `{self.entity}` does not support SET_COVER_POSITION or CLOSE",
level="WARNING",
ascii_encode=False,
)
return
await self.start_timer()
@action
async def stop(self) -> None:
await self.stop_timer()
await self.call_service("cover/stop_cover", entity_id=self.entity.name)
@action
async def toggle(self, action: Callable[[], Awaitable[None]]) -> None:
cover_state = await self.get_entity_state()
if (
cover_state == "opening"
or cover_state == "closing"
or self.is_supposedly_moving
):
await self.stop()
else:
await action()
@@ -0,0 +1,958 @@
import asyncio
from functools import lru_cache
from typing import Any, Literal
from cx_const import Light, Number, PredefinedActionsMapping, StepperDir, StepperMode
from cx_core.color_helper import Color, get_color_wheel
from cx_core.controller import action
from cx_core.feature_support.light import LightSupport
from cx_core.integration import EventData
from cx_core.integration.deconz import DeCONZIntegration
from cx_core.integration.z2m import Z2MIntegration
from cx_core.integration.zha import ZHAIntegration
from cx_core.release_hold_controller import ReleaseHoldController
from cx_core.stepper import MinMax, Stepper
from cx_core.stepper.bounce_stepper import BounceStepper
from cx_core.stepper.index_loop_stepper import IndexLoopStepper
from cx_core.stepper.loop_stepper import LoopStepper
from cx_core.stepper.stop_stepper import StopStepper
from cx_core.type_controller import Entity, TypeController
DEFAULT_MANUAL_STEPS = 10
DEFAULT_AUTOMATIC_STEPS = 10
DEFAULT_MIN_BRIGHTNESS = 1
DEFAULT_MAX_BRIGHTNESS = 255
DEFAULT_MIN_WHITE_VALUE = 1
DEFAULT_MAX_WHITE_VALUE = 255
DEFAULT_MIN_COLOR_TEMP = 153
DEFAULT_MAX_COLOR_TEMP = 500
DEFAULT_COLOR_TEMP = (
DEFAULT_MAX_COLOR_TEMP - DEFAULT_MIN_COLOR_TEMP
) // 2 + DEFAULT_MIN_COLOR_TEMP
DEFAULT_TRANSITION = 300
DEFAULT_ADD_TRANSITION = True
DEFAULT_TRANSITION_TURN_TOGGLE = False
DEFAULT_HOLD_TOGGLE_DIRECTION_INIT = "up"
ColorMode = Literal["auto", "xy_color", "color_temp"]
COLOR_MODES = {"hs", "xy", "rgb", "rgbw", "rgbww"}
STEPPER_MODES: dict[str, type[Stepper]] = {
StepperMode.STOP: StopStepper,
StepperMode.LOOP: LoopStepper,
StepperMode.BOUNCE: BounceStepper,
}
class LightEntity(Entity):
color_mode: ColorMode
def __init__(
self,
name: str,
entities: list[str] | None = None,
color_mode: ColorMode = "auto",
) -> None:
super().__init__(name, entities)
self.color_mode = color_mode
class LightController(TypeController[LightEntity], ReleaseHoldController):
"""
This is the main class that controls the lights for different devices.
Type of actions:
- On/Off/Toggle
- Brightness click and hold
- Color temperature click and hold
- xy color click and hold
If a light supports xy_color and color_temperature, then xy_color will be the
default functionality. Parameters taken:
- controller (required): Inherited from Controller
- light (required): This is either the light entity name or a dictionary as
{name: string, color_mode: auto | xy_color | color_temp}
- delay (optional): Inherited from ReleaseHoldController
- manual_steps (optional): Number of steps to go from min to max when clicking.
- automatic_steps (optional): Number of steps to go from min to max when smoothing.
"""
ATTRIBUTE_BRIGHTNESS = "brightness"
ATTRIBUTE_WHITE_VALUE = "white_value"
# With the following attribute, it will select color_temp or xy_color, depending on the light.
ATTRIBUTE_COLOR = "color"
ATTRIBUTE_COLOR_TEMP = "color_temp"
ATTRIBUTE_XY_COLOR = "xy_color"
ATTRIBUTES_LIST = [
ATTRIBUTE_BRIGHTNESS,
ATTRIBUTE_WHITE_VALUE,
ATTRIBUTE_COLOR,
ATTRIBUTE_COLOR_TEMP,
ATTRIBUTE_XY_COLOR,
]
index_color = 0
value_attribute = None
# These are intermediate variables to store the checked value
smooth_power_on_check: bool
remove_transition_check: bool
next_direction: str | None = None
manual_steps: Number
automatic_steps: Number
min_max_attributes: dict[str, MinMax]
domains = ["light"]
entity_arg = "light"
_supported_color_modes: set[str] | None
async def init(self) -> None:
self.manual_steps = self.args.get("manual_steps", DEFAULT_MANUAL_STEPS)
self.automatic_steps = self.args.get("automatic_steps", DEFAULT_AUTOMATIC_STEPS)
self.min_max_attributes = {
self.ATTRIBUTE_BRIGHTNESS: MinMax(
self.args.get("min_brightness", DEFAULT_MIN_BRIGHTNESS),
self.args.get("max_brightness", DEFAULT_MAX_BRIGHTNESS),
),
self.ATTRIBUTE_WHITE_VALUE: MinMax(
self.args.get("min_white_value", DEFAULT_MIN_WHITE_VALUE),
self.args.get("max_white_value", DEFAULT_MAX_WHITE_VALUE),
),
self.ATTRIBUTE_COLOR_TEMP: MinMax(
self.args.get("min_color_temp", DEFAULT_MIN_COLOR_TEMP),
self.args.get("max_color_temp", DEFAULT_MAX_COLOR_TEMP),
),
}
self.transition = self.args.get("transition", DEFAULT_TRANSITION)
self.color_wheel = get_color_wheel(
self.args.get("color_wheel", "default_color_wheel")
)
self._supported_color_modes = self.args.get("supported_color_modes")
self.smooth_power_on = self.args.get(
"smooth_power_on", self.supports_smooth_power_on()
)
self.add_transition = self.args.get("add_transition", DEFAULT_ADD_TRANSITION)
self.add_transition_turn_toggle = self.args.get(
"add_transition_turn_toggle", DEFAULT_TRANSITION_TURN_TOGGLE
)
self.hold_toggle_direction_init = self.get_option(
self.args.get(
"hold_toggle_direction_init", DEFAULT_HOLD_TOGGLE_DIRECTION_INIT
),
[StepperDir.UP, StepperDir.DOWN],
"`hold_toggle_direction_init`",
)
await super().init()
def _get_entity_type(self) -> type[LightEntity]:
return LightEntity
def get_predefined_actions_mapping(self) -> PredefinedActionsMapping:
return {
Light.ON: self.on,
Light.OFF: self.off,
Light.TOGGLE: self.toggle,
Light.TOGGLE_FULL_BRIGHTNESS: (
self.toggle_full,
(LightController.ATTRIBUTE_BRIGHTNESS,),
),
Light.TOGGLE_FULL_WHITE_VALUE: (
self.toggle_full,
(LightController.ATTRIBUTE_WHITE_VALUE,),
),
Light.TOGGLE_FULL_COLOR_TEMP: (
self.toggle_full,
(LightController.ATTRIBUTE_COLOR_TEMP,),
),
Light.TOGGLE_MIN_BRIGHTNESS: (
self.toggle_min,
(LightController.ATTRIBUTE_BRIGHTNESS,),
),
Light.TOGGLE_MIN_WHITE_VALUE: (
self.toggle_min,
(LightController.ATTRIBUTE_WHITE_VALUE,),
),
Light.TOGGLE_MIN_COLOR_TEMP: (
self.toggle_min,
(LightController.ATTRIBUTE_COLOR_TEMP,),
),
Light.RELEASE: self.release,
Light.ON_FULL_BRIGHTNESS: (
self.on_full,
(LightController.ATTRIBUTE_BRIGHTNESS,),
),
Light.ON_FULL_WHITE_VALUE: (
self.on_full,
(LightController.ATTRIBUTE_WHITE_VALUE,),
),
Light.ON_FULL_COLOR_TEMP: (
self.on_full,
(LightController.ATTRIBUTE_COLOR_TEMP,),
),
Light.ON_MIN_BRIGHTNESS: (
self.on_min,
(LightController.ATTRIBUTE_BRIGHTNESS,),
),
Light.ON_MIN_WHITE_VALUE: (
self.on_min,
(LightController.ATTRIBUTE_WHITE_VALUE,),
),
Light.ON_MIN_COLOR_TEMP: (
self.on_min,
(LightController.ATTRIBUTE_COLOR_TEMP,),
),
Light.ON_MIN_MAX_BRIGHTNESS: (
self.on_min_max,
(LightController.ATTRIBUTE_BRIGHTNESS,),
),
Light.ON_MAX_MIN_BRIGHTNESS: (
self.on_max_min,
(LightController.ATTRIBUTE_BRIGHTNESS,),
),
Light.ON_MIN_MAX_COLOR_TEMP: (
self.on_min_max,
(LightController.ATTRIBUTE_COLOR_TEMP,),
),
Light.ON_MAX_MIN_COLOR_TEMP: (
self.on_max_min,
(LightController.ATTRIBUTE_COLOR_TEMP,),
),
Light.SET_HALF_BRIGHTNESS: (
self.set_value,
(
LightController.ATTRIBUTE_BRIGHTNESS,
0.5,
),
),
Light.SET_HALF_WHITE_VALUE: (
self.set_value,
(
LightController.ATTRIBUTE_WHITE_VALUE,
0.5,
),
),
Light.SET_HALF_COLOR_TEMP: (
self.set_value,
(
LightController.ATTRIBUTE_COLOR_TEMP,
0.5,
),
),
Light.SYNC: self.sync,
Light.CLICK: self.click,
Light.CLICK_BRIGHTNESS_UP: (
self.click,
(
LightController.ATTRIBUTE_BRIGHTNESS,
StepperDir.UP,
),
),
Light.CLICK_BRIGHTNESS_DOWN: (
self.click,
(
LightController.ATTRIBUTE_BRIGHTNESS,
StepperDir.DOWN,
),
),
Light.CLICK_WHITE_VALUE_UP: (
self.click,
(
LightController.ATTRIBUTE_WHITE_VALUE,
StepperDir.UP,
),
),
Light.CLICK_WHITE_VALUE_DOWN: (
self.click,
(
LightController.ATTRIBUTE_WHITE_VALUE,
StepperDir.DOWN,
),
),
Light.CLICK_COLOR_UP: (
self.click,
(
LightController.ATTRIBUTE_COLOR,
StepperDir.UP,
),
),
Light.CLICK_COLOR_DOWN: (
self.click,
(
LightController.ATTRIBUTE_COLOR,
StepperDir.DOWN,
),
),
Light.CLICK_COLOR_TEMP_UP: (
self.click,
(
LightController.ATTRIBUTE_COLOR_TEMP,
StepperDir.UP,
),
),
Light.CLICK_COLOR_TEMP_DOWN: (
self.click,
(
LightController.ATTRIBUTE_COLOR_TEMP,
StepperDir.DOWN,
),
),
Light.CLICK_XY_COLOR_UP: (
self.click,
(
LightController.ATTRIBUTE_XY_COLOR,
StepperDir.UP,
),
),
Light.CLICK_XY_COLOR_DOWN: (
self.click,
(
LightController.ATTRIBUTE_XY_COLOR,
StepperDir.DOWN,
),
),
Light.HOLD: self.hold,
Light.HOLD_BRIGHTNESS_UP: (
self.hold,
(
LightController.ATTRIBUTE_BRIGHTNESS,
StepperDir.UP,
),
),
Light.HOLD_BRIGHTNESS_DOWN: (
self.hold,
(
LightController.ATTRIBUTE_BRIGHTNESS,
StepperDir.DOWN,
),
),
Light.HOLD_BRIGHTNESS_TOGGLE: (
self.hold,
(
LightController.ATTRIBUTE_BRIGHTNESS,
StepperDir.TOGGLE,
),
),
Light.HOLD_WHITE_VALUE_UP: (
self.hold,
(
LightController.ATTRIBUTE_WHITE_VALUE,
StepperDir.UP,
),
),
Light.HOLD_WHITE_VALUE_DOWN: (
self.hold,
(
LightController.ATTRIBUTE_WHITE_VALUE,
StepperDir.DOWN,
),
),
Light.HOLD_WHITE_VALUE_TOGGLE: (
self.hold,
(
LightController.ATTRIBUTE_WHITE_VALUE,
StepperDir.TOGGLE,
),
),
Light.HOLD_COLOR_UP: (
self.hold,
(
LightController.ATTRIBUTE_COLOR,
StepperDir.UP,
),
),
Light.HOLD_COLOR_DOWN: (
self.hold,
(
LightController.ATTRIBUTE_COLOR,
StepperDir.DOWN,
),
),
Light.HOLD_COLOR_TOGGLE: (
self.hold,
(
LightController.ATTRIBUTE_COLOR,
StepperDir.TOGGLE,
),
),
Light.HOLD_COLOR_TEMP_UP: (
self.hold,
(
LightController.ATTRIBUTE_COLOR_TEMP,
StepperDir.UP,
),
),
Light.HOLD_COLOR_TEMP_DOWN: (
self.hold,
(
LightController.ATTRIBUTE_COLOR_TEMP,
StepperDir.DOWN,
),
),
Light.HOLD_COLOR_TEMP_TOGGLE: (
self.hold,
(
LightController.ATTRIBUTE_COLOR_TEMP,
StepperDir.TOGGLE,
),
),
Light.HOLD_XY_COLOR_UP: (
self.hold,
(
LightController.ATTRIBUTE_XY_COLOR,
StepperDir.UP,
),
),
Light.HOLD_XY_COLOR_DOWN: (
self.hold,
(
LightController.ATTRIBUTE_XY_COLOR,
StepperDir.DOWN,
),
),
Light.HOLD_XY_COLOR_TOGGLE: (
self.hold,
(
LightController.ATTRIBUTE_XY_COLOR,
StepperDir.TOGGLE,
),
),
Light.XYCOLOR_FROM_CONTROLLER: self.xycolor_from_controller,
Light.COLORTEMP_FROM_CONTROLLER: self.colortemp_from_controller,
Light.COLORTEMP_FROM_CONTROLLER_STEP: (
self.attribute_from_controller_step,
(LightController.ATTRIBUTE_COLOR_TEMP,),
),
Light.BRIGHTNESS_FROM_CONTROLLER_LEVEL: self.brightness_from_controller_level,
Light.BRIGHTNESS_FROM_CONTROLLER_ANGLE: self.brightness_from_controller_angle,
Light.BRIGHTNESS_FROM_CONTROLLER_STEP: (
self.attribute_from_controller_step,
(LightController.ATTRIBUTE_BRIGHTNESS,),
),
}
async def check_remove_transition(self, on_from_user: bool) -> bool:
return (
not self.add_transition
or (on_from_user and not self.add_transition_turn_toggle)
or await self.feature_support.not_supported(LightSupport.TRANSITION)
)
async def call_light_service(
self, service: str, force_transition: bool = False, **attributes: Any
) -> None:
if "transition" not in attributes:
attributes["transition"] = self.transition / 1000
if self.remove_transition_check and not force_transition:
del attributes["transition"]
await self.call_service(service, entity_id=self.entity.name, **attributes)
async def _on(self, **attributes: Any) -> None:
await self.call_light_service("light/turn_on", **attributes)
@action
async def on(self, attributes: dict[str, float] | None = None) -> None:
attributes = {} if attributes is None else attributes
await self._on(**attributes)
async def _off(self, **attributes: Any) -> None:
await self.call_light_service("light/turn_off", **attributes)
@action
async def off(self) -> None:
await self._off()
async def _toggle(self, **attributes: Any) -> None:
await self.call_light_service("light/toggle", **attributes)
@action
async def toggle(self, attributes: dict[str, float] | None = None) -> None:
attributes = {} if attributes is None else attributes
await self._toggle(**attributes)
async def _set_value(self, attribute: str, fraction: float) -> None:
fraction = max(0, min(fraction, 1))
min_ = self.min_max_attributes[attribute].min
max_ = self.min_max_attributes[attribute].max
value = (max_ - min_) * fraction + min_
await self._on(**{attribute: value})
@action
async def set_value(self, attribute: str, fraction: float) -> None:
await self._set_value(attribute, fraction)
@action
async def toggle_full(self, attribute: str) -> None:
await self._toggle(**{attribute: self.min_max_attributes[attribute].max})
@action
async def toggle_min(self, attribute: str) -> None:
await self._toggle(**{attribute: self.min_max_attributes[attribute].min})
async def _on_full(self, attribute: str) -> None:
await self._set_value(attribute, 1)
@action
async def on_full(self, attribute: str) -> None:
await self._on_full(attribute)
async def _on_min(self, attribute: str) -> None:
await self._set_value(attribute, 0)
@action
async def on_min(self, attribute: str) -> None:
await self._on_min(attribute)
@action
async def on_min_max(self, attribute: str) -> None:
min_ = self.min_max_attributes[attribute].min
max_ = self.min_max_attributes[attribute].max
await self._on_min_max(attribute, default=min_, other=max_)
@action
async def on_max_min(self, attribute: str) -> None:
min_ = self.min_max_attributes[attribute].min
max_ = self.min_max_attributes[attribute].max
await self._on_min_max(attribute, default=max_, other=min_)
async def _on_min_max(
self, attribute: str, *, default: Number, other: Number
) -> None:
light_state: str
attribute_value: Number
light_state, attribute_value = await asyncio.gather(
self.get_entity_state(), self.get_entity_state(attribute=attribute)
)
if light_state == "off" or attribute_value != default:
await self._on(**{attribute: default})
else:
await self._on(**{attribute: other})
@action
async def sync(
self,
brightness: int | None = None,
color_temp: int = 370, # 2700K light
xy_color: Color = (0.323, 0.329), # white colour
) -> None:
attributes: dict[Any, Any] = {}
try:
color_attribute = await self.get_attribute(LightController.ATTRIBUTE_COLOR)
if color_attribute == LightController.ATTRIBUTE_COLOR_TEMP:
attributes[color_attribute] = color_temp
else:
attributes[color_attribute] = list(xy_color)
except ValueError:
self.log(
"⚠️ `sync` action will only change brightness",
level="WARNING",
ascii_encode=False,
)
await self._on(
**attributes,
brightness=(
brightness
or self.min_max_attributes[LightController.ATTRIBUTE_BRIGHTNESS].max
),
)
@action
async def xycolor_from_controller(self, extra: EventData | None = None) -> None:
if extra is None:
self.log("No event data present", level="WARNING")
return
if isinstance(self.integration, Z2MIntegration):
if "action_color" not in extra:
self.log(
"`action_color` is not present in the MQTT payload", level="WARNING"
)
return
xy_color = extra["action_color"]
await self._on(xy_color=[xy_color["x"], xy_color["y"]])
elif isinstance(self.integration, DeCONZIntegration):
if "xy" not in extra:
self.log("`xy` is not present in the deCONZ event", level="WARNING")
return
await self._on(xy_color=list(extra["xy"]))
@action
async def colortemp_from_controller(self, extra: EventData | None = None) -> None:
if extra is None:
self.log("No event data present", level="WARNING")
return
if isinstance(self.integration, Z2MIntegration):
if "action_color_temperature" not in extra:
self.log(
"`action_color_temperature` is not present in the MQTT payload",
level="WARNING",
)
return
await self._on(color_temp=extra["action_color_temperature"])
@action
async def brightness_from_controller_level(
self, extra: EventData | None = None
) -> None:
if extra is None:
self.log("No event data present", level="WARNING")
return
if isinstance(self.integration, Z2MIntegration):
if "action_level" not in extra:
self.log(
"`action_level` is not present in the MQTT payload",
level="WARNING",
)
return
await self._on(brightness=extra["action_level"])
@action
async def attribute_from_controller_step(
self, attribute: str, extra: EventData | None = None
) -> None:
if extra is None:
self.log("No event data present", level="WARNING")
return
if isinstance(self.integration, ZHAIntegration):
try:
step_mode: int = extra["params"]["step_mode"]
step_size: int = extra["params"]["step_size"]
stepper = self.generate_stepper(
attribute, step_size, StepperMode.STOP, relative_steps=False
)
direction = StepperDir.UP
if (
attribute == LightController.ATTRIBUTE_BRIGHTNESS and step_mode == 1
) or (
attribute == LightController.ATTRIBUTE_COLOR_TEMP and step_mode == 3
):
direction = StepperDir.DOWN
# Transition time in seconds
transition_time: int = extra["params"]["transition_time"]
self.value_attribute = await self.get_value_attribute(attribute)
extra_attributes = {
"transition": transition_time,
"force_transition": True,
}
await self.change_light_state(
self.value_attribute,
attribute,
direction,
stepper,
extra_attributes=extra_attributes,
)
except (KeyError, TypeError):
self.log(
"`params` should be present in ZHA event with "
"`step_mode`, `step_size`, and `transition_time`",
level="WARNING",
)
@action
async def brightness_from_controller_angle(
self,
mode: str = StepperMode.STOP,
steps: Number | None = None,
extra: EventData | None = None,
) -> None:
if extra is None:
self.log("No event data present", level="WARNING")
return
if isinstance(self.integration, Z2MIntegration):
if "action_rotation_angle" not in extra:
self.log(
"`action_rotation_angle` is not present in the MQTT payload",
level="WARNING",
)
return
angle = extra["action_rotation_angle"]
direction = StepperDir.UP if angle > 0 else StepperDir.DOWN
await self._hold(
LightController.ATTRIBUTE_BRIGHTNESS, direction, mode=mode, steps=steps
)
@property
async def supported_color_modes(self) -> set[str]:
if self._supported_color_modes is None or self.update_supported_features:
supported_color_modes: list[str] = await self.get_entity_state(
attribute="supported_color_modes"
)
if supported_color_modes is not None:
self._supported_color_modes = set(supported_color_modes)
else:
raise ValueError(
f"`supported_color_modes` could not be read from `{self.entity}`. "
"Entity might not be available."
)
return self._supported_color_modes
async def is_color_supported(self) -> bool:
return len(COLOR_MODES.intersection(await self.supported_color_modes)) > 0
async def is_colortemp_supported(self) -> bool:
return "color_temp" in await self.supported_color_modes
def generate_stepper(
self, attribute: str, steps: Number, mode: str, *, relative_steps: bool = True
) -> Stepper:
previous_direction = Stepper.invert_direction(self.hold_toggle_direction_init)
if attribute == LightController.ATTRIBUTE_XY_COLOR:
return IndexLoopStepper(
len(self.color_wheel),
previous_direction,
relative_steps,
)
if mode not in STEPPER_MODES:
raise ValueError(
f"`{mode}` mode is not available. Options are: {list(STEPPER_MODES.keys())}"
)
stepper_cls = STEPPER_MODES[mode]
return stepper_cls(
self.min_max_attributes[attribute],
steps,
previous_direction,
relative_steps,
)
@lru_cache(maxsize=None)
def get_stepper(
self, attribute: str, steps: Number, mode: str, *, tag: str
) -> Stepper:
return self.generate_stepper(attribute, steps, mode)
async def get_attribute(self, attribute: str) -> str:
if attribute == LightController.ATTRIBUTE_COLOR:
if self.entity.color_mode == "auto":
if await self.is_color_supported():
return LightController.ATTRIBUTE_XY_COLOR
elif await self.is_colortemp_supported():
return LightController.ATTRIBUTE_COLOR_TEMP
else:
raise ValueError(
"This light does not support xy_color or color_temp"
)
else:
return self.entity.color_mode
else:
return attribute
async def get_value_attribute(self, attribute: str) -> Number:
if self.smooth_power_on_check:
return 0
if attribute == LightController.ATTRIBUTE_XY_COLOR:
return 0
elif (
attribute == LightController.ATTRIBUTE_BRIGHTNESS
or attribute == LightController.ATTRIBUTE_WHITE_VALUE
or attribute == LightController.ATTRIBUTE_COLOR_TEMP
):
value = await self.get_entity_state(attribute=attribute)
if value is None and attribute == LightController.ATTRIBUTE_COLOR_TEMP:
return float(DEFAULT_COLOR_TEMP)
elif value is None and attribute != LightController.ATTRIBUTE_COLOR_TEMP:
raise ValueError(
f"Value for `{attribute}` attribute could not be retrieved "
f"from `{self.entity.main}`. "
"Check the FAQ to know more about this error: "
"https://xaviml.github.io/controllerx/faq"
)
else:
try:
return float(value)
except ValueError:
raise ValueError(
f"Attribute `{attribute}` with `{value}` as a value "
"could not be converted to float"
)
else:
raise ValueError(f"Attribute `{attribute}` not expected")
def check_smooth_power_on(
self, attribute: str, direction: str, light_state: str
) -> bool:
return (
direction != StepperDir.DOWN
and attribute == self.ATTRIBUTE_BRIGHTNESS
and self.smooth_power_on
and light_state == "off"
)
async def before_action(self, action: str, *args: Any, **kwargs: Any) -> bool:
to_return = True
self.next_direction = None
light_state: str
if action in ("click", "hold"):
if len(args) == 2:
attribute, direction = args
elif "attribute" in kwargs and "direction" in kwargs:
attribute, direction = kwargs["attribute"], kwargs["direction"]
else:
raise ValueError(
f"`attribute` and `direction` are mandatory fields for `{action}` action"
)
light_state = await self.get_entity_state()
self.smooth_power_on_check = self.check_smooth_power_on(
attribute, direction, light_state
)
self.remove_transition_check = await self.check_remove_transition(
on_from_user=False
)
to_return = (light_state == "on") or self.smooth_power_on_check
elif action == "attribute_from_controller_step":
light_state = await self.get_entity_state()
to_return = light_state == "on"
self.smooth_power_on_check = False
self.remove_transition_check = False
else:
self.remove_transition_check = await self.check_remove_transition(
on_from_user=True
)
self.smooth_power_on_check = False
return await super().before_action(action, *args, **kwargs) and to_return
@action
async def click(
self,
attribute: str,
direction: str,
mode: str = StepperMode.STOP,
steps: Number | None = None,
) -> None:
attribute = self.get_option(
attribute, LightController.ATTRIBUTES_LIST, "`click` action"
)
direction = self.get_option(
direction, [StepperDir.UP, StepperDir.DOWN], "`click` action"
)
mode = self.get_option(
mode, [StepperMode.STOP, StepperMode.LOOP], "`click` action"
)
attribute = await self.get_attribute(attribute)
self.value_attribute = await self.get_value_attribute(attribute)
await self.change_light_state(
self.value_attribute,
attribute,
direction,
self.get_stepper(attribute, steps or self.manual_steps, mode, tag="click"),
)
@action
async def hold( # type: ignore[override]
self,
attribute: str,
direction: str,
mode: str = StepperMode.STOP,
steps: Number | None = None,
) -> None:
await self._hold(attribute, direction, mode, steps)
async def _hold(
self,
attribute: str,
direction: str,
mode: str = StepperMode.STOP,
steps: Number | None = None,
) -> None:
attribute = self.get_option(
attribute, LightController.ATTRIBUTES_LIST, "`hold` action"
)
direction = self.get_option(
direction,
[StepperDir.UP, StepperDir.DOWN, StepperDir.TOGGLE],
"`hold` action",
)
mode = self.get_option(
mode,
[StepperMode.STOP, StepperMode.LOOP, StepperMode.BOUNCE],
"`hold` action",
)
attribute = await self.get_attribute(attribute)
self.value_attribute = await self.get_value_attribute(attribute)
self.log(
f"Attribute value before running the hold action: {self.value_attribute}",
level="DEBUG",
)
stepper = self.get_stepper(
attribute, steps or self.automatic_steps, mode, tag="hold"
)
if direction == StepperDir.TOGGLE:
self.log(
f"Previous direction: {stepper.previous_direction}",
level="DEBUG",
)
direction = stepper.get_direction(self.value_attribute, direction)
self.log(f"Going direction: {direction}", level="DEBUG")
await super().hold(attribute, direction, stepper)
async def hold_loop(
self,
attribute: str,
direction: str,
stepper: Stepper,
) -> bool:
if self.value_attribute is None:
return True
extra_attributes = {"transition": self.delay / 1000}
return await self.change_light_state(
self.value_attribute,
attribute,
direction,
stepper,
extra_attributes=extra_attributes,
)
async def change_light_state(
self,
old: float,
attribute: str,
direction: str,
stepper: Stepper,
*,
extra_attributes: dict[str, Any] | None = None,
) -> bool:
"""
This functions changes the state of the light depending on the previous
value and attribute. It returns True when no more changes will need to be done.
Otherwise, it returns False.
"""
attributes: dict[str, Any] = (
{} if extra_attributes is None else extra_attributes
)
direction = self.next_direction or direction
if attribute == LightController.ATTRIBUTE_XY_COLOR:
stepper_output = stepper.step(self.index_color, direction)
self.index_color = int(stepper_output.next_value)
xy_color = self.color_wheel[self.index_color]
attributes[attribute] = list(xy_color)
await self._on(**attributes)
# In case of xy_color mode it never finishes the loop, the hold loop
# will only stop if the hold action is called when releasing the button.
# I haven't experimented any problems with it, but a future implementation
# would be to force the loop to stop after 4 or 5 loops as a safety measure.
return False
if self.smooth_power_on_check:
await self._on_min(attribute)
# # After smooth power on, the light should not brighten up.
return True
stepper_output = stepper.step(old, direction)
self.next_direction = stepper_output.next_direction
next_value = int(stepper_output.next_value)
attributes[attribute] = next_value
await self._on(**attributes)
self.value_attribute = next_value
return stepper_output.exceeded
def supports_smooth_power_on(self) -> bool:
"""
This function can be overrided for each device to indicate the default behaviour of the controller
when the associated light is off and an event for incrementing brightness is received.
Returns True if the associated light should be turned on with minimum brightness if an event for incrementing
brightness is received, while the lamp is off.
The behaviour can be overridden by the user with the 'smooth_power_on' option in app configuration.
"""
return False
@@ -0,0 +1,196 @@
from typing import Any
from cx_const import MediaPlayer, Number, PredefinedActionsMapping, StepperDir
from cx_core.controller import action
from cx_core.feature_support.media_player import MediaPlayerSupport
from cx_core.integration import EventData
from cx_core.integration.z2m import Z2MIntegration
from cx_core.release_hold_controller import ReleaseHoldController
from cx_core.stepper import MinMax
from cx_core.stepper.index_loop_stepper import IndexLoopStepper
from cx_core.stepper.stop_stepper import StopStepper
from cx_core.type_controller import Entity, TypeController
DEFAULT_VOLUME_STEPS = 10
class MediaPlayerController(TypeController[Entity], ReleaseHoldController):
domains = ["media_player"]
entity_arg = "media_player"
volume_level: float
async def init(self) -> None:
volume_steps = self.args.get("volume_steps", DEFAULT_VOLUME_STEPS)
self.volume_stepper = StopStepper(MinMax(0, 1), volume_steps)
self.volume_level = 0.0
await super().init()
def _get_entity_type(self) -> type[Entity]:
return Entity
def get_predefined_actions_mapping(self) -> PredefinedActionsMapping:
return {
MediaPlayer.HOLD_VOLUME_DOWN: (self.hold, (StepperDir.DOWN,)),
MediaPlayer.HOLD_VOLUME_UP: (self.hold, (StepperDir.UP,)),
MediaPlayer.CLICK_VOLUME_DOWN: self.volume_down,
MediaPlayer.CLICK_VOLUME_UP: self.volume_up,
MediaPlayer.VOLUME_SET: self.volume_set,
MediaPlayer.RELEASE: self.release,
MediaPlayer.PLAY: self.play,
MediaPlayer.PAUSE: self.pause,
MediaPlayer.PLAY_PAUSE: self.play_pause,
MediaPlayer.NEXT_TRACK: self.next_track,
MediaPlayer.PREVIOUS_TRACK: self.previous_track,
MediaPlayer.NEXT_SOURCE: (self.change_source_list, (StepperDir.UP,)),
MediaPlayer.PREVIOUS_SOURCE: (self.change_source_list, (StepperDir.DOWN,)),
MediaPlayer.MUTE: self.volume_mute,
MediaPlayer.TTS: self.tts,
MediaPlayer.VOLUME_FROM_CONTROLLER_ANGLE: self.volume_from_controller_angle,
}
@action
async def change_source_list(self, direction: str) -> None:
entity_states = await self.get_entity_state(attribute="all")
entity_attributes = entity_states["attributes"]
source_list: list[str] | None = entity_attributes.get("source_list")
if source_list is None or len(source_list) == 0:
self.log(
f"⚠️ There is no `source_list` parameter in `{self.entity}`",
level="WARNING",
ascii_encode=False,
)
return
source = entity_attributes.get("source")
new_index_source: Number
if source is None:
new_index_source = 0
else:
index_source = source_list.index(source)
source_stepper = IndexLoopStepper(len(source_list))
stepper_output = source_stepper.step(index_source, direction)
new_index_source = stepper_output.next_value
await self.call_service(
"media_player/select_source",
entity_id=self.entity.name,
source=source_list[int(new_index_source)],
)
@action
async def play(self) -> None:
await self.call_service("media_player/media_play", entity_id=self.entity.name)
@action
async def pause(self) -> None:
await self.call_service("media_player/media_pause", entity_id=self.entity.name)
@action
async def play_pause(self) -> None:
await self.call_service(
"media_player/media_play_pause", entity_id=self.entity.name
)
@action
async def previous_track(self) -> None:
await self.call_service(
"media_player/media_previous_track", entity_id=self.entity.name
)
@action
async def next_track(self) -> None:
await self.call_service(
"media_player/media_next_track", entity_id=self.entity.name
)
@action
async def volume_set(self, volume_level: float) -> None:
await self.call_service(
"media_player/volume_set",
entity_id=self.entity.name,
volume_level=volume_level,
)
@action
async def volume_up(self) -> None:
await self.prepare_volume_change()
await self.volume_change(StepperDir.UP)
@action
async def volume_down(self) -> None:
await self.prepare_volume_change()
await self.volume_change(StepperDir.DOWN)
@action
async def volume_mute(self) -> None:
await self.call_service("media_player/volume_mute", entity_id=self.entity.name)
@action
async def tts(
self,
message: str,
service: str = "google_translate_say",
cache: bool | None = None,
language: str | None = None,
options: dict[str, Any] | None = None,
) -> None:
args: dict[str, Any] = {"entity_id": self.entity.name, "message": message}
if cache is not None:
args["cache"] = cache
if language is not None:
args["language"] = language
if options is not None:
args["options"] = options
await self.call_service(f"tts.{service}", **args)
@action
async def volume_from_controller_angle(
self, extra: EventData | None = None
) -> None:
if extra is None:
self.log("No event data present", level="WARNING")
return
if isinstance(self.integration, Z2MIntegration):
if "action_rotation_angle" not in extra:
self.log(
"`action_rotation_angle` is not present in the MQTT payload",
level="WARNING",
)
return
angle = extra["action_rotation_angle"]
direction = StepperDir.UP if angle > 0 else StepperDir.DOWN
await self._hold(direction)
@action
async def hold(self, direction: str) -> None: # type: ignore[override]
await self._hold(direction)
async def _hold(self, direction: str) -> None:
await self.prepare_volume_change()
await super().hold(direction)
async def prepare_volume_change(self) -> None:
volume_level = await self.get_entity_state(attribute="volume_level")
if volume_level is not None:
self.volume_level = volume_level
async def volume_change(self, direction: str) -> bool:
if await self.feature_support.is_supported(MediaPlayerSupport.VOLUME_SET):
stepper_output = self.volume_stepper.step(self.volume_level, direction)
self.volume_level = stepper_output.next_value
await self.volume_set(self.volume_level)
return stepper_output.exceeded
else:
if direction == StepperDir.UP:
await self.call_service(
"media_player/volume_up", entity_id=self.entity.name
)
else:
await self.call_service(
"media_player/volume_down", entity_id=self.entity.name
)
return False
async def hold_loop(self, direction: str) -> bool:
return await self.volume_change(direction)
def default_delay(self) -> int:
return 500
@@ -0,0 +1,48 @@
from cx_const import PredefinedActionsMapping, Switch
from cx_core.controller import action
from cx_core.type_controller import Entity, TypeController
class SwitchController(TypeController[Entity]):
"""
This is the main class that controls the switches for different devices.
Type of actions:
- On/Off/Toggle
Parameters taken:
- controller (required): Inherited from Controller
- switch (required): Switch entity name
"""
domains = [
"switch",
"alert",
"automation",
"cover",
"input_boolean",
"light",
"media_player",
"script",
]
entity_arg = "switch"
def get_predefined_actions_mapping(self) -> PredefinedActionsMapping:
return {
Switch.ON: self.on,
Switch.OFF: self.off,
Switch.TOGGLE: self.toggle,
}
def _get_entity_type(self) -> type[Entity]:
return Entity
@action
async def on(self) -> None:
await self.call_service("homeassistant/turn_on", entity_id=self.entity.name)
@action
async def off(self) -> None:
await self.call_service("homeassistant/turn_off", entity_id=self.entity.name)
@action
async def toggle(self) -> None:
await self.call_service("homeassistant/toggle", entity_id=self.entity.name)
@@ -0,0 +1,429 @@
import json
from collections.abc import Awaitable, Callable
from functools import lru_cache
from typing import Annotated, Any, Literal
from cx_const import PredefinedActionsMapping, StepperDir, Z2MLight
from cx_core.controller import Controller, action
from cx_core.integration import EventData
from cx_core.integration.z2m import Z2MIntegration
from cx_core.stepper import InvertStepper, MinMax
from cx_core.type_controller import Entity, TypeController
DEFAULT_CLICK_STEPS = 70
DEFAULT_HOLD_STEPS = 70
DEFAULT_TRANSITION = 0.5
DEFAULT_MODE = "ha"
DEFAULT_TOPIC_PREFIX = "zigbee2mqtt"
Mode = Annotated[str, Literal["ha", "mqtt"]]
class Z2MLightEntity(Entity):
mode: Mode
topic_prefix: str
def __init__(
self,
name: str,
entities: list[str] | None = None,
mode: Mode = DEFAULT_MODE,
topic_prefix: str = DEFAULT_TOPIC_PREFIX,
) -> None:
super().__init__(name, entities)
mode = Controller.get_option(mode, ["ha", "mqtt"])
self.mode = mode
self.topic_prefix = topic_prefix
class Z2MLightController(TypeController[Z2MLightEntity]):
"""
This is the main class that controls the Zigbee2MQTT lights for different devices.
Type of actions:
- On/Off/Toggle
- Brightness click and hold
- Color temp click and hold
"""
ATTRIBUTE_BRIGHTNESS = "brightness"
ATTRIBUTE_COLOR_TEMP = "color_temp"
ATTRIBUTES_LIST = [
ATTRIBUTE_BRIGHTNESS,
ATTRIBUTE_COLOR_TEMP,
]
MIN_MAX_ATTR = {
ATTRIBUTE_BRIGHTNESS: MinMax(min=1, max=254),
ATTRIBUTE_COLOR_TEMP: MinMax(min=250, max=454),
}
entity_arg = "light"
click_steps: float
hold_steps: float
transition: float
use_onoff: bool
hold_attribute: str | None
_mqtt_fn: dict[Mode, Callable[[str, str], Awaitable[None]]]
async def init(self) -> None:
self.click_steps = self.args.get("click_steps", DEFAULT_CLICK_STEPS)
self.hold_steps = self.args.get("hold_steps", DEFAULT_HOLD_STEPS)
self.transition = self.args.get("transition", DEFAULT_TRANSITION)
self.use_onoff = self.args.get("use_onoff", False)
self._mqtt_fn = {
"ha": self._ha_mqtt_call,
"mqtt": self._mqtt_plugin_call,
}
self.hold_attribute = None
await super().init()
def _get_entity_type(self) -> type[Z2MLightEntity]:
return Z2MLightEntity
def get_predefined_actions_mapping(self) -> PredefinedActionsMapping:
return {
Z2MLight.ON: self.on,
Z2MLight.OFF: self.off,
Z2MLight.TOGGLE: self.toggle,
Z2MLight.RELEASE: self.release,
Z2MLight.ON_FULL_BRIGHTNESS: (
self.on_full,
(Z2MLightController.ATTRIBUTE_BRIGHTNESS,),
),
Z2MLight.ON_FULL_COLOR_TEMP: (
self.on_full,
(Z2MLightController.ATTRIBUTE_COLOR_TEMP,),
),
Z2MLight.ON_MIN_BRIGHTNESS: (
self.on_min,
(Z2MLightController.ATTRIBUTE_BRIGHTNESS,),
),
Z2MLight.ON_MIN_COLOR_TEMP: (
self.on_min,
(Z2MLightController.ATTRIBUTE_COLOR_TEMP,),
),
Z2MLight.SET_HALF_BRIGHTNESS: (
self.set_value,
(
Z2MLightController.ATTRIBUTE_BRIGHTNESS,
0.5,
),
),
Z2MLight.SET_HALF_COLOR_TEMP: (
self.set_value,
(
Z2MLightController.ATTRIBUTE_COLOR_TEMP,
0.5,
),
),
Z2MLight.CLICK: self.click,
Z2MLight.CLICK_BRIGHTNESS_UP: (
self.click,
(
Z2MLightController.ATTRIBUTE_BRIGHTNESS,
StepperDir.UP,
),
),
Z2MLight.CLICK_COLOR_TEMP_UP: (
self.click,
(
Z2MLightController.ATTRIBUTE_COLOR_TEMP,
StepperDir.UP,
),
),
Z2MLight.CLICK_BRIGHTNESS_DOWN: (
self.click,
(
Z2MLightController.ATTRIBUTE_BRIGHTNESS,
StepperDir.DOWN,
),
),
Z2MLight.CLICK_COLOR_TEMP_DOWN: (
self.click,
(
Z2MLightController.ATTRIBUTE_COLOR_TEMP,
StepperDir.DOWN,
),
),
Z2MLight.HOLD: self.hold,
Z2MLight.HOLD_BRIGHTNESS_UP: (
self.hold,
(
Z2MLightController.ATTRIBUTE_BRIGHTNESS,
StepperDir.UP,
),
),
Z2MLight.HOLD_COLOR_TEMP_UP: (
self.hold,
(
Z2MLightController.ATTRIBUTE_COLOR_TEMP,
StepperDir.UP,
),
),
Z2MLight.HOLD_BRIGHTNESS_DOWN: (
self.hold,
(
Z2MLightController.ATTRIBUTE_BRIGHTNESS,
StepperDir.DOWN,
),
),
Z2MLight.HOLD_BRIGHTNESS_TOGGLE: (
self.hold,
(
Z2MLightController.ATTRIBUTE_BRIGHTNESS,
StepperDir.TOGGLE,
),
),
Z2MLight.HOLD_COLOR_TEMP_DOWN: (
self.hold,
(
Z2MLightController.ATTRIBUTE_COLOR_TEMP,
StepperDir.DOWN,
),
),
Z2MLight.HOLD_COLOR_TEMP_TOGGLE: (
self.hold,
(
Z2MLightController.ATTRIBUTE_COLOR_TEMP,
StepperDir.TOGGLE,
),
),
Z2MLight.XYCOLOR_FROM_CONTROLLER: self.xycolor_from_controller,
Z2MLight.COLORTEMP_FROM_CONTROLLER: self.colortemp_from_controller,
Z2MLight.BRIGHTNESS_FROM_CONTROLLER_LEVEL: self.brightness_from_controller_level,
Z2MLight.BRIGHTNESS_FROM_CONTROLLER_ANGLE: self.brightness_from_controller_angle,
}
async def before_action(self, action: str, *args: Any, **kwargs: Any) -> bool:
to_return = not (action == "hold" and self.hold_attribute is not None)
return await super().before_action(action, *args, **kwargs) and to_return
async def _ha_mqtt_call(self, topic: str, payload: str) -> None:
await self.call_service("mqtt.publish", topic=topic, payload=payload)
async def _mqtt_plugin_call(self, topic: str, payload: str) -> None:
await self.call_service(
"mqtt.publish", topic=topic, payload=payload, namespace="mqtt"
)
async def _mqtt_call(self, payload: dict[str, Any]) -> None:
await self._mqtt_fn[self.entity.mode](
f"{self.entity.topic_prefix}/{self.entity.name}/set", json.dumps(payload)
)
async def _on(self, **attributes: Any) -> None:
await self._mqtt_call({"state": "ON", **attributes})
@action
async def on(self, attributes: dict[str, float] | None = None) -> None:
attributes = attributes or {}
await self._on(**attributes)
async def _off(self) -> None:
await self._mqtt_call({"state": "OFF"})
@action
async def off(self) -> None:
await self._off()
async def _toggle(self, **attributes: Any) -> None:
await self._mqtt_call({"state": "TOGGLE", **attributes})
@action
async def toggle(self, attributes: dict[str, float] | None = None) -> None:
attributes = attributes or {}
await self._toggle(**attributes)
async def _set_value(self, attribute: str, fraction: float) -> None:
fraction = max(0, min(fraction, 1))
min_ = self.MIN_MAX_ATTR[attribute].min
max_ = self.MIN_MAX_ATTR[attribute].max
value = (max_ - min_) * fraction + min_
await self._on(**{attribute: value})
@action
async def set_value(self, attribute: str, fraction: float) -> None:
await self._set_value(attribute, fraction)
async def _on_full(self, attribute: str) -> None:
await self._set_value(attribute, 1)
@action
async def on_full(self, attribute: str) -> None:
await self._on_full(attribute)
async def _on_min(self, attribute: str) -> None:
await self._set_value(attribute, 0)
@action
async def on_min(self, attribute: str) -> None:
await self._on_min(attribute)
@lru_cache(maxsize=None)
def get_stepper(self, attribute: str, steps: float, *, tag: str) -> InvertStepper:
previous_direction = StepperDir.DOWN
return InvertStepper(self.MIN_MAX_ATTR[attribute], steps, previous_direction)
async def _change_light_state(
self,
*,
attribute: str,
direction: str,
stepper: InvertStepper,
transition: float | None,
use_onoff: bool,
mode: str,
) -> None:
onoff_cmd = (
"_onoff" if use_onoff and attribute == self.ATTRIBUTE_BRIGHTNESS else ""
)
stepper_output = stepper.step(stepper.steps, direction)
kwargs = {}
if transition is not None:
kwargs["transition"] = transition
await self._mqtt_call(
{
f"{attribute}_{mode}{onoff_cmd}": stepper_output.next_value,
**kwargs,
}
)
@action
async def click(
self,
attribute: str,
direction: str,
steps: float | None = None,
transition: float | None = None,
use_onoff: bool | None = None,
) -> None:
attribute = self.get_option(attribute, self.ATTRIBUTES_LIST, "`click` action")
direction = self.get_option(
direction, [StepperDir.UP, StepperDir.DOWN], "`click` action"
)
steps = steps if steps is not None else self.click_steps
stepper = self.get_stepper(attribute, steps, tag="click")
await self._change_light_state(
attribute=attribute,
direction=direction,
stepper=stepper,
transition=transition if transition is not None else self.transition,
use_onoff=use_onoff if use_onoff is not None else self.use_onoff,
mode="step",
)
async def _hold(
self,
attribute: str,
direction: str,
steps: float | None = None,
use_onoff: bool | None = None,
) -> None:
attribute = self.get_option(attribute, self.ATTRIBUTES_LIST, "`hold` action")
direction = self.get_option(
direction,
[StepperDir.UP, StepperDir.DOWN, StepperDir.TOGGLE],
"`hold` action",
)
steps = steps if steps is not None else self.hold_steps
stepper = self.get_stepper(attribute, steps, tag="hold")
direction = stepper.get_direction(steps, direction)
self.hold_attribute = attribute
await self._change_light_state(
attribute=attribute,
direction=direction,
stepper=stepper,
transition=None,
use_onoff=use_onoff if use_onoff is not None else self.use_onoff,
mode="move",
)
@action
async def hold(
self,
attribute: str,
direction: str,
steps: float | None = None,
use_onoff: bool | None = None,
) -> None:
await self._hold(attribute, direction, steps, use_onoff)
@action
async def release(self) -> None:
if self.hold_attribute is None:
return
await self._mqtt_call({f"{self.hold_attribute}_move": "stop"})
self.hold_attribute = None
@action
async def xycolor_from_controller(self, extra: EventData | None = None) -> None:
if extra is None:
self.log("No event data present", level="WARNING")
return
if isinstance(self.integration, Z2MIntegration):
if "action_color" not in extra:
self.log(
"`action_color` is not present in the MQTT payload", level="WARNING"
)
return
xy_color = extra["action_color"]
await self._on(color={"x": xy_color["x"], "y": xy_color["y"]})
@action
async def colortemp_from_controller(self, extra: EventData | None = None) -> None:
if extra is None:
self.log("No event data present", level="WARNING")
return
if isinstance(self.integration, Z2MIntegration):
if "action_color_temperature" not in extra:
self.log(
"`action_color_temperature` is not present in the MQTT payload",
level="WARNING",
)
return
await self._on(color_temp=extra["action_color_temperature"])
@action
async def brightness_from_controller_level(
self, extra: EventData | None = None
) -> None:
if extra is None:
self.log("No event data present", level="WARNING")
return
if isinstance(self.integration, Z2MIntegration):
if "action_level" not in extra:
self.log(
"`action_level` is not present in the MQTT payload",
level="WARNING",
)
return
await self._on(brightness=extra["action_level"])
@action
async def brightness_from_controller_angle(
self,
steps: float | None = None,
use_onoff: bool | None = None,
extra: EventData | None = None,
) -> None:
if extra is None:
self.log("No event data present", level="WARNING")
return
if isinstance(self.integration, Z2MIntegration):
if "action_rotation_angle" not in extra:
self.log(
"`action_rotation_angle` is not present in the MQTT payload",
level="WARNING",
)
return
angle = extra["action_rotation_angle"]
direction = StepperDir.UP if angle > 0 else StepperDir.DOWN
await self._hold(
self.ATTRIBUTE_BRIGHTNESS, direction, steps=steps, use_onoff=use_onoff
)
@@ -0,0 +1,133 @@
import abc
from typing import Any, Generic, TypeVar
from cx_core.controller import Controller
from cx_core.feature_support import FeatureSupport
EntityVar = TypeVar("EntityVar", bound="Entity")
class Entity:
name: str
entities: list[str]
def __init__(
self, name: str, entities: list[str] | None = None, **kwargs: Any
) -> None:
self.name = name
self.set_entities(entities)
@property
def main(self) -> str:
return self.entities[0]
@property
def is_group(self) -> bool:
return self.entities[0] != self.name
def set_entities(self, value: list[str] | None = None) -> None:
self.entities = value if value is not None else [self.name]
@classmethod
def instantiate(
cls: type[EntityVar],
name: str,
entities: list[str] | None = None,
**params: Any,
) -> EntityVar:
return cls(name=name, entities=entities, **params)
def __str__(self) -> str:
return self.name if not self.is_group else f"{self.name}({self.entities})"
class TypeController(Controller, abc.ABC, Generic[EntityVar]):
domains: list[str] = []
entity_arg: str
entity: EntityVar
update_supported_features: bool
feature_support: FeatureSupport
async def init(self) -> None:
if self.entity_arg not in self.args:
raise ValueError(
f"{self.__class__.__name__} class needs the `{self.entity_arg}` attribute"
)
self.entity = await self._get_entity(self.args[self.entity_arg])
self._check_domain(self.entity)
self.update_supported_features = self.args.get(
"update_supported_features", False
)
supported_features: int | None = self.args.get("supported_features")
self.feature_support = FeatureSupport(
self, supported_features, self.update_supported_features
)
await super().init()
@abc.abstractmethod
def _get_entity_type(self) -> type[EntityVar]:
raise NotImplementedError
async def _get_entities(self, entity_name: str) -> list[str] | None:
entities: str | list[str] | None = await self.get_state(
entity_name, attribute="entity_id"
)
self.log(
f"Entities from `{entity_name}` (entity_id attribute): `{entities}`",
level="DEBUG",
)
# If the entity groups other entities, this attribute will be a list
if not isinstance(entities, (list, tuple)):
return None
if entities is not None and len(entities) == 0:
raise ValueError(f"`{entity_name}` does not have any entities registered.")
return entities
async def _get_entity(self, entity: str | dict[str, Any]) -> EntityVar:
entity_args: dict[str, Any]
entity_name: str
if isinstance(entity, str):
entity_name = entity
entity_args = {}
elif isinstance(entity, dict):
entity_name = entity["name"]
entity_args = {key: value for key, value in entity.items() if key != "name"}
else:
raise ValueError(
f"Type {type(entity)} is not supported for `{self.entity_arg}` attribute"
)
entities = await self._get_entities(entity_name) if self.domains else None
return self._get_entity_type().instantiate(
name=entity_name, entities=entities, **entity_args
)
def _check_domain(self, entity: Entity) -> None:
if not self.domains:
return
if self.contains_templating(entity.name):
return
same_domain = all(
any(elem.startswith(domain + ".") for domain in self.domains)
for elem in entity.entities
)
if not same_domain:
if entity.is_group:
error_msg = (
f"All the subentities from {entity} must be from one "
f"of the following domains {self.domains} (e.g. {self.domains[0]}.bedroom)"
)
else:
error_msg = (
f"'{entity}' must be from one "
f"of the following domains {self.domains} (e.g. {self.domains[0]}.bedroom)"
)
raise ValueError(error_msg)
async def get_entity_state(self, attribute: str | None = None) -> Any:
entity = self.entity.main
if self.update_supported_features:
entities = await self._get_entities(self.entity.name)
self.entity.set_entities(entities)
entity = self.entity.entities[0]
out = await self.get_state(entity, attribute=attribute)
return out