Skip to main content

ControlForge Smart Washing Machine Controller

DISCLAIMER: This is a hypothetical project guide for educational and demonstration purposes. It illustrates how ControlForge can be used for appliance control. All responsibility for implementation, safety, electrical work, and compliance with local codes lies entirely with the user. Modifying appliances involves mains voltage and water — improper work can cause electrocution, fire, flooding, or property damage. Consult a licensed electrician before attempting any mains wiring.

Replace a dead washing machine control board with a Raspberry Pi running ControlForge, Waveshare Modbus I/O modules, and a phone dashboard via Node-RED.

Skill level: Intermediate (basic electrical wiring, comfortable with terminal blocks)

Safety: This project involves 120VAC mains wiring. All high-voltage connections must be made by a qualified person. Follow your local electrical code.


Why ControlForge for Appliance Control?

A modern washing machine controller is just a small PLC: it sequences valves, motors, and pumps based on sensor inputs, runs safety interlocks, and follows a state machine. Factory boards are expensive, proprietary, and non-repairable. ControlForge gives you:

  • Full control over every cycle parameter — wash time, spin speed, water level, temperature
  • Phone dashboard via Node-RED — start/stop, cycle selection, status, push notifications
  • Real industrial I/O — Waveshare DIN-rail Modbus modules rated for 10A 250VAC relays
  • State machine in Structured Text — readable, modifiable, no black-box firmware
  • Expandable — add sensors, logging, energy monitoring, or integrate with home automation

System Architecture

Data flow: ControlForge runs the wash cycle state machine in Structured Text. Each scan (100ms), it reads sensors via Modbus TCP through the Waveshare gateway, runs control logic, and writes relay outputs. Node-RED provides the operator interface on any browser.


Bill of Materials

#ItemPurposeEst. Cost
1Raspberry Pi 2/3/4/5Runs ControlForge + Node-RED$35-80
2MicroSD card (32GB+)Pi OS + ControlForge$8
3Waveshare RS485 TO ETH (B)Modbus TCP-to-RTU gateway$20
4Waveshare Modbus RTU Relay (D)8 relay outputs + 8 digital inputs$35
5Waveshare Modbus RTU Analog Input 8CHAnalog sensor inputs (12-bit)$30
6DIN rail power supply 120VAC to 24VDC (60W)Powers all Modbus modules + sensors$15
7DIN rail circuit breaker 15ABranch protection for 120VAC loads$8
8DIN rail terminal blocks (20-pack)All point-to-point wiring connections$12
935mm DIN rail (1 meter)Mounting for all DIN components$6
10Enclosure (IP54 or better)Houses all control components$25-40
1118 AWG stranded wire (assorted colors)120VAC load wiring$15
1222 AWG stranded wire (assorted colors)24VDC signal/sensor wiring$10
13Wire ferrules + crimp toolClean terminal connections$20
14Ethernet cable (Cat5e, length as needed)Pi to RS485-to-ETH gateway$5
Total (approx.)$245-305

About the Waveshare Modules

RS485 TO ETH (B) — Bridges Modbus TCP (Ethernet) to Modbus RTU (RS485). Configure it in "Modbus TCP to RTU gateway" mode. ControlForge talks standard Modbus TCP; the gateway handles serial framing and timing on the RS485 side. Supports 9-24V power, DIN rail mount.

Modbus RTU Relay (D) — 8 relay outputs (10A 250VAC each) + 8 optocoupled digital inputs. 7-36V power. DIN rail mount. Default: slave 1, 9600 baud, 8N1.

Modbus RTU Analog Input 8CH — 8 channels, 12-bit resolution. Configurable per-channel: 0-10V, 2-10V, 0-20mA, or 4-20mA. 7-36V power. DIN rail mount. Default: slave 1 (must be changed to slave 2 before connecting to the bus).


I/O Assignment

Relay (D) Module — Slave ID 1

Relay Outputs (Coils 0x0000-0x0007)

RelayCoil AddrFunctionLoadNotes
CH10x0000Hot water inlet valve120VAC solenoidNormally closed valve
CH20x0001Cold water inlet valve120VAC solenoidNormally closed valve
CH30x0002Drain pump120VAC motor~1A typical
CH40x0003Door lock solenoid24VDC solenoidEnergize to lock
CH50x0004Motor - agitate120VAC contactor coilLow speed, reversing
CH60x0005Motor - spin120VAC contactor coilHigh speed, one direction
CH70x0006Buzzer24VDC buzzerCycle complete alert
CH80x0007SpareFuture: fabric softener valve

Digital Inputs (Discrete Inputs 0x0000-0x0007)

InputAddrFunctionTypeNotes
DI10x0000Door closedN.O. switchTRUE = door closed
DI20x0001Door locked feedbackN.O. contactTRUE = lock engaged
DI30x0002Water level - lowPressure switchTRUE = above minimum
DI40x0003Water level - mediumPressure switchTRUE = medium fill
DI50x0004Water level - highPressure switchTRUE = full
DI60x0005Motor overload tripN.C. thermal OLFALSE = tripped
DI70x0006Water leak detectedLeak sensorTRUE = leak
DI80x0007Spare

