Skip to content

Commit

Permalink
feat(controller): add "previous_state" attribute
Browse files Browse the repository at this point in the history
Add `previous_state` attribute to restrict when an action is performed depending on the previous state of the entity. This is just applicable for `state` and `z2m` (with not MQTT) integrations.

related #366
  • Loading branch information
xaviml committed Oct 28, 2021
1 parent 9d11743 commit 89faa1a
Show file tree
Hide file tree
Showing 11 changed files with 92 additions and 14 deletions.
5 changes: 3 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ _This minor change does not contain any breaking changes._
_Note: Remember to restart the AppDaemon addon/server after updating to a new version._
PRERELEASE_NOTE

<!--

## :pencil2: Features
-->

- Add `previous_state` attribute to restrict when an action is performed depending on the previous state of the entity. This is just applicable for `state` and `z2m` (with not MQTT) integrations.

<!--
## :hammer: Fixes
Expand Down
48 changes: 46 additions & 2 deletions apps/controllerx/cx_core/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ class Controller(Hass, Mqtt):
action_delay: Dict[ActionEvent, int]
action_delta: Dict[ActionEvent, int]
action_times: Dict[str, float]
previous_states: Dict[ActionEvent, Optional[str]]
multiple_click_action_times: Dict[str, float]
click_counter: Counter[ActionEvent]
multiple_click_action_delay_tasks: DefaultDict[
Expand Down Expand Up @@ -140,7 +141,9 @@ async def init(self) -> None:

# Action delay
self.action_delay = self.get_mapping_per_action(
self.actions_mapping, custom=self.args.get("action_delay"), default=0
self.actions_mapping,
custom=self.args.get("action_delay"),
default=0,
)
self.action_delay_handles = defaultdict(lambda: None)
self.action_handles = defaultdict(lambda: None)
Expand All @@ -153,6 +156,13 @@ async def init(self) -> None:
)
self.action_times = defaultdict(lambda: 0.0)

# 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
Expand Down Expand Up @@ -242,13 +252,33 @@ def get_list(self, entities):
return list(entities)
return [entities]

@overload
def get_mapping_per_action(
self,
actions_mapping: ActionsMapping,
*,
custom: Optional[Union[T, Dict[ActionEvent, T]]],
default: None,
) -> Dict[ActionEvent, Optional[T]]:
...

@overload
def get_mapping_per_action(
self,
actions_mapping: ActionsMapping,
*,
custom: Optional[Union[T, Dict[ActionEvent, T]]],
default: T,
) -> Dict[ActionEvent, T]:
...

def get_mapping_per_action(
self,
actions_mapping,
*,
custom,
default,
):
if custom is not None and not isinstance(custom, dict):
default = custom
mapping = {action: default for action in actions_mapping}
Expand Down Expand Up @@ -339,8 +369,22 @@ async def get_state(
)

async def handle_action(
self, action_key: str, extra: Optional[EventData] = None
self,
action_key: str,
previous_state: Optional[str] = None,
extra: Optional[EventData] = 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
Expand Down
9 changes: 7 additions & 2 deletions apps/controllerx/cx_core/integration/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ async def listen_changes(self, controller_id: str) -> None:
)

