Skip to Content

Two ways to find out what Home Assistant just did, and what it is doing right now

the doorbell, and the cache behind it
Node-RED Guide · Part 5

Two ways to find out what Home Assistant just did, and what it is doing right now

Chapter 8 wired Node-RED to Home Assistant. This chapter is about what happens once that wire is live: how do you actually learn that a light turned on, or that a door has been open for ten minutes? Node-RED gives you two different families of node for that, and they answer two different questions. Events: state is a doorbell — it fires the instant a matching state_changed event arrives, and you never sent it a message to ask. Current State and Get Entities are the opposite: they wait for your flow to ask, then read whatever the integration currently has cached, never a fresh call to Home Assistant's own API. Get History and Wait Until sit outside both patterns, for the two things neither a doorbell nor a cache read can cover: the past, and a wait with a deadline.

v6
The persisted node type and version this chapter is pinned to for Events: state — server-state-changed. A different version on your install can behave differently
0.80.3
The HA WebSocket integration build every literal below comes from, pinned at commit 2cbbb69. Treat this chapter as version-specific, not universal
1 slot
What Wait Until actually holds. A second input does not queue — it cancels the running timer and replaces the message that was waiting
Push, pull, and the cache between

A doorbell rings on its own. A cache only answers when you knock

Every node in this chapter reads Home Assistant state one way or another, but they split cleanly into two families, and mixing them up is where most confusion starts. Events: state has no input at all — it is a zero-input event node that subscribes to matching entities the moment the flow deploys, and it emits a new message every time a real state_changed event arrives on the WebSocket. Current State and Get Entities are the reverse: they do nothing until an upstream message reaches them, and when one does, they read whatever the integration's cache currently holds — never a live round trip to Home Assistant's API.

NodePersisted type & versionWhen it readsScope in this chapter
Events: stateserver-state-changed, v6The moment a matching state_changed event arrivesMain focus: selectors, old/new state, filters, timers
Poll Statepoll-statePeriodically, at the interval you setOnly when no event source fits; see the note in Chapter 22
Trigger: statetrigger-stateWhen a state event satisfies its constraintsBuilt on the same events, with more elaborate conditions — a worked example lands in Chapter 13
Current Stateapi-current-state, v3Reads one cached entity when a msg arrivesCovered here: overrides, If State, For, output mapping
Get Entitiesha-get-entities, v1Scans the whole cache when a msg arrivesCovered here: rules, output modes, the missing lock on its inputs
Get Historyapi-get-historyQueries Home Assistant's own history storeCovered here: what it will not limit for you
Wait Untilha-wait-untilHolds one message until a condition or a timeoutCovered here: the one-slot behavior that surprises people
In plain terms

Events: state is a doorbell. It rings when someone is at the door, and you were not standing there watching for them — the ringing is the whole point. Current State and Get Entities are you walking up and knocking yourself, right now, to see who answers with whatever they can tell you this second. Neither one is wrong. A doorbell cannot tell you who is home when nobody has rung it in an hour, and knocking constantly on every door in the house is not a substitute for a bell that rings when something actually happens.

Every field name and behavior in this chapter is checked against the HA WebSocket integration's pinned commit 2cbbb69 (build 0.80.3) — see the source commit and the official Events: state documentation. A newer install of the integration is free to change any of this, so the safe habit is to check your own node's version before you rely on a detail here.
Selectors, and old and new state

Which entities wake the node, and the state shapes it actually receives

Events: state's entities configuration holds three selector groups: entity for exact matching, substring for a matching fragment, and regex for a pattern. An event only needs to satisfy any one of the three — they are not combined with AND. When only exact entity selectors are configured, the runtime opens a specific listener for each one. The moment substring or regex is used at all, it instead subscribes to the general state_changed topic and runs every event through shouldIncludeEvent to decide whether it counts.

SelectorSuitsMain riskSafer practice
Exact entityOne or a small, approved list of IDsMissing events after you rename or migrate an entityPrefer this. Write out the full list, e.g. light.living_room
SubstringA group that shares a naming conventionA new entity that happens to share the fragment gets pulled in without you choosing itList the set you expect, and treat an unexpected new match as a fail-closed signal, not a bonus
RegexA genuinely structured set that a list can't expressAn overly broad pattern, slower evaluation, false matchesAnchor the start and end, and test it offline against fictitious IDs first

