From 003f25feb04d31717527c240cd81895d51f14b4d Mon Sep 17 00:00:00 2001 From: Robert Schindler Date: Tue, 7 Jan 2020 10:30:12 +0100 Subject: [PATCH] [schedy] Added generic2 actor type --- docs/apps/schedy/CHANGELOG.md | 3 + docs/apps/schedy/actors/generic/index.rst | 4 +- docs/apps/schedy/actors/generic2/config.yaml | 42 ++++ docs/apps/schedy/actors/generic2/index.rst | 53 +++++ .../schedy/actors/switch/generic-config.yaml | 10 - .../schedy/actors/switch/generic2-config.yaml | 13 ++ docs/apps/schedy/actors/switch/index.rst | 6 +- hass_apps/schedy/actor/__init__.py | 10 +- hass_apps/schedy/actor/generic2.py | 204 ++++++++++++++++++ hass_apps/schedy/actor/switch.py | 21 +- 10 files changed, 339 insertions(+), 27 deletions(-) create mode 100644 docs/apps/schedy/actors/generic2/config.yaml create mode 100644 docs/apps/schedy/actors/generic2/index.rst delete mode 100644 docs/apps/schedy/actors/switch/generic-config.yaml create mode 100644 docs/apps/schedy/actors/switch/generic2-config.yaml create mode 100644 hass_apps/schedy/actor/generic2.py diff --git a/docs/apps/schedy/CHANGELOG.md b/docs/apps/schedy/CHANGELOG.md index 80541a2c..b8006fa4 100644 --- a/docs/apps/schedy/CHANGELOG.md +++ b/docs/apps/schedy/CHANGELOG.md @@ -13,8 +13,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Security ### Added +* Added new `generic2` actor type which is more flexible than the old `generic`. ### Changed +* The `switch` actor type is now driven by the new `generic2` actor type. Functionality + and syntax stays all the same. ### Deprecated diff --git a/docs/apps/schedy/actors/generic/index.rst b/docs/apps/schedy/actors/generic/index.rst index 566caa6a..a6cb2c79 100644 --- a/docs/apps/schedy/actors/generic/index.rst +++ b/docs/apps/schedy/actors/generic/index.rst @@ -1,7 +1,9 @@ Generic Actor ============= -.. include:: /advanced-topic.rst.inc +.. warning:: + + This actor type has been superseeded by the :doc:`../generic2/index`. Use that instead. The ``generic`` actor can be used for controlling different types of entities like numbers or media players, even those having multiple diff --git a/docs/apps/schedy/actors/generic2/config.yaml b/docs/apps/schedy/actors/generic2/config.yaml new file mode 100644 index 00000000..9c32db1b --- /dev/null +++ b/docs/apps/schedy/actors/generic2/config.yaml @@ -0,0 +1,42 @@ +WORK IN PROGRESS + +# Here you configure the attributes of the entity to be controlled by the schedule. +attributes: + # The attribute to be controlled, this could be e.g. "state" or "brightness". + # A value of null creates a write-only attribute. This has to be used when you want + # to control a property whose current value is not reflected in any of the entity's + # attributes. Don't do this if not really necessary, since doing so means that + # Schedy won't be able to verify that the value has been transmitted correctly. If + # you must use a write-only attribute, you may also want to set send_retries to a + # low value in order to avoid excessive network load. +- attribute: first +- attribute: second +- ... + +# Here you configure the values you want to be able to return from your schedule. +values: + # Each value is a list of the values for the individual attributes configured above. + # Schedy compares the entity's current attributes against the values defined here + # in order to find the value currently active. + # The special attribute value "*" is a wildcard and will, when used, match any + # value of that particular attribute. + # Additionally, you don't have to include all attributes in every single value, + # only the first N attributes which values are provided for are compared against + # the entity's state for the value to match. +- value: ["value of 1st attribute", "value of 2nd attribute", ...] + # The services that have to be called in order to make the actor report this value. + calls: + # Which service to call + - service: ... + # Optionally, provide service data. + data: + # Set to false if you don't want the entity_id field to be included in service data. + #include_entity_id: true +# More values# +- ... + +# Set this to true if you want Schedy to treat string attributes of an entity the +# same, no matter if they're reported in lower or upper case. This is handy for some +# MQTT devices, for instance, which sometimes report a state of "ON", while others say +# "on". +#ignore_case: false diff --git a/docs/apps/schedy/actors/generic2/index.rst b/docs/apps/schedy/actors/generic2/index.rst new file mode 100644 index 00000000..b5b6200a --- /dev/null +++ b/docs/apps/schedy/actors/generic2/index.rst @@ -0,0 +1,53 @@ +Generic Actor Version 2 +======================= + +The ``generic2`` actor can be used for controlling different types of entities like +numbers or media players, even those having multiple adjustable attributes such as +roller shutters with tilt and position. + +It works by defining a set of values and, for each of these values, what services +have to be called in order to reach the state represented by that value. + +Instead of a single value such as ``"on"`` or ``"off"``, you may also generate a +tuple of multiple values like ``(50, 75)`` or ``("on", 10)`` in your schedule rules, +where each slot in that tuple corresponds to a different attribute of the entity. + +If you want to see how this actor type can be used, have a look at the +:doc:`../switch/index`. + + +Configuration +------------- + +.. include:: ../config.rst.inc + + +Supported Values +---------------- + +Every value that has been configured in the ``values`` section of the actor +configuration may be returned from a schedule. + +Examples: + +:: + + - v: "on" + - x: "-40 if is_on(...) else Next()" + +As soon as you configure multiple slots (attributes to be controlled), a list or +tuple with a value for each attribute is expected. The order is the same in which +the slots were specified in the configuration. + +Examples: + +:: + + - v: ['on', 20] + - x: "(-40, 'something') if is_on(...) else Next()" + +.. note:: + + When specifying the values ``on`` and ``off``, enclose them in quotes + as shown above to inform the YAML parser you don't mean the booleans + ``True`` and ``False`` instead. diff --git a/docs/apps/schedy/actors/switch/generic-config.yaml b/docs/apps/schedy/actors/switch/generic-config.yaml deleted file mode 100644 index e36e8951..00000000 --- a/docs/apps/schedy/actors/switch/generic-config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -actor_type: generic -actor_templates: - default: - attributes: - - attribute: state - values: - "on": - service: homeassistant.turn_on - "off": - service: homeassistant.turn_off diff --git a/docs/apps/schedy/actors/switch/generic2-config.yaml b/docs/apps/schedy/actors/switch/generic2-config.yaml new file mode 100644 index 00000000..abb226d6 --- /dev/null +++ b/docs/apps/schedy/actors/switch/generic2-config.yaml @@ -0,0 +1,13 @@ +actor_type: generic2 +actor_templates: + default: + attributes: + - attribute: state + values: + - value: ["on"] + calls: + - service: homeassistant.turn_on + - value: ["off"] + calls: + - service: homeassistant.turn_off + ignore_case: true diff --git a/docs/apps/schedy/actors/switch/index.rst b/docs/apps/schedy/actors/switch/index.rst index 710997ee..ff899663 100644 --- a/docs/apps/schedy/actors/switch/index.rst +++ b/docs/apps/schedy/actors/switch/index.rst @@ -2,7 +2,7 @@ Switch ====== The ``switch`` actor is used to control binary on/off switches. Internally, it's a -:doc:`../generic/index`, but with a much simpler configuration, namely none at all. +:doc:`../generic2/index`, but with a much simpler configuration, namely none at all. .. note:: @@ -15,9 +15,9 @@ The ``switch`` actor is used to control binary on/off switches. Internally, it's Especially, this is true for ``input_boolean`` and ``light`` entities. For completeness, this is the configuration you had to use if you wanted to build -this switch actor out of the :doc:`../generic/index` yourself: +this switch actor out of the :doc:`../generic2/index` yourself: -.. literalinclude:: generic-config.yaml +.. literalinclude:: generic2-config.yaml :language: yaml diff --git a/hass_apps/schedy/actor/__init__.py b/hass_apps/schedy/actor/__init__.py index 5409bb4f..b3094bca 100644 --- a/hass_apps/schedy/actor/__init__.py +++ b/hass_apps/schedy/actor/__init__.py @@ -7,11 +7,19 @@ from .base import ActorBase from .custom import CustomActor from .generic import GenericActor +from .generic2 import Generic2Actor from .switch import SwitchActor from .thermostat import ThermostatActor -__all__ = ["ActorBase", "CustomActor", "GenericActor", "SwitchActor", "ThermostatActor"] +__all__ = [ + "ActorBase", + "CustomActor", + "GenericActor", + "Generic2Actor", + "SwitchActor", + "ThermostatActor", +] def get_actor_types() -> T.Iterable[T.Type[ActorBase]]: diff --git a/hass_apps/schedy/actor/generic2.py b/hass_apps/schedy/actor/generic2.py new file mode 100644 index 00000000..64f2fa46 --- /dev/null +++ b/hass_apps/schedy/actor/generic2.py @@ -0,0 +1,204 @@ +""" +This module implements the generic actor. +""" + +import typing as T + +import copy + +import voluptuous as vol + +from ... import common +from .base import ActorBase + + +ALLOWED_VALUE_TYPES = (bool, float, int, str, type(None)) +ALLOWED_VALUE_TYPES_T = T.Union[ # pylint: disable=invalid-name + bool, float, int, str, None +] +WILDCARD_ATTRIBUTE_VALUE = "*" + + +class Generic2Actor(ActorBase): + """A configurable, generic actor for Schedy that can control multiple + attributes at once.""" + + name = "generic2" + config_schema_dict = { + **ActorBase.config_schema_dict, + vol.Optional("attributes", default=None): vol.All( + vol.DefaultTo(list), + [ + vol.All( + vol.DefaultTo(dict), + {vol.Optional("attribute", default=None): vol.Any(str, None)}, + ) + ], + ), + vol.Optional("values", default=None): vol.All( + vol.DefaultTo(list), + [ + vol.All( + vol.DefaultTo(dict), + { + vol.Required("value"): vol.All( + [vol.Any(*ALLOWED_VALUE_TYPES)], vol.Coerce(tuple), + ), + vol.Optional("calls", default=None): vol.All( + vol.DefaultTo(list), + [ + vol.All( + vol.DefaultTo(dict), + { + vol.Required("service"): str, + vol.Optional("data", default=None): vol.All( + vol.DefaultTo(dict), dict + ), + vol.Optional( + "include_entity_id", default=True + ): bool, + }, + ) + ], + ), + }, + ) + ], + # Sort by number of attributes (descending) for longest prefix matching + lambda v: sorted(v, key=lambda k: -len(k["value"])), + ), + vol.Optional("ignore_case", default=False): bool, + } + + def _find_value_cfg(self, value: T.Tuple) -> T.Any: + """Returns the config matching given value or ValueError if none found.""" + for value_cfg in self.cfg["values"]: + _value = value_cfg["value"] + if len(_value) != len(value): + continue + for idx, attr_value in enumerate(_value): + if attr_value not in (WILDCARD_ATTRIBUTE_VALUE, value[idx]): + break + else: + return value_cfg + raise ValueError("No configuration for value {!r}".format(value)) + + def _populate_service_data(self, data: T.Dict, fmt: T.Dict[str, T.Any]) -> None: + """Fills in placeholders in the service data definition.""" + memo = {data} # type: T.Set[T.Union[T.Dict, T.List]] + while memo: + obj = memo.pop() + if isinstance(obj, dict): + _iter = obj.items() # type: T.Iterable[T.Tuple[T.Any, T.Any]] + elif isinstance(obj, list): + _iter = enumerate(obj) + else: + continue + for key, value in _iter: + if isinstance(value, str): + try: + formatted = value.format(fmt) + # Convert int/float values to appropriate type + try: + _float = float(formatted) + _int = int(formatted) + except ValueError: + # It's a string value + obj[key] = formatted + else: + # It's an int or float + obj[key] = _int if _float == _int else _float + except (IndexError, KeyError, ValueError) as err: + self.log( + "Couldn't format service data {!r} with values " + "{!r}: {!r}, omitting data.".format(value, fmt, err), + level="ERROR", + ) + elif isinstance(value, (dict, list)): + memo.add(value) + + def do_send(self) -> None: + """Executes the configured services for self._wanted_value.""" + value = self._wanted_value + # Build formatting data with values of all attributes + fmt = {"entity_id": self.entity_id} + for idx in range(len(self.cfg["attributes"])): + fmt["attr{}".format(idx + 1)] = value[idx] if idx < len(value) else None + + for call_cfg in self._find_value_cfg(value)["calls"]: + service = call_cfg["service"] + data = copy.deepcopy(call_cfg["data"]) + self._populate_service_data(data, fmt) + if call_cfg["include_entity_id"]: + data.setdefault("entity_id", self.entity_id) + self.log( + "Calling service {}, data = {}.".format(repr(service), repr(data)), + level="DEBUG", + prefix=common.LOG_PREFIX_OUTGOING, + ) + self.app.call_service(service, **data) + + def filter_set_value(self, value: T.Tuple) -> T.Any: + """Checks whether the actor supports this value.""" + if self.cfg["ignore_case"]: + value = tuple(v.lower() if isinstance(v, str) else v for v in value) + try: + self._find_value_cfg(value) + except ValueError: + self.log( + "Value {!r} is not known by this actor.".format(value), level="ERROR" + ) + return None + return value + + def notify_state_changed(self, attrs: dict) -> T.Any: + """Is called when the entity's state changes.""" + items = [] + for attr_cfg in self.cfg["attributes"]: + attr = attr_cfg["attribute"] + if attr is None: + self.log("Ignoring state change (write-only attribute).", level="DEBUG") + return None + state = attrs.get(attr) + self.log( + "Attribute {!r} is {!r}.".format(attr, state), + level="DEBUG", + prefix=common.LOG_PREFIX_INCOMING, + ) + if self.cfg["ignore_case"] and isinstance(state, str): + state = state.lower() + items.append(state) + + tpl = tuple(items) + # Goes from len(tpl) down to 0 + for size in range(len(tpl), -1, -1): + value = tpl[:size] + try: + self._find_value_cfg(value) + except ValueError: + continue + return value + + self.log( + "Received state {!r} which is not configured as a value.".format(items), + level="WARNING", + ) + return None + + @staticmethod + def validate_value(value: T.Any) -> T.Any: + """Converts lists to tuples.""" + if isinstance(value, list): + items = tuple(value) + elif isinstance(value, tuple): + items = value + else: + items = (value,) + + for index, item in enumerate(items): + if not isinstance(item, ALLOWED_VALUE_TYPES): + raise ValueError( + "Value {!r} for {}. attribute must be of one of these types: " + "{}".format(item, index + 1, ALLOWED_VALUE_TYPES) + ) + return items diff --git a/hass_apps/schedy/actor/switch.py b/hass_apps/schedy/actor/switch.py index 3d250394..9ba12282 100644 --- a/hass_apps/schedy/actor/switch.py +++ b/hass_apps/schedy/actor/switch.py @@ -2,22 +2,19 @@ This module implements a binary on/off switch, derived from the generic actor. """ -from .generic import GenericActor +from .generic2 import Generic2Actor -class SwitchActor(GenericActor): - """A binary on/off switch actor for Schedy.""" +class SwitchActor(Generic2Actor): + """A binary on/off switch actor.""" name = "switch" config_defaults = { - **GenericActor.config_defaults, - "attributes": [ - { - "attribute": "state", - "values": { - "on": {"service": "homeassistant/turn_on"}, - "off": {"service": "homeassistant/turn_off"}, - }, - } + **Generic2Actor.config_defaults, + "attributes": [{"attribute": "state"}], + "values": [ + {"value": ["on"], "calls": [{"service": "homeassistant.turn_on"}]}, + {"value": ["off"], "calls": [{"service": "homeassistant.turn_off"}],}, ], + "ignore_case": True, }