Analog Input Module — Slave ID 2

ChannelReg AddrFunctionRangeSensor
CH10x0000Water temperature4-20mAPT100 transmitter (0-100C)
CH20x0001Motor current0-10VSplit-core CT + signal conditioner
CH30x0002Vibration0-10VAccelerometer module
CH4-80x0003-0x0007Spare

Wiring — Point to Point

Power Distribution

FromToWireNotes
Mains 120VAC HotCB-15A input14 AWG blackHouse breaker should also protect this circuit
CB-15A outputTerminal TB-HOT14 AWG blackFused 120VAC hot bus
Mains 120VAC NeutralTerminal TB-NEU14 AWG whiteNeutral bus
Mains GroundTerminal TB-GND14 AWG greenGround bus, bond to enclosure
TB-HOT24VDC PSU L input18 AWG blackPSU line input
TB-NEU24VDC PSU N input18 AWG whitePSU neutral input
TB-GND24VDC PSU GND input18 AWG greenPSU earth ground
24VDC PSU +V outTerminal TB-24V+18 AWG red24VDC positive bus
24VDC PSU -V outTerminal TB-24V-18 AWG blue24VDC negative bus (0V)
TB-24V+Relay (D) V+22 AWG redModule power
TB-24V-Relay (D) V-22 AWG blueModule power
TB-24V+Analog Input V+22 AWG redModule power
TB-24V-Analog Input V-22 AWG blueModule power
TB-24V+RS485-to-ETH V+22 AWG redGateway power (9-24V)
TB-24V-RS485-to-ETH V-22 AWG blueGateway power

RS485 Bus (Daisy-Chain)

FromToWireNotes
RS485-to-ETH A+Relay (D) A22 AWG twisted pairUse shielded twisted pair
RS485-to-ETH B-Relay (D) B22 AWG twisted pairSame pair
Relay (D) AAnalog Input A22 AWG twisted pairContinue daisy chain
Relay (D) BAnalog Input B22 AWG twisted pairSame pair
Analog Input A-B120 ohm resistorTermination at last device on bus
Cable shieldTB-GNDGround shield at one end only

Ethernet

FromToWireNotes
Pi Ethernet portRS485-to-ETH RJ45Cat5e patch cableStandard Ethernet

Relay Outputs to Loads

FromToWireNotes
TB-HOTRelay CH1 COM18 AWG blackHot water valve circuit
Relay CH1 N.O.Hot water valve18 AWG blackValve other wire to TB-NEU
TB-HOTRelay CH2 COM18 AWG blackCold water valve circuit
Relay CH2 N.O.Cold water valve18 AWG blackValve other wire to TB-NEU
TB-HOTRelay CH3 COM18 AWG blackDrain pump circuit
Relay CH3 N.O.Drain pump18 AWG blackPump other wire to TB-NEU
TB-24V+Relay CH4 COM22 AWG redDoor lock circuit (24VDC)
Relay CH4 N.O.Door lock solenoid +22 AWG redSolenoid - to TB-24V-
TB-HOTRelay CH5 COM18 AWG blackMotor agitate contactor
Relay CH5 N.O.Agitate contactor coil18 AWG blackCoil other side to TB-NEU
TB-HOTRelay CH6 COM18 AWG blackMotor spin contactor
Relay CH6 N.O.Spin contactor coil18 AWG blackCoil other side to TB-NEU
TB-24V+Relay CH7 COM22 AWG redBuzzer circuit (24VDC)
Relay CH7 N.O.Buzzer +22 AWG redBuzzer - to TB-24V-

Digital Inputs

The Relay (D) module supports passive (dry contact) and active (wet contact) inputs. Wire all inputs as dry contacts with the module's internal pull-up:

FromToWireNotes
Relay (D) DI1Door closed switch N.O.22 AWGSwitch other terminal to DI COM
Relay (D) DI2Door lock feedback N.O.22 AWGContact other terminal to DI COM
Relay (D) DI3Level switch - low22 AWGCloses at low water level
Relay (D) DI4Level switch - medium22 AWGCloses at medium level
Relay (D) DI5Level switch - high22 AWGCloses at high level
Relay (D) DI6Motor thermal O/L N.C.22 AWGOpens on overload trip
Relay (D) DI7Leak sensor N.O.22 AWGCloses on water detection
DI COM (all)TB-24V-22 AWGCommon return for all DIs

Analog Inputs

FromToWireNotes
TB-24V+PT100 transmitter +22 AWG redLoop power for 4-20mA
PT100 transmitter signalAnalog CH1 +22 AWG4-20mA signal
Analog CH1 -TB-24V-22 AWG blueReturn
Motor CT signal +Analog CH2 +22 AWG0-10V from CT conditioner
Motor CT signal -Analog CH2 -22 AWGSignal ground
Vibration sensor +Analog CH3 +22 AWG0-10V from accelerometer
Vibration sensor -Analog CH3 -22 AWGSignal ground

Waveshare Gateway Configuration