Now the part that trips people up. In a raw Home Assistant state_changed event, event.old_state and event.new_state are each either a state object or null. An entity being created typically has no old_state; an entity being deleted has no new_state. Both of those are completely different from an existing state object whose state string happens to be unknown or unavailable — those are real strings sitting inside a real object, not an absence.

In plain terms

An empty parking space and a car sitting there with its engine off are different facts. A null old or new state is the empty space — there was never anything to describe. unknown or unavailable is the car: it exists, it has a state object with your name on it, and that state object is telling you, in words, that it currently has nothing useful to report. Treating both as “no data” with one blanket check throws away the difference between a car that isn't there and a car that won't start.

Here is where the pinned build adds its own twist. Home Assistant's own state object model allows a genuine deletion event with new_state: null. But in HA WebSocket 0.80.3, the integration's own WebSocket handler checks the new_state object and the entity ID before it ever emits its internal event topic — and if either one is falsy, it discards that state_changed event right there. Events: state v6 never sees it. There is no configuration on the node itself that restores a deletion branch; the event is gone before the node's selectors, ignore options, or anything else get a turn.

sequenceDiagram
  participant HA as Home Assistant core
  participant WS as WebSocket handler (0.80.3)
  participant N as Events: state node
  HA->>WS: state_changed, entity removed, new_state is null
  WS->>WS: new_state or entity_id falsy: event is discarded
  Note over WS,N: Events: state never receives this event
  HA->>WS: state_changed, state becomes "unknown"
  WS->>N: forwarded as a normal event
  N->>N: unknown is a string state, handled by the ignore options
One of two events reaches the nodeHome Assistant is the leftmost participant, the WebSocket handler sits in the middle, and Events: state is drawn last, on the right. The top event stops at the note and never reaches the right-hand participant at all. The bottom event does, because a string like unknown still has a state object to carry it — the handler's null check never triggers.

Do not assume that every state_changed event even changes the state string, either. Attribute-only updates and timestamp refreshes can fire the same event type. That fact matters as soon as the next section's only-change filter enters the picture.

Ignore options, only-change, If State, For

The full path from a raw event to a message on the wire

Once an event clears the selectors, v6 runs it through a fixed sequence: five ignore options, then only-change, then If State, then For, and only after all of that does it write your configured Output Properties onto a new msg. Each stage can end the story silently. None of the five ignore options do anything clever — each one just decides whether to skip the whole event.

old_state does not exist
old_state is unknown
old_state is unavailable
new_state is unknown
new_state is unavailable

Next is only-change (outputOnlyOnStateChange), which compares the converted old and new state strings for a normal event and returns with no output when they match. Then If State, which compares the converted new state using is, is not, greater-than, less-than, includes/not in, or a JSONata expression. With no condition configured there is simply one output. Once a condition exists, a true result uses the first output and a false result uses the second — and that second output is not an error channel, it is just “the condition was false.” Catch it with a genuine Catch or Status node if what you actually need is configuration or runtime errors, not a false condition.

