I wanted to learn PLC programming, but there was one obvious problem: I don't own a PLC, a tank, valves, or a panel with buttons. Buying that hardware just to practice ladder logic isn't realistic.
Then I realised something simple. A PLC doesn't actually care whether the tank is made of steel or pixels ā it only cares about the I/O image it reads and writes over the network. So I built a virtual rig instead: a 3D process simulator pretending to be the plant, a soft PLC pretending to be the controller, and a real industrial protocol (Modbus TCP) between them.
That last part matters. If I had glued the two programs together with a fake in-process link, I would have learned nothing about how plant I/O actually reaches a controller. Using Modbus TCP meant the two halves were genuinely two separate devices talking over a socket, exactly the way a real system does.
This post walks through the whole build: the setup, the address mapping, the ladder logic rung by rung, and how I verified that the logic was really driving the simulated process.
The Problem
The goal I set for myself: control a tank with a PLC program, where the process behaves like real equipment.
Requirements I wanted to hit:
- One button press starts filling ā the operator should not have to hold the button.
- Filling must stop by itself after a fixed time (no level sensor available, so time is the only control parameter).
- A second button drains the tank for a fixed time.
- Filling and draining must never happen at the same time ā that's a real interlock, not a nice-to-have.
- The panel lamps should show which phase is active.
- The panel display should show a live countdown, so an operator can see how much time is left.
The scene I used is Filling Tank (Timers) from Factory I/O: a tank, an inlet valve, an outlet valve, two illuminated push buttons (Fill, Discharge) and a numeric Timer display. The lamps inside the buttons are driven by the PLC, not by the operator's finger ā so the panel doubles as a status indicator.
The Rig
| Piece | What it plays | Role |
|---|---|---|
| Factory I/O v2.5.10 (Ultimate Edition) | The plant | Simulates the tank, valves, buttons, lamps and display; runs a Modbus TCP/IP server |
| OpenPLC Runtime v4 | The PLC | Executes the program; runs a Modbus master (client) plus its own slave server |
| OpenPLC Editor v4 | Engineering station | Where the ladder diagram is written and downloaded |
| Modbus TCP | The fieldbus | Carries the I/O between the two programs |
Two roles are worth spelling out, because this trips people up: Factory I/O is the Modbus server (slave) and the PLC is the Modbus master (client). The PLC is the one polling the process every 100 ms. It feels backwards at first ā the controller is the client here ā but that is exactly how a lot of real PLC-to-remote-I/O communication works.
Step 1 ā Set Up the Process Simulator
Install Factory I/O, then open the scene 3 - Filling Tank (Timers). Take a minute to click each element in the scene and read its tag name ā you'll need those exact names in the address map later.
Before wiring anything, note what the scene does not have. In the driver configuration I ended up with:
- Digital Inputs: 3 (Fill button, Discharge button, "FACTORY I/O (Running)")
- Digital Outputs: 4 (Fill valve, Discharge valve, Filling lamp, Discharging lamp)
- Register Inputs: 0
- Register Outputs: 1 (Timer display)
That zero is the whole design decision. There is no analogue level value being transmitted, so I could not control the tank by level even if I wanted to. The fill and drain durations became the only control parameters, and the solution had to be built on timers.
Step 2 ā Set Up the PLC
Start the OpenPLC Runtime first. The editor needs a running target to download to, and it also needs it for the live monitor.
Check the console output. Mine looked like this:
MODBUS_SLAVE Server listening on 0.0.0.0:502
MODBUS_SLAVE Plugin modbus_slave started successfully
MODBUS_MASTER (PASS) Connected to TCP 192.168.56.1:502 (attempt 1)
PLC base tick: 100000000 ns across 1 task(s)
PLC State: RUNNING
Three things to confirm in your own log:
- The slave plugin is listening on port 502 (that's the runtime's own I/O image, useful later for verification).
- The master connected to the simulator's IP and port.
- The PLC is actually in
RUNNING, not stopped.
You may also see messages like Memory locking not available and a failed SCHED_FIFO(99) dispatcher. On a virtualised host or a Windows machine these are expected ā it just means the runtime isn't hard real-time. For a simulated process running on a 100 ms tick, that's fine.
Step 3 ā Create the Project and Wire the Two Together
In the OpenPLC Editor, create a project (mine is called Tank_Filling) with one program, main. Open Device Configuration and point the target at the runtime. When the button changes from Connect to Disconnect and the status shows PLC: RUNNING, the editor is attached and you can watch the logic execute later.
Now the two communication objects.
The PLC's own slave server (project tree ā Servers ā modbus): enabled, transport Modbus TCP, network interface All Interfaces (0.0.0.0), port 502, slave ID 1. This is what lets an external client read the PLC's I/O image without touching the program ā I'll use it in the verification section.
The remote device (project tree ā Remote Devices ā modbus1): this is the PLC's client side and points at Factory I/O.
Protocol : Modbus (TCP/IP)
IP address : 192.168.56.1 # the adapter Factory I/O's server is bound to
Port : 502
Timeout : 1000 ms
Slave ID : 1
Then bind the process signals to PLC addresses:
| Direction | Factory I/O tag | Modbus reference | PLC address |
|---|---|---|---|
| Process ā PLC | Fill (push button) | Discrete Input 0 ā FC 02 | %IX0.0 |
| Process ā PLC | Discharge (push button) | Discrete Input 1 ā FC 02 | %IX0.1 |
| PLC ā Process | Fill valve | Coil 0 ā FC 15 | %QX0.0 |
| PLC ā Process | Discharge valve | Coil 1 ā FC 15 | %QX0.1 |
| PLC ā Process | Filling lamp (panel lens) | Coil 2 ā FC 15 | %QX0.2 |
| PLC ā Process | Discharging lamp (panel lens) | Coil 3 ā FC 15 | %QX0.3 |
| PLC ā Process | Timer display | Holding Register 0 ā FC 16 | %QW0 |
Two things I'd flag if you're copying this:
- The PLC reads two discrete inputs (length 2), so the simulator's third input ā "FACTORY I/O (Running)" ā never actually reaches the program. If you want to use it as a "simulation is running" gate, extend the length to 3 first.
- Keep the slave IDs consistent on both ends. Mismatched IDs give you silent timeouts, not helpful error messages.
Step 4 ā Declare the Process Interface
Fourteen variables is all it takes:
| Variable | Type | Address | Purpose |
|---|---|---|---|
Fill_Btn |
BOOL | %IX0.0 |
Fill button (operator input) |
Disch_Btn |
BOOL | %IX0.1 |
Discharge button (operator input) |
Fill_Valve |
BOOL | %QX0.0 |
Inlet valve |
Disch_Valve |
BOOL | %QX0.1 |
Outlet valve |
Fill_Light |
BOOL | %QX0.2 |
Fill button lens |
Disch_Light |
BOOL | %QX0.3 |
Discharge button lens |
Timer_Disp |
INT | %QW0 |
Panel countdown |
T_Fill |
TON | ā | Fill timer |
T_Disch |
TON | ā | Discharge timer |
Fill_Seq |
BOOL | ā | Fill phase latched |
Disch_Seq |
BOOL | ā | Discharge phase latched |
Fill_sec |
LREAL | ā | Elapsed fill seconds |
Disch_sec |
LREAL | ā | Elapsed discharge seconds |
Left_Calc |
LREAL | ā | Remaining seconds |
The naming keeps me honest: only *_Valve, *_Light and Timer_Disp touch the plant. Everything else is state and arithmetic.
Step 5 ā Write the Ladder Logic
The program is 14 rungs in four groups. Here's the logic in Structured Text first, because it's easier to read the intent:
(* ---- Fill phase ---- *)
Fill_Seq := (Fill_Btn OR Fill_Seq) AND NOT Disch_Seq AND NOT T_Fill.Q;
T_Fill(IN := Fill_Seq, PT := T#10s);
Fill_Valve := Fill_Seq;
Fill_Light := Fill_Seq;
(* ---- Discharge phase ---- *)
Disch_Seq := (Disch_Btn OR Disch_Seq) AND NOT Fill_Seq AND NOT T_Disch.Q;
T_Disch(IN := Disch_Seq, PT := T#10s);
Disch_Valve := Disch_Seq;
Disch_Light := Disch_Seq;
(* ---- Countdown shown on the panel ---- *)
IF Fill_Seq THEN
Fill_sec := TIME_TO_S(T_Fill.ET); (* elapsed time -> seconds *)
Left_Calc := 10.0 - Fill_sec; (* remaining *)
Timer_Disp := TO_INT(Left_Calc); (* panel takes an integer *)
ELSIF Disch_Seq THEN
Disch_sec := TIME_TO_S(T_Disch.ET);
Left_Calc := 10.0 - Disch_sec;
Timer_Disp := TO_INT(Left_Calc);
END_IF;
Now the same thing as ladder, rung by rung, because the rung order is what makes it work.
Rungs 1ā3 ā fill phase.
-
Rung 1 is the seal-in latch:
(Fill_Btn OR Fill_Seq) AND NOT Disch_Seq AND NOT T_Fill.Q ā Fill_Seq. TheFill_Seqcontact in parallel withFill_Btnis what lets a single press start the phase and keep it running.NOT Disch_Seqis the interlock, andNOT T_Fill.Qis how the phase ends on its own. -
Rung 2 is the timer:
TON T_FillwithPT := T#10s. - Rung 3 drives the inlet valve from the phase bit, so the valve cannot stay open if the latch drops.
Rungs 4ā6 ā discharge phase. Exactly the mirror image, and this is where the safety condition lives:
(Disch_Btn OR Disch_Seq) AND NOT Fill_Seq AND NOT T_Disch.Q ā Disch_Seq
That NOT Fill_Seq means draining can't start while filling, and Rung 1's NOT Disch_Seq means filling can't start while draining. The two phases are mutually exclusive by construction, not by operator discipline.
Rungs 7ā8 ā panel indication. Fill_Seq ā Fill_Light and Disch_Seq ā Disch_Light. Not required for the process, but they turn the panel into a status display for free.
Rungs 9ā14 ā countdown. A timer's ET is of type TIME, which you can't do arithmetic on, so Rung 9 converts it: TIME_TO_S(T_Fill.ET) ā Fill_sec. Rung 10 subtracts (10.0 - Fill_sec ā Left_Calc), and Rung 11 truncates to an integer and writes it to the display register (TO_INT(Left_Calc) ā Timer_Disp). Rungs 12ā14 repeat the chain for the discharge phase using the same two scratch variables and the same display register ā which is why the program stays short.
Step 6 ā Configure the Factory I/O Driver
Back in Factory I/O, open the driver page and select Modbus TCP/IP Server:
Port : 502
Slave ID : 1
Network adapter : the adapter the PLC can reach
Write Digital : Inputs # what the simulator sends to the PLC
Read Digital : Coils # what the simulator takes from the PLC
Write Register : Input Registers
Read Register : Holding Registers
Scale : 1
I/O points : 3 DI, 4 DO, 0 register inputs, 1 register output
The green connection indicator appears once the driver starts and the PLC's client connects. The driver page also shows the mapping in one screen, which is the quickest way to confirm that coil 0 is really the inlet valve and not, say, a lamp.
Step 7 ā Run It
With the runtime running and the driver started, attach the editor's debugger and press Fill.
-
Fill_Seq,Fill_ValveandFill_Lightall go TRUE, and the debugger highlights the rung green to show live power flow. - The inlet valve opens, the tank starts filling and the Fill lens lights up.
- The panel display counts down from 10.
In my capture the fill timer showed 2.2 s elapsed and 7.8 s remaining, and the panel displayed 8 ā which is 10 ā 2.2 truncated. That single number told me three things at once: the timer was counting, the arithmetic chain was right, and the value had actually travelled over Modbus to the simulator's display.
The interlock test. While the tank was filling, I pressed Discharge on purpose. The debugger showed Disch_Btn = TRUE but Disch_Seq = FALSE ā the command reached the PLC, and the program refused it, because Rung 4 requires NOT Fill_Seq. The outlet valve stayed shut. That's the whole point of an interlock: the operator can press the wrong thing and the process still behaves.
Fill completion. Ten seconds in, T_Fill.Q went TRUE, opened the latch and closed the inlet valve with no further input from me.
Discharge. Pressing Discharge latched Disch_Seq, opened the outlet valve, lit the second lens and restarted the countdown ā this time the debugger showed the fill timer holding at 10.0 s and the discharge timer at 1.9 s elapsed (8.1 s remaining, display 8).
How to Verify
Don't trust a simulator window on its own. Verify at three levels.
1. Check the network path before blaming your logic.
# Can the PLC side reach the simulator?
ping 192.168.56.1
# Is something listening on 502? (Windows)
netstat -ano | findstr :502
# Linux / macOS
ss -ltnp | grep 502
If the master says Connected ... (attempt 1), the socket is fine and any remaining problem is in your logic or your address map.
2. Poll the PLC's own I/O image. The runtime exposes a slave server on port 502, so you can read the live outputs without opening the editor:
# pip install pymodbus
from pymodbus.client import ModbusTcpClient
client = ModbusTcpClient("127.0.0.1", port=502)
client.connect()
coils = client.read_coils(0, 4) # %QX0.0 .. %QX0.3
regs = client.read_holding_registers(0, 1) # %QW0 = Timer_Disp
print("Fill valve :", coils.bits[0])
print("Discharge valve :", coils.bits[1])
print("Fill lamp :", coils.bits[2])
print("Discharge lamp :", coils.bits[3])
print("Countdown :", regs.registers[0])
client.close()
Run it while the tank is filling and you should see the fill valve and lamp TRUE, the countdown ticking down, and the discharge valve FALSE. Press Discharge mid-fill and nothing should change ā that's the interlock, seen from outside the PLC.
Note: depending on your pymodbus version, you may need read_coils(0, 4, slave=1) or device_id=1 instead.
3. Cross-check the numbers. The panel display should always equal preset ā elapsed, truncated. If the display shows a value that doesn't match the timer's ET in the debugger, the problem is in your conversion rungs, not in the communication.
What I Learned
Timers are a control strategy, not a fallback. Without a level sensor, time is the only thing I can control ā and that changes the design: the tank can't be filled "to a level", only "for a duration". Choosing 10 seconds meant accepting that the tank level is a consequence, not a target.
Seal-in latches change the operator's job. With a latch, one press starts a phase that finishes by itself. Without one, the operator holds the button for ten seconds, which is both tiring and dangerous in a real plant.
Interlocks belong in the logic, not in the procedure. Writing NOT Fill_Seq into the discharge rung means no sequence of button presses can open both valves. Writing it in a manual would just be a hope.
Data direction is the part people get wrong. The controller is the Modbus client here, polling the simulator's server. Getting the direction and the function codes right (FC 02 read, FC 15 write coils, FC 16 write registers) was most of the communication work.
A countdown is cheap and worth it. Three rungs turn a timer into an operator-facing display, and it's also the best end-to-end test I had: if the number on the panel is correct, the logic, the arithmetic and the Modbus write path are all working.
And the security side is worth a thought. Modbus TCP has no authentication, no encryption and no integrity checking. Anything that can reach port 502 can read and write the process I/O. That's exactly why my two programs connected with nothing more than an IP, a port and a slave ID ā and it's why real installations lean on segmentation, firewalls and monitored conduits rather than on the protocol itself.
Common Mistakes
| Mistake | What you'll see | Fix |
|---|---|---|
| Mismatched slave IDs on the two ends | Timeouts or silent failures, no useful error | Set both to the same ID (1 is fine) |
| Starting the PLC before the simulator's driver | PLC connects, but nothing moves and values never change | Start the Factory I/O driver first, then check the connection indicator |
| Binding the simulator's server to the wrong adapter | Master never connects; ping fails |
Bind it to the adapter the PLC can actually reach |
| Assuming there's a level sensor | No analogue value ever arrives; level control never works | Check the register input count ā if it's 0, the scene is timer-based |
| Level-sensed buttons with no edge detection | Holding the button restarts the cycle after the timer resets | Add a rising-edge (R_TRIG) in front of each button contact |
| Hard-coding the preset in two places | You change T#10s, the countdown stays wrong |
Use one constant for the duration and reference it in the subtraction too |
| Forgetting the display is an integer register | Values look off by one, or the display never changes | Convert explicitly (TO_INT) before writing to %QW0
|
| Port 502 already in use | The slave plugin fails to bind | Check what else is listening on 502 before starting the runtime |
| Debugging by watching the 3D scene only | You can't tell whether the PLC or the mapping is at fault | Use the live monitor and read the PLC's I/O image over Modbus |
Conclusion
A virtual rig gave me something a textbook couldn't: a control loop where I could see the button, the rung, the coil and the water in the same frame, and where every part of the chain ā logic, timing, arithmetic, Modbus mapping ā was mine to get wrong and then fix.
The final program is 14 rungs: two latched phases with their own timers, a cross-interlock between them, two lamps that show which phase is running, and a countdown computed in the PLC and displayed on the process panel. It fills and drains the tank from single button presses, stops on its own, and refuses to do both at once.
If you have a Windows machine and a couple of hours, you can build the same rig ā and the next things I want to add are an emergency stop that cuts both valves, edge detection on the buttons, and a small HMI reading the same Modbus server I used for verification.























