Refine PLC algorithm documentation for lighting control and EtherCAT integration

- Updated the output structure for `struct_room_outs` to focus solely on light states, removing unnecessary status feedback fields.
- Simplified the control logic for lights 1 to 6, aligning with the new design approach for Option C.
- Added detailed instructions for declaring `EtherCAT_RelayFeedback` and `EtherCAT_Outputs` in the Global Variable List, enhancing clarity for integration with the EL2809 module.

This update improves the documentation's accuracy and usability for developers working on the home automation system.
This commit is contained in:
2026-02-08 00:47:57 +02:00
parent 63b343f139
commit 6ef31cd12a
14 changed files with 873 additions and 80 deletions

View File

@@ -0,0 +1,14 @@
(*
DUT: struct_boiler_cmd
Boiler commands from Node-RED/Home Assistant.
*)
TYPE struct_boiler_cmd :
STRUCT
ha_on: BOOL;
ha_off: BOOL;
schedule_on: BOOL;
schedule_off: BOOL;
emergency_stop: BOOL;
max_on_time_minutes: INT;
END_STRUCT
END_TYPE

View File

@@ -0,0 +1,16 @@
(*
DUT: struct_boiler_status
Boiler status to Node-RED/Home Assistant.
*)
TYPE struct_boiler_status :
STRUCT
state: BOOL;
relay_output: BOOL;
on_time_minutes: INT;
remaining_minutes: INT;
emergency_active: BOOL;
time_limit_reached: BOOL;
error_state: BOOL;
error_code: INT;
END_STRUCT
END_TYPE

View File

@@ -0,0 +1,32 @@
(*
DUT: struct_room_cmds
Room lighting commands from Node-RED/Home Assistant (flat for JSON).
Used as input to fb_room.
*)
TYPE struct_room_cmds :
STRUCT
// Home Assistant Commands (set/reset)
ha_l1_on: BOOL;
ha_l1_off: BOOL;
ha_l2_on: BOOL;
ha_l2_off: BOOL;
ha_l3_on: BOOL;
ha_l3_off: BOOL;
ha_l4_on: BOOL;
ha_l4_off: BOOL;
ha_l5_on: BOOL;
ha_l5_off: BOOL;
ha_l6_on: BOOL;
ha_l6_off: BOOL;
// Zigbee Switch Inputs (edge detection for toggle)
zigbee_sw1: BOOL;
zigbee_sw2: BOOL;
zigbee_sw3: BOOL;
zigbee_sw4: BOOL;
zigbee_sw5: BOOL;
zigbee_sw6: BOOL;
// Global Commands
ha_all_on: BOOL;
ha_all_off: BOOL;
END_STRUCT
END_TYPE

View File

@@ -0,0 +1,15 @@
(*
DUT: struct_room_outs
Room light outputs (and status to Node-RED). With Option C, one set is enough:
we send this to the relay and to Node-RED as "light status".
*)
TYPE struct_room_outs :
STRUCT
l_1: BOOL;
l_2: BOOL;
l_3: BOOL;
l_4: BOOL;
l_5: BOOL;
l_6: BOOL;
END_STRUCT
END_TYPE

View File

@@ -0,0 +1,27 @@
(*
GVL: GVL_IO
Option C relay feedback only. EL2809 DO: map channels directly in the device tree
to NVL_Out (e.g. NVL_Out.l_masterBedroom.l_1..l_6, ...) and boiler relay.
Initialize EtherCAT_RelayFeedback to zero at startup (or use default init).
*)
VAR_GLOBAL
// ---------- Option C: relay feedback (copy of output for next cycle) ----------
EtherCAT_RelayFeedback: STRUCT
masterBedroom: struct_room_outs;
masterBathroom: struct_room_outs;
bedroom_1: struct_room_outs;
bedroom_2: struct_room_outs;
bathroom: struct_room_outs;
guest_wc: struct_room_outs;
kitchen: struct_room_outs;
pantry: struct_room_outs;
livingRoom: struct_room_outs;
diningRoom: struct_room_outs;
entrance: struct_room_outs;
hallway: struct_room_outs;
veranda: struct_room_outs;
front: struct_room_outs;
back: struct_room_outs;
side: struct_room_outs;
END_STRUCT;
END_VAR

View File

@@ -0,0 +1,48 @@
(*
GVL: NVL stubs (or replace by CODESYS Network Variable lists NVL_In / NVL_Out).
In the real project, bind these to your UDP/network variable sender/receiver.
DI_Emergency_Stop: link to physical input or leave FALSE.
*)
VAR_GLOBAL
NVL_In: STRUCT
masterBedroom: struct_room_cmds;
masterBathroom: struct_room_cmds;
bedroom_1: struct_room_cmds;
bedroom_2: struct_room_cmds;
bathroom: struct_room_cmds;
guestWc: struct_room_cmds;
kitchen: struct_room_cmds;
pantry: struct_room_cmds;
livingRoom: struct_room_cmds;
dinningRoom: struct_room_cmds;
entrance: struct_room_cmds;
hallway: struct_room_cmds;
outVeranda: struct_room_cmds;
outFront: struct_room_cmds;
outBack: struct_room_cmds;
outSide: struct_room_cmds;
boiler: struct_boiler_cmd;
END_STRUCT;
NVL_Out: STRUCT
l_masterBedroom: struct_room_outs;
l_masterBathroom: struct_room_outs;
l_bedroom_1: struct_room_outs;
l_bedroom_2: struct_room_outs;
l_bathroom: struct_room_outs;
l_guestWc: struct_room_outs;
l_kitchen: struct_room_outs;
l_pantry: struct_room_outs;
l_livingRoom: struct_room_outs;
l_dinningRoom: struct_room_outs;
l_entrance: struct_room_outs;
l_hallway: struct_room_outs;
l_outVeranda: struct_room_outs;
l_outFront: struct_room_outs;
l_outBack: struct_room_outs;
l_outSide: struct_room_outs;
boiler_status: struct_boiler_status;
END_STRUCT;
DI_Emergency_Stop: BOOL := FALSE;
END_VAR

82
codesys/src/NVL/README.md Normal file
View File