Before connecting, configure the RS485-to-ETH gateway via its web interface (default IP: 192.168.1.200):

  1. Set a static IP on your network (e.g., 192.168.1.100)
  2. Mode: Modbus TCP to RTU
  3. Serial: 9600 baud, 8N1 (matches Waveshare module defaults)
  4. TCP port: 502 (standard Modbus TCP port)

Also set the Analog Input module slave address to 2 (default is 1, same as the Relay module — they must be different). Use the module's configuration software or send the Modbus command to write holding register 0x4000 with value 0x0002.


ControlForge Configuration

Project File Setup

Create a new ControlForge project or add to an existing one. The washer needs two tasks:

  • wash_main — Cycle state machine, I/O scanning, interlocks (100ms scan)
  • wash_monitor — Analog scaling, trending, diagnostics (500ms scan)

YAML Task Configuration

tasks:
- name: wash_main
program: POU_WashMain
scan_time_ms: 100
- name: wash_monitor
program: POU_WashMonitor
scan_time_ms: 500

ST Programs

GVL — Global Variables

VAR_GLOBAL
(* --- Modbus Connection --- *)
gw_ip : STRING := '192.168.1.100'; (* RS485-to-ETH gateway IP *)
gw_port : INT := 502;
mb_connected : BOOL := FALSE;

(* --- Relay Outputs (coil addresses) --- *)
COIL_HOT_VALVE : INT := 0; (* CH1 *)
COIL_COLD_VALVE : INT := 1; (* CH2 *)
COIL_DRAIN_PUMP : INT := 2; (* CH3 *)
COIL_DOOR_LOCK : INT := 3; (* CH4 *)
COIL_MOTOR_AGIT : INT := 4; (* CH5 *)
COIL_MOTOR_SPIN : INT := 5; (* CH6 *)
COIL_BUZZER : INT := 6; (* CH7 *)

(* --- Output Commands (written by state machine) --- *)
cmd_hot_valve : BOOL := FALSE;
cmd_cold_valve : BOOL := FALSE;
cmd_drain_pump : BOOL := FALSE;
cmd_door_lock : BOOL := FALSE;
cmd_motor_agit : BOOL := FALSE;
cmd_motor_spin : BOOL := FALSE;
cmd_buzzer : BOOL := FALSE;

(* --- Digital Input States (read from module) --- *)
di_door_closed : BOOL := FALSE;
di_door_locked : BOOL := FALSE;
di_level_low : BOOL := FALSE;
di_level_med : BOOL := FALSE;
di_level_high : BOOL := FALSE;
di_motor_ol_ok : BOOL := TRUE; (* N.C. — TRUE = healthy *)
di_leak_detect : BOOL := FALSE;

(* --- Analog Values (scaled) --- *)
water_temp_c : REAL := 0.0; (* Degrees C *)
motor_current_a : REAL := 0.0; (* Amps *)
vibration_g : REAL := 0.0; (* g-force *)

(* --- Cycle Settings (set from dashboard) --- *)
cycle_select : INT := 0; (* 0=none, 1=normal, 2=heavy, 3=delicate, 4=rinse_only *)
water_temp_set : INT := 1; (* 0=cold, 1=warm, 2=hot *)
water_level_set : INT := 1; (* 0=low, 1=medium, 2=high *)
extra_rinse : BOOL := FALSE;
cmd_start : BOOL := FALSE; (* Start button from dashboard *)
cmd_stop : BOOL := FALSE; (* Stop/cancel from dashboard *)

(* --- Cycle Parameters (set by cycle_select) --- *)
wash_time_s : INT := 600; (* Wash duration seconds *)
rinse_time_s : INT := 300; (* Rinse duration seconds *)
spin_time_s : INT := 360; (* Spin duration seconds *)
agitate_on_s : INT := 10; (* Agitate on-time per stroke *)
agitate_off_s : INT := 3; (* Pause between strokes *)

(* --- State Machine --- *)
wash_state : INT := 0; (* Current state *)
state_timer : DINT := 0; (* Seconds in current state *)
cycle_active : BOOL := FALSE;
fault_code : INT := 0; (* 0=none, see fault list *)
fault_active : BOOL := FALSE;

(* --- State Constants --- *)
ST_IDLE : INT := 0;
ST_DOOR_LOCK : INT := 1;
ST_FILL_WASH : INT := 2;
ST_HEAT_WAIT : INT := 3;
ST_AGITATE : INT := 4;
ST_DRAIN_1 : INT := 5;
ST_FILL_RINSE : INT := 6;
ST_RINSE : INT := 7;
ST_DRAIN_2 : INT := 8;
ST_SPIN : INT := 9;
ST_DRAIN_FINAL : INT := 10;
ST_COMPLETE : INT := 11;
ST_FAULT : INT := 99;

