From 2fe74d2c12f1fe68329a33400163636c313abd79 Mon Sep 17 00:00:00 2001 From: Piotr Majkrzak Date: Tue, 31 Jan 2023 19:59:45 +0100 Subject: [PATCH 1/6] Add initial verion of state condition --- components/state_machine/__init__.py | 14 +++++++++++++- components/state_machine/state_machine.cpp | 7 +++++-- components/state_machine/state_machine.h | 3 +++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/components/state_machine/__init__.py b/components/state_machine/__init__.py index 4c8a547..d5d45fe 100644 --- a/components/state_machine/__init__.py +++ b/components/state_machine/__init__.py @@ -4,6 +4,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome import automation +from esphome.automation import validate_condition, build_condition from esphome.const import ( CONF_ID, @@ -12,7 +13,8 @@ CONF_FROM, CONF_TO, CONF_STATE, - CONF_VALUE + CONF_VALUE, + CONF_CONDITION ) _LOGGER = logging.getLogger(__name__) @@ -87,12 +89,19 @@ CONF_STATE_MACHINE_ID = 'state_machine_id' +memorizer = dict() +async def build_condition_(config): + if config['type_id'] not in memorizer: + memorizer[config['type_id']] = await build_condition(config, cg.TemplateArguments(), []) + cg.add(memorizer[config['type_id']]) + def validate_transition(value): if isinstance(value, dict): return cv.Schema( { cv.Required(CONF_FROM): cv.string_strict, cv.Required(CONF_TO): cv.string_strict, + cv.Optional(CONF_CONDITION): validate_condition, cv.Optional(CONF_BEFORE_TRANSITION_KEY): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateMachineBeforeTransitionTrigger), @@ -281,6 +290,7 @@ async def to_code(config): ("from_state", transition[CONF_FROM]), ("input", input[CONF_NAME]), ("to_state", transition[CONF_TO]), + ("condition", await build_condition_(transition[CONF_CONDITION]) if CONF_CONDITION in transition else cg.nullptr) ) ) ) @@ -319,6 +329,7 @@ async def to_code(config): ("from_state", transition[CONF_FROM]), ("input", input[CONF_NAME]), ("to_state", transition[CONF_TO]), + ("condition", await build_condition_(transition[CONF_CONDITION]) if CONF_CONDITION in transition else cg.nullptr) ) ) await automation.build_automation(trigger, [], action) @@ -347,6 +358,7 @@ async def to_code(config): ("from_state", transition[CONF_FROM]), ("input", input[CONF_NAME]), ("to_state", transition[CONF_TO]), + ("condition", await build_condition_(transition[CONF_CONDITION]) if CONF_CONDITION in transition else cg.nullptr) ) ) await automation.build_automation(trigger, [], action) diff --git a/components/state_machine/state_machine.cpp b/components/state_machine/state_machine.cpp index dd7542f..8efb527 100644 --- a/components/state_machine/state_machine.cpp +++ b/components/state_machine/state_machine.cpp @@ -62,8 +62,11 @@ namespace esphome for (StateTransition &transition : this->transitions_) { - if (transition.from_state == this->current_state_ && transition.input == input) - return transition; + if (transition.from_state == this->current_state_ && transition.input == input) { + if (!transition.condition || transition.condition->check()) { + return transition; + } + } } return {}; diff --git a/components/state_machine/state_machine.h b/components/state_machine/state_machine.h index bffe487..a6cc214 100644 --- a/components/state_machine/state_machine.h +++ b/components/state_machine/state_machine.h @@ -2,6 +2,8 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/automation.h" + namespace esphome { namespace state_machine @@ -12,6 +14,7 @@ namespace esphome std::string from_state; std::string input; std::string to_state; + Condition<> * condition; }; class StateMachineComponent : public Component From d1ed57d7c10df88f846d54c10397a1ea98d9f1b4 Mon Sep 17 00:00:00 2001 From: Piotr Majkrzak Date: Tue, 31 Jan 2023 20:32:44 +0100 Subject: [PATCH 2/6] Fix codegen --- components/state_machine/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/state_machine/__init__.py b/components/state_machine/__init__.py index d5d45fe..d4df8e0 100644 --- a/components/state_machine/__init__.py +++ b/components/state_machine/__init__.py @@ -93,7 +93,7 @@ async def build_condition_(config): if config['type_id'] not in memorizer: memorizer[config['type_id']] = await build_condition(config, cg.TemplateArguments(), []) - cg.add(memorizer[config['type_id']]) + return memorizer[config['type_id']] def validate_transition(value): if isinstance(value, dict): From c594fceb7d9f752ffe733d65f51842402493a287 Mon Sep 17 00:00:00 2001 From: Daniel Dunn Date: Wed, 16 Aug 2023 13:56:21 -0600 Subject: [PATCH 3/6] Show conditions in Mermaid charts --- components/state_machine/__init__.py | 101 ++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/components/state_machine/__init__.py b/components/state_machine/__init__.py index d4df8e0..d7e580e 100644 --- a/components/state_machine/__init__.py +++ b/components/state_machine/__init__.py @@ -1,6 +1,7 @@ import logging import os import urllib.parse +import textwrap import esphome.codegen as cg import esphome.config_validation as cv from esphome import automation @@ -127,6 +128,82 @@ def validate_transition(value): a, b = a.strip(), b.strip() return validate_transition({CONF_FROM: a, CONF_TO: b}) + +def format_lambda(s): + s=s.strip() + if s.startswith('return'): + s = s[len('return'):] + + if s.endswith(";"): + s = s[:-1] + + return s.strip() + + +def format_condition_argument(ca): + if 'id' in ca: + ca = ca['id'] + if hasattr(ca, 'id'): + return ca.id + return '' + +def format_condition(c): + try: + v = '' + long = '' + + if 'lambda' in c: + v = format_lambda(c['lambda'].value) + else: + c2 = c.copy() + for i in c: + if i in ['type_id', 'type', 'ID', 'manual']: + c2.pop(i) + + conditionname = None + + for i in c2: + if "." in i: + conditionname = i + break + + if not conditionname: + for i in c2: + conditionname = i + break + + arg = format_condition_argument(c2[conditionname]) + long = conditionname + " " + arg + + conditionname = conditionname.split('.') + + + + + # If we have something like binary_sensor.is_on, the part before the dot + # needs to go to keep things short. + # But if it is something short already we can keep it. + if len(conditionname)==1: + conditionname = conditionname[0] + else: + suffix = conditionname[-1] + p = '.'.join(conditionname[:-1]) + + if len(p) < 7: + conditionname = p + "." + suffix + else: + conditionname = suffix + + v = conditionname + " " + arg + + v = v.strip() + + return (v, long or v) + except Exception as e: + _LOGGER.exception(f"Mermaid chart error:{e}") + return('','') + + def output_graph(config): if not CONF_DIAGRAM in config: return config @@ -138,15 +215,37 @@ def output_graph(config): return config + +MAX_CONDITION_LENGTH_IN_DIAGRAM = 22 def output_mermaid_graph(config): graph_data = f"stateDiagram-v2{os.linesep}" graph_data = graph_data + f" direction LR{os.linesep}" initial_state = config[CONF_INITIAL_STATE] if CONF_INITIAL_STATE in config else config[CONF_STATES_KEY][0][CONF_NAME] graph_data = graph_data + f" [*] --> {initial_state}{os.linesep}" + footnotes = [] + for input in config[CONF_INPUTS_KEY]: if CONF_INPUT_TRANSITIONS_KEY in input: for transition in input[CONF_INPUT_TRANSITIONS_KEY]: - graph_data = graph_data + f" {transition[CONF_FROM]} --> {transition[CONF_TO]}: {input[CONF_NAME]}{os.linesep}" + if CONF_CONDITION in transition and transition[CONF_CONDITION]: + cond, longcond = format_condition(transition[CONF_CONDITION]) + + if len(cond) > MAX_CONDITION_LENGTH_IN_DIAGRAM: + footnotes.append(f'[{len(footnotes)+1}] {longcond}') + + cond2 = textwrap.shorten(cond, MAX_CONDITION_LENGTH_IN_DIAGRAM, placeholder=f"[{len(footnotes)}]") + cond2 = f"(? {cond2})" + graph_data = graph_data + f" {transition[CONF_FROM]} --> {transition[CONF_TO]}: {input[CONF_NAME]}{cond2}{os.linesep}" + else: + graph_data = graph_data + f" {transition[CONF_FROM]} --> {transition[CONF_TO]}: {input[CONF_NAME]}{os.linesep}" + + + if footnotes: + graph_data = graph_data + f"note: legend{os.linesep}" + + for i in footnotes: + graph_data = graph_data + f"note: {i}{os.linesep}" + graph_url = "" # f"https://quickchart.io/graphviz?format=svg&graph={urllib.parse.quote(graph_data)}" From 88820bceaa40b2e81e33757cb83d2ce3f9a34400 Mon Sep 17 00:00:00 2001 From: Daniel Dunn Date: Wed, 16 Aug 2023 14:04:08 -0600 Subject: [PATCH 4/6] Clean up newlines --- components/state_machine/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/state_machine/__init__.py b/components/state_machine/__init__.py index d7e580e..434766f 100644 --- a/components/state_machine/__init__.py +++ b/components/state_machine/__init__.py @@ -231,9 +231,9 @@ def output_mermaid_graph(config): cond, longcond = format_condition(transition[CONF_CONDITION]) if len(cond) > MAX_CONDITION_LENGTH_IN_DIAGRAM: - footnotes.append(f'[{len(footnotes)+1}] {longcond}') + footnotes.append(f'[{len(footnotes)+1}] {longcond}').replace(os.linesep,'
') - cond2 = textwrap.shorten(cond, MAX_CONDITION_LENGTH_IN_DIAGRAM, placeholder=f"[{len(footnotes)}]") + cond2 = textwrap.shorten(cond, MAX_CONDITION_LENGTH_IN_DIAGRAM, placeholder=f"[{len(footnotes)}]").replace(os.linesep, '') cond2 = f"(? {cond2})" graph_data = graph_data + f" {transition[CONF_FROM]} --> {transition[CONF_TO]}: {input[CONF_NAME]}{cond2}{os.linesep}" else: From f9a418a7d62a028ce5227469ad554b455e7cc063 Mon Sep 17 00:00:00 2001 From: Daniel Dunn Date: Wed, 16 Aug 2023 14:19:15 -0600 Subject: [PATCH 5/6] Nice handling for in_range --- components/state_machine/__init__.py | 45 ++++++++++++++++++---------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/components/state_machine/__init__.py b/components/state_machine/__init__.py index 434766f..0d59026 100644 --- a/components/state_machine/__init__.py +++ b/components/state_machine/__init__.py @@ -157,7 +157,7 @@ def format_condition(c): else: c2 = c.copy() for i in c: - if i in ['type_id', 'type', 'ID', 'manual']: + if i in ['type_id', 'type', 'id', 'manual']: c2.pop(i) conditionname = None @@ -171,30 +171,43 @@ def format_condition(c): for i in c2: conditionname = i break - - arg = format_condition_argument(c2[conditionname]) - long = conditionname + " " + arg - conditionname = conditionname.split('.') + if conditionname in ("number.in_range", "sensor.in_range"): + v = c2[conditionname]['id'].id + + # No spaces, we don't want it broken by the shortener and confusing anyone + if 'above' in c2[conditionname]: + v = str(c2[conditionname]['above']) + "<" + v + if 'below' in c2[conditionname]: + v = v + "<" + str(c2[conditionname]['below']) - # If we have something like binary_sensor.is_on, the part before the dot - # needs to go to keep things short. - # But if it is something short already we can keep it. - if len(conditionname)==1: - conditionname = conditionname[0] else: - suffix = conditionname[-1] - p = '.'.join(conditionname[:-1]) + arg = format_condition_argument(c2[conditionname]) + long = conditionname + " " + arg + + conditionname = conditionname.split('.') + - if len(p) < 7: - conditionname = p + "." + suffix + + + # If we have something like binary_sensor.is_on, the part before the dot + # needs to go to keep things short. + # But if it is something short already we can keep it. + if len(conditionname)==1: + conditionname = conditionname[0] else: - conditionname = suffix + suffix = conditionname[-1] + p = '.'.join(conditionname[:-1]) + + if len(p) < 7: + conditionname = p + "." + suffix + else: + conditionname = suffix - v = conditionname + " " + arg + v = conditionname + " " + arg v = v.strip() From a18fd467a5fdaf7673e9d7b1cfb7ceb0412c0d9b Mon Sep 17 00:00:00 2001 From: Daniel Dunn Date: Fri, 18 Aug 2023 15:17:17 -0600 Subject: [PATCH 6/6] Support DOT graphs with conditions --- components/state_machine/__init__.py | 31 +++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/components/state_machine/__init__.py b/components/state_machine/__init__.py index 0d59026..dae2ad1 100644 --- a/components/state_machine/__init__.py +++ b/components/state_machine/__init__.py @@ -240,6 +240,7 @@ def output_mermaid_graph(config): for input in config[CONF_INPUTS_KEY]: if CONF_INPUT_TRANSITIONS_KEY in input: for transition in input[CONF_INPUT_TRANSITIONS_KEY]: + # TODO: significant duplicated code with the DOT graph? if CONF_CONDITION in transition and transition[CONF_CONDITION]: cond, longcond = format_condition(transition[CONF_CONDITION]) @@ -256,26 +257,43 @@ def output_mermaid_graph(config): if footnotes: graph_data = graph_data + f"note: legend{os.linesep}" - for i in footnotes: - graph_data = graph_data + f"note: {i}{os.linesep}" + for i in footnotes: + graph_data = graph_data + f"note: {i}{os.linesep}" graph_url = "" # f"https://quickchart.io/graphviz?format=svg&graph={urllib.parse.quote(graph_data)}" if CONF_NAME in config: - _LOGGER.info(f"State Machine Diagram (for {config[CONF_NAME]}):{os.linesep}{graph_url}{os.linesep}") + _LOGGER.info(f"State Machine Diagram (for {config[CONF_NAME]}):{os.linesep}{graph_url}{os.linesep}{os.linesep}") else: - _LOGGER.info(f"State Machine Diagram:{os.linesep}{graph_url}{os.linesep}") + _LOGGER.info(f"State Machine Diagram:{os.linesep}{graph_url}{os.linesep}{os.linesep}") _LOGGER.info(f"Mermaid chart:{os.linesep}{graph_data}") def output_dot_graph(config): graph_data = f"digraph \"{config[CONF_NAME] if CONF_NAME in config else 'State Machine'}\" {{\n" graph_data = graph_data + " node [shape=ellipse];\n" + + # TODO do something with footnotes besides just log them. + + footnotes = [] for input in config[CONF_INPUTS_KEY]: if CONF_INPUT_TRANSITIONS_KEY in input: for transition in input[CONF_INPUT_TRANSITIONS_KEY]: - graph_data = graph_data + f" {transition[CONF_FROM]} -> {transition[CONF_TO]} [label={input[CONF_NAME]}];\n" + + if CONF_CONDITION in transition and transition[CONF_CONDITION]: + cond, longcond = format_condition(transition[CONF_CONDITION]) + + if len(cond) > MAX_CONDITION_LENGTH_IN_DIAGRAM: + footnotes.append(f'[{len(footnotes)+1}]: {longcond}') + + cond2 = textwrap.shorten(cond, MAX_CONDITION_LENGTH_IN_DIAGRAM, placeholder=f"[{len(footnotes)}]").replace(os.linesep, '') + cond2 = f"(? {cond2})" + + graph_data = graph_data + f" {transition[CONF_FROM]} -> {transition[CONF_TO]} [label=\"{input[CONF_NAME]}\\n{cond2}\"];\n" + + else: + graph_data = graph_data + f" {transition[CONF_FROM]} -> {transition[CONF_TO]} [label={input[CONF_NAME]}];\n" graph_data = graph_data + "}" graph_url = f"https://quickchart.io/graphviz?format=svg&graph={urllib.parse.quote(graph_data)}" @@ -287,6 +305,9 @@ def output_dot_graph(config): _LOGGER.info(f"DOT language graph:{os.linesep}{graph_data}") + _LOGGER.info(f":{os.linesep}Footnotes:{os.linesep}{os.linesep.join(footnotes)}") + + def validate_transitions(config): states = set(map(lambda x: x[CONF_NAME], config[CONF_STATES_KEY]))