async def state_callback(
self, entity: Optional[str], attribute: Optional[str], old, new, kwargs
self,
entity: Optional[str],
attribute: Optional[str],
old: Optional[str],
new: str,
kwargs,
) -> None:
await self.controller.handle_action(new)
await self.controller.handle_action(new, previous_state=old)
9 changes: 7 additions & 2 deletions apps/controllerx/cx_core/integration/z2m.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ async def event_callback(
await self.controller.handle_action(payload[action_key], extra=payload)

async def state_callback(
self, entity: Optional[str], attribute: Optional[str], old, new, kwargs
self,
entity: Optional[str],
attribute: Optional[str],
old: Optional[str],
new: str,
kwargs,
) -> None:
await self.controller.handle_action(new)
await self.controller.handle_action(new, previous_state=old)
1 change: 1 addition & 0 deletions docs/start/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ These are the generic app parameters for all type of controllers. You can see th
| `action_delta` | dict \| int | 300 | This is the threshold time between the previous action and the next one (being the same action). If the time difference between the two actions is less than this attribute, then the action won't be called. I recommend changing this if you see the same action being called twice. A different `action_delta` per action can be defined in a mapping. |
| `multiple_click_delay` | int | 500 | Indicates the delay (in milliseconds) when a multiple click action should be trigger. The higher the number, the more time there can be between clicks, but there will be more delay for the action to be triggered. |
| `action_delay` | dict \| int | 0 | This can be used to set a delay to each action. By default, the delay for all actions is 0. If defining a map, the key for the map is the action and the value is the delay in seconds. Otherwise, we can set a default time like `action_delay: 10`, and this will add a delay to all actions. |
| `previous_state` | dict \| str | - | This can be used to restrict when an action is performed depending on the previous state of the entity. This is just applicable for `state` and `z2m` (with not MQTT) integrations. For example, it can be used when we want the action to be triggered only with a specific previous state. |
| `mapping` | dict | - | This can be used to replace the behaviour of the controller and manually select what each button should be doing. By default it will ignore this parameter. Read more about it in [here](/controllerx/advanced/custom-controllers). The functionality included in this attribute will remove the default mapping. |
| `merge_mapping` | dict | - | This can be used to merge the default mapping from the controller and manually select what each button should be doing. By default it will ignore this parameter. Read more about it in [here](/controllerx/advanced/custom-controllers). The functionality included in this attribute is added on top of the default mapping. |
| `mode` | dict \| int | `single` | This has the purpose of defining what to do when an ation(s) is/are executing. The options and the behaviour is the same as [Home Assistant automation modes](https://www.home-assistant.io/docs/automation/modes) since it is based on that. The only difference is that `queued` only queues 1 task after the one is being executed. One can define a mapping for each action event with different modes. |
Expand Down
5 changes: 4 additions & 1 deletion tests/integ_tests/integ_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ async def test_integ_configs(
):
entity_state_attributes = data.get("entity_state_attributes", {})
entity_state = data.get("entity_state", None)
previous_state = data.get("previous_state", None)
fired_actions = data.get("fired_actions", [])
render_template_response = data.get("render_template_response")
extra = data.get("extra")
Expand Down Expand Up @@ -77,7 +78,9 @@ async def test_integ_configs(
await controller.initialize()
for idx, action in enumerate(fired_actions):
if any(isinstance(action, type_) for type_ in (str, int)):
coroutine = controller.handle_action(action, extra=extra)
coroutine = controller.handle_action(
action, previous_state=previous_state, extra=extra
)
if idx == len(fired_actions) - 1:
await coroutine
else:
Expand Down
10 changes: 10 additions & 0 deletions tests/integ_tests/previous_state/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
example_app:
module: controllerx
class: E1810Controller
controller: sensor.livingroom_controller_action
integration: z2m
light: light.livingroom
previous_state:
toggle: previous_toggle
mapping:
toggle: toggle
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
previous_state: null
entity_state: "off"
fired_actions: [toggle]
expected_calls_count: 0
7 changes: 7 additions & 0 deletions tests/integ_tests/previous_state/previous_toggle_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
previous_state: previous_toggle
entity_state: "off"
fired_actions: [toggle]
expected_calls:
- service: light/toggle
data:
entity_id: light.livingroom
6 changes: 2 additions & 4 deletions tests/unit_tests/cx_core/controller_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,10 +417,8 @@ async def test_handle_action(
sut.action_delta = {action_called: action_delta}
sut.action_times = defaultdict(lambda: 0)

actions_mapping: ActionsMapping = {
action: [fake_action_type] for action in actions_input
}
sut.actions_mapping = actions_mapping
sut.actions_mapping = {action: [fake_action_type] for action in actions_input}
sut.previous_states = defaultdict(lambda: None)
call_action_patch = mocker.patch.object(sut, "call_action")

# SUT
Expand Down
2 changes: 1 addition & 1 deletion tests/unit_tests/cx_core/integration/state_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ async def test_callback(

await state_integration.state_callback("test", None, "old_state", "new_state", {})

handle_action_patch.assert_called_once_with("new_state")
handle_action_patch.assert_called_once_with("new_state", previous_state="old_state")

0 comments on commit 89faa1a

Please sign in to comment.