Skip to content

Commit f256752

Browse files
author
Robert Schindler
committed
[schedy] Added generic2 actor type
1 parent ce4e709 commit f256752

File tree

10 files changed

+347
-27
lines changed

10 files changed

+347
-27
lines changed

docs/apps/schedy/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1313
### Security
1414

1515
### Added
16+
* Added new `generic2` actor type which is more flexible than the old `generic`.
1617

1718
### Changed
19+
* The `switch` actor type is now driven by the new `generic2` actor type. Functionality
20+
and syntax stays all the same.
1821

1922
### Deprecated
2023

docs/apps/schedy/actors/generic/index.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
Generic Actor
22
=============
33

4-
.. include:: /advanced-topic.rst.inc
4+
.. warning::
5+
6+
This actor type has been superseeded by the :doc:`../generic2/index`. Use that instead.
57

68
The ``generic`` actor can be used for controlling different types of
79
entities like numbers or media players, even those having multiple
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
WORK IN PROGRESS
2+
3+
# Here you configure the attributes of the entity to be controlled by the schedule.
4+
attributes:
5+
# The attribute to be controlled, this could be e.g. "state" or "brightness".
6+
# A value of null creates a write-only attribute. This has to be used when you want
7+
# to control a property whose current value is not reflected in any of the entity's
8+
# attributes. Don't do this if not really necessary, since doing so means that
9+
# Schedy won't be able to verify that the value has been transmitted correctly. If
10+
# you must use a write-only attribute, you might also want to set send_retries to a
11+
# low value in order to avoid excessive network load.
12+
- attribute: first
13+
- attribute: second
14+
- ...
15+
16+
# Here you configure the values you want to be able to return from your schedule.
17+
values:
18+
# Each value is a list of the values for the individual attributes configured above.
19+
# Schedy compares the entity's current attributes against the values defined here
20+
# in order to find the value currently active.
21+
# The special attribute value "*" is a wildcard and will, when used, match any
22+
# value of that particular attribute.
23+
# Additionally, you don't have to include all attributes in every single value,
24+
# only the first N attributes which values are provided for are compared against
25+
# the entity's state for the value to match.
26+
- value: ["value of 1st attribute", "value of 2nd attribute", ...]
27+
# The services that have to be called in order to make the actor report this value.
28+
calls:
29+
# Which service to call
30+
- service: ...
31+
# Optionally, provide service data.
32+
data:
33+
# Set to false if you don't want the entity_id field to be included in service data.
34+
#include_entity_id: true
35+
# More values#
36+
- ...
37+
38+
# Set this to true if you want Schedy to treat string attributes of an entity the
39+
# same, no matter if they're reported in lower or upper case. This is handy for some
40+
# MQTT devices, for instance, which sometimes report a state of "ON", while others say
41+
# "on".
42+
#ignore_case: false
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
Generic Actor Version 2
2+
=======================
3+
4+
The ``generic2`` actor can be used for controlling different types of entities like
5+
numbers or media players, even those having multiple adjustable attributes such as
6+
roller shutters with tilt and position.
7+
8+
It works by defining a set of values and, for each of these values, what services
9+
have to be called in order to reach the state represented by that value.
10+
11+
Instead of a single value such as ``"on"`` or ``"off"``, you may also generate a
12+
tuple of multiple values like ``(50, 75)`` or ``("on", 10)`` in your schedule rules,
13+
where each slot in that tuple corresponds to a different attribute of the entity.
14+
15+
If you want to see how this actor type can be used, have a look at the
16+
:doc:`../switch/index`.
17+
18+
19+
Configuration
20+
-------------
21+
22+
.. include:: ../config.rst.inc
23+
24+
25+
Supported Values
26+
----------------
27+
28+
Every value that has been configured in the ``values`` section of the actor
29+
configuration may be returned from a schedule.
30+
31+
Examples:
32+
33+
::
34+
35+
- v: "on"
36+
- x: "-40 if is_on(...) else Next()"
37+
38+
As soon as you configure multiple slots (attributes to be controlled), a list or
39+
tuple with a value for each attribute is expected. The order is the same in which
40+
the slots were specified in the configuration.
41+
42+
Examples:
43+
44+
::
45+
46+
- v: ['on', 20]
47+
- x: "(-40, 'something') if is_on(...) else Next()"
48+
49+
.. note::
50+
51+
When specifying the values ``on`` and ``off``, enclose them in quotes
52+
as shown above to inform the YAML parser you don't mean the booleans
53+
``True`` and ``False`` instead.

docs/apps/schedy/actors/switch/generic-config.yaml

