An OR latch for your Node-RED projects

published Dec 02, 2022

Do one thing when a condition becomes true. Do another thing when all conditions become false.

An OR latch for your Node-RED projects

Have you ever encountered the need to do a thing when one of your inputs "goes high", and then do something else when all of your inputs have "gone low"?

If so, here is a generic solution for Node-RED.  The following program is a subflow you can copy and import to your Node-RED workbench:

[
    {
        "id": "939193159b60ae0d",
        "type": "subflow",
        "name": "OR latch",
        "info": "Sends a \"signal high\" message on output #1 the first time any\nincoming message's `condition_key` evaluates to true, and sends\na \"signal low\" message the last time that all of the incoming\nmessages have evaluated to false.\n\nMessages are classified by their `classifier_key` (default `topic`)\nand their true/false value is evaluated from their `condition_key`\n(default `payload`).  The classifier key and condition key are\nconfigurable through node properties.\n\nFor the following message sequence, this is what the latch does:\n\n1. `msg.topic: \"a\", msg.payload: true}`  -> emit output #1\n2. `msg.topic: \"b\", msg.payload: true}`  -> silence\n3. `msg.topic: \"a\", msg.payload: false}` -> silence\n4. `msg.topic: \"b\", msg.payload: false}` -> emit output #2\n5. `msg.topic: \"b\", msg.payload: false}` -> silence\n\n(1) emits output because the latch went from no classified message's\n`condition_key` being true to at least one classified message\nbeing true.  (4) emits output because all of the known classified\nmessages have changed to evaluating to false.\n\nMessages without a classifier or whose value is undefined are\nignored.  With default classifier and condition keys, the following\nmessages exemplify what would get ignored:\n\n* `{topic2: \"a\", payload: true}` (classifier absent)\n* `{topic: \"a\", mode: true}` (condition absent)\n\nIf a message with an attribute named after the `reset_key` variable\nis received, all memory of past messages is cleared.  No message will\nbe forwarded in that case.",
        "category": "",
        "in": [
            {
                "x": 60,
                "y": 100,
                "wires": [
                    {
                        "id": "9e698e07e2c75c21"
                    }
                ]
            }
        ],
        "out": [
            {
                "x": 450,
                "y": 60,
                "wires": [
                    {
                        "id": "9e698e07e2c75c21",
                        "port": 0
                    }
                ]
            },
            {
                "x": 430,
                "y": 140,
                "wires": [
                    {
                        "id": "9e698e07e2c75c21",
                        "port": 1
                    }
                ]
            }
        ],
        "env": [
            {
                "name": "classifier_key",
                "type": "str",
                "value": "topic"
            },
            {
                "name": "condition_key",
                "type": "str",
                "value": "payload"
            },
            {
                "name": "reset_key",
                "type": "str",
                "value": "reset"
            }
        ],
        "meta": {},
        "color": "#DDAA99",
        "outputLabels": [
            "at least one",
            "none"
        ],
        "status": {
            "x": 400,
            "y": 220,
            "wires": [
                {
                    "id": "076a4807936cbb67",
                    "port": 0
                }
            ]
        }
    },
    {
        "id": "9e698e07e2c75c21",
        "type": "function",
        "z": "939193159b60ae0d",
        "name": "State latch",
        "func": "var classifier_key = env.get(\"classifier_key\");\nvar condition_key = env.get(\"condition_key\");\nvar reset_key = env.get(\"reset_key\");\n\nif (msg[reset_key] !== undefined) {\n    context.set(\"objects\", {});\n    node.status(\"Latch reset\");\n    return;\n}\n\nvar objects = context.get(\"objects\") || {};\ncontext.set(\"objects\", objects);\n\nvar objects_on_before = Object.fromEntries(\n    Object.entries(objects).filter(\n        ([k, v]) => v == true\n    )\n);\n\nvar classifier = msg[classifier_key];\nif (classifier === null || classifier === undefined) {\n    return\n}\nvar value = msg[condition_key];\nif (value === null || value === undefined) {\n    return\n}\nvalue = Boolean(value);\nobjects[classifier] = value;\n\nvar objects_on_after = Object.fromEntries(\n    Object.entries(objects).filter(\n        ([k, v]) => v == true\n    )\n);\n\nvar status = \"\";\n\nif (Object.keys(objects_on_after).length) {\n    status = \"active: \" + Object.keys(objects_on_after).join(\", \");\n} else {\n    status = \"none active\";\n}\n\nif (Object.keys(objects_on_before).length == 0 && Object.keys(objects_on_after).length > 0) {\n    node.status({\"fill\": \"green\", \"text\": status});\n    return [msg, null];\n} else if (Object.keys(objects_on_before).length > 0 && Object.keys(objects_on_after).length == 0) {\n    node.status({ \"fill\": \"green\", \"text\": status});\n    return [null, msg];\n}\n\nnode.status({\"fill\": \"yellow\", \"text\": status})",
        "outputs": 2,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 210,
        "y": 100,
        "wires": [
            [],
            []
        ],
        "outputLabels": [
            "one on",
            "all off"
        ]
    },
    {
        "id": "076a4807936cbb67",
        "type": "status",
        "z": "939193159b60ae0d",
        "name": "",
        "scope": null,
        "x": 200,
        "y": 220,
        "wires": [
            []
        ]
    }
]

This is how it looks like, internally:

The subflow has one input and two outputs. The input expects messages that will be classified according a to a key (default topic), and which will be evaluated according to a value (default payload).  The logic is fairly simple:

  • The first time a message is received whose value evaluates to true, the associated value is classified according to the classifier key, and the first output gets the message.
  • Then, all messages are blocked, until every message seen has been classified to evaluate to false.  At this point, the message is sent through the second output.

How to use this latch in practical terms

To give a more concrete example: let's say you have two cars, represented as nodes in Node-RED, that emit their on/off state (true / false) in the message payload, and their names in the message topic.  We'll do a demo flow using this subflow, with inject nodes pretending to be said cars, all attached to this latch:

Here is what the flow depicted above would do:

  • The first time you hop in your car and you turn the ignition key, the first output of the latch will receive a message.
  • Your daughter now gets the second car, and starts it.  No message is sent past the latch.
  • You come back home, and now you park the car — then switch it off.  No message is sent.
  • Finally, your daughter arrives from her errand and parks the car.  At this point, the second output of the latch gets a message.

In graphical sequence:

Implementation notes

  • Messages without an attribute matching the classifier key or the condition key will simply be ignored.
  • Any message with an attribute matching the reset key (by default, reset) will cause the latch to forget all known classified messages and values, essentially returning the latch to its "zero state".  No reset message will be forwarded.