Skip to content

Commit

Permalink
[schedy] Added generic2 actor type
Browse files Browse the repository at this point in the history
  • Loading branch information
Robert Schindler committed Jan 7, 2020
1 parent ce4e709 commit 003f25f
Show file tree
Hide file tree
Showing 10 changed files with 339 additions and 27 deletions.
3 changes: 3 additions & 0 deletions docs/apps/schedy/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/apps/schedy/actors/generic/index.rst
Original file line number Diff line number Diff line change
@@ -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
Expand Down
42 changes: 42 additions & 0 deletions docs/apps/schedy/actors/generic2/config.yaml
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions docs/apps/schedy/actors/generic2/index.rst
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 0 additions & 10 deletions docs/apps/schedy/actors/switch/generic-config.yaml

This file was deleted.

13 changes: 13 additions & 0 deletions docs/apps/schedy/actors/switch/generic2-config.yaml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions docs/apps/schedy/actors/switch/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand All @@ -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


Expand Down
10 changes: 9 additions & 1 deletion hass_apps/schedy/actor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down
204 changes: 204 additions & 0 deletions hass_apps/schedy/actor/generic2.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 003f25f

Please sign in to comment.