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.
server-state-changed. A different version on your install can behave differentlyA 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.
| Node | Persisted type & version | When it reads | Scope in this chapter |
|---|---|---|---|
| Events: state | server-state-changed, v6 | The moment a matching state_changed event arrives | Main focus: selectors, old/new state, filters, timers |
| Poll State | poll-state | Periodically, at the interval you set | Only when no event source fits; see the note in Chapter 22 |
| Trigger: state | trigger-state | When a state event satisfies its constraints | Built on the same events, with more elaborate conditions — a worked example lands in Chapter 13 |
| Current State | api-current-state, v3 | Reads one cached entity when a msg arrives | Covered here: overrides, If State, For, output mapping |
| Get Entities | ha-get-entities, v1 | Scans the whole cache when a msg arrives | Covered here: rules, output modes, the missing lock on its inputs |
| Get History | api-get-history | Queries Home Assistant's own history store | Covered here: what it will not limit for you |
| Wait Until | ha-wait-until | Holds one message until a condition or a timeout | Covered here: the one-slot behavior that surprises people |
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.
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.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.
| Selector | Suits | Main risk | Safer practice |
|---|---|---|---|
| Exact entity | One or a small, approved list of IDs | Missing events after you rename or migrate an entity | Prefer this. Write out the full list, e.g. light.living_room |
| Substring | A group that shares a naming convention | A new entity that happens to share the fragment gets pulled in without you choosing it | List the set you expect, and treat an unexpected new match as a fail-closed signal, not a bonus |
| Regex | A genuinely structured set that a list can't express | An overly broad pattern, slower evaluation, false matches | Anchor 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.
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
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.
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 existold_state is unknownold_state is unavailablenew_state is unknownnew_state is unavailableNext 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"]
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.
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.
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.
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.
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"]
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.
| Field | v3 behavior | Worth knowing |
|---|---|---|
| Entity ID | Configured value may contain Mustache; two payload keys can override it unless blocked | Keep Block Input Overrides on unless you have a specific reason not to |
| If State | Compares the current entity.state; true and false use separate outputs when configured | Route unknown and unavailable as their own explicit branches, not as a generic “else” |
| For | Tests entity.timeSinceChangedMs > forDurationMs — strictly greater than | This is a cache-timestamp calculation, derived from last_changed. It is not a wait and not a history query |
| State Type | Defaults to string; a non-string conversion preserves original_state | Avoid the common trap where a nonempty off-state converts to boolean true |
| Output Properties | Defaults to the state string in msg.payload, the full entity in msg.data | Map 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 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.
outputType | Shape | 0-result behavior | Limit to enforce yourself |
|---|---|---|---|
array | Writes the matching entities to msg, flow, or global | Empty array only if outputEmptyResults is true; otherwise no message | No built-in maximum result count |
count | Writes a number to the specified location | Sends 0 | Contains no entity detail — a safe first threshold check |
random | One entity if the limit is 1, an array if it's greater | No message | outputResultsCount controls this mode only |
split | One message per entity, in msg.payload | No message | Fans 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"]
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.
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 property | What it changes | Handling for untrusted input |
|---|---|---|
msg.payload.rules | The entire set of filtering rules | Delete it. Only a trusted Change node should set the fixed, real rules |
msg.payload.outputType | Switches between array / count / random / split | Delete it, or a message could flip this to split and amplify itself downstream |
msg.payload.outputEmptyResults | Whether an empty array is emitted at all | Delete it to keep control flow predictable |
msg.payload.outputLocationType | msg / flow / global | Delete it to stop a write landing in a scope you didn't intend |
msg.payload.outputLocation | The destination property or path | Delete it to stop it overwriting an unrelated message or context property |
msg.payload.outputResultsCount | Random's result limit; the schema only checks that it's a number | Delete 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.)
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.endDatemsg.payload.entityId / msg.payload.entityIdTypemsg.payload.relativeTime / msg.payload.flattenWait 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.
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.The symptoms that actually show up, in the order you are likely to meet them
| Symptom | Likely cause | What to check |
|---|---|---|
| Events: state produces no output at all | Could be several stages in the sequence | Work 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 change | only-change compares the state string, not attributes | If attributes genuinely matter, that needs an explicit allowlist — only-change alone will not surface them |
| A burst of messages right after startup | Output on Connect's synthetic events run with runAll=true, bypassing only-change | Before disabling it, confirm whether anything downstream actually depends on that initial snapshot |
unknown is being treated as missing | unknown is a real string state; null means the state object itself does not exist | Handle the two cases separately, with the matching ignore option, rather than one truthiness test |
| The state had already changed by the time For expired | For emits the original cloned event; it never re-queries at expiry | Add a Current State read after the timer if the value at that exact moment matters |
| Numeric or boolean comparisons behave oddly | parseFloat can return NaN; bare boolean conversion treats any nonempty string, including "off", as true | Keep the original string and compare it explicitly |
| Current State reports "not found" | A genuine cache miss throws an InputError | Do 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 configured | Block Input Overrides is off, and the payload carried entity_id or entityId | Re-enable the block, or strip that payload property upstream |
| Downstream sees nothing for a 0-result Get Entities query | Depends on outputType: array needs outputEmptyResults=true, random and split send nothing regardless | Silence at 0 results is not a failure — check which mode you configured |
| Split produces far more messages than expected | The candidate set was larger than assumed | Disconnect 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 rows | The node has no built-in result limit or request timeout | Add your own time window, entity restriction, and caller-side timeout — none of it is automatic |
| Wait Until seems to "forget" the first message | A later input replaced it — there is only one active slot, not a queue | Route each condition through its own Wait Until if more than one message needs to wait independently |
The ones that come up again and again
Are unknown, unavailable, and null the same thing?
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?
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?
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?
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?
Does Block Input Overrides protect Get Entities the way it protects Current State?
Does outputResultsCount limit results in every Get Entities mode?
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?
Can Events: state recover everything that happened while Node-RED was disconnected?
How does a downstream Join node identify messages from the same Get Entities split?
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?
Can a complete entity array be stored in global context for every flow?
Where to go from here
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 guidePart 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