Skip to content

Commit

Permalink
Merge pull request #2 from muxa/dev
Browse files Browse the repository at this point in the history
Release 1.0.0
  • Loading branch information
muxa authored Aug 27, 2021
2 parents 4d537c6 + bf92a39 commit 145cd46
Show file tree
Hide file tree
Showing 13 changed files with 985 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@
*.exe
*.out
*.app
__pycache__
.DS_Store
toggle_example
.esphome
dimmable_light_example
175 changes: 173 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,173 @@
# esphome-state-machine
State Machine implemented using text_sensor
# ESPHome State Machine
A flexible [Finite-State Machine](https://en.wikipedia.org/wiki/Finite-state_machine) for [ESPHome](https://esphome.io/) implemented on top of a [text_sensor](https://esphome.io/components/text_sensor/index.html). It lets you model complex behaviours with limited inputs, such as:

* Controlling dimmable `light` with a single button.
* Controlling a garage door `cover` with a single button.
* Controlling a `display` with a button (e.g. flip through pages on click, and go into editing mode on hold).
* And more...

## Installing

```yaml
external_components:
- source:
type: git
url: https://github.com/muxa/esphome-state-machine
```
## Configuration
The basic state machine configuration involves providing:
* A list of `states`
* A list of `inputs`
* A list of allowed `transitions` for each input.

Example for a simple on/off toggle state machine:

![Toggle State Machine Diagram](images/state-machine-toggle.svg)

```yaml
text_sensor:
- platform: state_machine
name: On/Off Toggle State Machine
states:
- "OFF"
- "ON"
inputs:
- name: TOGGLE
transitions:
- ON -> OFF
- OFF -> ON
```

And to transition between states it you'll need to trigger the machine by providing input, e.g:

```yaml
binary_sensor:
- platform: gpio
pin: D6
name: "Button"
filters:
- delayed_on: 100ms
on_press:
- state_machine.transition: TOGGLE
```

## Configuration variables:

* **initial_state** (**Optional**, string): The intial state of the state machine. Defaults to first defined state.
* **states** (**Required**, list): The list of states that the state machine has.

* **name** (**Required**, string): The name of the state. Must not repeat.
* **on_enter** (*Optional*, [Automation](https://esphome.io/guides/automations.html#automation)): An automation to perform when entering this state.
* **on_leave** (*Optional*, [Automation](https://esphome.io/guides/automations.html#automation)): An automation to perform when leaving this state.

* **inputs** (**Required**, list): The list of inputs that the state machine supports with allowed state transitions.

* **name** (**Required**, string): The name of the input. Must not repeat.
* **transitions** (**Required**, list): The list of allowed transitions. Short form is `FROM_STATE -> TO_STATE`, or advanced configuration:
* **from** (**Required**, string): Source state that this input is allowed on.
* **to** (**Required**, string): Target state that this input transitions to.
* **action** (*Optional*, [Automation](https://esphome.io/guides/automations.html#automation)): An automation to perform when transition is performed.
* **action** (*Optional*, [Automation](https://esphome.io/guides/automations.html#automation)): An automation to perform when transition is done by this input. This action is performed after transition-specific action.

> ### Note:
>
> Any running state machine automations (state, input and transition) will be stopped before running next automations. This is useful when there's a delayed transition in one of the automation and it needs to be cancelled because a new input was provided which results in a different transition.

## `state_machine.transition` Action

You can provide input to the state machine from elsewhere in your WAML file with the `state_machine.transition` action.
```yaml
# in some trigger
on_...:
# Basic:
- state_machine.transition: TOGGLE
# Advanced (if you have multiple state machines in one YAML)
- state_machine.transition:
id: sm1
input: TOGGLE
```

Configuration options:

* **id** (*Optional*, [ID](https://esphome.io/guides/configuration-types.html#config-id)): The ID of the state machine.
* **input** (**Required**, string): The input to provide in order to transition to the next state.

## `state_machine.transition` Condition

This condition lets you check what transition last occurred.

```yaml
# in some trigger
on_...:
# Basic
if:
condition:
state_machine.transition:
trigger: TOGGLE
then:
- logger.log: Toggled
# Advanced
if:
condition:
state_machine.transition:
id: sm1
from: "OFF"
trigger: TOGGLE
to: "ON"
then:
- logger.log: Turned on by toggle
```

## Diagrams

When compiling or validating your YAML a state machine diagram will be generated using [DOT notation](https://en.wikipedia.org/wiki/DOT_(graph_description_language)), with a link to view the diagram, e.g:

```
State Machine Diagram (for On/Off Toggle State Machine):
https://quickchart.io/graphviz?graph=digraph%20%22On/Off%20Toggle%20State%20Machine%22%20%7B%0A%20%20node%20%5Bshape%3Dellipse%5D%3B%0A%20%20ON%20-%3E%20OFF%20%5Blabel%3DTOGGLE%5D%3B%0A%20%20OFF%20-%3E%20ON%20%5Blabel%3DTOGGLE%5D%3B%0A%7D
digraph "On/Off Toggle State Machine" {
node [shape=ellipse];
ON -> OFF [label=TOGGLE];
OFF -> ON [label=TOGGLE];
}
```

To get just the url use this command:

```bash
esphome config <config.yaml> 2>/dev/null | grep quickchart.io
```

To open the diagram in Chrome use this command:

```bash
esphome config <config.yaml> 2>/dev/null | grep quickchart.io | xargs open -n -a "Google Chrome" --args "-0"
```


## All Examples

### Simple Toggle

![Simple Toggle State Machine Diagram](images/state-machine-toggle.svg)

This example illustrates toggling an LED using a button.

See [toggle_example.yaml](toggle_example.yaml).

### Button Controlled Dimmable Light

![Button Controlled Dimmable Light State Machine Diagram](images/state-machine-brightness.svg)

This example models a single button control for a dimmable light with the following functionality:
* CLICK to toggle ON of OFF
* HOLD to go into EDITING mode to adjust brightness with a CLICK.

See [dimmable_light_example.yaml](dimmable_light_example.yaml).

Empty file.
122 changes: 122 additions & 0 deletions components/state_machine/automation.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#pragma once

#include "esphome/core/component.h"
#include "esphome/core/automation.h"
#include "state_machine_text_sensor.h"

namespace esphome
{

class StateMachineOnEnterTrigger : public Trigger<>
{
public:
StateMachineOnEnterTrigger(StateMachineTextSensor *text_sensor, std::string state)
{
text_sensor->add_on_transition_callback(
[this, state](StateTransition transition)
{
this->stop_action(); // stop any previous running actions
if (transition.to_state == state)
{
this->trigger();
}
});
}
};

class StateMachineOnLeaveTrigger : public Trigger<>
{
public:
StateMachineOnLeaveTrigger(StateMachineTextSensor *text_sensor, std::string state)
{
text_sensor->add_on_transition_callback(
[this, state](StateTransition transition)
{
this->stop_action(); // stop any previous running actions
if (transition.from_state == state)
{
this->trigger();
}
});
}
};

class StateMachineTransitionActionTrigger : public Trigger<>
{
public:StateMachineTransitionActionTrigger(StateMachineTextSensor *text_sensor, StateTransition for_transition)
{
text_sensor->add_on_transition_callback(
[this, for_transition](StateTransition transition)
{
this->stop_action(); // stop any previous running actions
if (transition.from_state == for_transition.from_state
&& transition.input == for_transition.input
&& transition.to_state == for_transition.to_state)
{
this->trigger();
}
});
}
};

class StateMachineInputActionTrigger : public Trigger<>
{
public:
StateMachineInputActionTrigger(StateMachineTextSensor *text_sensor, std::string input)
{
text_sensor->add_on_transition_callback(
[this, input](StateTransition transition)
{
this->stop_action(); // stop any previous running actions
if (transition.input == input)
{
this->trigger();
}
});
}
};

template <typename... Ts>
class StateMachineTransitionAction : public Action<Ts...>, public Parented<StateMachineTextSensor>
{
public:
StateMachineTransitionAction(std::string input)
{
this->input_ = input;
}

void play(Ts... x) override { this->parent_->transition(this->input_); }

protected:
std::string input_;
};

template <typename... Ts>
class StateMachineTransitionCondition : public Condition<Ts...>
{
public:
explicit StateMachineTransitionCondition(StateMachineTextSensor *parent) : parent_(parent) {}

TEMPLATABLE_VALUE(std::string, from_state)
TEMPLATABLE_VALUE(std::string, input)
TEMPLATABLE_VALUE(std::string, to_state)

bool check(Ts... x) override
{
if (!this->parent_->last_transition.has_value())
return false;
StateTransition transition = this->parent_->last_transition.value();
if (this->from_state_.has_value() && this->from_state_.value(x...) != transition.from_state)
return false;
if (this->input_.has_value() && this->input_.value(x...) != transition.input)
return false;
if (this->to_state_.has_value() && this->to_state_.value(x...) != transition.to_state)
return false;
return true;
}

protected:
StateMachineTextSensor *parent_;
};

} // namespace esphome
78 changes: 78 additions & 0 deletions components/state_machine/state_machine.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#include "esphome/core/log.h"
#include "state_machine.h"

namespace esphome
{

static const char *const TAG = "state_machine";

StateMachine::StateMachine(
std::vector<std::string> states,
std::vector<std::string> inputs,
std::vector<StateTransition> transitions,
std::string initial_state)
{
this->states_ = states;
this->inputs_ = inputs;
this->transitions_ = transitions;
this->current_state_ = initial_state;
this->last_transition_ = {};
}

void StateMachine::dump_config()
{
ESP_LOGCONFIG(TAG, "Initial State: %s", this->current_state_.c_str());

ESP_LOGCONFIG(TAG, "States: %d", this->states_.size());
for (auto &state : this->states_)
{
ESP_LOGCONFIG(TAG, " %s", state.c_str());
}

ESP_LOGCONFIG(TAG, "Inputs: %d", this->inputs_.size());
for (auto &input : this->inputs_)
{
ESP_LOGCONFIG(TAG, " %s", input.c_str());
}

ESP_LOGCONFIG(TAG, "Transitions: %d", this->transitions_.size());
for (StateTransition &transition : this->transitions_)
{
ESP_LOGCONFIG(TAG, " %s: %s -> %s", transition.input.c_str(), transition.from_state.c_str(), transition.to_state.c_str());
}
}

optional<StateTransition> StateMachine::get_transition(std::string input)
{
if (std::find(this->inputs_.begin(), this->inputs_.end(), input) == this->inputs_.end())
{
ESP_LOGE(TAG, "Invalid input value: %s", input.c_str());
return {};
}

for (StateTransition &transition : this->transitions_)
{
if (transition.from_state == this->current_state_ && transition.input == input)
return transition;
}

return {};
}

optional<StateTransition> StateMachine::transition(std::string input)
{
optional<StateTransition> transition = get_transition(input);
if (transition)
{
ESP_LOGD(TAG, "%s: transitioned from %s to %s", input.c_str(), transition.value().from_state.c_str(), transition.value().to_state.c_str());
this->last_transition_ = transition;
this->current_state_ = transition.value().to_state;
}
else
{
ESP_LOGW(TAG, "%s: invalid input %s", input.c_str(), this->current_state_.c_str());
}
return transition;
}

} // namespace esphome
Loading

0 comments on commit 145cd46

Please sign in to comment.