I work in cybersecurity, and for a while I kept reading about industrial control systems (ICS) without ever touching one. Reading about Modbus and PLC scan cycles is fine, but it doesn't really click until you build something that moves.
So I built a small virtual setup: a simulated water tank, a software PLC that controls it, and a SCADA screen where an operator can start and watch the process. No hardware needed, just three free tools on one machine.
This post walks through how I put it together, what each piece does, and what I still want to fix.
The Problem
A typical PLC-based control system has three layers:
| Layer | Role | What I used |
|---|---|---|
| Process (Level 0) | The physical thing being controlled | Factory I/O, Filling Tank scene |
| Controller (Level 1) | Runs the control logic | OpenPLC (Editor + Runtime) |
| Operator panel (Level 2) | Lets a human monitor and send commands | SoLiSCADA |
My goal was simple:
- Control the tank with ladder logic: fill it, discharge it, never both at once.
- Build an operator screen with Fill and Discharge buttons, a tank graphic, a countdown timer and alarms.
- Make all three talk to each other over Modbus TCP.
Step 1: Plan the Variables
Before drawing any ladder rungs, I wrote down every signal I needed. This saved me a lot of rewiring later. Here is the declaration block from OpenPLC Editor (trimmed to the important ones):
VAR
Fill_Btn : BOOL AT %IX0.0; (* fill push-button *)
Disch_Btn : BOOL AT %IX0.1; (* discharge push-button *)
Fill_Valve : BOOL AT %QX0.0;
Disch_Valve : BOOL AT %QX0.1;
Fill_Light : BOOL AT %QX0.2;
Disch_Light : BOOL AT %QX0.3;
Fill_Cmd : BOOL AT %QX0.4; (* command bit for SCADA *)
Disch_Cmd : BOOL AT %QX0.5; (* command bit for SCADA *)
Preset_s : INT AT %MW1 := 10; (* operation time, seconds *)
Level : INT AT %MW0 := 0; (* tank level, 0-100 % *)
Timer_Disp : INT AT %QW0; (* remaining seconds *)
Fill_Analog : INT AT %QW1;
Disch_Analog: INT AT %QW2;
T_Fill, T_Disch, Tick : TON;
Fill_Seq, Disch_Seq : BOOL;
END_VAR
Step 2: Write the Ladder Logic
The program is 30 rungs, but it breaks down into a few small ideas.
The fill sequence (rungs 01 to 03)
This is the heart of it. Written as Structured Text, the fill rung looks like this:
Fill_Seq := (Fill_Btn OR Fill_Seq OR Fill_Cmd)
AND NOT Disch_Seq
AND NOT T_Fill.Q;
T_Fill(IN := Fill_Seq, PT := Preset_T);
Fill_Valve := Fill_Seq;
Three things happen here:
-
Latch.
Fill_Seqholds itself on after the button is released. -
Interlock.
NOT Disch_Seqmeans fill cannot start while a discharge is running. The discharge rung has the matching check onFill_Seq, so the two operations block each other. -
Timer stop. When
T_Fillfinishes,Fill_Seqdrops and the valve closes.
The discharge sequence (rungs 04 to 06)
The discharge sequence mirrors the fill one, with its own timer (T_Disch), its own valve, and the interlock pointing the other way (it checks Fill_Seq).