Lines changed: 0 additions & 10 deletions
This file was deleted.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
actor_type: generic2
2+
actor_templates:
3+
default:
4+
attributes:
5+
- attribute: state
6+
values:
7+
- value: ["on"]
8+
calls:
9+
- service: homeassistant.turn_on
10+
- value: ["off"]
11+
calls:
12+
- service: homeassistant.turn_off
13+
ignore_case: true

docs/apps/schedy/actors/switch/index.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ Switch
22
======
33

44
The ``switch`` actor is used to control binary on/off switches. Internally, it's a
5-
:doc:`../generic/index`, but with a much simpler configuration, namely none at all.
5+
:doc:`../generic2/index`, but with a much simpler configuration, namely none at all.
66

77
.. note::
88

@@ -15,9 +15,9 @@ The ``switch`` actor is used to control binary on/off switches. Internally, it's
1515
Especially, this is true for ``input_boolean`` and ``light`` entities.
1616

1717
For completeness, this is the configuration you had to use if you wanted to build
18-
this switch actor out of the :doc:`../generic/index` yourself:
18+
this switch actor out of the :doc:`../generic2/index` yourself:
1919

20-
.. literalinclude:: generic-config.yaml
20+
.. literalinclude:: generic2-config.yaml
2121
:language: yaml
2222

2323

hass_apps/schedy/actor/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,19 @@
77
from .base import ActorBase
88
from .custom import CustomActor
99
from .generic import GenericActor
10+
from .generic2 import Generic2Actor
1011
from .switch import SwitchActor
1112
from .thermostat import ThermostatActor
1213

1314

14-
__all__ = ["ActorBase", "CustomActor", "GenericActor", "SwitchActor", "ThermostatActor"]
15+
__all__ = [
16+
"ActorBase",
17+
"CustomActor",
18+
"GenericActor",
19+
"Generic2Actor",
20+
"SwitchActor",
21+
"ThermostatActor",
22+
]
1523

1624

1725
def get_actor_types() -> T.Iterable[T.Type[ActorBase]]:

