Initial commit: CODESYS tree, integration docs, Node-RED scripts.

PLC application sources (NVL, rooms, boiler), HA/Node-RED integration guide, and NVL patch utilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 21:21:56 +03:00
commit d98c44bfa3
27 changed files with 3844 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
# 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 K1K4]
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` | Room-level FBs |
**`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.
---
## 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 ~80 ms, **Clear NVL edges** / **Clear zigbee edge** reset pulse bits so the PLC sees rising edges on the next command (matches `R_TRIG` in ST).
### 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 |