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>
280 lines
13 KiB
Markdown
280 lines
13 KiB
Markdown
# 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`.
|
||
|
||
```mermaid
|
||
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`)
|
||
|
||
1. HA OFF (`ha_off` / `ha_lN_off` pulse)
|
||
2. HA ON (`ha_on` / `ha_lN_on` pulse)
|
||
3. Zigbee toggle (`zigbee_sw` pulse)
|
||
4. 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`):**
|
||
|
||
1. `R_TRIG` on `ha_all_on` / `ha_all_off`.
|
||
2. Rising edge on `ha_all_on` sets internal `all_on_latch := TRUE` (all six outputs forced on each scan until released).
|
||
3. Rising edge on `ha_all_off` clears `all_on_latch`; per-light `fb_light` outputs 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)
|
||
|
||
1. **Inputs** accumulate into `flow.nvlInState` (`{ rooms: {}, boiler: {} }`):
|
||
- **HA to NVL** — `server-state-changed` on helpers matching `plc_*`, `living_room`, etc.; sets `ha_lN_on` or `ha_lN_off` for the mapped `cmd_*` room.
|
||
- **Zigbee to NVL** — MQTT actions → `zigbee_swN` pulse on the bound `cmd_*` room.
|
||
- **Zigbee switch dispatch** — routes each button to PLC, direct Zigbee2MQTT light, or HA `switch.*` per `switchBindings` in `room-config.js`.
|
||
|
||
2. **STATE TO NVL** — calls `roomConfigLib.buildNvlSendPayload()` with all `roomNames` from config.
|
||
|
||
3. **`nvl-send`** — serializes GVL matching **`NVL_Receiver`** (see node `definition` in flows; aligns with `NVL_Receiver.st` in the tree).
|
||
|
||
4. **`udp out`** → `10.77.70.5:1202`.
|
||
|
||
5. **Return path:** **`udp in`** `:1202` → **`nvl-receive`** (parses **`NVL_Sender`**) → **NVL to HA Sync** → Home Assistant `api-call-service` (`turn_on` / `turn_off` on mapped helpers).
|
||
|
||
6. **Edge clearing** — after ~120 ms, **Clear NVL edges** reset `zigbee_sw*` / `ha_lN_*` pulse bits via `buildNvlClearPayload` (explicit `FALSE` merge only for cleared keys). **`ha_all_on` / `ha_all_off`** are not cleared over UDP (one rising-edge frame only); the PLC latch in `fb_room` holds the room on until `ha_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:
|
||
|
||
```javascript
|
||
{ 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 for `10.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
|
||
|
||
1. Switches publish to `zigbee2mqtt/<friendly_name>/action` (and related topics).
|
||
2. Node-RED **zigbee2mqtt-in** nodes receive events.
|
||
3. **Zigbee switch dispatch** looks up `switchBindings[device][button]`:
|
||
- **PLC:** `{ room: 'cmd_hallway', light: 2 }` → `zigbee_sw2` pulse.
|
||
- **Direct Z2M light:** `{ z2m: { device: 'Master Bedroom Spots', command: 'state', payload: 'toggle' } }`.
|
||
- **HA switch:** `{ ha: { entity_id: 'switch.xxx', service: 'toggle' } }`.
|
||
|
||
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
|
||
|
||
1. **CODESYS:** Ensure `cmd_<room>` and `light_<room>` exist in `NVL_Receiver` / `NVL_Sender`; wire `fb_light` / `fb_room`; map `NVL_Sender.light_* .l_N` in device IO (`_io.md` / `write_io_mapping`).
|
||
2. **Node-RED:** Add entry to `lightEntityMap` and, if needed, `switchBindings` in `room-config.js`; confirm `cmd_<room>` is in `roomNames`.
|
||
3. **Home Assistant:** Deploy helper (manual YAML package or let Node-RED **Create HA helper** on boot).
|
||
4. **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_inventory` and refresh `codesys-tree/` (see `codesys-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_1` is 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 |
|