Indicator lights and the countdown (rungs 07 to 14)
Rungs 07 and 08 simply switch the Fill and Discharge lights with the two sequence flags. After that, the elapsed time of the running timer is turned into seconds and subtracted from the preset:
Left_Calc := Preset_R - TIME_TO_S(T_Fill.ET);
Timer_Disp := TO_INT(Left_Calc);
The same is done for discharge with T_Disch.ET.
Making the preset configurable (rungs 15 to 18, 25, 26)
I did not want to hard-code the duration. The operator sets seconds, and the PLC converts it into a TIME value for the timer:
Preset_s := LIMIT(1, Preset_s, 30); (* keep it between 1 and 30 s *)
Preset_ms := Preset_s * 1000;
Preset_T := TO_TIME(Preset_ms);
Step_Lvl := 100 / Preset_s; (* level step used later *)
Simulating the level (rungs 19 to 23)
A small 900 ms tick adds or subtracts a fixed step from Level while filling or discharging, and a LIMIT block keeps it between 0 and 100:
(* every 900 ms *)
IF Fill_Seq THEN Level := Level + Step_Lvl; END_IF;
IF Disch_Seq THEN Level := Level - Step_Lvl; END_IF;
Level := LIMIT(0, Level, 100);
Rung 23 also resets Fill_Cmd while a sequence is active, so a SCADA button press behaves like a single pulse. Rung 24 does the same for Disch_Cmd.
Valve outputs to Factory I/O (rungs 27 to 30)
Each valve writes 1000 to its analog output when open and 0 when closed. Factory I/O reads these values to move the valves in the scene.
Step 3: Connect OpenPLC to Factory I/O
In Factory I/O I loaded the Filling Tank (Timers) scene and started its Modbus TCP/IP server (you can see "Modbus TCP/IP Server (Started)" in the bottom right of the window).
OpenPLC then acts as a Modbus master. From the runtime log, the device was loaded like this:
Device 'modbustank' loaded: 192.168.56.1:502
slave_id: 1
PLC State: RUNNING
Step 4: Build the SCADA Project
In SoLiSCADA I created a project called Tank_Filling_SCADA.
Inside the project explorer, I added a graphics page (Tank_Control) and a trend object called Tank Level Trend.
Then I added eight tags, all on the Modbus TCP driver:
| Tag | Type | Used for |
|---|---|---|
Fill_Cmd |
Switch | Fill command from the operator |
Disch_Cmd |
Switch | Discharge command from the operator |
Filling_Status |
Switch | Filling state |
Discharging_Status |
Switch | Discharging state |
Level |
Integer | Tank level |
Tank_Level_Raw |
Integer | Raw level value |
Preset_s |
Integer | Operation time in seconds |
Timer_Remaining |
Integer | Countdown value |
Step 5: Design the Operator Panel
The screen is drawn in VFDraw. It has:
- a tank graphic with a level bar
- Fill and Discharge buttons, each with an ON/OFF text under it
- fields for preset time, tank level (%) and time remaining
- an alarm bar at the bottom
In runtime, the question marks turn into live values:
How to Verify
Here is the order I use to check that everything works end to end:
- Factory I/O: the scene is running and the status bar says the Modbus TCP/IP server is started.
-
OpenPLC runtime: the log shows the Modbus device loaded and
PLC State: RUNNING. -
SCADA runtime: open the
Tank_Controlpage. The tags should show live values, not question marks. - Fill test: press Fill. The Fill text should change to ON, the level bar should rise, and Time Remaining should count down from the preset.
- Finish test: when the timer hits zero, the fill valve should close and the Fill text should return to OFF.
- Interlock test: press Discharge while a fill is running. The ladder logic is built to ignore it until the fill ends.
What I Learned
-
Write the variable list first. Every address (
%IX,%QX,%MW) has to line up across three tools. A wrong address just gives you silence, not an error. - A latch plus an interlock is the simplest safe pattern. Two NC contacts crossing between the two sequences is all it takes to stop conflicting operations.
-
Command bits need to be cleared. If the PLC never resets
Fill_Cmd, a SCADA button press stays on. I reset the command bits as soon as a sequence starts (rungs 23 and 24). - Check the scan time. My runtime log showed scan overrun warnings (the program took longer than its 100 ms period). It still ran, but it is on my list to investigate.
- From a security point of view, this is eye-opening. Modbus TCP has no authentication. Anyone who can reach port 502 can write coils and registers. That matters a lot once you think about real plants.
Common Mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Wrong Modbus address between tools | Values stay at zero or never change | Make a mapping table first and check every tag against it |
| Forgetting to start the Factory I/O Modbus server | OpenPLC cannot connect, you get timeouts | Start the server before running the PLC |
| Not clearing command bits | The SCADA button looks stuck on | Reset Fill_Cmd / Disch_Cmd in the ladder logic |
| Timer preset in the wrong type | Timer never fires or fires instantly | Convert seconds to a TIME value (rungs 25 and 26) |
| Leaving unused blocks in the ladder | Confusing logic and wasted scan time | Delete empty blocks (I had one empty MOVE in rung 16) |
What Is Still on My List
I want to be upfront about the parts that are not finished:
- The level on the SCADA screen is calculated inside the PLC. I declared the Factory I/O level sensor input (
%IW0) but have not wired it into the logic yet, so the two can drift apart. - The countdown shows values like
0:3instead of a padded00:03. - The Idle field on the screen is not a real status indicator yet.
- The level trend graph, a high-level alarm, alarm timestamps and the acknowledge button still need to be configured and tested. A low-level alarm entry already shows in the alarm bar.
Conclusion
This project took me from reading about PLCs and SCADA to seeing the whole chain work: a button on a screen, a Modbus write, a ladder rung, a valve opening in a simulated plant. It is a small system, but it made the ICS stack feel real in a way no diagram did.
Next I plan to use the real level sensor, finish the alarms and trend, and then look at the security side: capturing the Modbus traffic and seeing how easy it is to spoof a command.
If you have built something similar or have tips on the scan overrun issue, I would love to hear them in the comments.
