flowchart TD
  A["A state_changed event
arrives on the WebSocket"] --> B{"Node enabled, and
HA already running?"} B -->|"no"| Z1["Not processed"] B -->|"yes"| C{"Does entity, substring,
or regex match?"} C -->|"no"| Z2["Skipped, no output"] C -->|"yes"| D{"Ignore option hits: null
old_state, or unknown /
unavailable on either side?"} D -->|"yes, ignored"| Z3["No output, event ignored"] D -->|"no"| E{"only-change on, and
the state string is
unchanged?"} E -->|"yes"| Z4["No output"] E -->|"no"| F{"Is If State configured?"} F -->|"no"| G["Standard output,
Output Properties applied"] F -->|"yes, true"| G F -->|"yes, false"| H["Second output,
not an error"]
Six boxes end this pathFour are dead ends, each reached by a single branch: not processed, skipped, ignored, and no-change. The fifth box, standard output, is the only one with two arrows flowing into it — from “If State not configured” and from “If State true.” The sixth box, the second output, means the condition evaluated false; it does not mean anything broke.

Last comes For, which can be a plain number, a JSONata expression, or a value from flow or global context, and it must be nonnegative — an empty string or 0 means no delay at all. A separate timer is tracked per entity ID. When a normal event starts one, a later event for the same entity that fails If State marks that timer inactive and sends it down the false path; a new event that does pass clears any running timeout and starts a fresh one. This timer lives only in the node's memory. A restart does not preserve it as a countdown, and it is not an HA history query of any kind.

In plain terms

For is like agreeing to call someone back in ten minutes based on what they just told you — then, when the timer goes off, reading out their original sentence from memory instead of actually picking up the phone to check whether anything has changed since. If the answer at the ten-minute mark genuinely matters, that is a second, separate question, and this timer was never built to answer it.

For is not retrospective validation. When the timer expires, v6 emits the cloned event it captured back when the timer started — it does not query the current state again at that moment. If a decision genuinely needs to know the state at expiry, put a Current State node after the timer and treat a cache miss or an unknown/unavailable result as a reason to stop, not a reason to guess.

One more trap worth naming here: the default v6 stateType is str. A numeric conversion runs the value through parseFloat, and the general boolean conversion is plain JavaScript !!value — which means every nonempty string is true, including "off". If you need a real on/off test, keep the string and compare it explicitly, or use one of Home Assistant's own boolean rules rather than a bare truthiness check.

Startup, reconnection, Output on Connect

What happens the moment the listener starts, and after it drops

With Output on Connect turned on, the moment the node's listener starts — immediately if HA is already running, or once InitialConnectionReady fires if it isn't — v6 converts every currently cached entity that matches its selectors into a synthetic state_changed event, with both old_state and new_state pointing at the same current object. Each one is processed with runAll=true. That flag bypasses the only-change comparison entirely, which is the main source of a burst of messages right after deploy or restart: only-change was never asked whether anything changed, because for a synthetic event it wasn't given the chance.

runAll=true also skips the For timer path outright — the controller returns directly along the valid-timer branch rather than starting a new one. So if you combine Output on Connect with a For duration, do not assume every currently matching entity will patiently wait out the full For window after reconnecting before it produces output; that is not how the two interact, and the only honest way to know for a specific combination is to test it, not to guess from the field names.

The controller also checks homeAssistant.isHomeAssistantRunning directly: it will not process a normal event while the WebSocket itself is connected but Home Assistant has not finished starting. And after any reconnection, remember what the cache actually is — a snapshot of right now, not a replay. The event stream gives you no guarantee that it will hand you everything that happened while you were disconnected.

Do not infer a sequence of events from a before-and-after cache comparison. If you genuinely need to know what happened during a disconnected window, that is what Get History is for, with a deliberately constrained time window — not a guess built from two snapshots.
Current State

Current State v3: one entity, read on demand, with a lock on the door

Current State does the opposite job from everything above: it waits for a msg, then reads exactly one entity out of the WebSocket integration's cache with getState(entityId). The entity ID itself can come from three places. The node checks msg.payload.entity_id first, then falls back to the camel-case msg.payload.entityId, and otherwise uses the configured entity_id — which is itself rendered through Mustache. Block Input Overrides is the switch that decides whether either payload property is even allowed to matter: turn it on, and the configured entity always wins regardless of what arrives in the message.

flowchart TD
  A["msg arrives at
Current State v3"] --> B{"Block Input
Overrides enabled?"} B -->|"yes"| C["Configured entity_id is
used. Payload overrides
are ignored"] B -->|"no"| D{"Does payload carry
entity_id or entityId?"} D -->|"neither"| C D -->|"either one"| E["That value replaces the
configured entity_id"] C --> F{"Entity found in the
WebSocket cache?"} E --> F F -->|"no"| G["InputError: not found.
No normal message is sent"] F -->|"yes"| H["timeSinceChangedMs added,
Output Properties applied"]
Two roads into one lookupTwo separate paths reach the entity-lookup diamond in the middle — overrides blocked or simply absent on one side, an override supplied and allowed on the other — and from there only two boxes are reachable: an error when the cache has nothing for that ID, or a normal output when it does. Nothing before that diamond can skip the lookup itself.

When the entity is missing from the cache, the node throws an InputError reading “not found.” It does not quietly send a normal message with some default or false-ish payload — catch the error with a Catch node if you want to observe it, but do not fabricate a value in its place. When the entity is found, the controller adds timeSinceChangedMs to it, and if you selected a non-string State Type, it preserves original_state before converting — the same truthiness trap from the previous section applies here too: a bare boolean conversion turns any nonempty string, "off" included, into true.

Fieldv3 behaviorWorth knowing
Entity IDConfigured value may contain Mustache; two payload keys can override it unless blockedKeep Block Input Overrides on unless you have a specific reason not to
If StateCompares the current entity.state; true and false use separate outputs when configuredRoute unknown and unavailable as their own explicit branches, not as a generic “else”
ForTests entity.timeSinceChangedMs > forDurationMs — strictly greater thanThis is a cache-timestamp calculation, derived from last_changed. It is not a wait and not a history query
State TypeDefaults to string; a non-string conversion preserves original_stateAvoid the common trap where a nonempty off-state converts to boolean true
Output PropertiesDefaults to the state string in msg.payload, the full entity in msg.dataMap only the fields downstream actually needs

The For comparator here only combines with is, is not, includes, and does not include, and it only applies once the condition itself has already matched. To actually wait for a condition rather than test a snapshot's age, that is Wait Until's job, with a real timeout. (The official Current State documentation covers every field directly; this section only adds the version-specific behavior worth knowing before you rely on it.)

Get Entities

Get Entities v1: rules across the whole cache, with no lock on the door

Where Current State reads one entity, Get Entities scans every cached state and keeps the ones that pass a set of rules. A rule can inspect a state property directly, or reach into the entity registry for device, area, floor, or label metadata — an area comes from the entity itself or from its device, a floor comes from that area, and a label rule checks labels on the entity, its device, and its area. Multiple rules are combined with AND: any single failed rule excludes the entity. At runtime the node happens to sort rules into label, state, device, area, floor order before evaluating them, but no downstream logic should ever depend on that particular order. Except for a JSONata rule, a state property that is simply missing does not match — it is treated as a fail, not as a wildcard pass.

outputTypeShape0-result behaviorLimit to enforce yourself
arrayWrites the matching entities to msg, flow, or globalEmpty array only if outputEmptyResults is true; otherwise no messageNo built-in maximum result count
countWrites a number to the specified locationSends 0Contains no entity detail — a safe first threshold check
randomOne entity if the limit is 1, an array if it's greaterNo messageoutputResultsCount controls this mode only
splitOne message per entity, in msg.payloadNo messageFans out — estimate the count before you enable it
flowchart TD
  A["Get Entities v1 evaluates
its rules against the cache"] --> B{"outputType"} B -->|"array"| C{"outputEmptyResults
true?"} C -->|"yes"| C1["Empty array is sent"] C -->|"no"| C2["No message is sent"] B -->|"count"| D1["Number is sent,
0 counts as a normal result"] B -->|"random"| E{"Any matches?"} E -->|"none"| E1["No message is sent"] E -->|"one or more"| E2["One entity, or an array
sized by outputResultsCount"] B -->|"split"| F{"Any matches?"} F -->|"none"| F1["No message is sent"] F -->|"one or more"| F2["One message per entity,
msg.parts added"]
Seven ways this can endOne box per branch outputType can take. Three of the seven — the no-message outcome under array, random, and split — send nothing at all when zero entities match. Count is the exception: its box always sends a message, because 0 is treated as an ordinary number, not a reason to stay silent.

Random shuffles the matching set and then takes the requested count — it is sampling, not secure allocation or fair rotation. Split deletes the original message's _msgid, creates msg.parts with a new sequence id, a count, and a per-message index, then clones one message per entity; a downstream Join node should key off msg.parts to reassemble the batch, and its own wait should carry a timeout.

In plain terms

Current State has a lock on its door: turn on Block Input Overrides and nobody outside can change which entity gets queried. Get Entities was built with no lock at all. Six settings that decide what it does — not just what it reports, but where it writes its answer — sit directly in the incoming payload, read every single time, waiting for whoever sends the next message to rewrite them.

Those six properties are always live, with no checkbox anywhere to disable them:

Message propertyWhat it changesHandling for untrusted input
msg.payload.rulesThe entire set of filtering rulesDelete it. Only a trusted Change node should set the fixed, real rules
msg.payload.outputTypeSwitches between array / count / random / splitDelete it, or a message could flip this to split and amplify itself downstream
msg.payload.outputEmptyResultsWhether an empty array is emitted at allDelete it to keep control flow predictable
msg.payload.outputLocationTypemsg / flow / globalDelete it to stop a write landing in a scope you didn't intend
msg.payload.outputLocationThe destination property or pathDelete it to stop it overwriting an unrelated message or context property
msg.payload.outputResultsCountRandom's result limit; the schema only checks that it's a numberDelete it, or validate it yourself as an integer of 1 or more before trusting it

Validating the incoming payload and forwarding it is still not enough on its own, because the dangerous keys are still sitting there afterward — safe handling means building a fresh message, or explicitly deleting every one of the six before a trusted node writes the real, fixed rules. (See the official Get Entities documentation for the rule syntax itself.)

Get History and Wait Until

Two nodes with no built-in fence, and where you have to put one yourself

Get History reaches past the cache into Home Assistant's own history store, and it too reads several overrides straight from msg.payload, with no Block Input Overrides switch to stop it: startDate, endDate, entityId, entityIdType, relativeTime, and flatten. Before an untrusted input reaches this node, delete all six of those properties, or rebuild a clean payload against an allowlist and a proper date schema. Just as importantly, the node itself sets no ceiling on the number of results it can return, and its underlying HTTP client applies no request timeout of its own. A short time window, a restriction to one entity, a response-size budget, and any proxy or caller timeout are all fences you have to build outside the node — none of them come from Get History itself.

msg.payload.startDate / msg.payload.endDate
msg.payload.entityId / msg.payload.entityIdType
msg.payload.relativeTime / msg.payload.flatten

Wait Until plays a different role entirely: it holds one incoming message, plus its configuration, in a single active slot, until a condition is met or a timeout expires. A later input does not queue behind the first — it cancels whatever timer is currently running and replaces the stored message outright. A timeout of 0 creates no timer at all, which means the node simply waits until the condition becomes true, until it is reset, until a new input replaces it, or until the node itself stops; there is no deadline in play. A safer configuration uses a finite, positive timeout with separate outputs for success and for timeout, and treats a timeout strictly as “the condition was not observed by the deadline” — not as a green light to act on its own.

This guide's own downloadable examples include one working Wait Until flow, 08-wait-until.json — a disabled node with placeholder values, a ten-second timeout, and both outputs wired to separate Debug nodes. It is the one example in this pair of chapters built to actually demonstrate a timeout firing, rather than only to be read.
When it does not behave

The symptoms that actually show up, in the order you are likely to meet them

SymptomLikely causeWhat to check
Events: state produces no output at allCould be several stages in the sequenceWork through it in order: node enabled and HA running, then selectors, then the five ignore options, then If State. Do not start by widening a regex
No message after an attribute-only changeonly-change compares the state string, not attributesIf attributes genuinely matter, that needs an explicit allowlist — only-change alone will not surface them
A burst of messages right after startupOutput on Connect's synthetic events run with runAll=true, bypassing only-changeBefore disabling it, confirm whether anything downstream actually depends on that initial snapshot
unknown is being treated as missingunknown is a real string state; null means the state object itself does not existHandle the two cases separately, with the matching ignore option, rather than one truthiness test
The state had already changed by the time For expiredFor emits the original cloned event; it never re-queries at expiryAdd a Current State read after the timer if the value at that exact moment matters
Numeric or boolean comparisons behave oddlyparseFloat can return NaN; bare boolean conversion treats any nonempty string, including "off", as trueKeep the original string and compare it explicitly
Current State reports "not found"A genuine cache miss throws an InputErrorDo not substitute a false-ish value. Check the ID is not a leftover placeholder and that the cache has actually loaded
Current State queries a different entity than configuredBlock Input Overrides is off, and the payload carried entity_id or entityIdRe-enable the block, or strip that payload property upstream
Downstream sees nothing for a 0-result Get Entities queryDepends on outputType: array needs outputEmptyResults=true, random and split send nothing regardlessSilence at 0 results is not a failure — check which mode you configured
Split produces far more messages than expectedThe candidate set was larger than assumedDisconnect the downstream side effect first, use count to check the size, then narrow the rules
Get History runs long or returns an unbounded number of rowsThe node has no built-in result limit or request timeoutAdd your own time window, entity restriction, and caller-side timeout — none of it is automatic
Wait Until seems to "forget" the first messageA later input replaced it — there is only one active slot, not a queueRoute each condition through its own Wait Until if more than one message needs to wait independently
Questions people ask

The ones that come up again and again

Are unknown, unavailable, and null the same thing?
No. The first two are string states sitting inside a real state object. null means the old or new state object does not exist at all. Events: state v6 does have separate ignore options for a null old state versus unknown/unavailable — but in build 0.80.3 the WebSocket handler discards a raw new_state: null deletion event before it ever reaches the node, so there is no downstream branch that ever sees that particular case from this node.
With only-change on, is Output on Connect guaranteed to stay silent?
No. The synthetic events it generates run with runAll=true, and the only-change equality check applies only to normal events processed with runAll=false. Expect output for every currently cached entity that matches your selectors when the listener starts.
Does For re-check the current state when it expires?
No, in either node. Events: state emits the cloned event captured when the timer began; Current State's For is a one-shot comparison against timeSinceChangedMs, not a wait. If a decision depends on the state being true at the moment the timer ends, add a separate, read-only Current State query afterward.
Why shouldn't I convert a state directly to a boolean?
The general boolean conversion is plain JavaScript truthiness — every nonempty string is true, so "off" converts to true just like "on" does. Keep the string and compare it explicitly, or use a reviewed Home Assistant boolean rule instead.
Does Current State make a live request to Home Assistant's API?
No. v3 reads one entity out of the cache the WebSocket integration already maintains. A returned object is not proof of a fresh round trip — handle disconnection, startup, and cache misses as their own cases.
Does Block Input Overrides protect Get Entities the way it protects Current State?
No. Current State has that switch; Get Entities v1 does not offer an equivalent option at all. Anything upstream of it must delete or validate all six override properties itself before the message arrives.
Does outputResultsCount limit results in every Get Entities mode?
No. The controller only applies it to random. Array can still return the full matching set, split still fans out one message per entity, and count only ever reports how many matched. If a message is allowed to override this value, validate it as an integer of 1 or greater — checking that it is merely a number is not a safe limit on its own.
Does Current State's second output mean something went wrong?
No. A cache miss throws an InputError, which is a separate thing entirely and belongs on a Catch node. The second output only ever means the configured If State condition evaluated to false — it is not an error channel.
Can Events: state recover everything that happened while Node-RED was disconnected?
No. Neither the reconnection cache nor an Output on Connect burst is a history replay. When the actual sequence of events during a disconnected window matters, query Get History with a constrained time window instead of inferring it from two snapshots.
How does a downstream Join node identify messages from the same Get Entities split?
Split assigns msg.parts with an id, a count, and a per-message index, and clones one message per matching entity. A Join node downstream should group by msg.parts, and its own wait for the full set should still carry a timeout in case the count estimate was wrong.
Can I connect the example directly to an Action node for testing?
No. The example is deliberately disabled and uses placeholders. Review it offline first, and connect only a limited Debug node. The Action target, data, queue, response, and error contracts require a separate review under Chapter 11.
Can a complete entity array be stored in global context for every flow?
The output location technically supports global context, but it is not a safe default. Doing so retains attributes for longer and creates stale copies. Prefer placing the minimum required fields in msg; use context only after defining retention and authorized readers.
Next

Where to go from here

reading is not doing

You can now tell what happened, and what is true right now. Turning that into an action is a different chapter.

Everything in this part has been read-only: a doorbell that rings, and a cache you can knock on. Part 6 covers the other half — the Action node that actually calls a Home Assistant service, followed by device triggers and the time-based nodes that decide when an automation is even allowed to run.

Open the full guide

Part 5 of the WoowTech Complete Node-RED Guide series on the Apporo blog.

Adapted from the WoowTech Complete Node-RED Guide, produced by WoowTech and released under CC BY 4.0. This adaptation is published by Apporo under the same licence.

Light · Air · Water · Control · apporo

Nothing you store remembers on its own, and one connection is already built for you