@@ -0,0 +1,82 @@
# Network Variables (NVL) for Node-RED
This folder describes the **network variable** setup used for CODESYS ↔ Node-RED communication. The PLC sends light/boiler **status** to Node-RED and receives **commands** from Node-RED over UDP.
## Overview
| Direction | CODESYS name | Node-RED role | Content |
|-----------|--------------|---------------|---------|
| **PLC → Node-RED** | **NVL_Out** (Sender) | Receive UDP | Light states + boiler status |
| **Node-RED → PLC** | **NVL_In** (Receiver) | Send UDP | Light commands + boiler commands |
- **Protocol**: UDP
- **Typical interval**: 50 ms cyclic (configurable in CODESYS)
- **Payload**: Binary (struct layout) or see [Payload layout for Node-RED](nodered-payload.md) for parsing.
---
## 1. NVL_Out (PLC → Node-RED)
**Purpose:** PLC sends current light outputs and boiler status so Node-RED can forward them to Home Assistant / MQTT.
**Variables sent (in order):**
| Variable | Type | Description |
|----------|------|-------------|
| `l_masterBedroom` | struct_room_outs | 12 BOOLs (l_1..l_6, l_1_status..l_6_status) |
| `l_masterBathroom` | struct_room_outs | same |
| `l_bedroom_1` .. `l_bedroom_2` | struct_room_outs | same |
| `l_bathroom`, `l_guestWc`, `l_kitchen`, `l_pantry` | struct_room_outs | same |
| `l_livingRoom`, `l_dinningRoom`, `l_entrance`, `l_hallway` | struct_room_outs | same |
| `l_outVeranda`, `l_outFront`, `l_outBack`, `l_outSide` | struct_room_outs | same |
| `boiler_status` | struct_boiler_status | state, relay_output, on_time_minutes, remaining_minutes, emergency_active, time_limit_reached, error_state, error_code |
**In CODESYS:** Create an **NVL Sender** (Network Variable List), bind it to the **NVL_Out** structure (the same struct as in `GVL_NVL_placeholders.gvl``NVL_Out`). Set protocol to UDP, destination IP/port to your Node-RED host, task and interval (e.g. EtherCAT_Task, 50 ms).
---
## 2. NVL_In (Node-RED → PLC)
**Purpose:** Node-RED sends commands (HA ON/OFF, Zigbee toggle edges, boiler on/off, etc.) so the PLC can drive lights and the boiler.
**Variables received (in order):**
| Variable | Type | Description |
|----------|------|-------------|
| `masterBedroom` .. `side` (15 rooms) | struct_room_cmds | ha_l1_on, ha_l1_off, ... ha_l6_on, ha_l6_off, zigbee_sw1..6, ha_all_on, ha_all_off |
| `boiler` | struct_boiler_cmd | ha_on, ha_off, schedule_on, schedule_off, emergency_stop, max_on_time_minutes |
**In CODESYS:** Create an **NVL Receiver**, bind it to the **NVL_In** structure. Set protocol to UDP, listen port, task and interval. The PLC will overwrite `NVL_In` with received data each cycle.
**In Node-RED:** Send one UDP packet per update (or cyclic) with the same binary layout as `NVL_In` so CODESYS can unpack it into the receiver struct.
---
## 3. CODESYS configuration steps
1. **Add NVL Sender**
- Device tree → Add Object → Network Variable List (Sender).
- Set protocol to **UDP**, destination IP = Node-RED host, port = e.g. **5555** (receive port on Node-RED).
- Add variables: add the **entire NVL_Out** struct (or add each member; see CODESYS help).
- Set task (e.g. EtherCAT_Task) and interval (e.g. T#50ms).
2. **Add NVL Receiver**
- Add Object → Network Variable List (Receiver).
- Set protocol to **UDP**, listen port = e.g. **5556**.
- Add variables: add the **entire NVL_In** struct.
- Set task and interval.
3. **Bind to GVL**
- Either the NVL Sender/Receiver **are** the GVL variables (you map the NVL to the symbols `NVL_Out` and `NVL_In`), or you copy NVL → GVL in the program. Typically you bind the NVL list to a GVL so that `NVL_Out` and `NVL_In` in your code are the same memory the NVL driver sends/receives.
4. **Ports and IP**
- Fill in your PLC IP and Node-RED IP. Node-RED **receives** on the port you set as “destination port” in the NVL Sender. Node-RED **sends** to the PLC IP and the port you set as “listen port” in the NVL Receiver.
---
## 4. Node-RED usage
- **Receive (PLC → Node-RED):** Use a **UDP in** node listening on the port configured in the CODESYS NVL Sender (e.g. 5555). The payload is binary; see [nodered-payload.md](nodered-payload.md) for struct layout and how to parse it to JSON for MQTT/HA.
- **Send (Node-RED → PLC):** Use a **UDP out** node targeting the PLC IP and the NVL Receiver port (e.g. 5556). Build the payload buffer from your command (e.g. from MQTT/HA) using the same layout as [nodered-payload.md](nodered-payload.md).
See **nodered-payload.md** in this folder for byte layout and example parsing/building.

View File

@@ -0,0 +1,154 @@
# Node-RED: NVL payload layout
CODESYS sends and receives **binary** UDP packets (struct layout). Use this layout in Node-RED to parse **NVL_Out** (PLC → Node-RED) and to build **NVL_In** (Node-RED → PLC).
**Important:** Packing and alignment depend on the CODESYS runtime. Typical: BOOL = 1 byte, INT = 2 bytes (little-endian on Intel/ARM). If your platform uses different alignment, adjust offsets. You can log the first few packets and compare with the PLC online values to confirm.
---
## 1. Type sizes (typical)
| Type | Size (bytes) |
|------|--------------|
| BOOL | 1 |
| INT | 2 |
| DINT | 4 |
---
## 2. NVL_Out (PLC → Node-RED) receive and parse
PLC sends one block: all rooms `struct_room_outs` followed by `struct_boiler_status`.
### struct_room_outs (per room)
6 BOOLs in order: `l_1, l_2, l_3, l_4, l_5, l_6` (light state; with Option C this is both output and status).
- **Size per room:** 6 bytes.
- **Rooms in order:** l_masterBedroom, l_masterBathroom, l_bedroom_1, l_bedroom_2, l_bathroom, l_guestWc, l_kitchen, l_pantry, l_livingRoom, l_dinningRoom, l_entrance, l_hallway, l_outVeranda, l_outFront, l_outBack, l_outSide.
- **Total for lights:** 16 × 6 = **96 bytes**.
### struct_boiler_status (after all rooms)
| Offset (from start of boiler_status) | Type | Field |
|-------------------------------------|------|--------|
| 0 | BOOL | state |
| 1 | BOOL | relay_output |
| 2 | INT | on_time_minutes |
| 4 | INT | remaining_minutes |
| 6 | BOOL | emergency_active |
| 7 | BOOL | time_limit_reached |
| 8 | BOOL | error_state |
| 9 | BOOL | (padding possible) |
| 10 | INT | error_code |
**Size:** 12 bytes (or 16 if aligned). So **total NVL_Out** ≈ 96 + 12 = **108 bytes** (or 112 if boiler is aligned).
### Node-RED: parse NVL_Out (example)
```javascript
// msg.payload = Buffer (UDP payload from PLC)
const buf = msg.payload;
const roomSize = 6;
const roomNames = ['l_masterBedroom','l_masterBathroom','l_bedroom_1','l_bedroom_2','l_bathroom','l_guestWc','l_kitchen','l_pantry','l_livingRoom','l_dinningRoom','l_entrance','l_hallway','l_outVeranda','l_outFront','l_outBack','l_outSide'];
const out = { rooms: {}, boiler_status: {} };
for (let r = 0; r < 16; r++) {
const base = r * roomSize;
out.rooms[roomNames[r]] = {
l_1: !!buf[base+0], l_2: !!buf[base+1], l_3: !!buf[base+2],
l_4: !!buf[base+3], l_5: !!buf[base+4], l_6: !!buf[base+5]
};
}
const bo = 96; // offset of boiler_status
out.boiler_status = {
state: !!buf[bo+0],
relay_output: !!buf[bo+1],
on_time_minutes: buf.readInt16LE(bo+2),
remaining_minutes: buf.readInt16LE(bo+4),
emergency_active: !!buf[bo+6],
time_limit_reached: !!buf[bo+7],
error_state: !!buf[bo+8],
error_code: buf.readInt16LE(bo+10)
};
msg.payload = out;
return msg;
```
---
## 3. NVL_In (Node-RED → PLC) build and send
PLC expects one block: all rooms `struct_room_cmds` followed by `struct_boiler_cmd`.
### struct_room_cmds (per room)
20 BOOLs in order:
ha_l1_on, ha_l1_off, ha_l2_on, ha_l2_off, ha_l3_on, ha_l3_off, ha_l4_on, ha_l4_off, ha_l5_on, ha_l5_off, ha_l6_on, ha_l6_off, zigbee_sw1..6, ha_all_on, ha_all_off.
- **Size per room:** 20 bytes.
- **Rooms in order:** masterBedroom, masterBathroom, bedroom_1, bedroom_2, bathroom, guestWc, kitchen, pantry, livingRoom, dinningRoom, entrance, hallway, outVeranda, outFront, outBack, outSide.
- **Total for rooms:** 16 × 20 = **320 bytes**.
### struct_boiler_cmd
| Offset | Type | Field |
|--------|------|--------|
| 0 | BOOL | ha_on |
| 1 | BOOL | ha_off |
| 2 | BOOL | schedule_on |
| 3 | BOOL | schedule_off |
| 4 | BOOL | emergency_stop |
| 5 | (padding) | - |
| 6 | INT | max_on_time_minutes |
**Size:** 8 bytes. So **total NVL_In** = 320 + 8 = **328 bytes**.
### Node-RED: build NVL_In (example)
```javascript
// Build binary payload for PLC from commands
const roomSize = 20;
const roomNames = ['masterBedroom','masterBathroom','bedroom_1','bedroom_2','bathroom','guestWc','kitchen','pantry','livingRoom','dinningRoom','entrance','hallway','outVeranda','outFront','outBack','outSide'];
const buf = Buffer.alloc(328);
const rooms = msg.payload.rooms || {};
const boiler = msg.payload.boiler || {};
for (let r = 0; r < 16; r++) {
const c = rooms[roomNames[r]] || {};
const base = r * roomSize;
buf[base+0] = c.ha_l1_on ? 1 : 0; buf[base+1] = c.ha_l1_off ? 1 : 0;
buf[base+2] = c.ha_l2_on ? 1 : 0; buf[base+3] = c.ha_l2_off ? 1 : 0;
buf[base+4] = c.ha_l3_on ? 1 : 0; buf[base+5] = c.ha_l3_off ? 1 : 0;
buf[base+6] = c.ha_l4_on ? 1 : 0; buf[base+7] = c.ha_l4_off ? 1 : 0;
buf[base+8] = c.ha_l5_on ? 1 : 0; buf[base+9] = c.ha_l5_off ? 1 : 0;
buf[base+10]= c.ha_l6_on ? 1 : 0; buf[base+11]= c.ha_l6_off ? 1 : 0;
for (let i = 0; i < 6; i++) buf[base+12+i] = c['zigbee_sw'+(i+1)] ? 1 : 0;
buf[base+18] = c.ha_all_on ? 1 : 0;
buf[base+19] = c.ha_all_off ? 1 : 0;
}
const bo = 320;
buf[bo+0] = boiler.ha_on ? 1 : 0;
buf[bo+1] = boiler.ha_off ? 1 : 0;
buf[bo+2] = boiler.schedule_on ? 1 : 0;
buf[bo+3] = boiler.schedule_off ? 1 : 0;
buf[bo+4] = boiler.emergency_stop ? 1 : 0;
buf.writeInt16LE(boiler.max_on_time_minutes ?? 480, bo+6);
msg.payload = buf;
return msg;
```
---
## 4. Ports and flow
- **NVL_Out (PLC → Node-RED):** Node-RED **UDP In** listens on e.g. **5555** (same as “destination port” in CODESYS NVL Sender).
- **NVL_In (Node-RED → PLC):** Node-RED **UDP Out** sends to **PLC_IP:5556** (5556 = listen port of CODESYS NVL Receiver).
Send **NVL_In** when commands change (or at a fixed interval, e.g. 100 ms) so the PLC always has the latest commands.

View File

@@ -0,0 +1,65 @@
(*
POU: PLC_App
Main program (Option C: copy of output as relay feedback).
Requires: NVL_In, NVL_Out, GVL_IO (EtherCAT_RelayFeedback).
Map EL2809 DO channels in the device tree directly to NVL_Out (e.g. Ch0..5 = l_masterBedroom.l_1..l_6)
and Ch15 to boiler relay (e.g. NVL_Out.boiler_status.relay_output or a GVL BOOL).
Optional: DI_Emergency_Stop (physical E-Stop input).
*)
PROGRAM PLC_App
VAR
masterBedroom: fb_room;
masterBathroom: fb_room;
bedroom_1: fb_room;
bedroom_2: fb_room;
bathroom: fb_room;
guest_wc: fb_room;
kitchen: fb_room;
pantry: fb_room;
livingRoom: fb_room;
diningRoom: fb_room;
entrance: fb_room;
hallway: fb_room;
veranda: fb_room;
front: fb_room;
back: fb_room;
side: fb_room;
boiler: fb_boiler;
END_VAR
// ========== SECTION 1: LIGHTING (Option C: relay feedback = copy of output) ==========
// Master Bedroom
masterBedroom(switches := NVL_In.masterBedroom, relay_status := EtherCAT_RelayFeedback.masterBedroom);
EtherCAT_RelayFeedback.masterBedroom := masterBedroom.lights;
NVL_Out.l_masterBedroom := masterBedroom.lights;
// Master Bathroom
masterBathroom(switches := NVL_In.masterBathroom, relay_status := EtherCAT_RelayFeedback.masterBathroom);
EtherCAT_RelayFeedback.masterBathroom := masterBathroom.lights;
NVL_Out.l_masterBathroom := masterBathroom.lights;
// Bedroom 1..2, bathroom, guest_wc, kitchen, pantry, livingRoom, diningRoom,
// entrance, hallway, veranda, front, back, side: same pattern (fb_room, RelayFeedback, NVL_Out).
bedroom_1(switches := NVL_In.bedroom_1, relay_status := EtherCAT_RelayFeedback.bedroom_1);
EtherCAT_RelayFeedback.bedroom_1 := bedroom_1.lights;
NVL_Out.l_bedroom_1 := bedroom_1.lights;
// ========== SECTION 2: BOILER ==========
boiler(
ha_on := NVL_In.boiler.ha_on,
ha_off := NVL_In.boiler.ha_off,
schedule_on := NVL_In.boiler.schedule_on,
schedule_off := NVL_In.boiler.schedule_off,
emergency_stop := NVL_In.boiler.emergency_stop OR DI_Emergency_Stop,
max_on_time := T#8H
);
NVL_Out.boiler_status.relay_output := boiler.relay_control;
NVL_Out.boiler_status.state := boiler.state;
NVL_Out.boiler_status.on_time_minutes := DINT_TO_INT(boiler.on_time_seconds / 60);
NVL_Out.boiler_status.remaining_minutes := DINT_TO_INT(boiler.remaining_seconds / 60);
NVL_Out.boiler_status.emergency_active := boiler.emergency_active;
NVL_Out.boiler_status.time_limit_reached := boiler.time_limit_reached;
NVL_Out.boiler_status.error_state := boiler.error_state;
NVL_Out.boiler_status.error_code := boiler.error_code;

View File

@@ -0,0 +1,89 @@
(*
POU: fb_boiler
Simple ON/OFF boiler: time limit + emergency stop. Priority: Emergency > Time limit > OFF > ON.
*)
FUNCTION_BLOCK fb_boiler
VAR_INPUT
ha_on: BOOL;
ha_off: BOOL;
schedule_on: BOOL;
schedule_off: BOOL;
emergency_stop: BOOL;
max_on_time: TIME := T#8H;
END_VAR
VAR_OUTPUT
relay_control: BOOL;
state: BOOL;
on_time_seconds: DINT;
remaining_seconds: DINT;
emergency_active: BOOL;
time_limit_reached: BOOL;
error_state: BOOL;
error_code: INT;
END_VAR
VAR
internal_state: BOOL := FALSE;
r_trig_ha_on: R_TRIG;
r_trig_ha_off: R_TRIG;
r_trig_schedule_on: R_TRIG;
r_trig_schedule_off: R_TRIG;
r_trig_emergency: R_TRIG;
f_trig_emergency: F_TRIG;
on_timer: TON;
max_on_seconds: DINT;
END_VAR
max_on_seconds := TIME_TO_DINT(max_on_time) / 1000;
r_trig_ha_on(CLK := ha_on);
r_trig_ha_off(CLK := ha_off);
r_trig_schedule_on(CLK := schedule_on);
r_trig_schedule_off(CLK := schedule_off);
r_trig_emergency(CLK := emergency_stop);
f_trig_emergency(CLK := emergency_stop);
IF emergency_stop THEN
internal_state := FALSE;
emergency_active := TRUE;
error_state := TRUE;
error_code := 1;
ELSIF on_timer.Q THEN
internal_state := FALSE;
time_limit_reached := TRUE;
error_state := TRUE;
error_code := 2;
ELSE
IF f_trig_emergency.Q THEN
emergency_active := FALSE;
error_state := FALSE;
error_code := 0;
END_IF;
IF NOT internal_state THEN
time_limit_reached := FALSE;
IF error_code = 2 THEN
error_state := FALSE;
error_code := 0;
END_IF;
END_IF;
IF r_trig_ha_off.Q OR r_trig_schedule_off.Q THEN
internal_state := FALSE;
ELSIF (r_trig_ha_on.Q OR r_trig_schedule_on.Q) AND NOT time_limit_reached THEN
internal_state := TRUE;
END_IF;
END_IF;
on_timer(IN := internal_state AND NOT emergency_active, PT := max_on_time);
IF internal_state THEN
on_time_seconds := TIME_TO_DINT(on_timer.ET) / 1000;
remaining_seconds := max_on_seconds - on_time_seconds;
IF remaining_seconds < 0 THEN
remaining_seconds := 0;
END_IF;
ELSE
on_time_seconds := 0;
remaining_seconds := max_on_seconds;
END_IF;
relay_control := internal_state AND NOT emergency_active;
state := internal_state;

View File

@@ -0,0 +1,37 @@
(*
POU: fb_light
Single light control: HA ON/OFF + Zigbee toggle. Priority: HA OFF > HA ON > Zigbee.
*)
FUNCTION_BLOCK fb_light
VAR_INPUT
ha_on: BOOL;
ha_off: BOOL;
zigbee_sw: BOOL;
relay_status: BOOL;
END_VAR
VAR_OUTPUT
light_output: BOOL;
light_status: BOOL;
END_VAR
VAR
r_trig_ha_on: R_TRIG;
r_trig_ha_off: R_TRIG;
r_trig_zigbee: R_TRIG;
light_state: BOOL := FALSE;
END_VAR
// Implementation
r_trig_ha_on(CLK := ha_on);
r_trig_ha_off(CLK := ha_off);
r_trig_zigbee(CLK := zigbee_sw);
IF r_trig_ha_off.Q THEN
light_state := FALSE;
ELSIF r_trig_ha_on.Q THEN
light_state := TRUE;
ELSIF r_trig_zigbee.Q THEN
light_state := NOT light_state;
END_IF;
light_output := light_state;
light_status := relay_status;

View File

@@ -0,0 +1,56 @@
(*
POU: fb_room
Room-level lighting: 6× fb_light; global ha_all_on / ha_all_off overwrite outputs.
*)
FUNCTION_BLOCK fb_room
VAR_INPUT
switches: struct_room_cmds;
relay_status: struct_room_outs;
END_VAR
VAR_OUTPUT
lights: struct_room_outs;
END_VAR
VAR
l1: fb_light;
l2: fb_light;
l3: fb_light;
l4: fb_light;
l5: fb_light;
l6: fb_light;
END_VAR
// Light 1..6 (only l_X output; relay_status = previous cycle output for Option C)
l1(ha_on := switches.ha_l1_on, ha_off := switches.ha_l1_off, zigbee_sw := switches.zigbee_sw1, relay_status := relay_status.l_1);
lights.l_1 := l1.light_output;
l2(ha_on := switches.ha_l2_on, ha_off := switches.ha_l2_off, zigbee_sw := switches.zigbee_sw2, relay_status := relay_status.l_2);
lights.l_2 := l2.light_output;
l3(ha_on := switches.ha_l3_on, ha_off := switches.ha_l3_off, zigbee_sw := switches.zigbee_sw3, relay_status := relay_status.l_3);
lights.l_3 := l3.light_output;
l4(ha_on := switches.ha_l4_on, ha_off := switches.ha_l4_off, zigbee_sw := switches.zigbee_sw4, relay_status := relay_status.l_4);
lights.l_4 := l4.light_output;
l5(ha_on := switches.ha_l5_on, ha_off := switches.ha_l5_off, zigbee_sw := switches.zigbee_sw5, relay_status := relay_status.l_5);
lights.l_5 := l5.light_output;
l6(ha_on := switches.ha_l6_on, ha_off := switches.ha_l6_off, zigbee_sw := switches.zigbee_sw6, relay_status := relay_status.l_6);
lights.l_6 := l6.light_output;
// Global commands
IF switches.ha_all_off THEN
lights.l_1 := FALSE;
lights.l_2 := FALSE;
lights.l_3 := FALSE;
lights.l_4 := FALSE;
lights.l_5 := FALSE;
lights.l_6 := FALSE;
ELSIF switches.ha_all_on THEN
lights.l_1 := TRUE;
lights.l_2 := TRUE;
lights.l_3 := TRUE;
lights.l_4 := TRUE;
lights.l_5 := TRUE;
lights.l_6 := TRUE;
END_IF;

64
codesys/src/README.md Normal file
View File

@@ -0,0 +1,64 @@
# CODESYS Source Tree (Redesign / PLC_App)
Structured folder and source files for the home automation redesign: lighting (fb_light, fb_room) and water boiler (fb_boiler), with Option C relay feedback.
## Folder Structure
```
codesys/src/
├── DUT/ # Data Unit Types
│ ├── struct_room_cmds.typ
│ ├── struct_room_outs.typ
│ ├── struct_boiler_cmd.typ
│ └── struct_boiler_status.typ
├── GVL/ # Global Variable Lists
│ ├── GVL_IO.gvl # EtherCAT_RelayFeedback, EtherCAT_Outputs (EL2809)
│ └── GVL_NVL_placeholders.gvl # NVL_In, NVL_Out, DI_Emergency_Stop (for Node-RED)
├── NVL/ # Network Variables (Node-RED)
│ ├── README.md # NVL_Out / NVL_In config, CODESYS + Node-RED setup
│ └── nodered-payload.md # Binary payload layout + parse/build examples
├── POUs/ # Program Organization Units
│ ├── fb_light.st # Single light FB
│ ├── fb_room.st # Room (6 lights) FB
│ ├── fb_boiler.st # Boiler FB
│ └── PLC_App.st # Main program
└── README.md
```
## Contents
| Item | Description |
|------|-------------|
| **DUT** | `struct_room_cmds`, `struct_room_outs`, `struct_boiler_cmd`, `struct_boiler_status` |
| **GVL_IO** | `EtherCAT_RelayFeedback` (Option C), `EtherCAT_Outputs` (Ch0..Ch15 for EL2809) |
| **GVL_NVL_placeholders** | `NVL_In`, `NVL_Out` (struct stubs), `DI_Emergency_Stop`; replace with real NVL config if needed |
| **fb_light** | One light: HA ON/OFF + Zigbee toggle, relay_status in → light_output/light_status out |
| **fb_room** | Six fb_light instances + ha_all_on / ha_all_off overwrite |
| **fb_boiler** | ON/OFF with max-on-time and emergency stop |
| **PLC_App** | Calls all rooms and boiler; copies room outputs to EtherCAT_RelayFeedback and EtherCAT_Outputs |
## How to Use in CODESYS
1. **Create or open a CODESYS project** (e.g. Control for Raspberry Pi).
2. **Add DUTs**: Create new DUTs under the device or application and paste the content of each `.typ` file (or use **Add Object → DUT** and paste).
3. **Add GVLs**: Create a GVL (e.g. `GVL_IO`), paste `GVL_IO.gvl`. Create/configure NVL_In and NVL_Out for your UDP/network variables and ensure their structure matches the design (struct_room_cmds per room, struct_boiler_cmd; struct_room_outs per room, struct_boiler_status).
4. **Add POUs**: Create POUs (Function Block / Program), set language to Structured Text, and paste the corresponding `.st` file. Order: add `fb_light`, then `fb_room`, then `fb_boiler`, then `PLC_App`.
5. **Task**: Call `PLC_App` from your main task (e.g. EtherCAT_Task or MainTask).
6. **I/O**: Link `EtherCAT_Outputs.Ch0`..`Ch15` to the EL2809 output process image (or build one WORD from Ch0..Ch15 and link that to the EL2809).
## Network variables (Node-RED)
- **NVL_Out** (PLC → Node-RED): light states + boiler status, sent via UDP.
- **NVL_In** (Node-RED → PLC): light commands + boiler commands, received via UDP.
See **NVL/README.md** for CODESYS NVL Sender/Receiver setup and **NVL/nodered-payload.md** for binary payload layout and Node-RED parse/build examples.
## Dependencies
- **Standard library** (R_TRIG, F_TRIG, TON, TIME_TO_DINT, DINT_TO_INT, etc.).
- **NVL_In / NVL_Out**: Use **GVL_NVL_placeholders.gvl** or bind your NVL Sender/Receiver to the same struct layout (see NVL/README.md).
- **DI_Emergency_Stop**: Optional BOOL; set to FALSE or link to a physical input.
## Design Reference
See **docs/codesys/plc-algorithm-design.md** for full algorithm description, I/O mapping, and Option C relay feedback.

View File

@@ -139,25 +139,19 @@ END_STRUCT
END_TYPE
```
#### Output: struct_room_outs (flat, control + status feedback)
#### Output: struct_room_outs (flat light state only)
With Option C (no relay read-back), one set of values is enough: we send `l_1..l_6` to the relay and to Node-RED as "light status".
```iec
TYPE struct_room_outs :
STRUCT
l_1: BOOL; // Light 1 control output
l_2: BOOL; // Light 2 control output
l_3: BOOL; // Light 3 control output
l_4: BOOL; // Light 4 control output
l_5: BOOL; // Light 5 control output
l_6: BOOL; // Light 6 control output
// Status feedback (read from actual relay outputs)
l_1_status: BOOL; // Actual relay 1 state
l_2_status: BOOL; // Actual relay 2 state
l_3_status: BOOL; // Actual relay 3 state
l_4_status: BOOL; // Actual relay 4 state
l_5_status: BOOL; // Actual relay 5 state
l_6_status: BOOL; // Actual relay 6 state
l_1: BOOL; // Light 1
l_2: BOOL; // Light 2
l_3: BOOL; // Light 3
l_4: BOOL; // Light 4
l_5: BOOL; // Light 5
l_6: BOOL; // Light 6
END_STRUCT
END_TYPE
```
@@ -258,65 +252,24 @@ END_VAR
// fb_room - Implementation (per redesign)
// =====================================================
// Light 1 Control
l1(
ha_on := switches.ha_l1_on,
ha_off := switches.ha_l1_off,
zigbee_sw := switches.zigbee_sw1,
relay_status := relay_status.l_1_status
);
// Light 1..6 (relay_status = previous cycle output for Option C)
l1(ha_on := switches.ha_l1_on, ha_off := switches.ha_l1_off, zigbee_sw := switches.zigbee_sw1, relay_status := relay_status.l_1);
lights.l_1 := l1.light_output;
lights.l_1_status := l1.light_status;
// Light 2 Control
l2(
ha_on := switches.ha_l2_on,
ha_off := switches.ha_l2_off,
zigbee_sw := switches.zigbee_sw2,
relay_status := relay_status.l_2_status
);
l2(ha_on := switches.ha_l2_on, ha_off := switches.ha_l2_off, zigbee_sw := switches.zigbee_sw2, relay_status := relay_status.l_2);
lights.l_2 := l2.light_output;
lights.l_2_status := l2.light_status;
// Light 3 Control
l3(
ha_on := switches.ha_l3_on,
ha_off := switches.ha_l3_off,
zigbee_sw := switches.zigbee_sw3,
relay_status := relay_status.l_3_status
);
l3(ha_on := switches.ha_l3_on, ha_off := switches.ha_l3_off, zigbee_sw := switches.zigbee_sw3, relay_status := relay_status.l_3);
lights.l_3 := l3.light_output;
lights.l_3_status := l3.light_status;
// Light 4 Control
l4(
ha_on := switches.ha_l4_on,
ha_off := switches.ha_l4_off,
zigbee_sw := switches.zigbee_sw4,
relay_status := relay_status.l_4_status
);
l4(ha_on := switches.ha_l4_on, ha_off := switches.ha_l4_off, zigbee_sw := switches.zigbee_sw4, relay_status := relay_status.l_4);
lights.l_4 := l4.light_output;
lights.l_4_status := l4.light_status;
// Light 5 Control
l5(
ha_on := switches.ha_l5_on,
ha_off := switches.ha_l5_off,
zigbee_sw := switches.zigbee_sw5,
relay_status := relay_status.l_5_status
);
l5(ha_on := switches.ha_l5_on, ha_off := switches.ha_l5_off, zigbee_sw := switches.zigbee_sw5, relay_status := relay_status.l_5);
lights.l_5 := l5.light_output;
lights.l_5_status := l5.light_status;
// Light 6 Control
l6(
ha_on := switches.ha_l6_on,
ha_off := switches.ha_l6_off,
zigbee_sw := switches.zigbee_sw6,
relay_status := relay_status.l_6_status
);
l6(ha_on := switches.ha_l6_on, ha_off := switches.ha_l6_off, zigbee_sw := switches.zigbee_sw6, relay_status := relay_status.l_6);
lights.l_6 := l6.light_output;
lights.l_6_status := l6.light_status;
// Global Commands (overwrite outputs per redesign)
IF switches.ha_all_off THEN
@@ -594,6 +547,149 @@ state := internal_state;
## Part 3: Main Program Integration
### 3.0 Where to declare EtherCAT_RelayFeedback (and optional EtherCAT_Outputs)
You only need **EtherCAT_RelayFeedback** in a GVL for Option C (previous-cycle output). For the EL2809 DO channels you can **map directly in the device tree** to the variables you already write: e.g. link Ch0..Ch5 to `NVL_Out.l_masterBedroom.l_1``l_6`, Ch6..Ch11 to `NVL_Out.l_masterBathroom.l_1``l_6`, and Ch15 to `NVL_Out.boiler_status.relay_output` (or a GVL BOOL). Then no **EtherCAT_Outputs** GVL or assignments in `PLC_App` are needed.
1. In CODESYS, add or open a **Global Variable List** (e.g. `GVL_IO`).
2. Declare **EtherCAT_RelayFeedback** (one `struct_room_outs` per room) as below. Optionally add **EtherCAT_Outputs** (or a WORD) only if you prefer not to map the EL2809 directly to NVL_Out.
**Example GVL (e.g. `GVL_IO`):**
```iec
VAR_GLOBAL
// ---------- Option C: relay feedback (copy of output) ----------
EtherCAT_RelayFeedback: STRUCT
masterBedroom: struct_room_outs;
masterBathroom: struct_room_outs;
bedroom_1: struct_room_outs;
bedroom_2: struct_room_outs;
bathroom: struct_room_outs;
guest_wc: struct_room_outs;
kitchen: struct_room_outs;
pantry: struct_room_outs;
livingRoom: struct_room_outs;
diningRoom: struct_room_outs;
entrance: struct_room_outs;
hallway: struct_room_outs;
veranda: struct_room_outs;
front: struct_room_outs;
back: struct_room_outs;
side: struct_room_outs;
END_STRUCT;
// ---------- Outputs to EL2809 (15 lights + 1 boiler) ----------
EtherCAT_Outputs: STRUCT
masterBedroom_l1: BOOL;
masterBedroom_l2: BOOL;
masterBedroom_l3: BOOL;
masterBedroom_l4: BOOL;
masterBedroom_l5: BOOL;
masterBedroom_l6: BOOL;
masterBathroom_l1: BOOL;
masterBathroom_l2: BOOL;
masterBathroom_l3: BOOL;
masterBathroom_l4: BOOL;
masterBathroom_l5: BOOL;
masterBathroom_l6: BOOL;
bedroom_1_l1: BOOL;
bedroom_1_l2: BOOL;
bedroom_1_l3: BOOL;
bedroom_1_l4: BOOL;
bedroom_1_l5: BOOL;
bedroom_1_l6: BOOL;
bedroom_2_l1: BOOL;
bedroom_2_l2: BOOL;
bedroom_2_l3: BOOL;
bedroom_2_l4: BOOL;
bedroom_2_l5: BOOL;
bedroom_2_l6: BOOL;
bathroom_l1: BOOL;
bathroom_l2: BOOL;
bathroom_l3: BOOL;
bathroom_l4: BOOL;
bathroom_l5: BOOL;
bathroom_l6: BOOL;
guest_wc_l1: BOOL;
guest_wc_l2: BOOL;
guest_wc_l3: BOOL;
guest_wc_l4: BOOL;
guest_wc_l5: BOOL;
guest_wc_l6: BOOL;
kitchen_l1: BOOL;
kitchen_l2: BOOL;
kitchen_l3: BOOL;
kitchen_l4: BOOL;
kitchen_l5: BOOL;
kitchen_l6: BOOL;
pantry_l1: BOOL;
pantry_l2: BOOL;
pantry_l3: BOOL;
pantry_l4: BOOL;
pantry_l5: BOOL;
pantry_l6: BOOL;
livingRoom_l1: BOOL;
livingRoom_l2: BOOL;
livingRoom_l3: BOOL;
livingRoom_l4: BOOL;
livingRoom_l5: BOOL;
livingRoom_l6: BOOL;
diningRoom_l1: BOOL;
diningRoom_l2: BOOL;
diningRoom_l3: BOOL;
diningRoom_l4: BOOL;
diningRoom_l5: BOOL;
diningRoom_l6: BOOL;
entrance_l1: BOOL;
entrance_l2: BOOL;
entrance_l3: BOOL;
entrance_l4: BOOL;
entrance_l5: BOOL;
entrance_l6: BOOL;
hallway_l1: BOOL;
hallway_l2: BOOL;
hallway_l3: BOOL;
hallway_l4: BOOL;
hallway_l5: BOOL;
hallway_l6: BOOL;
veranda_l1: BOOL;
veranda_l2: BOOL;
veranda_l3: BOOL;
veranda_l4: BOOL;
veranda_l5: BOOL;
veranda_l6: BOOL;
front_l1: BOOL;
front_l2: BOOL;
front_l3: BOOL;
front_l4: BOOL;
front_l5: BOOL;
front_l6: BOOL;
back_l1: BOOL;
back_l2: BOOL;
back_l3: BOOL;
back_l4: BOOL;
back_l5: BOOL;
back_l6: BOOL;
side_l1: BOOL;
side_l2: BOOL;
side_l3: BOOL;
side_l4: BOOL;
side_l5: BOOL;
side_l6: BOOL;
boiler_relay: BOOL;
END_STRUCT;
// ---------- Optional: EL2809 output word (link this to the device in I/O mapping) ----------
// EL2809_Output: WORD; // Then in PLC_App assign bits from EtherCAT_Outputs to EL2809_Output
END_VAR
```
3. **Initialization:** In the GVL properties, set **"Variable Initialization"** so that `EtherCAT_RelayFeedback` is initialized (e.g. CODESYS initializes STRUCT to zero by default). Or in the first scan of `PLC_App` you can clear it once.
4. **Linking to the EL2809:** In the **Device Tree**, open the EtherCAT master → EL2809 → **I/O Mapping** (or **Process Image**). Link each EL2809 **Output** channel directly to the variable you write (e.g. Ch0 → `NVL_Out.l_masterBedroom.l_1`, Ch1 → `l_2`, … Ch15 → boiler relay). Then `PLC_App` only assigns `NVL_Out.l_* := <room>.lights` and boiler status; no separate output struct needed. Alternatively, link the 16 bits to a **WORD** or 16 BOOLs that you assign from room lights and boiler in `PLC_App` (e.g. an optional `EtherCAT_Outputs` GVL).
So: **EtherCAT_RelayFeedback** is in a **GVL** for Option C. The EL2809 output is linked either **directly to NVL_Out** (and boiler) or to an optional output GVL that you fill in `PLC_App`.
### 3.1 PLC_App structure
```iec
@@ -633,7 +729,7 @@ VAR
END_VAR
```
**Option C (relay feedback):** In a GVL, define `EtherCAT_RelayFeedback` (one `struct_room_outs` per room) and `EtherCAT_Outputs` (outputs to EL2809). Initialize `EtherCAT_RelayFeedback` to zero so the first cycle has defined behavior. Each cycle, after each `fb_room` call, copy `EtherCAT_RelayFeedback.<room> := <room>.lights` (see Section 3.2).
**Option C (relay feedback):** In a GVL, define `EtherCAT_RelayFeedback` (one `struct_room_outs` per room). Initialize it to zero so the first cycle has defined behavior. Each cycle, after each `fb_room` call, copy `EtherCAT_RelayFeedback.<room> := <room>.lights`. Map the EL2809 DO channels in the device tree directly to the variables you write (e.g. `NVL_Out.l_*` and boiler relay); a separate `EtherCAT_Outputs` GVL is not required (see Section 3.2).
### 3.2 Main Program Logic
@@ -724,7 +820,7 @@ The EL2809 has a **16-bit process image** (one word) for the output commands. In
**Chosen for this project: Option C** — relay “feedback” is a **copy of the output** we write to the EL2809. No hardware read-back; HA and the PLC stay in sync with the commanded state. Relay/wiring faults are not detected.
Relay feedback is the value passed into `fb_room` as `relay_status` and used for `l_X_status` in the status sent to Node-RED/HA. With Option C it is the same as the control output (from the previous cycle), so the UI reflects what we commanded.
Relay feedback is the value passed into `fb_room` as `relay_status` (same struct as output: `l_1..l_6`) and sent to Node-RED/HA as light status. With Option C it is the same as the control output (from the previous cycle), so the UI reflects what we commanded.
**Your hardware**: **EL2809** (16-channel DO). The EL2809 drives the outputs from the process image; whether it exposes a **read-back** (TxPDO / “Input” or “Status”) depends on the EtherCAT PDO configuration. In CODESYS, after scanning the EL2809, check the devices process image for an **input** or **status** word (data from device → PLC). **EL2809 has no read-back (confirmed):** process image = 16 output bits only, no input. Use **Option C** (copy of output) or **Option B** (auxiliary contacts). Which models *do* have input? See table below.
@@ -813,13 +909,13 @@ Assume the EL2809s read-back is available as a **WORD** in a GVL named `GVL_I
```iec
// Map EL2809 read-back (WORD) to relay feedback. Adjust bit order and room/channel
// mapping to match your wiring. Example: Ch0..Ch5 = masterBedroom l_1..l_6.
EtherCAT_RelayFeedback.masterBedroom.l_1_status := GVL_IO.EL2809_Input.%X0; // Ch0
EtherCAT_RelayFeedback.masterBedroom.l_2_status := GVL_IO.EL2809_Input.%X1;
EtherCAT_RelayFeedback.masterBedroom.l_3_status := GVL_IO.EL2809_Input.%X2;
EtherCAT_RelayFeedback.masterBedroom.l_4_status := GVL_IO.EL2809_Input.%X3;
EtherCAT_RelayFeedback.masterBedroom.l_5_status := GVL_IO.EL2809_Input.%X4;
EtherCAT_RelayFeedback.masterBedroom.l_6_status := GVL_IO.EL2809_Input.%X5;
EtherCAT_RelayFeedback.masterBathroom.l_1_status := GVL_IO.EL2809_Input.%X6;
EtherCAT_RelayFeedback.masterBedroom.l_1 := GVL_IO.EL2809_Input.%X0; // Ch0
EtherCAT_RelayFeedback.masterBedroom.l_2 := GVL_IO.EL2809_Input.%X1;
EtherCAT_RelayFeedback.masterBedroom.l_3 := GVL_IO.EL2809_Input.%X2;
EtherCAT_RelayFeedback.masterBedroom.l_4 := GVL_IO.EL2809_Input.%X3;
EtherCAT_RelayFeedback.masterBedroom.l_5 := GVL_IO.EL2809_Input.%X4;
EtherCAT_RelayFeedback.masterBedroom.l_6 := GVL_IO.EL2809_Input.%X5;
EtherCAT_RelayFeedback.masterBathroom.l_1 := GVL_IO.EL2809_Input.%X6;
// ... continue for all 15 lights (channels 0..14). Channel 15 = boiler relay;
// if you want boiler relay read-back, assign to your boiler status struct instead.
```
@@ -827,8 +923,8 @@ EtherCAT_RelayFeedback.masterBathroom.l_1_status := GVL_IO.EL2809_Input.%X6;
**If you have 16 BOOLs (e.g. `GVL_IO.EL2809_Ch0` … `GVL_IO.EL2809_Ch15`):**
```iec
EtherCAT_RelayFeedback.masterBedroom.l_1_status := GVL_IO.EL2809_Ch0;
EtherCAT_RelayFeedback.masterBedroom.l_2_status := GVL_IO.EL2809_Ch1;
EtherCAT_RelayFeedback.masterBedroom.l_1 := GVL_IO.EL2809_Ch0;
EtherCAT_RelayFeedback.masterBedroom.l_2 := GVL_IO.EL2809_Ch1;
// ... same mapping as above, using EL2809_Ch2 .. EL2809_Ch15
```
@@ -862,7 +958,7 @@ Each `fb_room` is then called with `relay_status := EtherCAT_RelayFeedback.<room
| 1 | In Device Tree → EL2809, check for **Input** (or Status) in process image. |
| 2 | If present, note the symbol (e.g. `GVL_IO.EL2809_Input` or 16 BOOLs). |
| 3 | Define your channel → room/light mapping table. |
| 4 | In `PLC_App` (before calling `fb_room`), assign each Input bit to the corresponding `EtherCAT_RelayFeedback.<room>.l_X_status` (and boiler if applicable). |
| 4 | In `PLC_App` (before calling `fb_room`), assign each Input bit to the corresponding `EtherCAT_RelayFeedback.<room>.l_X` (and boiler if applicable). |
| 5 | Pass `EtherCAT_RelayFeedback.<room>` as `relay_status` into each `fb_room`. |
So: **relay feedback = value read from the EL2809s Input process image**, when the device provides it.
@@ -875,7 +971,7 @@ The relay has (or you add) an **auxiliary (feedback) contact** that closes when
- **Wiring**: Relay coil ← DO channel; relay auxiliary contact → DI channel.
- **In the program**: The DI channel *is* the relay feedback.
e.g. `EtherCAT_RelayFeedback.masterBedroom.l_1_status := GVL.EtherCAT_Master.DI_Module.Ch5;`
e.g. `EtherCAT_RelayFeedback.masterBedroom.l_1 := GVL.EtherCAT_Master.DI_Module.Ch5;`
(where Ch5 is the DI connected to relay 1s auxiliary contact.)
So: **relay feedback = state of the DI that is wired to the relays auxiliary contact.**
@@ -886,10 +982,8 @@ So: **relay feedback = state of the DI that is wired to the relays auxiliary
If the EL2809 does **not** expose an input/read-back in the process image and you do **not** use auxiliary contacts (Option B), use the **output command as the “feedback”**:
- **In the program**: Build `EtherCAT_RelayFeedback` from the **same variables** you write to the EL2809 outputs. For example, after you assign `EtherCAT_Outputs.masterBedroom_l1 := masterBedroom.lights.l_1;`, also set
`EtherCAT_RelayFeedback.masterBedroom.l_1_status := masterBedroom.lights.l_1;`
(or copy from a single source of truth for each channel).
- Then `l_X_status` in `struct_room_outs` reflects “what we commanded,” not “what the relay actually did.” HA and the PLC stay in sync, but **relay or wiring faults are not detected**.
- **In the program**: After each `fb_room` call, set `EtherCAT_RelayFeedback.<room> := <room>.lights`. So `l_1..l_6` are both the relay output and the "status" passed back next cycle and sent to Node-RED.
- Then the single set `l_1..l_6` reflects “what we commanded,” not “what the relay actually did.” HA and the PLC stay in sync, but **relay or wiring faults are not detected**.
**Implementation:** Section 3.2 (PLC_App): after each `fb_room` call, set `EtherCAT_RelayFeedback.<room> := <room>.lights`; initialize `EtherCAT_RelayFeedback` to zero at startup.