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:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
credentials.json
|
||||
@@ -0,0 +1,18 @@
|
||||
(* === DECLARATION === *)
|
||||
TYPE struct_boiler_cmd :
|
||||
STRUCT
|
||||
// Control Commands
|
||||
ha_on: BOOL; // Home Assistant: Turn ON
|
||||
ha_off: BOOL; // Home Assistant: Turn OFF
|
||||
schedule_on: BOOL; // Scheduled ON (from automation)
|
||||
schedule_off: BOOL; // Scheduled OFF (from automation)
|
||||
|
||||
// Safety Input
|
||||
emergency_stop: BOOL; // Emergency stop (physical button or HA)
|
||||
|
||||
// Configuration (can be set via Node-RED)
|
||||
max_on_time_minutes: INT; // Maximum ON time in minutes (default: 480 = 8 hours)
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,22 @@
|
||||
(* === DECLARATION === *)
|
||||
TYPE struct_boiler_status :
|
||||
STRUCT
|
||||
// State
|
||||
state: BOOL; // Current boiler state (ON/OFF)
|
||||
relay_output: BOOL; // Actual relay output state
|
||||
|
||||
// Runtime Info
|
||||
on_time_minutes: INT; // Current ON time in minutes
|
||||
remaining_minutes: INT; // Remaining time before auto-shutoff
|
||||
|
||||
// Safety Status
|
||||
emergency_active: BOOL; // Emergency stop is active
|
||||
time_limit_reached: BOOL; // Max time limit was reached
|
||||
|
||||
// Error Handling
|
||||
error_state: BOOL; // Error detected
|
||||
error_code: INT; // Error code (0=none, 1=emergency, 2=time limit)
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,13 @@
|
||||
(* === DECLARATION === *)
|
||||
TYPE struct_lights:
|
||||
STRUCT
|
||||
l_1 : BOOL;
|
||||
l_2 : BOOL;
|
||||
l_3 : BOOL;
|
||||
l_4 : BOOL;
|
||||
l_5 : BOOL;
|
||||
l_6 : BOOL;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,32 @@
|
||||
(* === DECLARATION === *)
|
||||
TYPE struct_room_cmds :
|
||||
STRUCT
|
||||
// Home Assistant Commands (set/reset)
|
||||
ha_l1_on: BOOL; // HA command: Light 1 ON
|
||||
ha_l1_off: BOOL; // HA command: Light 1 OFF
|
||||
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 switch 1 (edge detected)
|
||||
zigbee_sw2: BOOL;
|
||||
zigbee_sw3: BOOL;
|
||||
zigbee_sw4: BOOL;
|
||||
zigbee_sw5: BOOL;
|
||||
zigbee_sw6: BOOL;
|
||||
|
||||
// Global Commands
|
||||
ha_all_on: BOOL; // HA: All lights ON
|
||||
ha_all_off: BOOL; // HA: All lights OFF
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,21 @@
|
||||
(* === DECLARATION === *)
|
||||
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
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,15 @@
|
||||
(* === DECLARATION === *)
|
||||
TYPE struct_switches :
|
||||
STRUCT
|
||||
all_off:BOOL;
|
||||
all_on:BOOL;
|
||||
sw_1:BOOL;
|
||||
sw_2:BOOL;
|
||||
sw_3:BOOL;
|
||||
sw_4:BOOL;
|
||||
sw_5:BOOL;
|
||||
sw_6:BOOL;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,23 @@
|
||||
(* === DECLARATION === *)
|
||||
TYPE struct_zones :
|
||||
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_TYPE
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,8 @@
|
||||
(* === DECLARATION === *)
|
||||
{attribute 'qualified_only'}
|
||||
VAR_GLOBAL
|
||||
// ---------- Option C: relay feedback (copy of output) ----------
|
||||
|
||||
END_VAR
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,26 @@
|
||||
(* === DECLARATION === *)
|
||||
//This gobal variable list is received via the network.
|
||||
//Protocol: UDP
|
||||
//Layout matches Node-RED nvl-send (no legacy struct_switches — removed on PLC)
|
||||
|
||||
{attribute 'qualified_only'}
|
||||
VAR_GLOBAL
|
||||
cmd_livingRoom : struct_room_cmds;
|
||||
cmd_dinningRoom : struct_room_cmds;
|
||||
cmd_kitchenArea : struct_room_cmds;
|
||||
cmd_hallway : struct_room_cmds;
|
||||
cmd_office : struct_room_cmds;
|
||||
cmd_bedroom : struct_room_cmds;
|
||||
cmd_masterBedroom : struct_room_cmds;
|
||||
cmd_masterBath : struct_room_cmds;
|
||||
cmd_shower : struct_room_cmds;
|
||||
cmd_guestWc : struct_room_cmds;
|
||||
cmd_outFront : struct_room_cmds;
|
||||
cmd_coveredVeranda : struct_room_cmds;
|
||||
cmd_outYard : struct_room_cmds;
|
||||
cmd_outBack : struct_room_cmds;
|
||||
boiler_cmd : struct_boiler_cmd;
|
||||
|
||||
END_VAR
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,23 @@
|
||||
(* === DECLARATION === *)
|
||||
{attribute 'qualified_only'}
|
||||
|
||||
VAR_GLOBAL
|
||||
light_livingRoom : struct_room_outs;
|
||||
light_dinningRoom : struct_room_outs;
|
||||
light_kitchenArea : struct_room_outs;
|
||||
light_hallway : struct_room_outs;
|
||||
light_office : struct_room_outs;
|
||||
light_bedroom : struct_room_outs;
|
||||
light_masterBedroom : struct_room_outs;
|
||||
light_masterBath : struct_room_outs;
|
||||
light_shower : struct_room_outs;
|
||||
light_guestWc : struct_room_outs;
|
||||
light_outFront : struct_room_outs;
|
||||
light_coveredVeranda : struct_room_outs;
|
||||
light_outYard : struct_room_outs;
|
||||
light_outBack : struct_room_outs;
|
||||
boiler_status : struct_boiler_status;
|
||||
|
||||
END_VAR
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,151 @@
|
||||
(* === DECLARATION === *)
|
||||
FUNCTION_BLOCK fb_boiler
|
||||
VAR_INPUT
|
||||
// Control Commands
|
||||
ha_on: BOOL; // Home Assistant ON command
|
||||
ha_off: BOOL; // Home Assistant OFF command
|
||||
schedule_on: BOOL; // Scheduled ON
|
||||
schedule_off: BOOL; // Scheduled OFF
|
||||
|
||||
// Safety
|
||||
emergency_stop: BOOL; // Emergency stop input
|
||||
|
||||
// Configuration
|
||||
max_on_time: TIME := T#8H; // Maximum ON time (default 8 hours)
|
||||
END_VAR
|
||||
VAR_OUTPUT
|
||||
// Outputs
|
||||
relay_control: BOOL; // Control output to relay
|
||||
|
||||
// Status
|
||||
state: BOOL; // Current state
|
||||
on_time_seconds: DINT; // Current ON time in seconds
|
||||
remaining_seconds: DINT; // Remaining time in seconds
|
||||
|
||||
// Safety Status
|
||||
emergency_active: BOOL; // Emergency stop active
|
||||
time_limit_reached: BOOL; // Time limit was reached
|
||||
error_state: BOOL; // Error flag
|
||||
error_code: INT; // Error code
|
||||
END_VAR
|
||||
VAR
|
||||
// Internal State
|
||||
internal_state: BOOL := FALSE;
|
||||
last_state: BOOL := FALSE;
|
||||
|
||||
// Edge Detection
|
||||
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;
|
||||
|
||||
// Timers
|
||||
on_timer: TON; // Counts ON time
|
||||
|
||||
// Time tracking
|
||||
max_on_seconds: DINT;
|
||||
END_VAR
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
// =====================================================
|
||||
// fb_boiler - Implementation
|
||||
// =====================================================
|
||||
// Priority (highest to lowest):
|
||||
// 1. Emergency Stop - Immediate shutdown
|
||||
// 2. Time Limit Exceeded - Auto shutdown
|
||||
// 3. OFF Commands - Turn off
|
||||
// 4. ON Commands - Turn on (if safe)
|
||||
// =====================================================
|
||||
|
||||
// Calculate max time in seconds
|
||||
max_on_seconds := TIME_TO_DINT(max_on_time) / 1000;
|
||||
|
||||
// Edge detection for commands
|
||||
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);
|
||||
|
||||
// =====================================================
|
||||
// SAFETY CHECKS (highest priority)
|
||||
// =====================================================
|
||||
|
||||
// Priority 1: Emergency Stop
|
||||
IF emergency_stop THEN
|
||||
internal_state := FALSE;
|
||||
emergency_active := TRUE;
|
||||
error_state := TRUE;
|
||||
error_code := 1; // Emergency stop active
|
||||
|
||||
// Priority 2: Time Limit Check
|
||||
ELSIF on_timer.Q THEN
|
||||
internal_state := FALSE;
|
||||
time_limit_reached := TRUE;
|
||||
error_state := TRUE;
|
||||
error_code := 2; // Time limit exceeded
|
||||
|
||||
// =====================================================
|
||||
// CONTROL LOGIC (only when safe)
|
||||
// =====================================================
|
||||
ELSE
|
||||
// Clear safety flags when emergency released
|
||||
IF f_trig_emergency.Q THEN
|
||||
emergency_active := FALSE;
|
||||
error_state := FALSE;
|
||||
error_code := 0;
|
||||
END_IF;
|
||||
|
||||
// Clear time limit flag when boiler turned off
|
||||
IF NOT internal_state THEN
|
||||
time_limit_reached := FALSE;
|
||||
IF error_code = 2 THEN
|
||||
error_state := FALSE;
|
||||
error_code := 0;
|
||||
END_IF;
|
||||
END_IF;
|
||||
|
||||
// Priority 3: OFF Commands (HA OFF or Schedule OFF)
|
||||
IF r_trig_ha_off.Q OR r_trig_schedule_off.Q THEN
|
||||
internal_state := FALSE;
|
||||
|
||||
// Priority 4: ON Commands (HA ON or Schedule ON)
|
||||
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;
|
||||
|
||||
// =====================================================
|
||||
// TIMER MANAGEMENT
|
||||
// =====================================================
|
||||
|
||||
// ON timer - counts total ON time
|
||||
on_timer(
|
||||
IN := internal_state AND NOT emergency_active,
|
||||
PT := max_on_time
|
||||
);
|
||||
|
||||
// Calculate current ON time in seconds
|
||||
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;
|
||||
|
||||
// =====================================================
|
||||
// OUTPUT ASSIGNMENT
|
||||
// =====================================================
|
||||
|
||||
// Relay control - only ON when state is ON and no emergency
|
||||
relay_control := internal_state AND NOT emergency_active;
|
||||
|
||||
// State output
|
||||
state := internal_state;
|
||||
@@ -0,0 +1,57 @@
|
||||
(* === DECLARATION === *)
|
||||
FUNCTION_BLOCK fb_light
|
||||
VAR_INPUT
|
||||
// Home Assistant Commands
|
||||
ha_on: BOOL; // HA command: Turn ON
|
||||
ha_off: BOOL; // HA command: Turn OFF
|
||||
|
||||
// Zigbee Switch Input
|
||||
zigbee_sw: BOOL; // Zigbee switch (edge detected)
|
||||
|
||||
// Status Feedback
|
||||
relay_status: BOOL; // Actual relay state (read from EtherCAT)
|
||||
END_VAR
|
||||
VAR_OUTPUT
|
||||
light_output: BOOL; // Control output to relay
|
||||
light_status: BOOL; // Status to send back (actual relay state)
|
||||
END_VAR
|
||||
VAR
|
||||
// Edge triggers
|
||||
r_trig_ha_on: R_TRIG;
|
||||
r_trig_ha_off: R_TRIG;
|
||||
r_trig_zigbee: R_TRIG;
|
||||
|
||||
// Internal state
|
||||
light_state: BOOL := FALSE;
|
||||
END_VAR
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
// =====================================================
|
||||
// fb_light - Implementation (per redesign)
|
||||
// =====================================================
|
||||
// Priority (highest to lowest):
|
||||
// 1. HA OFF command - Always turns light OFF
|
||||
// 2. HA ON command - Always turns light ON
|
||||
// 3. Zigbee toggle - Toggles current state
|
||||
// 4. Maintain current state
|
||||
// =====================================================
|
||||
|
||||
// Edge detection for commands
|
||||
r_trig_ha_on(CLK := ha_on);
|
||||
r_trig_ha_off(CLK := ha_off);
|
||||
r_trig_zigbee(CLK := zigbee_sw);
|
||||
|
||||
// State machine
|
||||
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;
|
||||
|
||||
// Output assignment
|
||||
light_output := light_state;
|
||||
|
||||
// Status feedback (read from actual relay)
|
||||
light_status := relay_status;
|
||||
@@ -0,0 +1,58 @@
|
||||
(* === DECLARATION === *)
|
||||
FUNCTION_BLOCK fb_room
|
||||
VAR_INPUT
|
||||
switches: struct_room_cmds;
|
||||
relay_status: struct_room_outs; // Read from EtherCAT outputs (actual relay states)
|
||||
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
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
// =====================================================
|
||||
// fb_room - Implementation (per redesign)
|
||||
// =====================================================
|
||||
|
||||
// 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;
|
||||
|
||||
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 (overwrite outputs per redesign)
|
||||
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;
|
||||
@@ -0,0 +1,20 @@
|
||||
(* === DECLARATION === *)
|
||||
FUNCTION_BLOCK fb_switch
|
||||
VAR_INPUT
|
||||
switches: struct_switches;
|
||||
END_VAR
|
||||
VAR_OUTPUT
|
||||
lights: struct_lights;
|
||||
END_VAR
|
||||
VAR
|
||||
all_off: fb_toogleButton;
|
||||
all_on: fb_toogleButton;
|
||||
sw_1: fb_toogleButton;
|
||||
sw_2: fb_toogleButton;
|
||||
sw_3: fb_toogleButton;
|
||||
sw_4: fb_toogleButton;
|
||||
sw_5: fb_toogleButton;
|
||||
sw_6: fb_toogleButton;
|
||||
END_VAR
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,25 @@
|
||||
(* === DECLARATION === *)
|
||||
FUNCTION_BLOCK fb_toogleButton
|
||||
VAR_INPUT
|
||||
|
||||
Input :BOOL;
|
||||
END_VAR
|
||||
VAR_OUTPUT
|
||||
Output :BOOL;
|
||||
|
||||
END_VAR
|
||||
VAR
|
||||
Switch: BOOL;
|
||||
Light: BOOL;
|
||||
Flag1: BOOL;
|
||||
Flag2: BOOL;
|
||||
RT: R_TRIG;
|
||||
Pulse: TP;
|
||||
CTU_0: CTU;
|
||||
CTD_0: CTD;
|
||||
CTU_1: CTU;
|
||||
CTU_2: CTU;
|
||||
TOF_0: TOF;
|
||||
END_VAR
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,33 @@
|
||||
(* === DECLARATION === *)
|
||||
(* Water heater — fb_boiler; coil mapped to NVL_Sender.boiler_status.relay_output (%QX5.4). *)
|
||||
PROGRAM Boiler_PRG
|
||||
VAR
|
||||
heater : fb_boiler;
|
||||
max_on_minutes : INT;
|
||||
max_on_time : TIME;
|
||||
END_VAR
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
max_on_minutes := NVL_Receiver.boiler_cmd.max_on_time_minutes;
|
||||
IF max_on_minutes <= 0 THEN
|
||||
max_on_minutes := 480;
|
||||
END_IF;
|
||||
max_on_time := T#1M * max_on_minutes;
|
||||
|
||||
heater(
|
||||
ha_on := NVL_Receiver.boiler_cmd.ha_on,
|
||||
ha_off := NVL_Receiver.boiler_cmd.ha_off,
|
||||
schedule_on := NVL_Receiver.boiler_cmd.schedule_on,
|
||||
schedule_off := NVL_Receiver.boiler_cmd.schedule_off,
|
||||
emergency_stop := NVL_Receiver.boiler_cmd.emergency_stop,
|
||||
max_on_time := max_on_time
|
||||
);
|
||||
|
||||
NVL_Sender.boiler_status.state := heater.state;
|
||||
NVL_Sender.boiler_status.relay_output := heater.relay_control;
|
||||
NVL_Sender.boiler_status.on_time_minutes := DINT_TO_INT(heater.on_time_seconds / 60);
|
||||
NVL_Sender.boiler_status.remaining_minutes := DINT_TO_INT(heater.remaining_seconds / 60);
|
||||
NVL_Sender.boiler_status.emergency_active := heater.emergency_active;
|
||||
NVL_Sender.boiler_status.time_limit_reached := heater.time_limit_reached;
|
||||
NVL_Sender.boiler_status.error_state := heater.error_state;
|
||||
NVL_Sender.boiler_status.error_code := heater.error_code;
|
||||
@@ -0,0 +1,22 @@
|
||||
(* === DECLARATION === *)
|
||||
PROGRAM Lights
|
||||
VAR
|
||||
masterBedroom : fb_switch;
|
||||
masterBathroom: fb_switch;
|
||||
bedroom_1: fb_switch;
|
||||
bedroom_2: fb_switch;
|
||||
bathroom: fb_switch;
|
||||
guest_wc: fb_switch;
|
||||
kitchen: fb_switch;
|
||||
pantry: fb_switch;
|
||||
livingRoom: fb_switch;
|
||||
diningRoom: fb_switch;
|
||||
veranda: fb_switch;
|
||||
entrance: fb_switch;
|
||||
front: fb_switch;
|
||||
back: fb_switch;
|
||||
side: fb_switch;
|
||||
hallway: fb_switch;
|
||||
END_VAR
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
@@ -0,0 +1,116 @@
|
||||
(* === DECLARATION === *)
|
||||
(* Room lighting (redesign). Shower channels l_1..l_5 only — l_6 is water heater (Boiler_PRG). *)
|
||||
PROGRAM Lights_new
|
||||
VAR
|
||||
Living_Room: FB_ROOM;
|
||||
Dininng_Room: fb_room;
|
||||
Kitchen_Area: fb_room;
|
||||
Hallway: fb_room;
|
||||
Office: fb_room;
|
||||
Bedroom: fb_room;
|
||||
Master_Bedroom: fb_room;
|
||||
Master_bath: fb_room;
|
||||
Shower: fb_room;
|
||||
Guest_wc: fb_room;
|
||||
Outside_front: fb_room;
|
||||
Covered_Veranda: fb_room;
|
||||
Yard: fb_room;
|
||||
Outside_back: fb_room;
|
||||
END_VAR
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
Living_Room(
|
||||
switches := NVL_Receiver.cmd_livingRoom,
|
||||
relay_status := NVL_Sender.light_livingRoom
|
||||
);
|
||||
NVL_Sender.light_livingRoom := Living_Room.lights;
|
||||
|
||||
Dininng_Room(
|
||||
switches := NVL_Receiver.cmd_dinningRoom,
|
||||
relay_status := NVL_Sender.light_dinningRoom
|
||||
);
|
||||
NVL_Sender.light_dinningRoom := Dininng_Room.lights;
|
||||
|
||||
Kitchen_Area(
|
||||
switches := NVL_Receiver.cmd_kitchenArea,
|
||||
relay_status := NVL_Sender.light_kitchenArea
|
||||
);
|
||||
NVL_Sender.light_kitchenArea := Kitchen_Area.lights;
|
||||
|
||||
Hallway(
|
||||
switches := NVL_Receiver.cmd_hallway,
|
||||
relay_status := NVL_Sender.light_hallway
|
||||
);
|
||||
NVL_Sender.light_hallway := Hallway.lights;
|
||||
|
||||
Office(
|
||||
switches := NVL_Receiver.cmd_office,
|
||||
relay_status := NVL_Sender.light_office
|
||||
);
|
||||
NVL_Sender.light_office := Office.lights;
|
||||
|
||||
Bedroom(
|
||||
switches := NVL_Receiver.cmd_bedroom,
|
||||
relay_status := NVL_Sender.light_bedroom
|
||||
);
|
||||
NVL_Sender.light_bedroom := Bedroom.lights;
|
||||
|
||||
Master_Bedroom(
|
||||
switches := NVL_Receiver.cmd_masterBedroom,
|
||||
relay_status := NVL_Sender.light_masterBedroom
|
||||
);
|
||||
NVL_Sender.light_masterBedroom := Master_Bedroom.lights;
|
||||
|
||||
Master_bath(
|
||||
switches := NVL_Receiver.cmd_masterBath,
|
||||
relay_status := NVL_Sender.light_masterBath
|
||||
);
|
||||
NVL_Sender.light_masterBath := Master_bath.lights;
|
||||
|
||||
Shower(
|
||||
switches := NVL_Receiver.cmd_shower,
|
||||
relay_status := NVL_Sender.light_shower
|
||||
);
|
||||
// l_6 not used for shower — water heater uses boiler_status.relay_output (%QX5.4)
|
||||
NVL_Sender.light_shower.l_6 := FALSE;
|
||||
NVL_Sender.light_shower.l_6_status := FALSE;
|
||||
NVL_Sender.light_shower.l_1 := Shower.lights.l_1;
|
||||
NVL_Sender.light_shower.l_2 := Shower.lights.l_2;
|
||||
NVL_Sender.light_shower.l_3 := Shower.lights.l_3;
|
||||
NVL_Sender.light_shower.l_4 := Shower.lights.l_4;
|
||||
NVL_Sender.light_shower.l_5 := Shower.lights.l_5;
|
||||
NVL_Sender.light_shower.l_1_status := Shower.lights.l_1;
|
||||
NVL_Sender.light_shower.l_2_status := Shower.lights.l_2;
|
||||
NVL_Sender.light_shower.l_3_status := Shower.lights.l_3;
|
||||
NVL_Sender.light_shower.l_4_status := Shower.lights.l_4;
|
||||
NVL_Sender.light_shower.l_5_status := Shower.lights.l_5;
|
||||
|
||||
Guest_wc(
|
||||
switches := NVL_Receiver.cmd_guestWc,
|
||||
relay_status := NVL_Sender.light_guestWc
|
||||
);
|
||||
NVL_Sender.light_guestWc := Guest_wc.lights;
|
||||
|
||||
Outside_front(
|
||||
switches := NVL_Receiver.cmd_outFront,
|
||||
relay_status := NVL_Sender.light_outFront
|
||||
);
|
||||
NVL_Sender.light_outFront := Outside_front.lights;
|
||||
|
||||
Covered_Veranda(
|
||||
switches := NVL_Receiver.cmd_coveredVeranda,
|
||||
relay_status := NVL_Sender.light_coveredVeranda
|
||||
);
|
||||
NVL_Sender.light_coveredVeranda := Covered_Veranda.lights;
|
||||
|
||||
Yard(
|
||||
switches := NVL_Receiver.cmd_outYard,
|
||||
relay_status := NVL_Sender.light_outYard
|
||||
);
|
||||
NVL_Sender.light_outYard := Yard.lights;
|
||||
|
||||
Outside_back(
|
||||
switches := NVL_Receiver.cmd_outBack,
|
||||
relay_status := NVL_Sender.light_outBack
|
||||
);
|
||||
NVL_Sender.light_outBack := Outside_back.lights;
|
||||
10
codesys-tree/Device/Plc Logic/Application/PLC_PRG.st
Normal file
10
codesys-tree/Device/Plc Logic/Application/PLC_PRG.st
Normal file
@@ -0,0 +1,10 @@
|
||||
(* === DECLARATION === *)
|
||||
PROGRAM PLC_PRG
|
||||
VAR
|
||||
masterBedroom: fb_switch;
|
||||
END_VAR
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
// Room lights first; Boiler_PRG last (dedicated boiler_status.relay_output, not shower l_6).
|
||||
Lights_new();
|
||||
Boiler_PRG();
|
||||
118
codesys-tree/README.md
Normal file
118
codesys-tree/README.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Local CODESYS Tree
|
||||
|
||||
Local mirror of the CODESYS project staged at:
|
||||
|
||||
`C:\codesys-projects\Codesys_Home_Automation\Home_Automation.project`
|
||||
|
||||
This tree was generated from the `read_project_inventory` MCP call so you can edit
|
||||
POUs, GVLs, DUTs and Programs directly in Cursor on macOS, then push the changes
|
||||
back to the Windows VM with the `sync_project` MCP tool.
|
||||
|
||||
## Layout
|
||||
|
||||
Mirrors the CODESYS POU pane:
|
||||
|
||||
```
|
||||
Device/
|
||||
Plc Logic/
|
||||
Application/
|
||||
PLC_PRG.st
|
||||
0.Structures/ - DUTs (struct_*)
|
||||
1.Variables/ - GVLs (NVL_Sender, NVL_Receiver, GVL)
|
||||
2.Functions/ - (empty)
|
||||
3.Function Blocks/
|
||||
4.Programs/
|
||||
```
|
||||
|
||||
Hardware / non-textual nodes (`Device`, `EtherCAT_Master`, `EK1100`, `K1..K4`,
|
||||
`GPIOs_A_B`, `Onewire`, `Camera device`, `SPI`, `I?C`, `Task Configuration`,
|
||||
`Library Manager`, `Project Settings`, `__VisualizationStyle`) are **not** mirrored
|
||||
as files — they live only in the .project XML and are listed in
|
||||
`_manifest.json` under `non_textual_tree` for reference.
|
||||
|
||||
## File format (.st files)
|
||||
|
||||
Each file contains a single CODESYS object. Two sections are delimited by sentinel
|
||||
banner comments so the splitter is unambiguous:
|
||||
|
||||
```
|
||||
(* === DECLARATION === *)
|
||||
<everything that goes in the declaration editor (VAR blocks, header, attribs)>
|
||||
|
||||
(* === IMPLEMENTATION === *)
|
||||
<the ST body — empty for DUTs / GVLs / declaration-only programs>
|
||||
```
|
||||
|
||||
If a section is empty, leave the line after its banner blank — do not delete the
|
||||
banner. The sync helper expects both banners to be present.
|
||||
|
||||
## Manifest
|
||||
|
||||
`_manifest.json` maps every `.st` file back to its CODESYS `object_path`
|
||||
(e.g. `Device / Plc Logic / Application / 3.Function Blocks / fb_light`) and
|
||||
records whether the object originally had a declaration and/or implementation.
|
||||
This is what the sync step uses to build the `textual_updates` payload.
|
||||
|
||||
## I/O snapshot
|
||||
|
||||
`_io.json` is the raw output of `read_device_io` (all devices + channels +
|
||||
variable mappings + IEC addresses).
|
||||
|
||||
`_io.md` is the human-readable summary: per-device tables, current
|
||||
mappings against `NVL_Sender.*`, plus a "findings worth flagging" section
|
||||
that highlights duplicates, unmapped channels, and gaps between the FB logic
|
||||
and the physical wiring.
|
||||
|
||||
Refresh both with `read_device_io` then regenerate. The IO snapshot is
|
||||
**read-only here** — to push mapping changes back, use the `write_io_mapping`
|
||||
tool (or the `io_mappings` list inside `sync_project`).
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Edit locally
|
||||
|
||||
Open any `.st` file in Cursor and edit either the declaration or the implementation
|
||||
section. Don't move the banner lines.
|
||||
|
||||
### 2. Dry-run sync (preview)
|
||||
|
||||
Call the MCP tool `sync_project` with `dry_run: true` and a `textual_updates`
|
||||
list built from the files you changed. Each entry looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"object_path": "Device / Plc Logic / Application / 3.Function Blocks / fb_light",
|
||||
"declaration": "FUNCTION_BLOCK fb_light\nVAR_INPUT\n ...\nEND_VAR",
|
||||
"implementation": "// ST body here"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Apply
|
||||
|
||||
Re-run `sync_project` with `dry_run: false`. The agent opens the project once,
|
||||
applies all updates in a single open/save cycle, and reports back per-object
|
||||
status.
|
||||
|
||||
### 4. Build
|
||||
|
||||
After a successful sync, run `build_project` to verify the project still
|
||||
compiles on the VM.
|
||||
|
||||
## Re-syncing from CODESYS → local
|
||||
|
||||
If the project changes on the Windows side, re-run `read_project_inventory` and
|
||||
regenerate the affected `.st` files plus `_manifest.json`. Treat the VM project
|
||||
file as the source of truth for the **structure** (folders, object kinds, IO
|
||||
mappings); treat the local tree as the source of truth only for content you've
|
||||
edited here.
|
||||
|
||||
## Not included
|
||||
|
||||
These items live in the project but are not mirrored as files:
|
||||
|
||||
- `Library Manager` (library refs — managed in CODESYS UI)
|
||||
- `Task Configuration` / `MainTask` / `EtherCAT_Task` (task settings)
|
||||
- Device / fieldbus nodes (`Device`, `EtherCAT_Master`, `EK1100`, `K1..K4`,
|
||||
`GPIOs_A_B`, `Onewire`, `Camera device`, `SPI`, `I?C`) — manage via
|
||||
`manage_device`, `read_device_io`, `write_io_mapping`.
|
||||
- `Project Settings`, `SoftMotion General Axis Pool`, `__VisualizationStyle`.
|
||||
2204
codesys-tree/_io.json
Normal file
2204
codesys-tree/_io.json
Normal file
File diff suppressed because it is too large
Load Diff
164
codesys-tree/_io.md
Normal file
164
codesys-tree/_io.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Device I/O Inventory
|
||||
|
||||
Snapshot of all hardware I/O channels and their CODESYS variable mappings.
|
||||
Source: `read_device_io` against `C:\codesys-projects\Codesys_Home_Automation\Home_Automation.project`
|
||||
(channels_only=true). Raw data: `_io.json`.
|
||||
|
||||
## Device tree (hardware)
|
||||
|
||||
| Device | Path | In | Out | Mapped out | Addr range |
|
||||
|--------|------|---:|---:|---:|------------|
|
||||
| GPIOs_A_B | `Device/GPIOs_A_B` | 1 (DWORD) | 1 (DWORD) | 0 | `%ID1` / `%QD2` |
|
||||
| K1 | `Device/EtherCAT_Master/EK1100/K1` | 16 BOOL | 0 | — | `%IX0.0..%IX1.7` |
|
||||
| K2 | `Device/EtherCAT_Master/EK1100/K2` | 0 | 16 BOOL | 14 / 16 | `%QX0.0..%QX1.7` |
|
||||
| K3 | `Device/EtherCAT_Master/EK1100/K3` | 0 | 16 BOOL | 15 / 16 | `%QX2.0..%QX3.7` |
|
||||
| K4 | `Device/EtherCAT_Master/EK1100/K4` | 0 | 16 BOOL | 12 / 16 | `%QX4.0..%QX5.7` |
|
||||
|
||||
`Onewire`, `Camera device`, `SPI`, `I?C`, `EtherCAT_Master` and `EK1100`
|
||||
return no host parameters / channels of their own (they're parents/couplers).
|
||||
|
||||
**Totals:** 16 digital inputs (none mapped), 48 digital outputs (41 mapped, 7 free).
|
||||
|
||||
## Findings worth flagging
|
||||
|
||||
### Duplicate output mapping (likely a bug)
|
||||
|
||||
`Application.NVL_Sender.light_livingRoom.l_1` is written by **two** physical output channels:
|
||||
|
||||
| Where | IEC addr |
|
||||
|-------|----------|
|
||||
| K2 ch 10 | `%QX1.1` |
|
||||
| K4 ch 16 | `%QX5.7` |
|
||||
|
||||
Both physical relays will switch together whenever the variable is written.
|
||||
One of these should be remapped — usually `K4 %QX5.7` would be the one to free up
|
||||
(based on K4's role as the spillover terminal).
|
||||
|
||||
### Unmapped output channels
|
||||
|
||||
| Device | IEC addr | Channel |
|
||||
|--------|----------|---------|
|
||||
| GPIOs_A_B | `%QD2` | (DWORD) |
|
||||
| K2 | `%QX0.5` | Ch 6 |
|
||||
| K2 | `%QX0.7` | Ch 8 |
|
||||
| K3 | `%QX2.3` | Ch 4 |
|
||||
| K4 | `%QX5.2` | Ch 11 |
|
||||
| K4 | `%QX5.3` | Ch 12 |
|
||||
| K4 | `%QX5.5` | Ch 14 |
|
||||
| K4 | `%QX5.6` | Ch 15 |
|
||||
|
||||
### No inputs mapped
|
||||
|
||||
K1 (16-channel digital input terminal, `%IX0.0..%IX1.7`) is wired up at hardware
|
||||
level but has zero variable mappings. If you have physical switches on K1, the
|
||||
PLC currently can't see them.
|
||||
|
||||
### Variable target
|
||||
|
||||
All mapped outputs write into `Application.NVL_Sender.*` — i.e. the global
|
||||
variable list that is *broadcast over UDP* to the receiver. This is the "Option C"
|
||||
relay-feedback pattern referenced in the `GVL` comment: the program writes the
|
||||
desired relay state into the NVL_Sender variable, the EtherCAT terminal switches
|
||||
the actual relay, and the value read back is exactly the value just written.
|
||||
|
||||
No outputs are mapped against the application's room/light function block outputs
|
||||
(`fb_room.lights.*` / `fb_light.light_output`). The new `fb_room` / `fb_light`
|
||||
logic is therefore not yet driving the physical relays — that wiring would need
|
||||
to be done in `PLC_PRG` (or `Lights_new`) by assigning each FB output into the
|
||||
corresponding `NVL_Sender.light_*.l_N` slot before the IO write phase.
|
||||
|
||||
## Per-channel detail
|
||||
|
||||
### K1 (`Device/EtherCAT_Master/EK1100/K1`)
|
||||
|
||||
| Ch | IEC addr | Dir | Variable |
|
||||
|---:|----------|-----|----------|
|
||||
| 1 | `%IX0.0` | INP | _(unmapped)_ |
|
||||
| 2 | `%IX0.1` | INP | _(unmapped)_ |
|
||||
| 3 | `%IX0.2` | INP | _(unmapped)_ |
|
||||
| 4 | `%IX0.3` | INP | _(unmapped)_ |
|
||||
| 5 | `%IX0.4` | INP | _(unmapped)_ |
|
||||
| 6 | `%IX0.5` | INP | _(unmapped)_ |
|
||||
| 7 | `%IX0.6` | INP | _(unmapped)_ |
|
||||
| 8 | `%IX0.7` | INP | _(unmapped)_ |
|
||||
| 9 | `%IX1.0` | INP | _(unmapped)_ |
|
||||
| 10 | `%IX1.1` | INP | _(unmapped)_ |
|
||||
| 11 | `%IX1.2` | INP | _(unmapped)_ |
|
||||
| 12 | `%IX1.3` | INP | _(unmapped)_ |
|
||||
| 13 | `%IX1.4` | INP | _(unmapped)_ |
|
||||
| 14 | `%IX1.5` | INP | _(unmapped)_ |
|
||||
| 15 | `%IX1.6` | INP | _(unmapped)_ |
|
||||
| 16 | `%IX1.7` | INP | _(unmapped)_ |
|
||||
|
||||
### K2 (`Device/EtherCAT_Master/EK1100/K2`)
|
||||
|
||||
| Ch | IEC addr | Dir | Variable |
|
||||
|---:|----------|-----|----------|
|
||||
| 1 | `%QX0.0` | OUT | `Application.NVL_Sender.light_dinningRoom.l_1` |
|
||||
| 2 | `%QX0.1` | OUT | `Application.NVL_Sender.l_entrance.l_1` |
|
||||
| 3 | `%QX0.2` | OUT | `Application.NVL_Sender.light_dinningRoom.l_2` |
|
||||
| 4 | `%QX0.3` | OUT | `Application.NVL_Sender.light_dinningRoom.l_3` |
|
||||
| 5 | `%QX0.4` | OUT | `Application.NVL_Sender.light_guestWc.l_1` |
|
||||
| 6 | `%QX0.5` | OUT | _(unmapped)_ |
|
||||
| 7 | `%QX0.6` | OUT | `Application.NVL_Sender.light_dinningRoom.l_4` |
|
||||
| 8 | `%QX0.7` | OUT | _(unmapped)_ |
|
||||
| 9 | `%QX1.0` | OUT | `Application.NVL_Sender.l_entrance.l_2` |
|
||||
| 10 | `%QX1.1` | OUT | `Application.NVL_Sender.light_livingRoom.l_1` ⚠ duplicated on K4 ch 16 |
|
||||
| 11 | `%QX1.2` | OUT | `Application.NVL_Sender.light_livingRoom.l_2` |
|
||||
| 12 | `%QX1.3` | OUT | `Application.NVL_Sender.light_livingRoom.l_3` |
|
||||
| 13 | `%QX1.4` | OUT | `Application.NVL_Sender.light_livingRoom.l_4` |
|
||||
| 14 | `%QX1.5` | OUT | `Application.NVL_Sender.light_kitchenArea.l_1` |
|
||||
| 15 | `%QX1.6` | OUT | `Application.NVL_Sender.light_kitchenArea.l_2` |
|
||||
| 16 | `%QX1.7` | OUT | `Application.NVL_Sender.light_kitchenArea.l_3` |
|
||||
|
||||
### K3 (`Device/EtherCAT_Master/EK1100/K3`)
|
||||
|
||||
| Ch | IEC addr | Dir | Variable |
|
||||
|---:|----------|-----|----------|
|
||||
| 1 | `%QX2.0` | OUT | `Application.NVL_Sender.light_kitchenArea.l_4` |
|
||||
| 2 | `%QX2.1` | OUT | `Application.NVL_Sender.light_hallway.l_1` |
|
||||
| 3 | `%QX2.2` | OUT | `Application.NVL_Sender.light_office.l_1` |
|
||||
| 4 | `%QX2.3` | OUT | _(unmapped)_ |
|
||||
| 5 | `%QX2.4` | OUT | `Application.NVL_Sender.light_shower.l_1` |
|
||||
| 6 | `%QX2.5` | OUT | `Application.NVL_Sender.light_shower.l_2` |
|
||||
| 7 | `%QX2.6` | OUT | `Application.NVL_Sender.light_hallway.l_2` |
|
||||
| 8 | `%QX2.7` | OUT | `Application.NVL_Sender.light_bedroom.l_1` |
|
||||
| 9 | `%QX3.0` | OUT | `Application.NVL_Sender.light_bedroom.l_2` |
|
||||
| 10 | `%QX3.1` | OUT | `Application.NVL_Sender.light_masterBedroom.l_1` |
|
||||
| 11 | `%QX3.2` | OUT | `Application.NVL_Sender.light_masterBedroom.l_2` |
|
||||
| 12 | `%QX3.3` | OUT | `Application.NVL_Sender.light_masterBedroom.l_3` |
|
||||
| 13 | `%QX3.4` | OUT | `Application.NVL_Sender.light_masterBedroom.l_4` |
|
||||
| 14 | `%QX3.5` | OUT | `Application.NVL_Sender.light_masterBath.l_1` |
|
||||
| 15 | `%QX3.6` | OUT | `Application.NVL_Sender.light_masterBath.l_2` |
|
||||
| 16 | `%QX3.7` | OUT | `Application.NVL_Sender.light_masterBath.l_3` |
|
||||
|
||||
### K4 (`Device/EtherCAT_Master/EK1100/K4`)
|
||||
|
||||
| Ch | IEC addr | Dir | Variable |
|
||||
|---:|----------|-----|----------|
|
||||
| 1 | `%QX4.0` | OUT | `Application.NVL_Sender.light_livingRoom.l_5` |
|
||||
| 2 | `%QX4.1` | OUT | `Application.NVL_Sender.light_outBack.l_1` |
|
||||
| 3 | `%QX4.2` | OUT | `Application.NVL_Sender.light_outBack.l_2` |
|
||||
| 4 | `%QX4.3` | OUT | `Application.NVL_Sender.light_outFront.l_2` |
|
||||
| 5 | `%QX4.4` | OUT | `Application.NVL_Sender.light_coveredVeranda.l_2` |
|
||||
| 6 | `%QX4.5` | OUT | `Application.NVL_Sender.light_coveredVeranda.l_3` |
|
||||
| 7 | `%QX4.6` | OUT | `Application.NVL_Sender.light_coveredVeranda.l_4` |
|
||||
| 8 | `%QX4.7` | OUT | `Application.NVL_Sender.l_outSide.l_1` |
|
||||
| 9 | `%QX5.0` | OUT | `Application.NVL_Sender.l_outFront.l_1` |
|
||||
| 10 | `%QX5.1` | OUT | `Application.NVL_Sender.l_outVeranda.l_4` |
|
||||
| 11 | `%QX5.2` | OUT | _(unmapped)_ |
|
||||
| 12 | `%QX5.3` | OUT | _(unmapped)_ |
|
||||
| 13 | `%QX5.4` | OUT | `Application.NVL_Sender.boiler_status.relay_output` |
|
||||
| 14 | `%QX5.5` | OUT | _(unmapped)_ |
|
||||
| 15 | `%QX5.6` | OUT | _(unmapped)_ |
|
||||
| 16 | `%QX5.7` | OUT | `Application.NVL_Sender.light_livingRoom.l_1` ⚠ duplicates K2 ch 10 |
|
||||
|
||||
### GPIOs_A_B (`Device/GPIOs_A_B`)
|
||||
|
||||
| Ch | IEC addr | Dir | Variable |
|
||||
|---:|----------|-----|----------|
|
||||
| GPIOs in | `%ID1` | INP (DWORD) | _(unmapped)_ |
|
||||
| GPIOs out | `%QD2` | OUT (DWORD) | _(unmapped)_ |
|
||||
|
||||
The Raspberry Pi onboard GPIO bank exists in the project but is not wired to any
|
||||
variables.
|
||||
185
codesys-tree/_manifest.json
Normal file
185
codesys-tree/_manifest.json
Normal file
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"project_path": "C:\\codesys-projects\\Codesys_Home_Automation\\Home_Automation.project",
|
||||
"staged_project_path": "C:\\codesys-projects\\Codesys_Home_Automation\\Home_Automation_Boiler_Phase1\\Home_Automation.project",
|
||||
"staged_project_folder": "Home_Automation_Boiler_Phase1",
|
||||
"active_application": "Application",
|
||||
"generated_at": "2026-05-24T20:24:00+03:00",
|
||||
"inventory_refreshed_at": "2026-05-24T20:24:00+03:00",
|
||||
"io_snapshot": {
|
||||
"file": "_io.json",
|
||||
"summary": "_io.md",
|
||||
"captured_at": "2026-05-24T20:24:00+03:00",
|
||||
"device_count": 9,
|
||||
"channels_only": true
|
||||
},
|
||||
"convention": {
|
||||
"declaration_marker": "(* === DECLARATION === *)",
|
||||
"implementation_marker": "(* === IMPLEMENTATION === *)",
|
||||
"notes": "Each .st file contains the declaration section followed by the implementation section, separated by the markers above. Empty section after a marker means CODESYS reported no content for that section."
|
||||
},
|
||||
"objects": [
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/PLC_PRG.st",
|
||||
"object_path": "Device / Plc Logic / Application / PLC_PRG",
|
||||
"kind": "program",
|
||||
"has_declaration": true,
|
||||
"has_implementation": true
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/0.Structures/struct_switches.st",
|
||||
"object_path": "Device / Plc Logic / Application / 0.Structures / struct_switches",
|
||||
"kind": "dut",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/0.Structures/struct_lights.st",
|
||||
"object_path": "Device / Plc Logic / Application / 0.Structures / struct_lights",
|
||||
"kind": "dut",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/0.Structures/struct_room_outs.st",
|
||||
"object_path": "Device / Plc Logic / Application / 0.Structures / struct_room_outs",
|
||||
"kind": "dut",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/0.Structures/struct_room_cmds.st",
|
||||
"object_path": "Device / Plc Logic / Application / 0.Structures / struct_room_cmds",
|
||||
"kind": "dut",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/0.Structures/struct_boiler_cmd.st",
|
||||
"object_path": "Device / Plc Logic / Application / 0.Structures / struct_boiler_cmd",
|
||||
"kind": "dut",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/0.Structures/struct_boiler_status.st",
|
||||
"object_path": "Device / Plc Logic / Application / 0.Structures / struct_boiler_status",
|
||||
"kind": "dut",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/0.Structures/struct_zones.st",
|
||||
"object_path": "Device / Plc Logic / Application / 0.Structures / struct_zones",
|
||||
"kind": "dut",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/1.Variables/NVL_Sender.st",
|
||||
"object_path": "Device / Plc Logic / Application / 1.Variables / NVL_Sender",
|
||||
"kind": "gvl",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/1.Variables/NVL_Receiver.st",
|
||||
"object_path": "Device / Plc Logic / Application / 1.Variables / NVL_Receiver",
|
||||
"kind": "gvl",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/1.Variables/GVL.st",
|
||||
"object_path": "Device / Plc Logic / Application / 1.Variables / GVL",
|
||||
"kind": "gvl",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/3.Function Blocks/fb_toogleButton.st",
|
||||
"object_path": "Device / Plc Logic / Application / 3.Function Blocks / fb_toogleButton",
|
||||
"kind": "function_block",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/3.Function Blocks/fb_switch.st",
|
||||
"object_path": "Device / Plc Logic / Application / 3.Function Blocks / fb_switch",
|
||||
"kind": "function_block",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/3.Function Blocks/fb_light.st",
|
||||
"object_path": "Device / Plc Logic / Application / 3.Function Blocks / fb_light",
|
||||
"kind": "function_block",
|
||||
"has_declaration": true,
|
||||
"has_implementation": true
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/3.Function Blocks/fb_room.st",
|
||||
"object_path": "Device / Plc Logic / Application / 3.Function Blocks / fb_room",
|
||||
"kind": "function_block",
|
||||
"has_declaration": true,
|
||||
"has_implementation": true
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/3.Function Blocks/fb_boiler.st",
|
||||
"object_path": "Device / Plc Logic / Application / 3.Function Blocks / fb_boiler",
|
||||
"kind": "function_block",
|
||||
"has_declaration": true,
|
||||
"has_implementation": true
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/4.Programs/Lights.st",
|
||||
"object_path": "Device / Plc Logic / Application / 4.Programs / Lights",
|
||||
"kind": "program",
|
||||
"has_declaration": true,
|
||||
"has_implementation": false
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/4.Programs/Lights_new.st",
|
||||
"object_path": "Device / Plc Logic / Application / 4.Programs / Lights_new",
|
||||
"kind": "program",
|
||||
"has_declaration": true,
|
||||
"has_implementation": true
|
||||
},
|
||||
{
|
||||
"file": "Device/Plc Logic/Application/4.Programs/Boiler_PRG.st",
|
||||
"object_path": "Device / Plc Logic / Application / 4.Programs / Boiler_PRG",
|
||||
"kind": "program",
|
||||
"has_declaration": true,
|
||||
"has_implementation": true
|
||||
}
|
||||
],
|
||||
"non_textual_tree": [
|
||||
{"depth": 0, "kind": "node", "name": "Project Settings"},
|
||||
{"depth": 0, "kind": "device", "name": "Device"},
|
||||
{"depth": 1, "kind": "node", "name": "Plc Logic"},
|
||||
{"depth": 2, "kind": "application", "name": "Application"},
|
||||
{"depth": 3, "kind": "node", "name": "Library Manager"},
|
||||
{"depth": 3, "kind": "node", "name": "Task Configuration"},
|
||||
{"depth": 4, "kind": "node", "name": "MainTask"},
|
||||
{"depth": 5, "kind": "node", "name": "PLC_PRG"},
|
||||
{"depth": 4, "kind": "node", "name": "EtherCAT_Task"},
|
||||
{"depth": 3, "kind": "folder", "name": "3.Function Blocks"},
|
||||
{"depth": 3, "kind": "folder", "name": "1.Variables"},
|
||||
{"depth": 3, "kind": "folder", "name": "2.Functions"},
|
||||
{"depth": 3, "kind": "folder", "name": "4.Programs"},
|
||||
{"depth": 3, "kind": "folder", "name": "0.Structures"},
|
||||
{"depth": 1, "kind": "node", "name": "SoftMotion General Axis Pool"},
|
||||
{"depth": 1, "kind": "device", "name": "GPIOs_A_B"},
|
||||
{"depth": 1, "kind": "node", "name": "Onewire"},
|
||||
{"depth": 1, "kind": "node", "name": "Camera device"},
|
||||
{"depth": 2, "kind": "device", "name": "<Empty>"},
|
||||
{"depth": 1, "kind": "node", "name": "SPI"},
|
||||
{"depth": 1, "kind": "node", "name": "I?C"},
|
||||
{"depth": 1, "kind": "device", "name": "EtherCAT_Master"},
|
||||
{"depth": 2, "kind": "device", "name": "EK1100"},
|
||||
{"depth": 3, "kind": "device", "name": "K1"},
|
||||
{"depth": 3, "kind": "device", "name": "K2"},
|
||||
{"depth": 3, "kind": "device", "name": "K3"},
|
||||
{"depth": 3, "kind": "device", "name": "K4"},
|
||||
{"depth": 0, "kind": "node", "name": "__VisualizationStyle"}
|
||||
]
|
||||
}
|
||||
265
docs/integration-home-assistant-nodered.md
Normal file
265
docs/integration-home-assistant-nodered.md
Normal 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 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` | 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 |
|
||||
167
scripts/nodered_fast_toggle_patch.py
Normal file
167
scripts/nodered_fast_toggle_patch.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch Node-RED flows for fast HA toggle reliability."""
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
path = "/srv/docker-volumes/config/nodered/data/flows.json"
|
||||
backup = path + ".bak-fasttoggle-" + datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
shutil.copy2(path, backup)
|
||||
print("backup:", backup)
|
||||
|
||||
with open(path) as f:
|
||||
flows = json.load(f)
|
||||
|
||||
HA_TO_NVL = r"""const lib = global.get('roomConfigLib');
|
||||
const config = global.get('roomConfig') || {};
|
||||
const parsed = msg.plcLight || (lib ? lib.parseHaStateMessage(msg, config) : null);
|
||||
if (!parsed) return null;
|
||||
|
||||
const ROOM_NAME = parsed.cmdRoom;
|
||||
const lightNum = parsed.lightNum;
|
||||
const isOn = parsed.isOn;
|
||||
|
||||
// User toggle: update mirror cache so NVL→HA feedback does not re-write HA or set echo suppress.
|
||||
const flowKey = 'nvlToHa_' + parsed.entityId.replace(/\./g, '_');
|
||||
flow.set(flowKey, isOn);
|
||||
const echo = flow.get('haEchoSuppress') || {};
|
||||
delete echo[parsed.entityId];
|
||||
flow.set('haEchoSuppress', echo);
|
||||
|
||||
if (!flow.get('nvlInState')) flow.set('nvlInState', { rooms: {}, boiler: {} });
|
||||
const state = flow.get('nvlInState');
|
||||
if (!state.rooms[ROOM_NAME]) state.rooms[ROOM_NAME] = {};
|
||||
const r = state.rooms[ROOM_NAME];
|
||||
const onKey = 'ha_l' + lightNum + '_on';
|
||||
const offKey = 'ha_l' + lightNum + '_off';
|
||||
r[onKey] = false;
|
||||
r[offKey] = false;
|
||||
if (isOn) r[onKey] = true; else r[offKey] = true;
|
||||
flow.set('nvlInState', state);
|
||||
|
||||
const seq = (flow.get('nvlClearSeq') || 0) + 1;
|
||||
flow.set('nvlClearSeq', seq);
|
||||
|
||||
node.warn('[HA to NVL] ' + parsed.entityId + ' → ' + ROOM_NAME + ' ' + (isOn ? onKey : offKey));
|
||||
|
||||
msg.payload = { buildAndSend: true };
|
||||
msg.zigbeeClear = {
|
||||
seq,
|
||||
list: [
|
||||
{ room: ROOM_NAME, key: onKey },
|
||||
{ room: ROOM_NAME, key: offKey },
|
||||
],
|
||||
};
|
||||
return msg;"""
|
||||
|
||||
CLEAR_NVL = r"""if (msg.zigbeeClear) {
|
||||
const spec = Array.isArray(msg.zigbeeClear)
|
||||
? { seq: msg.zigbeeClearSeq, list: msg.zigbeeClear }
|
||||
: msg.zigbeeClear;
|
||||
const list = spec.list || (spec.room ? [spec] : []);
|
||||
const seq = spec.seq != null ? spec.seq : msg.zigbeeClearSeq;
|
||||
if (seq != null && seq !== flow.get('nvlClearSeq')) {
|
||||
return null;
|
||||
}
|
||||
const state = flow.get('nvlInState') || { rooms: {}, boiler: {} };
|
||||
for (const item of list) {
|
||||
if (item.room === 'boiler') {
|
||||
if (!state.boiler) state.boiler = {};
|
||||
if (item.key) state.boiler[item.key] = false;
|
||||
continue;
|
||||
}
|
||||
const r = state.rooms[item.room];
|
||||
if (r && item.key) r[item.key] = false;
|
||||
}
|
||||
flow.set('nvlInState', state);
|
||||
}
|
||||
msg.payload = { buildAndSend: true };
|
||||
return msg;"""
|
||||
|
||||
NVL_TO_HA = r"""const config = global.get('roomConfig');
|
||||
const LIGHT_ENTITY_MAP = (config && config.lightEntityMap && config.lightEntityMap.length)
|
||||
? config.lightEntityMap
|
||||
: [];
|
||||
|
||||
const ECHO_MS = 400;
|
||||
const payload = msg.payload || {};
|
||||
const calls = [];
|
||||
const echo = flow.get('haEchoSuppress') || {};
|
||||
const until = Date.now() + ECHO_MS;
|
||||
|
||||
for (const entry of LIGHT_ENTITY_MAP) {
|
||||
const room = payload[entry.room] || {};
|
||||
const li = entry.light;
|
||||
const isOn = !!(room['l_' + li + '_status'] || room['l_' + li] || room['l' + li]);
|
||||
const flowKey = 'nvlToHa_' + entry.entityId.replace(/\./g, '_');
|
||||
const last = flow.get(flowKey);
|
||||
if (last === isOn) continue;
|
||||
flow.set(flowKey, isOn);
|
||||
echo[entry.entityId] = until;
|
||||
|
||||
const domain = entry.entityId.split('.')[0] || 'input_boolean';
|
||||
const service = isOn ? 'turn_on' : 'turn_off';
|
||||
calls.push({
|
||||
payload: {
|
||||
action: domain + '.' + service,
|
||||
target: { entity_id: [entry.entityId] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
flow.set('haEchoSuppress', echo);
|
||||
if (!calls.length) return null;
|
||||
return calls;"""
|
||||
|
||||
patches = {
|
||||
"4045239f675b77d2": HA_TO_NVL,
|
||||
"44822e048dbaee31": CLEAR_NVL,
|
||||
"d5cdc38171230744": NVL_TO_HA,
|
||||
}
|
||||
|
||||
for n in flows:
|
||||
nid = n.get("id")
|
||||
if nid in patches:
|
||||
n["func"] = patches[nid]
|
||||
print("patched", nid, n.get("name"))
|
||||
|
||||
if nid == "wh_pulse_fn" and "nvlClearSeq" not in n.get("func", ""):
|
||||
n["func"] = n["func"].replace(
|
||||
"return {\n payload: { buildAndSend: true },\n zigbeeClear: { room: 'boiler', key: key },\n};",
|
||||
"""const seq = (flow.get('nvlClearSeq') || 0) + 1;
|
||||
flow.set('nvlClearSeq', seq);
|
||||
return {
|
||||
payload: { buildAndSend: true },
|
||||
zigbeeClear: { seq, list: [{ room: 'boiler', key }] },
|
||||
};""",
|
||||
)
|
||||
print("patched wh_pulse_fn")
|
||||
|
||||
if nid == "plc_tt_pulse" and "nvlClearSeq" not in n.get("func", ""):
|
||||
n["func"] = n["func"].replace(
|
||||
"return {\n payload: { buildAndSend: true },\n zigbeeClear: { room: room, key: key },\n};",
|
||||
"""const seq = (flow.get('nvlClearSeq') || 0) + 1;
|
||||
flow.set('nvlClearSeq', seq);
|
||||
return {
|
||||
payload: { buildAndSend: true },
|
||||
zigbeeClear: { seq, list: [{ room: room, key: key }] },
|
||||
};""",
|
||||
)
|
||||
print("patched plc_tt_pulse")
|
||||
|
||||
if nid == "718ba68ae647874f" and "nvlClearSeq" not in n.get("func", ""):
|
||||
n["func"] = n["func"].replace(
|
||||
" outPlc.push({\n payload: { buildAndSend: true },\n zigbeeClear: clearList,\n });",
|
||||
""" const seq = (flow.get('nvlClearSeq') || 0) + 1;
|
||||
flow.set('nvlClearSeq', seq);
|
||||
outPlc.push({
|
||||
payload: { buildAndSend: true },
|
||||
zigbeeClear: { seq, list: clearList },
|
||||
});""",
|
||||
)
|
||||
print("patched zigbee dispatch")
|
||||
|
||||
with open(path, "w") as f:
|
||||
json.dump(flows, f)
|
||||
|
||||
print("done")
|
||||
40
scripts/nodered_global_nvl_state_patch.py
Normal file
40
scripts/nodered_global_nvl_state_patch.py
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Use global context for nvlInState/nvlClearSeq (shared across Node-RED tabs)."""
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
path = "/srv/docker-volumes/config/nodered/data/flows.json"
|
||||
backup = path + ".bak-global-nvl-" + datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
shutil.copy2(path, backup)
|
||||
print("backup:", backup)
|
||||
|
||||
with open(path) as f:
|
||||
flows = json.load(f)
|
||||
|
||||
replacements = [
|
||||
("flow.get('nvlInState')", "global.get('nvlInState')"),
|
||||
('flow.get("nvlInState")', 'global.get("nvlInState")'),
|
||||
("flow.set('nvlInState'", "global.set('nvlInState'"),
|
||||
('flow.set("nvlInState"', 'global.set("nvlInState"'),
|
||||
("flow.get('nvlClearSeq')", "global.get('nvlClearSeq')"),
|
||||
("flow.set('nvlClearSeq'", "global.set('nvlClearSeq'"),
|
||||
]
|
||||
|
||||
count = 0
|
||||
for n in flows:
|
||||
fn = n.get("func")
|
||||
if not fn:
|
||||
continue
|
||||
original = fn
|
||||
for old, new in replacements:
|
||||
fn = fn.replace(old, new)
|
||||
if fn != original:
|
||||
n["func"] = fn
|
||||
count += 1
|
||||
print("patched", n.get("id"), n.get("name"))
|
||||
|
||||
with open(path, "w") as f:
|
||||
json.dump(flows, f)
|
||||
|
||||
print("patched nodes:", count, "done")
|
||||
Reference in New Issue
Block a user