Chapter 13

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.

Danger: Do not use this flow for emergency lighting, security, medical applications, or any other safety-critical purpose.Missed motion, an unavailable Home Assistant instance, a Node-RED restart, or a network outage can make the result differ from the physical environment. Every Home Assistant listener, query, wait, and Action node remains disabled in the example design. Action nodes are neither wired nor deployed for execution.

Separate events, states, intents, and actions

LayerExample dataFlow responsibilityWhat it must not assume
EventA state_changed event arrives for the sensor entityIdentify the source and preserve the old/new boundaryAn event necessarily means that someone is still present
StateActive, clear, unknown, unavailableNormalize values and reject unknown onesAll binary sensors use the same displayed text
IntentPropose light-on, propose light-off, cancel light-offHandle deduplication, cooldowns, manual overrides, and racesThe device has carried out the intent
ActionCall an approved action to turn the light on or offRecheck conditions at the last moment and constrain the targetThe 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

  1. Define the state dictionary.

    In an offline document, first record the values the sensor integration can actually produce, then map them to ACTIVE, CLEAR, and INVALID. Treat unknown, unavailable, null, and a missing new_state as INVALID, never as CLEAR.

  2. 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.

  3. 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.

  4. 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 send msg.reset before 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.

  5. 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.

  6. 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.

  7. 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.
Test input is not a production sensor source.Trigger: state can receive messages containing entity_id, old_state, and new_state to simulate events, but this validates only the node's conditions and outputs. It does not prove that the Home Assistant integration, wireless transport, or physical light will behave the same way. Synthetic events must use placeholder IDs, and the node must remain disabled until isolated testing is approved.

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.

MechanismProblem addressedWhen a new ACTIVE arrivesSafe default after restart
Input debounceBrief chatterRestart the stability periodWait for a new, complete stability window
Light-on cooldownRepeated ACTIVE callsUpdate the observation time without resending the same intentCheck the current state first; do not resend immediately
Independent fixed Delay + explicit resetDelayed light-off after CLEARmsg.reset clears every pending message in that nodeTreat the candidate as unproven and do not turn off the light
Wait UntilWait for an explicit state or timeoutComplete when the state condition matches, or reset from another pathRe-evaluate without assuming the old wait still exists
Final Current StatePrevent an old message from controlling a new stateBlock if the state has changed to ACTIVETake 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.
Acceptance criteria: Every exceptional case results in no action; a new ACTIVE after CLEAR cancels the old candidate; a restart never triggers automatic light-off; no opposing intent is generated during manual override; and Action stays disabled throughout. If any criterion fails, stop and do not proceed to the production environment.

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

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.

FAQ

When the sensor becomes unavailable, can I start the light-off countdown?
No. unavailable means that data is unavailable, not that there is no activity. Cancel the candidate light-off, emit a redacted diagnostic, and wait for the next trusted state or manual intervention.
Should I choose Delay, Trigger, or Wait Until?
Use Trigger's extend delay and bytopic for input debouncing. A separate Delay for each area can provide a fixed delay, but explicitly send msg.reset before replacement; ordinary new messages do not reset the timer, and reset clears every pending message in that node. Use Wait Until when you need to wait for an entity property and distinguish success from timeout. All three require restart and race-condition strategies.
Can Trigger: state's blocked output turn off the light directly?
No. blocked means only that one or more conditions did not pass; it does not mean “safe to turn off.” Route it to diagnostics or an ignored branch.
Why is Current State needed before light-off?
New activity, manual control, or reconnection may occur during the wait, so an old message no longer represents the current situation. The final query is an essential safeguard. If the query fails or the data is stale or unavailable, take no action.
Can I use a regex for every sensor in an area?
Production flows should list exact entities. Regex or substring matching can expand monitoring when new entities are added and is harder to review. If it is truly necessary, establish naming governance and change testing.
When can Action nodes be enabled?
This chapter does not authorize enabling them. First complete acceptance with synthetic data and read-only observation, including race, restart, manual override, and unavailable cases. Then have another reviewer approve the test target under your organization's procedures. Production deployment is still a separate step.