hass_apps/schedy/actor/generic2.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""
2+
This module implements the generic actor.
3+
"""
4+
5+
import typing as T
6+
7+
import copy
8+
9+
import voluptuous as vol
10+
11+
from ... import common
12+
from .base import ActorBase
13+
14+
15+
ALLOWED_VALUE_TYPES = (bool, float, int, str, type(None))
16+
ALLOWED_VALUE_TYPES_T = T.Union[ # pylint: disable=invalid-name
17+
bool, float, int, str, None
18+
]
19+
WILDCARD_ATTRIBUTE_VALUE = "*"
20+
21+
22+
class Generic2Actor(ActorBase):
23+
"""A configurable, generic actor for Schedy that can control multiple
24+
attributes at once."""
25+
26+
name = "generic2"
27+
config_schema_dict = {
28+
**ActorBase.config_schema_dict,
29+
vol.Optional("attributes", default=None): vol.All(
30+
vol.DefaultTo(list),
31+
[
32+
vol.All(
33+
vol.DefaultTo(dict),
34+
{vol.Optional("attribute", default=None): vol.Any(str, None)},
35+
)
36+
],
37+
),
38+
vol.Optional("values", default=None): vol.All(
39+
vol.DefaultTo(list),
40+
[
41+
vol.All(
42+
vol.DefaultTo(dict),
43+
{
44+
vol.Required("value"): vol.All(
45+
[vol.Any(*ALLOWED_VALUE_TYPES)], vol.Coerce(tuple),
46+
),
47+
vol.Optional("calls", default=None): vol.All(
48+
vol.DefaultTo(list),
49+
[
50+
vol.All(
51+
vol.DefaultTo(dict),
52+
{
53+
vol.Required("service"): str,
54+
vol.Optional("data", default=None): vol.All(
55+
vol.DefaultTo(dict), dict
56+
),
57+
vol.Optional(
58+
"include_entity_id", default=True
59+
): bool,
60+
},
61+
)
62+
],
63+
),
64+
},
65+
)
66+
],
67+
# Sort by number of attributes (descending) for longest prefix matching
68+
lambda v: sorted(v, key=lambda k: -len(k["value"])),
69+
),
70+
vol.Optional("ignore_case", default=False): bool,
71+
}
72+
73+
def _find_value_cfg(self, value: T.Tuple) -> T.Any:
74+
"""Returns the config matching given value or ValueError if none found."""
75+
for value_cfg in self.cfg["values"]:
76+
_value = value_cfg["value"]
77+
if len(_value) != len(value):
78+
continue
79+
for idx, attr_value in enumerate(_value):
80+
if attr_value not in (WILDCARD_ATTRIBUTE_VALUE, value[idx]):
81+
break
82+
else:
83+
return value_cfg
84+
raise ValueError("No configuration for value {!r}".format(value))
85+
86+
def _populate_service_data(self, data: T.Dict, fmt: T.Dict[str, T.Any]) -> None:
87+
"""Fills in placeholders in the service data definition."""
88+
# pylint: disable=too-many-nested-blocks
89+
memo = {data} # type: T.Set[T.Union[T.Dict, T.List]]
90+
while memo:
91+
obj = memo.pop()
92+
if isinstance(obj, dict):
93+
_iter = obj.items() # type: T.Iterable[T.Tuple[T.Any, T.Any]]
94+
elif isinstance(obj, list):
95+
_iter = enumerate(obj)
96+
else:
97+
continue
98+
for key, value in _iter:
99+
if isinstance(value, str):
100+
try:
101+
formatted = value.format(fmt)
102+
# Convert special values to appropriate type
103+
if formatted == "None":
104+
obj[key] = None
105+
elif formatted == "True":
106+
obj[key] = True
107+
elif formatted == "False":
108+
obj[key] = False
109+
else:
110+
try:
111+
_float = float(formatted)
112+
_int = int(formatted)
113+
except ValueError:
114+
# It's a string value
115+
obj[key] = formatted
116+
else:
117+
# It's an int or float
118+
obj[key] = _int if _float == _int else _float
119+
except (IndexError, KeyError, ValueError) as err:
120+
self.log(
121+
"Couldn't format service data {!r} with values "
122+
"{!r}: {!r}, omitting data.".format(value, fmt, err),
123+
level="ERROR",
124+
)
125+
elif isinstance(value, (dict, list)):
126+
memo.add(value)
127+
128+
def do_send(self) -> None:
129+
"""Executes the configured services for self._wanted_value."""
130+
value = self._wanted_value
131+
# Build formatting data with values of all attributes
132+
fmt = {"entity_id": self.entity_id}
133+
for idx in range(len(self.cfg["attributes"])):
134+
fmt["attr{}".format(idx + 1)] = value[idx] if idx < len(value) else None
135+
136+
for call_cfg in self._find_value_cfg(value)["calls"]:
137+
service = call_cfg["service"]
138+
data = copy.deepcopy(call_cfg["data"])
139+
self._populate_service_data(data, fmt)
140+
if call_cfg["include_entity_id"]:
141+
data.setdefault("entity_id", self.entity_id)
142+
self.log(
143+
"Calling service {}, data = {}.".format(repr(service), repr(data)),
144+
level="DEBUG",
145+
prefix=common.LOG_PREFIX_OUTGOING,
146+
)
147+
self.app.call_service(service, **data)
148+
149+
def filter_set_value(self, value: T.Tuple) -> T.Any:
150+
"""Checks whether the actor supports this value."""
151+
if self.cfg["ignore_case"]:
152+
value = tuple(v.lower() if isinstance(v, str) else v for v in value)
153+
try:
154+
self._find_value_cfg(value)
155+
except ValueError:
156+
self.log(
157+
"Value {!r} is not known by this actor.".format(value), level="ERROR"
158+
)
159+
return None
160+
return value
161+
162+
def notify_state_changed(self, attrs: dict) -> T.Any:
163+
"""Is called when the entity's state changes."""
164+
items = []
165+
for attr_cfg in self.cfg["attributes"]:
166+
attr = attr_cfg["attribute"]
167+
if attr is None:
168+
self.log("Ignoring state change (write-only attribute).", level="DEBUG")
169+
return None
170+
state = attrs.get(attr)
171+
self.log(
172+
"Attribute {!r} is {!r}.".format(attr, state),
173+
level="DEBUG",
174+
prefix=common.LOG_PREFIX_INCOMING,
175+
)
176+
if self.cfg["ignore_case"] and isinstance(state, str):
177+
state = state.lower()
178+
items.append(state)
179+
180+
tpl = tuple(items)
181+
# Goes from len(tpl) down to 0
182+
for size in range(len(tpl), -1, -1):
183+
value = tpl[:size]
184+
try:
185+
self._find_value_cfg(value)
186+
except ValueError:
187+
continue
188+
return value
189+
190+
self.log(
191+
"Received state {!r} which is not configured as a value.".format(items),
192+
level="WARNING",
193+
)
194+
return None
195+
196+
@staticmethod
197+
def validate_value(value: T.Any) -> T.Any:
198+
"""Converts lists to tuples."""
199+
if isinstance(value, list):
200+
items = tuple(value)
201+
elif isinstance(value, tuple):
202+
items = value
203+
else:
204+
items = (value,)
205+
206+
for index, item in enumerate(items):
207+
if not isinstance(item, ALLOWED_VALUE_TYPES):
208+
raise ValueError(
209+
"Value {!r} for {}. attribute must be of one of these types: "
210+
"{}".format(item, index + 1, ALLOWED_VALUE_TYPES)
211+
)
212+
return items

0 commit comments

Comments
 (0)