(* --- Fault Codes --- *)
FLT_NONE : INT := 0;
FLT_DOOR_OPEN : INT := 1;
FLT_DOOR_LOCK : INT := 2; (* Lock didn't engage in time *)
FLT_FILL_TIMEOUT : INT := 3; (* Didn't reach level in 5 min *)
FLT_MOTOR_OL : INT := 4; (* Motor thermal overload *)
FLT_LEAK : INT := 5; (* Water leak detected *)
FLT_UNBALANCE : INT := 6; (* Excessive vibration in spin *)
FLT_TEMP_HIGH : INT := 7; (* Water over-temperature *)
FLT_COMM_LOSS : INT := 8; (* Modbus communication lost *)

(* --- Diagnostics --- *)
total_cycles : DINT := 0;
scan_counter : DINT := 0;
agitate_toggle : BOOL := FALSE; (* Alternates for agitate stroke *)
agitate_timer : DINT := 0;
spin_ramp_done : BOOL := FALSE;
rinse_count : INT := 0; (* Tracks rinse passes *)
END_VAR

POU_WashMain — Main Cycle Controller

PROGRAM POU_WashMain
VAR
mb_init_done : BOOL := FALSE;
prev_second : DINT := 0;
now_ms : DINT;
now_s : DINT;
coil_states : ARRAY[0..7] OF BOOL;
di_values : ARRAY[0..7] OF BOOL;
target_level : BOOL;
END_VAR

(* ============================================================
SECTION 1: MODBUS INITIALIZATION
Create and connect the Modbus TCP client once.
============================================================ *)
IF NOT mb_init_done THEN
MB_CLIENT_CREATE('washer', GVL.gw_ip, GVL.gw_port, 1);
MB_CLIENT_CONNECT('washer');
mb_init_done := TRUE;
END_IF;

GVL.mb_connected := MB_CLIENT_CONNECTED('washer');

(* Reconnect if we lose connection *)
IF mb_init_done AND NOT GVL.mb_connected THEN
MB_CLIENT_CONNECT('washer');
END_IF;

(* ============================================================
SECTION 2: READ INPUTS
Read digital inputs and map to GVL booleans.
============================================================ *)
IF GVL.mb_connected THEN
(* Read 8 discrete inputs from slave 1, starting at address 0 *)
di_values := MB_READ_DISCRETE('washer', 0, 8);

GVL.di_door_closed := di_values[0];
GVL.di_door_locked := di_values[1];
GVL.di_level_low := di_values[2];
GVL.di_level_med := di_values[3];
GVL.di_level_high := di_values[4];
GVL.di_motor_ol_ok := di_values[5]; (* N.C. — TRUE = healthy *)
GVL.di_leak_detect := di_values[6];
END_IF;

(* ============================================================
SECTION 3: TIMEKEEPING
Increment state_timer once per second.
============================================================ *)
now_ms := NOW_MS();
now_s := now_ms / 1000;
IF now_s <> prev_second THEN
prev_second := now_s;
GVL.state_timer := GVL.state_timer + 1;
GVL.scan_counter := GVL.scan_counter + 1;
END_IF;

(* ============================================================
SECTION 4: SAFETY INTERLOCKS
These override everything — checked every scan.
============================================================ *)

(* Leak detection — immediate shutdown *)
IF GVL.di_leak_detect THEN
GVL.fault_code := GVL.FLT_LEAK;
GVL.fault_active := TRUE;
GVL.wash_state := GVL.ST_FAULT;
END_IF;

(* Motor overload — stop motor immediately *)
IF NOT GVL.di_motor_ol_ok THEN
GVL.cmd_motor_agit := FALSE;
GVL.cmd_motor_spin := FALSE;
GVL.fault_code := GVL.FLT_MOTOR_OL;
GVL.fault_active := TRUE;
GVL.wash_state := GVL.ST_FAULT;
END_IF;

(* Over-temperature — stop heating (close hot valve) *)
IF GVL.water_temp_c > 85.0 THEN
GVL.cmd_hot_valve := FALSE;
GVL.fault_code := GVL.FLT_TEMP_HIGH;
GVL.fault_active := TRUE;
GVL.wash_state := GVL.ST_FAULT;
END_IF;

(* Communication loss — if active cycle, go to fault *)
IF GVL.cycle_active AND NOT GVL.mb_connected THEN
GVL.fault_code := GVL.FLT_COMM_LOSS;
GVL.fault_active := TRUE;
GVL.wash_state := GVL.ST_FAULT;
END_IF;

(* Stop button — drain and unlock *)
IF GVL.cmd_stop AND GVL.cycle_active THEN
GVL.cmd_stop := FALSE;
GVL.cmd_hot_valve := FALSE;
GVL.cmd_cold_valve := FALSE;
GVL.cmd_motor_agit := FALSE;
GVL.cmd_motor_spin := FALSE;
GVL.cmd_drain_pump := TRUE;
GVL.wash_state := GVL.ST_DRAIN_FINAL;
GVL.state_timer := 0;
END_IF;

(* ============================================================
SECTION 5: CYCLE PARAMETER SELECTION
Set wash/rinse/spin times based on cycle_select.
============================================================ *)
CASE GVL.cycle_select OF
1: (* Normal *)
GVL.wash_time_s := 600;
GVL.rinse_time_s := 300;
GVL.spin_time_s := 360;
GVL.agitate_on_s := 10;
GVL.agitate_off_s := 3;
2: (* Heavy Duty *)
GVL.wash_time_s := 900;
GVL.rinse_time_s := 420;
GVL.spin_time_s := 480;
GVL.agitate_on_s := 12;
GVL.agitate_off_s := 2;
3: (* Delicate *)
GVL.wash_time_s := 360;
GVL.rinse_time_s := 240;
GVL.spin_time_s := 180;
GVL.agitate_on_s := 6;
GVL.agitate_off_s := 5;
4: (* Rinse Only *)
GVL.wash_time_s := 0;
GVL.rinse_time_s := 300;
GVL.spin_time_s := 360;
GVL.agitate_on_s := 8;
GVL.agitate_off_s := 3;
END_CASE;

(* ============================================================
SECTION 6: MAIN STATE MACHINE
============================================================ *)
CASE GVL.wash_state OF

(* ---- IDLE: Waiting for start command ---- *)
0: (* ST_IDLE *)
GVL.cycle_active := FALSE;
GVL.cmd_hot_valve := FALSE;
GVL.cmd_cold_valve := FALSE;
GVL.cmd_drain_pump := FALSE;
GVL.cmd_door_lock := FALSE;
GVL.cmd_motor_agit := FALSE;
GVL.cmd_motor_spin := FALSE;
GVL.cmd_buzzer := FALSE;

IF GVL.cmd_start AND GVL.di_door_closed AND GVL.cycle_select > 0 THEN
GVL.cmd_start := FALSE;
GVL.fault_code := GVL.FLT_NONE;
GVL.fault_active := FALSE;
GVL.rinse_count := 0;
GVL.cycle_active := TRUE;
GVL.state_timer := 0;

(* Rinse-only skips to fill_rinse *)
IF GVL.cycle_select = 4 THEN
GVL.wash_state := GVL.ST_DOOR_LOCK;
ELSE
GVL.wash_state := GVL.ST_DOOR_LOCK;
END_IF;
END_IF;

(* ---- DOOR LOCK: Engage lock, verify feedback ---- *)
1: (* ST_DOOR_LOCK *)
GVL.cmd_door_lock := TRUE;

IF GVL.di_door_locked THEN
GVL.state_timer := 0;
IF GVL.cycle_select = 4 THEN
GVL.wash_state := GVL.ST_FILL_RINSE;
ELSE
GVL.wash_state := GVL.ST_FILL_WASH;
END_IF;
ELSIF GVL.state_timer > 5 THEN
(* Lock didn't engage in 5 seconds *)
GVL.fault_code := GVL.FLT_DOOR_LOCK;
GVL.fault_active := TRUE;
GVL.wash_state := GVL.ST_FAULT;
END_IF;

(* ---- FILL WASH: Open valve(s) until target level ---- *)
2: (* ST_FILL_WASH *)
(* Select valve based on temperature setting *)
CASE GVL.water_temp_set OF
0: (* Cold *)
GVL.cmd_hot_valve := FALSE;
GVL.cmd_cold_valve := TRUE;
1: (* Warm — both valves *)
GVL.cmd_hot_valve := TRUE;
GVL.cmd_cold_valve := TRUE;
2: (* Hot *)
GVL.cmd_hot_valve := TRUE;
GVL.cmd_cold_valve := FALSE;
END_CASE;

(* Check target level reached *)
CASE GVL.water_level_set OF
0: target_level := GVL.di_level_low;
1: target_level := GVL.di_level_med;
2: target_level := GVL.di_level_high;
END_CASE;

IF target_level THEN
GVL.cmd_hot_valve := FALSE;
GVL.cmd_cold_valve := FALSE;
GVL.state_timer := 0;

(* If hot or warm, wait for temperature *)
IF GVL.water_temp_set > 0 THEN
GVL.wash_state := GVL.ST_HEAT_WAIT;
ELSE
GVL.wash_state := GVL.ST_AGITATE;
END_IF;
ELSIF GVL.state_timer > 300 THEN
(* 5 minute fill timeout *)
GVL.cmd_hot_valve := FALSE;
GVL.cmd_cold_valve := FALSE;
GVL.fault_code := GVL.FLT_FILL_TIMEOUT;
GVL.fault_active := TRUE;
GVL.wash_state := GVL.ST_FAULT;
END_IF;

(* ---- HEAT WAIT: Let hot water stabilize (no heater element) ---- *)
3: (* ST_HEAT_WAIT *)
(* No electric heater — just using hot water supply.
Wait 30 seconds for mixing, then proceed. *)
IF GVL.state_timer > 30 THEN
GVL.state_timer := 0;
GVL.wash_state := GVL.ST_AGITATE;
END_IF;

(* ---- AGITATE: Motor on/off strokes for wash time ---- *)
4: (* ST_AGITATE *)
GVL.agitate_timer := GVL.agitate_timer + 1;

IF GVL.agitate_toggle THEN
(* Motor ON phase *)
GVL.cmd_motor_agit := TRUE;
IF GVL.agitate_timer >= GVL.agitate_on_s THEN
GVL.agitate_timer := 0;
GVL.agitate_toggle := FALSE;
GVL.cmd_motor_agit := FALSE;
END_IF;
ELSE
(* Pause phase *)
GVL.cmd_motor_agit := FALSE;
IF GVL.agitate_timer >= GVL.agitate_off_s THEN
GVL.agitate_timer := 0;
GVL.agitate_toggle := TRUE;
END_IF;
END_IF;

(* Check if wash time complete *)
IF GVL.state_timer >= GVL.wash_time_s THEN
GVL.cmd_motor_agit := FALSE;
GVL.agitate_timer := 0;
GVL.state_timer := 0;
GVL.wash_state := GVL.ST_DRAIN_1;
END_IF;

(* ---- DRAIN 1: Drain wash water ---- *)
5: (* ST_DRAIN_1 *)
GVL.cmd_drain_pump := TRUE;

(* Drain until below low level, plus 30s extra *)
IF NOT GVL.di_level_low AND GVL.state_timer > 30 THEN
GVL.cmd_drain_pump := FALSE;
GVL.state_timer := 0;
GVL.wash_state := GVL.ST_FILL_RINSE;
ELSIF GVL.state_timer > 180 THEN
(* 3 minute drain timeout — pump may be clogged but continue *)
GVL.cmd_drain_pump := FALSE;
GVL.state_timer := 0;
GVL.wash_state := GVL.ST_FILL_RINSE;
END_IF;

(* ---- FILL RINSE: Fill with cold water ---- *)
6: (* ST_FILL_RINSE *)
GVL.cmd_hot_valve := FALSE;
GVL.cmd_cold_valve := TRUE;

CASE GVL.water_level_set OF
0: target_level := GVL.di_level_low;
1: target_level := GVL.di_level_med;
2: target_level := GVL.di_level_high;
END_CASE;

IF target_level THEN
GVL.cmd_cold_valve := FALSE;
GVL.state_timer := 0;
GVL.wash_state := GVL.ST_RINSE;
ELSIF GVL.state_timer > 300 THEN
GVL.cmd_cold_valve := FALSE;
GVL.fault_code := GVL.FLT_FILL_TIMEOUT;
GVL.fault_active := TRUE;
GVL.wash_state := GVL.ST_FAULT;
END_IF;

(* ---- RINSE: Agitate in clean water ---- *)
7: (* ST_RINSE *)
GVL.agitate_timer := GVL.agitate_timer + 1;

IF GVL.agitate_toggle THEN
GVL.cmd_motor_agit := TRUE;
IF GVL.agitate_timer >= GVL.agitate_on_s THEN
GVL.agitate_timer := 0;
GVL.agitate_toggle := FALSE;
GVL.cmd_motor_agit := FALSE;
END_IF;
ELSE
GVL.cmd_motor_agit := FALSE;
IF GVL.agitate_timer >= GVL.agitate_off_s THEN
GVL.agitate_timer := 0;
GVL.agitate_toggle := TRUE;
END_IF;
END_IF;

IF GVL.state_timer >= GVL.rinse_time_s THEN
GVL.cmd_motor_agit := FALSE;
GVL.agitate_timer := 0;
GVL.state_timer := 0;
GVL.rinse_count := GVL.rinse_count + 1;
GVL.wash_state := GVL.ST_DRAIN_2;
END_IF;

(* ---- DRAIN 2: Drain rinse water ---- *)
8: (* ST_DRAIN_2 *)
GVL.cmd_drain_pump := TRUE;

IF NOT GVL.di_level_low AND GVL.state_timer > 30 THEN
GVL.cmd_drain_pump := FALSE;
GVL.state_timer := 0;

(* Extra rinse? Do another rinse pass *)
IF GVL.extra_rinse AND GVL.rinse_count < 2 THEN
GVL.wash_state := GVL.ST_FILL_RINSE;
ELSE
GVL.wash_state := GVL.ST_SPIN;
END_IF;
ELSIF GVL.state_timer > 180 THEN
GVL.cmd_drain_pump := FALSE;
GVL.state_timer := 0;
GVL.wash_state := GVL.ST_SPIN;
END_IF;

(* ---- SPIN: High-speed spin to extract water ---- *)
9: (* ST_SPIN *)
GVL.cmd_drain_pump := TRUE; (* Keep draining during spin *)
GVL.cmd_motor_spin := TRUE;

(* Unbalance detection via vibration sensor *)
IF GVL.vibration_g > 2.5 THEN
(* Excessive vibration — stop spin, redistribute, retry *)
GVL.cmd_motor_spin := FALSE;
GVL.fault_code := GVL.FLT_UNBALANCE;
GVL.fault_active := TRUE;
GVL.wash_state := GVL.ST_FAULT;
END_IF;

IF GVL.state_timer >= GVL.spin_time_s THEN
GVL.cmd_motor_spin := FALSE;
GVL.cmd_drain_pump := FALSE;
GVL.state_timer := 0;
GVL.wash_state := GVL.ST_DRAIN_FINAL;
END_IF;

(* ---- DRAIN FINAL: Final drain after spin ---- *)
10: (* ST_DRAIN_FINAL *)
GVL.cmd_motor_agit := FALSE;
GVL.cmd_motor_spin := FALSE;
GVL.cmd_drain_pump := TRUE;

IF GVL.state_timer > 15 THEN
GVL.cmd_drain_pump := FALSE;
GVL.state_timer := 0;
GVL.wash_state := GVL.ST_COMPLETE;
END_IF;

(* ---- COMPLETE: Unlock door, signal done ---- *)
11: (* ST_COMPLETE *)
GVL.cmd_door_lock := FALSE;
GVL.cmd_buzzer := TRUE;
GVL.total_cycles := GVL.total_cycles + 1;

(* Buzzer for 10 seconds, then silence *)
IF GVL.state_timer > 10 THEN
GVL.cmd_buzzer := FALSE;
END_IF;

(* Wait for door to open, then return to idle *)
IF NOT GVL.di_door_closed THEN
GVL.cmd_buzzer := FALSE;
GVL.cycle_active := FALSE;
GVL.wash_state := GVL.ST_IDLE;
END_IF;

(* ---- FAULT: Safe state ---- *)
99: (* ST_FAULT *)
GVL.cmd_motor_agit := FALSE;
GVL.cmd_motor_spin := FALSE;
GVL.cmd_hot_valve := FALSE;
GVL.cmd_cold_valve := FALSE;
(* Keep drain pump ON in fault to empty tub *)
GVL.cmd_drain_pump := TRUE;
GVL.cmd_buzzer := TRUE;
GVL.cycle_active := FALSE;

(* After 60 seconds draining, unlock door *)
IF GVL.state_timer > 60 THEN
GVL.cmd_drain_pump := FALSE;
GVL.cmd_door_lock := FALSE;
GVL.cmd_buzzer := FALSE;
END_IF;

(* Reset fault from dashboard — returns to idle *)
IF GVL.cmd_start AND NOT GVL.cycle_active THEN
GVL.cmd_start := FALSE;
GVL.fault_code := GVL.FLT_NONE;
GVL.fault_active := FALSE;
GVL.wash_state := GVL.ST_IDLE;
END_IF;

END_CASE;

(* ============================================================
SECTION 7: WRITE OUTPUTS
Write all coil commands to the relay module every scan.
============================================================ *)
IF GVL.mb_connected THEN
coil_states[0] := GVL.cmd_hot_valve;
coil_states[1] := GVL.cmd_cold_valve;
coil_states[2] := GVL.cmd_drain_pump;
coil_states[3] := GVL.cmd_door_lock;
coil_states[4] := GVL.cmd_motor_agit;
coil_states[5] := GVL.cmd_motor_spin;
coil_states[6] := GVL.cmd_buzzer;
coil_states[7] := FALSE; (* Spare *)

MB_WRITE_COILS('washer', 0, coil_states);
END_IF;

END_PROGRAM

POU_WashMonitor — Analog Scaling and Diagnostics

PROGRAM POU_WashMonitor
VAR
mb_init_done : BOOL := FALSE;
raw_values : ARRAY[0..7] OF INT;
raw_ch1 : INT;
raw_ch2 : INT;
raw_ch3 : INT;
END_VAR

(* ============================================================
Connect to analog input module (slave ID 2)
Uses same gateway IP but different unit ID.
============================================================ *)
IF NOT mb_init_done THEN
MB_CLIENT_CREATE('washer_ai', GVL.gw_ip, GVL.gw_port, 2);
MB_CLIENT_CONNECT('washer_ai');
mb_init_done := TRUE;
END_IF;

(* ============================================================
Read 3 analog input channels (input registers, FC04)
12-bit raw values: 0-4095
============================================================ *)
IF MB_CLIENT_CONNECTED('washer_ai') THEN
raw_values := MB_READ_INPUT('washer_ai', 0, 3);
raw_ch1 := raw_values[0];
raw_ch2 := raw_values[1];
raw_ch3 := raw_values[2];

(* --- CH1: Water Temperature ---
4-20mA → PT100 transmitter → 0 to 100 C
Raw 0-4095 maps to 0-20mA, but 4mA = 0C, 20mA = 100C
4mA = 4095 * (4/20) = 819
20mA = 4095 = 4095
Scale: temp = (raw - 819) * 100.0 / (4095 - 819) *)
IF raw_ch1 > 819 THEN
GVL.water_temp_c := INT_TO_REAL(raw_ch1 - 819) * 100.0 / 3276.0;
ELSE
GVL.water_temp_c := 0.0;
END_IF;

(* --- CH2: Motor Current ---
0-10V → CT signal conditioner → 0 to 15A
Raw 0-4095 maps to 0-10V
Scale: amps = raw * 15.0 / 4095 *)
GVL.motor_current_a := INT_TO_REAL(raw_ch2) * 15.0 / 4095.0;

(* --- CH3: Vibration ---
0-10V → accelerometer module → 0 to 5g
Scale: g = raw * 5.0 / 4095 *)
GVL.vibration_g := INT_TO_REAL(raw_ch3) * 5.0 / 4095.0;
END_IF;

END_PROGRAM

Node-RED Dashboard

ControlForge includes built-in Node-RED integration. The dashboard gives you phone control of the washing machine from any browser on your network.

Tab 1 — Control

  • Cycle selector dropdown (Normal / Heavy / Delicate / Rinse Only)
  • Water temp selector (Cold / Warm / Hot)
  • Water level selector (Low / Medium / High)
  • Extra rinse toggle
  • START button (green, writes cmd_start = TRUE)
  • STOP button (red, writes cmd_stop = TRUE)

Tab 2 — Status

  • Current state display (text: "Filling", "Washing", "Rinsing", "Spinning", etc.)
  • State timer (minutes:seconds remaining)
  • Progress bar (calculated from state position in cycle)
  • Water temperature gauge
  • Motor current gauge
  • Door status indicator
  • Water level indicators (3 LEDs: low/med/high)

Tab 3 — Diagnostics

  • Fault code and description
  • Total cycle count
  • Motor current trend chart
  • Water temperature trend chart
  • Vibration trend chart
  • Modbus connection status

Node-RED Flow Outline

Use the ControlForge Node-RED nodes to read/write variables:

[goplc-read: wash_state] → [function: state-to-text] → [ui_text: "Status"]
[goplc-read: water_temp_c] → [ui_gauge: "Water Temp"]
[goplc-read: motor_current_a] → [ui_chart: "Motor Current"]
[ui_dropdown: "Cycle"] → [goplc-write: cycle_select]
[ui_button: "START"] → [goplc-write: cmd_start = TRUE]
[ui_button: "STOP"] → [goplc-write: cmd_stop = TRUE]

State-to-Text Function

var states = {
0: "Idle — Ready",
1: "Locking Door...",
2: "Filling — Wash",
3: "Heating Water...",
4: "Washing",
5: "Draining",
6: "Filling — Rinse",
7: "Rinsing",
8: "Draining",
9: "Spinning",
10: "Final Drain",
11: "Complete!",
99: "FAULT"
};
msg.payload = states[msg.payload] || "Unknown (" + msg.payload + ")";
return msg;

Testing and Commissioning

Phase 1: Bench Test (No Loads Connected)

  1. Power up the Pi, 24VDC PSU, and Waveshare modules
  2. Open the ControlForge web IDE and load the project
  3. Verify Modbus connection: check mb_connected = TRUE in variable monitor
  4. Toggle DI inputs manually (jumper wire to simulate switches)
  5. Verify di_door_closed, di_level_low, etc. respond correctly
  6. Trigger a start command — watch state machine step through states
  7. Verify relay LEDs on the Relay (D) module activate in the correct sequence
  8. Use a multimeter on relay N.O. contacts to confirm switching

Phase 2: Individual Load Test

Connect one load at a time and verify:

  1. Door lock solenoid — engages and releases, feedback switch works
  2. Cold water valve — opens when commanded, closes cleanly
  3. Hot water valve — same
  4. Drain pump — runs, no dry-run damage (fill tub first)
  5. Motor agitate — runs at low speed, reverses per stroke pattern
  6. Motor spin — runs at high speed
  7. Buzzer — sounds on cycle complete

Phase 3: Full Cycle Test

  1. Run a Normal cycle with a small load of towels
  2. Monitor the Node-RED dashboard on your phone
  3. Watch water fill, agitate pattern, drain, rinse, spin sequence
  4. Verify unbalance detection by deliberately unbalancing (optional)
  5. Test the STOP button mid-cycle — should drain and unlock
  6. Test door-open fault — open door switch mid-cycle (with lock disengaged for testing)

Safety Considerations

HazardMitigation
Electric shock (120VAC)All connections in enclosed panel, ground fault on house circuit
Water + electricityIP54 enclosure, leak sensor on DI7, fault drains and de-energizes
Door opening during spinDoor lock solenoid + feedback, software interlock
Motor overload/fireThermal overload relay on motor, wired to DI6
Uncontrolled fill (flood)5-minute fill timeout, high-level switch as hard limit
Software crashControlForge watchdog restarts task; fault state is fail-safe (drain + unlock)
Power lossAll valves are normally closed (spring return), door lock releases

Fail-safe design principle: On loss of power or controller fault, all valves close (spring return), the drain pump stops, and the door lock releases. Water cannot flow without active relay output. The motor cannot run without active contactor coils.


Fault Reference

CodeNameCauseRecovery
1DOOR_OPENDoor switch opened during cycleClose door, press START to reset
2DOOR_LOCKLock solenoid didn't engage within 5sCheck solenoid wiring, press START
3FILL_TIMEOUTWater level not reached in 5 minutesCheck water supply valves, press START
4MOTOR_OLMotor thermal overload trippedLet motor cool, reset OL relay, press START
5LEAKWater leak sensor activatedFind and fix leak, press START
6UNBALANCEExcessive vibration during spinRedistribute load, press START
7TEMP_HIGHWater temperature exceeded 85CCheck hot water supply, press START
8COMM_LOSSModbus connection to gateway lostCheck Ethernet/gateway, auto-reconnects

Parts Sources


Expanding the Project

  • Energy monitoring — Add a CT on the mains feed to the washer, track kWh per cycle
  • Water usage — Add a flow meter on the cold water inlet, track gallons per cycle
  • Predictive maintenance — Trend motor current over cycles; rising current = worn bearings
  • Home automation — MQTT integration to Home Assistant, trigger notifications
  • Custom cycles — Add more cycle types (sanitize, quick wash, soak) as new state paths
  • Multi-appliance — Add a second Relay (D) module on the RS485 bus for a dryer controller