Motion sensing, lighting, and retrigger protection
Reliable motion-activated lighting requires more than turning on a light when motion is detected and turning it off a few minutes later. Define explicit branches for state boundaries, repeated events, new motion during a wait, manual control, restarts, and unavailable states. This chapter develops the design in stages with disabled Home Assistant nodes and synthetic data, without operating a real light.
Why this matters
A motion sensor may change state repeatedly within a short period, or it may return to clear only after motion ends. A wall switch, Home Assistant, another automation, or recovery from a disconnection can also change the light state. With only a fixed Delay, an old turn-off message may arrive after new motion and create a race condition. Creating a separate wait for every motion event instead leaves multiple messages pending at once.
This chapter uses a specific but safe scenario: a hallway light in a non-critical area. Motion generates only a proposed turn-on intent. A clear state that persists for a defined period generates only a proposed turn-off intent. Both actual Action nodes remain disabled. The target is PLACEHOLDER_LIGHT_ENTITY, and the sensor is PLACEHOLDER_MOTION_ENTITY; neither identifies a real environment.
Separate events, states, intents, and actions
| Layer | Example data | Flow responsibility | What it must not assume |
|---|---|---|---|
| Event | A state_changed event arrives for the sensor entity | Identify the source and preserve the old/new boundary | An event necessarily means that someone is still present |
| State | Active, clear, unknown, unavailable | Normalize values and reject unknown ones | All binary sensors use the same displayed text |
| Intent | Propose light-on, propose light-off, cancel light-off | Handle deduplication, cooldowns, manual overrides, and races | The device has carried out the intent |
| Action | Call an approved action to turn the light on or off | Recheck conditions at the last moment and constrain the target | The call necessarily succeeds or has a universal response format |
Debouncing requires an input to remain stable for a period before accepting the transition. A cooldown suppresses another intent of the same kind for a period after one is generated. A delayed light-off is a business-rule wait. These mechanisms serve different purposes. Calling all of them a Delay makes it impossible to tell whether a new event resets, queues, discards, or runs alongside an earlier one.
Occupancy is not synonymous with motion. A single passive infrared sensor can generally report only a form of activity; it cannot prove that a room is empty. Aggregating multiple sensors can also expose household routines. Use “activity signal,” not “someone is home,” in flows and Debug output, and do not retain fine-grained time series longer than troubleshooting requires.
Seven steps for a safe, cancelable flow
- Define the state dictionary.
In an offline document, first record the values the sensor integration can actually produce, then map them to
ACTIVE,CLEAR, andINVALID. Treat unknown, unavailable, null, and a missing new_state as INVALID, never as CLEAR. - Choose Events: state or Trigger: state.
Choose Events: state for a single entity transition and a basic duration requirement. Choose Trigger: state when you need allowed/blocked conditions, multiple custom outputs, or test-event input. Use one as the primary entry point; do not listen with both and trigger the flow twice.
- Draw the core message route first.
Use manual Inject, Change, Switch, and Debug nodes to create ACTIVE, CLEAR, INVALID, duplicate ACTIVE, and out-of-order timestamps. You can refer to 02-switch-routing.json, but do not add a Home Assistant node or real ID.
- Add a cancelable wait.
CLEAR creates only a candidate light-off; a new ACTIVE must cancel or replace the old candidate. For debouncing, use the core Trigger node's extend-delay option with
bytopic. If you use a fixed Delay to postpone the candidate, explicitly sendmsg.resetbefore every replacement, and use an independent Delay for each area. Wait Until is another option for waiting on a condition. Do not assume that an ordinary new message automatically resets a Delay. - Add manual override and a final recheck.
After a wall-switch or manual operation, set a time-limited
manual_override. When a candidate light-off expires, recheck the activity state, light state, override flag, and data freshness. If any item is uncertain, take no action. - Connect to a test environment in stages.
In the first stage, keep every Home Assistant node disabled. In the second stage, with separate approval, enable only one read-only event node while its downstream remains a redacted Debug. In the third stage, verify with test entities outside production hours. The production Action remains disabled.
- Document restarts and recovery.
Confirm whether context is persistent, whether pending messages disappear after deployment, and whether reconnection produces initial output. When the current state cannot be established, return to “wait for the next trusted event” rather than guessing and turning off the light.
State machine for hallway lighting
The following is a design specification, not an importable flow, and it does not indicate that any node has been enabled:
Disabled Events: state / Trigger: state
├─ ACTIVE → Cancel pending light-off → Check cooldown and manual override → Propose light-on → Disabled Action
├─ CLEAR → Stable after debounce → Create one pending light-off → Wait / may be canceled by ACTIVE
└─ INVALID → Cancel pending light-off → Redacted diagnostic Debug
Pending light-off expires
→ Current State rechecks activity, light, data timestamp, and manual_override
├─ Everything is explicitly safe → Propose light-off → Disabled Action
└─ Anything is uncertain → Take no action → Diagnostic Debug
The light-on path should not issue a call for every ACTIVE event. First determine whether the light is already on, whether the same intent was sent recently, and whether a manual override requires preserving the current state. The light-off path is even more conservative: generate a proposed light-off only when CLEAR persists, the light is still on because this automation previously turned it on, no new activity has occurred, and no manual override is active.
Minimum contract for manual override
- An override value must include its source, creation time, and expiration time; do not store a person's name or location.
- When someone turns on the light manually, the override can prevent automatic light-off for its duration. When someone turns it off manually, prevent an activity signal from immediately turning it back on.
- Override expiration does not trigger an immediate action; it only resumes evaluation. The flow must still wait for the next trusted event or query the state again.
- After Node-RED restarts, if override data is missing or expired, use the conservative default: do not turn off the light automatically.
If different entry points can control the same light, designate one coordinator or use shared intent/override context. Otherwise, the motion flow, schedule flow, and manual operation can overwrite one another. For flow architecture and context lifetimes, see Chapter 7: Time and context.
Choosing between Events: state and Trigger: state
Events: state: the narrowest state transition
Events: state is easier to review when the requirement can be stated as “for a specified entity, accept a transition from a known value to ACTIVE/CLEAR, optionally after it persists for a set duration.” Before starting, read Chapter 9: State-event boundaries and review the disabled listener in 04-events-state.json. That example shows only the fields persisted by 0.80.3; it does not contain this chapter's actual sensor or light.
Trigger: state: conditional allowed/blocked routing
In 0.80.3, Trigger: state can filter entities by exact match, list, substring, or regex and apply conditions. Its default allowed and blocked outputs indicate whether all conditions passed, and you can also create custom outputs. It also supports Output on connect, Enable input, and test messages. Greater flexibility requires tighter boundaries:
- Prefer exact matching in production flows. Substring or regex matching can unexpectedly widen scope when entities are added.
- Output on connect produces initial output at connection or deployment. Keep it disabled by default for motion lighting so a restart is not interpreted as new activity.
- Enable input is unnecessary by default. If enabled for offline testing, accept only synthetic events with a fixed schema, then disable it after testing.
- Route failed conditions to the blocked diagnostic branch. Do not interpret blocked as “turn off the light”; a failed condition means only that the current intent is not allowed.
- State Type conversion must not infer numbers or Booleans from unknown/unavailable. First isolate exceptional raw strings, then convert only known values.
Timestamps from either node may still reflect source latency. The normalization layer should retain both event and receipt times, rejecting data that moves significantly backward or exceeds the freshness threshold. Do not use Debug to retain complete old_state/new_state objects; show only an anonymized source, normalized state, and latency band.
Delay, Wait Until, debouncing, and race conditions
Mode A: Trigger debouncing or a fixed Delay with an explicit reset
In Node-RED 5.0.2, a fixed Delay creates an independent timeout for every ordinary message. A new message does not automatically reset an earlier timer, and there is no per-topic reset. msg.reset clears all pending fixed-delay messages in that Delay node at once. For debouncing, therefore, use the core Trigger node's extend-delay option and set bytopic. If a fixed Delay implements the business-rule wait after CLEAR, explicitly send reset before replacing the candidate, then send the sole candidate. ACTIVE also sends reset. Give each area its own Delay so one area cannot clear another's messages. Deployment or a process restart may still erase an in-memory wait, so “no timer” must not be interpreted as permission to turn off the light.
Mode B: Wait Until for a state condition
After receiving input, Wait Until listens to the specified entity until its property matches the comparator/value. Version 0.80.3 supports a timeout, with separate success and timeout outputs. When Check against current state is selected, an immediate current-state check is available only for one exact entity. Each node has only one active slot: later input cancels the existing timer and replaces the saved message/config instead of queuing. msg.payload can also override fields including entities, property, comparator, value, and timeout. A safety-conscious flow should enable Block Input Overrides so upstream messages cannot change the listener target or timeout. A timeout of 0 creates no timer and is not a finite wait.
08-wait-until.json provides a disabled 0.80.3 Wait Until node using PLACEHOLDER_HA_SERVER_CONFIG and PLACEHOLDER_ENTITY_ID. It configures a positive timeout of 10 seconds and two outputs: on success, only a limited-field Debug is connected; the timeout output is left unwired. This is a field-level review aid, not a ready-made lighting flow. Do not enable it immediately after import, and never connect its timeout output directly to an Action.
| Mechanism | Problem addressed | When a new ACTIVE arrives | Safe default after restart |
|---|---|---|---|
| Input debounce | Brief chatter | Restart the stability period | Wait for a new, complete stability window |
| Light-on cooldown | Repeated ACTIVE calls | Update the observation time without resending the same intent | Check the current state first; do not resend immediately |
| Independent fixed Delay + explicit reset | Delayed light-off after CLEAR | msg.reset clears every pending message in that node | Treat the candidate as unproven and do not turn off the light |
| Wait Until | Wait for an explicit state or timeout | Complete when the state condition matches, or reset from another path | Re-evaluate without assuming the old wait still exists |
| Final Current State | Prevent an old message from controlling a new state | Block if the state has changed to ACTIVE | Take no action if the query fails or returns unavailable |
Race conditions and restarts
Give every candidate light-off a monotonically increasing generation or request ID. ACTIVE advances the generation. When a wait ends, immediately discard the message if its generation is no longer current. This guards against the race in which cancellation and expiration messages arrive together, but a generation is not a device state; the final Current State recheck is still required.
With persistent context, handle old data versions, expiration times, and backup privacy. With in-memory context, a restart loses candidates and overrides. Neither choice is implicitly safe; represent the restart branch explicitly in the state machine.
Four-stage testing with Action safeguards
Stage 0: Offline review
Inspect the JSON, node settings, and wiring without opening a Node-RED connection. Build a test matrix covering ACTIVE, repeated ACTIVE, CLEAR chatter, CLEAR followed by ACTIVE, unknown, unavailable, missing old_state, restart, and manual override. Every ID must be a placeholder.
Stage 1: Synthetic data with core nodes only
Use a manual Inject to send normalized states; do not use automatic repeat or once. Debug displays only scenario, decision, and generation. Confirm that each case produces at most one intent. This stage has no Home Assistant Server config and no external side effects.
Stage 2: Read-only observation
With separate approval, enable only one Events: state or Trigger: state node at a time for an isolated Home Assistant test entity. Current State and Wait Until may remain disabled; Action must remain disabled. Compare event ordering, chatter intervals, and unavailable behavior without recording detailed occupant activity.
Stage 3: End-to-end walkthrough with Action disabled
Use 07-action.json as a field reference. Configure the Action node with PLACEHOLDER_HA_ACTION and PLACEHOLDER_ENTITY_ID, retain d: true, and enable Block Input Overrides. Wire the upstream path only to a “prepare action” Debug, not to the Action. Do not treat the example's message data as a universal schema for lighting services.
- Use distinct intent types for light-on and light-off, each with its own cooldown and diagnostic record.
- Fix the target to one approved test entity; do not widen the scope through area, device, floor, or label targets.
- Include only the minimum data fields verified against that action's schema, and do not accept arbitrary upstream merges.
- An Action does not universally guarantee that the device completed the operation. Verify through a later state and tolerance interval, without creating infinite retries.
Troubleshooting
- One activity produces multiple light-on intents: Check whether Events: state and Trigger: state are both in use, whether property-only updates are being observed, and whether state-change filtering is missing. Disable every Action, count by event time and anonymized source, then add deduplication and a cooldown.
- The light still receives a light-off intent while someone is moving: Check whether a new ACTIVE actually sends
msg.reset, whether each area has an independent Delay, whether every pending message is cleared before replacement, whether the generation is stale, and whether the state is rechecked before expiration. A fixed Delay has no per-topic reset; when anything is uncertain, take no action. - Wait Until waits forever: Confirm the entities filter, property path, raw state string, and comparator. Set a finite timeout and wire a separate timeout branch. Do not connect the timeout output to light-off; a timeout means only that the condition was not observed.
- Wait Until completes immediately when it should wait: For one exact entity with Check against current state enabled, it completes immediately if the current state matches. Confirm that this meets the specification; do not disable the check merely to conceal a stale-state problem.
- Behavior changes after deployment or restart: The wait and in-memory context may have been cleared, while Trigger: state's Output on connect may have produced initial output. Keep that option disabled, query state in the restart branch first, and prohibit automatic light-off.
- unknown/unavailable is treated as clear: Check Switch-rule order and type conversion. INVALID must be intercepted first and cancel the candidate; it must never fall through to a default CLEAR. After correction, retest both string cases.
- Automation turns the light straight back on after a manual light-off: The manual override may not be checked before the entry path, or its manual source may not be recorded. Create a time-limited override during which the flow observes activity but generates no opposing Action.
Pinned sources and related nodes
- HA WebSocket 0.80.3 pinned commit 2cbbb69
- Official Trigger: state documentation
- Official Wait Until documentation
- Official Home Assistant state-object documentation
This chapter focuses on the inputs, conditions, outputs, and safety boundaries of Trigger: state and Wait Until. Events: state, Current State, Delay, and Action are adjacent components needed by the scenario; detailed settings link back to Chapters 9, 10, and 11 and to the safe flow example. This chapter does not extrapolate from unverified UI behavior or service responses.