Node-RED sends ha_all_on and ha_all_off as short NVL pulses (same as zigbee_swN). Level-sensitive IF switches.ha_all_on caused all outputs to drop when the pulse cleared (~1s flash then off). Use R_TRIG plus all_on_latch: ALL ON edge latches room on until ALL OFF edge. Document behaviour and Node-RED sparse merge in integration guide. Co-authored-by: Cursor <cursoragent@cursor.com>
13 KiB
CODESYS ↔ Node-RED ↔ Home Assistant integration
Documented: 2026-05-24
Sources: Live config on root@10.77.50.5 (/srv/docker-volumes/…) and local codesys-tree/ mirror.
CODESYS Phase 1 project (copy, does not overwrite original):
C:\codesys-projects\Codesys_Home_Automation\Home_Automation_Boiler_Phase1\Home_Automation.project
Original remains: C:\codesys-projects\Codesys_Home_Automation\Home_Automation.project
This stack runs home automation logic on a CODESYS PLC (EtherCAT relays, room function blocks). Node-RED is the protocol bridge: it maps Home Assistant and Zigbee events into PLC Network Variables (NVL) over UDP, and mirrors PLC relay state back into HA. Home Assistant is the UI, automations, and helper entities—not the real-time I/O path to the PLC.
Infrastructure overview
| Role | Host / container | VLAN70 IP | Notes |
|---|---|---|---|
| Docker host (mgmt) | Proxmox CT, SSH root@10.77.50.5 |
10.77.50.5 |
Runs home_infra compose; macvlan services live on VLAN70 |
| Mosquitto | mqtt |
10.77.70.6 |
Port 1883, auth required (allow_anonymous false) |
| Home Assistant | homeassistant |
10.77.70.11 |
Config: /srv/docker-volumes/config/homeassistant/config |
| Node-RED | nodered |
10.77.70.12 |
Data: /srv/docker-volumes/config/nodered/data |
| Zigbee2MQTT | zigbee2mqtt |
10.77.70.13 |
MQTT mqtt://10.77.70.6, base topic zigbee2mqtt |
| CODESYS PLC | (device, not in this compose) | 10.77.70.5 |
NVL UDP port 1202 |
Compose file: /srv/docker-volumes/stacks/home_infra/docker-compose.yml.
flowchart LR
subgraph HA["Home Assistant 10.77.70.11"]
UI[Dashboard / automations]
Helpers[input_boolean helpers]
end
subgraph NR["Node-RED 10.77.70.12"]
HA2NVL[HA to NVL]
Z2NVL[Zigbee to NVL]
NVL2HA[NVL to HA Sync]
NVLSend[nvl-send]
NVLRecv[nvl-receive]
end
subgraph MQTT["Mosquitto 10.77.70.6"]
end
subgraph Z2M["Zigbee2MQTT 10.77.70.13"]
end
subgraph PLC["CODESYS PLC 10.77.70.5"]
RecvGVL[NVL_Receiver]
SendGVL[NVL_Sender]
FB[fb_light / fb_room / fb_boiler]
IO[EtherCAT K1–K4]
end
UI --> Helpers
Helpers <-->|WebSocket API| HA2NVL
Helpers <-->|WebSocket API| NVL2HA
Z2M --> MQTT
MQTT --> NR
Z2M --> Z2NVL
HA2NVL --> NVLSend
Z2NVL --> NVLSend
NVLSend -->|UDP :1202| RecvGVL
SendGVL -->|UDP :1202| NVLRecv
NVLRecv --> NVL2HA
RecvGVL --> FB --> IO
IO --> SendGVL
CODESYS side (this repository)
Network Variable Lists (UDP)
| GVL | Direction (from PLC view) | Node-RED node | Purpose |
|---|---|---|---|
NVL_Receiver |
Inbound to PLC | nvl-send → udp out |
Commands: HA, Zigbee, schedules |
NVL_Sender |
Outbound from PLC | udp in → nvl-receive |
Status: relay commands + feedback |
NVL_Receiver.st is documented as received via UDP. Node-RED sends to 10.77.70.5:1202; Node-RED listens on 1202 for PLC telegrams.
Key structures
struct_room_cmds (in NVL_Receiver as cmd_* rooms)—one instance per room, e.g. cmd_livingRoom:
| Field group | Members | Used by |
|---|---|---|
| HA pulses | ha_l1_on … ha_l6_on, ha_l1_off … ha_l6_off |
fb_light via R_TRIG (rising edge only) |
| Zigbee pulses | zigbee_sw1 … zigbee_sw6 |
fb_light toggle on edge |
| Room-wide | ha_all_on, ha_all_off |
fb_room: R_TRIG + all_on_latch (rising edge only; see below) |
struct_room_outs (in NVL_Sender as light_* rooms)—per-room outputs:
| Field | Meaning |
|---|---|
l_1 … l_6 |
Commanded relay state (written to NVL before EtherCAT mapping) |
l_1_status … l_6_status |
Actual relay feedback |
Physical mapping: see codesys-tree/_io.md (EtherCAT %QX* → Application.NVL_Sender.light_*).
Legacy / parallel structs in NVL_Sender / NVL_Receiver: struct_lights (l_* rooms) and struct_switches (sw_*, all_on/all_off)—still present in GVLs; the active redesign path for rooms is cmd_* + light_* + fb_light.
Boiler (Phase 1 on PLC): NVL_Receiver.boiler_cmd → fb_boiler in Boiler_PRG; status on NVL_Sender.boiler_status; coil on boiler_status.relay_output (%QX5.4 on K4 ch 13) — not light_shower.l_6. Node-RED must use boiler_cmd / boiler_status (Phase 2); legacy cmd_shower.ha_l6_* no longer drives the heater on the PLC.
Logic priority (fb_light)
- HA OFF (
ha_off/ha_lN_offpulse) - HA ON (
ha_on/ha_lN_onpulse) - Zigbee toggle (
zigbee_swpulse) - Hold state
Outputs feed NVL_Sender; EtherCAT maps those BOOLs to contactors.
Room-wide ALL ON / ALL OFF (fb_room)
Node-RED sends ha_all_on / ha_all_off as short rising-edge pulses (PLC Troubleshoot dashboard, same as zigbee_swN). They must not be treated as level-sensitive BOOLs.
Previous behaviour (bug): IF switches.ha_all_on THEN … forced all outputs on only while the NVL bit stayed TRUE. When Node-RED cleared the pulse (~120 ms–1 s), outputs fell back to per-light fb_light state (usually off) → “flash then all off”.
Current behaviour (fb_room.st):
R_TRIGonha_all_on/ha_all_off.- Rising edge on
ha_all_onsets internalall_on_latch := TRUE(all six outputs forced on each scan until released). - Rising edge on
ha_all_offclearsall_on_latch; per-lightfb_lightoutputs apply again.
Node-RED alignment: room-config-lib.js uses sparse buildNvlSendPayload (only TRUE bits merged into nvl-send) and does not send a follow-up ha_all_on := FALSE frame for ALL ON. After deploying this PLC change, download the application to the controller.
Node-RED side
Main tabs (flows)
| Tab | Role |
|---|---|
| House Lights (PLC) | Production path: NVL UDP, HA sync, Zigbee dispatch, room-config.js |
| Zigbee | MQTT subscription to zigbee2mqtt/# (switches; some direct light control) |
| Water heater | Boiler HA entities, timers, safety shutoff, bridge to cmd_shower |
| PLC Troubleshoot (cmd_*) | Dashboard UI to pulse any cmd_* field for testing |
| Flow 7 | Disabled (legacy) |
NVL pipeline (House Lights tab)
-
Inputs accumulate into
flow.nvlInState({ rooms: {}, boiler: {} }):- HA to NVL —
server-state-changedon helpers matchingplc_*,living_room, etc.; setsha_lN_onorha_lN_offfor the mappedcmd_*room. - Zigbee to NVL — MQTT actions →
zigbee_swNpulse on the boundcmd_*room. - Zigbee switch dispatch — routes each button to PLC, direct Zigbee2MQTT light, or HA
switch.*perswitchBindingsinroom-config.js.
- HA to NVL —
-
STATE TO NVL — calls
roomConfigLib.buildNvlSendPayload()with allroomNamesfrom config. -
nvl-send— serializes GVL matchingNVL_Receiver(see nodedefinitionin flows; aligns withNVL_Receiver.stin the tree). -
udp out→10.77.70.5:1202. -
Return path:
udp in:1202→nvl-receive(parsesNVL_Sender) → NVL to HA Sync → Home Assistantapi-call-service(turn_on/turn_offon mapped helpers). -
Edge clearing — after ~120 ms, Clear NVL edges reset
zigbee_sw*/ha_lN_*pulse bits viabuildNvlClearPayload(explicitFALSEmerge only for cleared keys).ha_all_on/ha_all_offare not cleared over UDP (one rising-edge frame only); the PLC latch infb_roomholds the room on untilha_all_off.
Configuration files (single source of truth)
| File | Purpose |
|---|---|
/data/room-config.js |
Room list, plcRoomToCmd, lightEntityMap, switchBindings, Zigbee deviceIdToName |
/data/room-config-lib.js |
buildNvlSendPayload, HA slug/entity helpers, Zigbee action parsing, switch dispatch |
settings.js |
Loads room-config.js at startup; exposes global.roomConfig / global.roomConfigLib |
roomNames in room-config.js must match CODESYS cmd_* variables in NVL_Receiver.st.
Example mapping entry:
{ room: 'light_livingRoom', light: 1, cmdRoom: 'cmd_livingRoom', entityId: 'input_boolean.living_room_new', name: 'Living Room Main' }
room— key in NVL_Sender payload (light_*).cmdRoom— key in NVL_Receiver payload (cmd_*).entityId— HA helper updated on PLC state changes.
Home Assistant side
Core config
configuration.yaml—default_config, reverse proxy trust for10.77.70.1/10.77.10.1, packages.- MQTT integration — broker
10.77.70.6:1883(credentials in.storage, not in git). - Node-RED custom integration (
custom_components/nodered) — links HA events to Node-RED flows. - Public URL:
ha.paraskeva.net(via OPNsense Caddy).
PLC light helpers
Package packages/lights_by_room.yaml defines input_boolean helpers; naming convention <room>_<light_num>. The active House Lights flow also uses helpers like input_boolean.living_room_new and auto-provisioned input_boolean.plc_<room>_lN from lightEntityMap (via Create HA helper nodes on boot).
Helpers are mirrors of PLC state, not the authority for relay coils—the PLC is.
Echo suppression
When Node-RED writes a helper from NVL→HA, it sets a short haEchoSuppress window so the resulting HA state-change event does not loop back through HA→NVL.
Zigbee path
- Switches publish to
zigbee2mqtt/<friendly_name>/action(and related topics). - Node-RED zigbee2mqtt-in nodes receive events.
- Zigbee switch dispatch looks up
switchBindings[device][button]:- PLC:
{ room: 'cmd_hallway', light: 2 }→zigbee_sw2pulse. - Direct Z2M light:
{ z2m: { device: 'Master Bedroom Spots', command: 'state', payload: 'toggle' } }. - HA switch:
{ ha: { entity_id: 'switch.xxx', service: 'toggle' } }.
- PLC:
Coordinator: SLZB-06 (10.77.70.60) — separate from the PLC path.
Water heater / boiler
| Layer | Mechanism |
|---|---|
| HA | Water_boiler entities, timer remaining sensor, automations in Water heater tab |
| Node-RED | Boiler → boiler_cmd pulse → NVL; feedback from boiler_status → input_boolean.water_boiler |
| PLC | fb_boiler / struct_boiler_cmd (full boiler FB exists; shower light channel used as integration shortcut) |
| Safety | Startup Safety Check turns boiler off if PLC reports ON; 3-Hour Safety Timer in Node-RED |
Boiler command fields in struct_boiler_cmd (ha_on, ha_off, schedule_*, emergency_stop, max_on_time_minutes) are also packed in legacy Build NVL_In buffer logic for older flows; the current Water heater tab primarily uses the cmd_shower / light_shower.l_6 bridge.
MQTT vs UDP (when each is used)
| Transport | Between | Data |
|---|---|---|
| UDP :1202 | Node-RED ↔ PLC | Binary NVL (GVL mirror)—time-critical I/O |
| MQTT | Zigbee2MQTT ↔ Node-RED; HA ↔ MQTT devices | Zigbee events; non-PLC devices; optional HA entities |
| HA WebSocket | Node-RED ↔ Home Assistant | Helper state, services, entity registry |
The PLC does not speak MQTT in this architecture.
Adding a new PLC-controlled light
- CODESYS: Ensure
cmd_<room>andlight_<room>exist inNVL_Receiver/NVL_Sender; wirefb_light/fb_room; mapNVL_Sender.light_* .l_Nin device IO (_io.md/write_io_mapping). - Node-RED: Add entry to
lightEntityMapand, if needed,switchBindingsinroom-config.js; confirmcmd_<room>is inroomNames. - Home Assistant: Deploy helper (manual YAML package or let Node-RED Create HA helper on boot).
- Test: Use PLC Troubleshoot tab to pulse
ha_l1_on, then confirm NVL→HA sync updates the helper.
Operational notes
- Re-sync local tree: After CODESYS changes on the Windows VM, run
read_project_inventoryand refreshcodesys-tree/(seecodesys-tree/README.md). - Node-RED backups: Multiple
flows.json.bak-*files exist under/data; production tab is House Lights (PLC). - Known IO issue:
light_livingRoom.l_1is mapped to two EtherCAT outputs (K2 ch 10 and K4 ch 16)—see_io.md. - Secrets: MQTT and HA tokens live in Docker volumes / HA
.storage; do not commit them to this repo.
Quick reference: variable name alignment
| Concept | CODESYS (NVL_Receiver) |
CODESYS (NVL_Sender) |
Node-RED nvl-send / nvl-receive |
|---|---|---|---|
| Living room commands | cmd_livingRoom : struct_room_cmds |
— | payload.cmd_livingRoom |
| Living room outputs | — | light_livingRoom : struct_room_outs |
payload.light_livingRoom |
| Hallway switch → PLC | cmd_hallway.zigbee_sw* |
— | Via switchBindings |
| HA toggle living room 1 | cmd_livingRoom.ha_l1_on/off |
— | From input_boolean.living_room_new |
Related files in this repo
| Path | Content |
|---|---|
codesys-tree/Device/.../NVL_Receiver.st |
Inbound GVL (commands) |
codesys-tree/Device/.../NVL_Sender.st |
Outbound GVL (status + coil image) |
codesys-tree/Device/.../struct_room_cmds.st |
HA/Zigbee command struct |
codesys-tree/Device/.../struct_room_outs.st |
Per-light outputs + status |
codesys-tree/Device/.../fb_light.st |
Single-light logic |
codesys-tree/_io.md |
EtherCAT ↔ NVL_Sender mapping |