Chapter 29 / 32 · Part 8 · Industrial patterns

HMI and machine interfaces

Design command and status words, heartbeats and handshakes so an HMI, SCADA system or another PLC can talk to your program safely.

Your program has more than one author

Up to now every input in this course came from a terminal: a push button, a proximity switch, a 4–20 mA transmitter. Real machines have a second kind of input that is far less polite. An operator panel, a SCADA server, a manufacturing execution system and the PLC on the next machine along the line all write values into your controller's memory while your program is running. They do not wire into an input card. They arrive over the network, they arrive whenever the other system feels like sending them, and they can say anything at all.

What actually lands in your program depends on the transport, but there are only a handful of shapes:

  • Tags over OPC UA or a vendor protocol. A WinCC Unified panel writing to an S7-1500 over S7 communication, a FactoryTalk View terminal writing to a ControlLogix over CIP, an Ignition gateway writing to anything through OPC UA. The client addresses a named variable — "HMI_Interface".Line1.SpeedSP, HMI_Line1.SpeedSP, ns=4;s=|var|PLC.Application.GVL_HMI.SpeedSP — and the value appears in that variable.
  • Modbus registers. Older HMIs, many third-party SCADA packages and nearly every power meter or chiller speak Modbus RTU or Modbus TCP (port 502). The other side writes 16-bit holding registers with function code 06 (one register) or 16 (several), and coils with 05 or 15. Your PLC maps a block of registers onto an array or a structure.
  • Cyclic I/O between controllers. A Profinet I-device, Rockwell produced and consumed tags, an EtherNet/IP adapter assembly, EtherCAT between two TwinCAT systems. These are not "writes" at all: a block of bytes is copied across every few milliseconds whether anything changed or not, like a remote input card.

The protocol lessons elsewhere on this site cover the wire formats — Modbus function codes, Profinet frames, OPC UA sessions. This chapter is about the other end: the Structured Text that receives those values and stays correct when the system on the far side is slow, wrong, frozen or switched off.

Two facts follow from that list and shape everything below.

First, writes are asynchronous to your scan. On a Rockwell Logix controller, communications are serviced independently of the program, so an HMI write can change a tag between two lines of the same routine. On an S7-1500, communication runs at a higher priority than the main cycle OB and interleaves with it, limited by the "cycle load due to communication" setting (50 % by default). On most CODESYS and TwinCAT targets a write lands between cycles of the task, but nothing in your code should depend on that. The habit that protects you on all of them is the one from chapter 21: copy every interface value into a local variable once, at the top of the scan, and use only the copy below.

Second, the other side decides when it writes, and how often. An HMI typically writes only when an operator does something. A SCADA script might write every second. A partner PLC over Profinet effectively "writes" every 4 ms. An OPC UA client reads by subscription, with a sampling interval of perhaps 250 ms or 1 s, so what it displays may be several of your scans old. Design every interface as if the partner were slow and occasionally absent, because sooner or later it will be both.

Command bits and command words

The simplest interface gives every command its own BOOL: HMI_Start, HMI_Stop, HMI_Reset, HMI_JogFwd. It is easy to read online and easy to configure on the panel, and on a small machine it is the right choice. It has two weaknesses. Nothing stops two contradictory bits being TRUE at once (start and stop, jog forward and jog reverse), and every new command means another tag on both sides of the link, which on Modbus means another coil or bit and on a large SCADA system means another licensed tag.

The alternative is a command word: one integer the other side writes with a code. 1 means start, 2 means stop, 3 means reset, 0 means "nothing pending". Two commands cannot be active at once because the word only holds one number. The receiving program handles each code exactly once and then writes the word back to 0, which tells the sender that the command was consumed. PackML, which you will meet at the end of this chapter, uses exactly this pattern.

A packed bit word (a WORD whose bits are separate commands) sits between the two. It saves tags and fits neatly into a single Modbus register, but it brings back the "two at once" problem and adds bit-numbering bugs; chapter 25 shows how to pack and unpack them. In Studio 5000 you address a bit as HMI_CmdWord.3, in CODESYS and TwinCAT the same .3 works on any integer, and in TIA Portal you use slice access, "HMI".CmdWord.%X3.

The live program below simulates both sides of a command word. The first block plays the panel: whenever the OperatorEntry field changes, it writes that number into HMI_Cmd once, the way a panel writes a tag when someone presses a button. The second block is the PLC.

PROGRAM Main
VAR
    OperatorEntry : INT;    (* input: the code the operator sends *)
    Faulted       : BOOL;   (* input: a drive fault *)
    PanelLast     : INT;
    HMI_Cmd       : INT;    (* shared tag: HMI writes, PLC clears *)
    Running       : BOOL;
    LastAccepted  : INT;
    LastRejected  : INT;
    Accepted      : DINT;
END_VAR

(* ---- HMI side, simulated: a new entry is written into the tag once ---- *)
IF OperatorEntry <> PanelLast THEN
    HMI_Cmd   := OperatorEntry;
    PanelLast := OperatorEntry;
END_IF;

(* ---- PLC side: act on the code once, then clear it ---- *)
IF HMI_Cmd <> 0 THEN
    CASE HMI_Cmd OF
        1:  (* start: refused while faulted *)
            IF NOT Faulted THEN
                Running      := TRUE;
                LastAccepted := 1;
                Accepted     := Accepted + 1;
            ELSE
                LastRejected := 1;
            END_IF;
        2:  (* stop: always accepted *)
            Running      := FALSE;
            LastAccepted := 2;
            Accepted     := Accepted + 1;
    ELSE
        LastRejected := HMI_Cmd;    (* a code this machine does not know *)
    END_CASE;
    HMI_Cmd := 0;                   (* consumed *)
END_IF;

IF Faulted THEN
    Running := FALSE;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: OperatorEntry, Faulted.

Type 1 in OperatorEntry: Running goes TRUE, Accepted counts one, and HMI_Cmd is already back at 0 when the watch table shows it — the word lives for less than one scan. Type 2 to stop, then 7: an unknown code lands in LastRejected and changes nothing. Switch Faulted on and send 1 again (go via 0, because the simulated panel only writes when the field changes): the start is refused and recorded.

Three details in this program are deliberate. The ELSE branch catches codes the program does not know, so a SCADA engineer who sends 4 for "pause" because another machine uses it gets a visible rejection rather than silence. The stop is accepted in every state, because a stop that can be refused is a bug. And the PLC clears the word after acting on it, in the same scan, so a code can never be executed twice.

Command words have one trap of their own. If the other side writes the same code twice in quick succession — an impatient operator pressing Start twice — and the PLC clears the word between the two writes, both are executed. For start and stop that is harmless. For "index the turntable one station" or "add 10 kg" it is not. Commands that must happen exactly once per request need the handshake described later in this chapter, not just a self-clearing word.

Momentary buttons on a touch screen

A push button on a panel door is a contact. When the operator lets go, a spring opens it, and the input drops. A "momentary" button on a touch screen is nothing of the sort. It is two network writes: TRUE when the finger goes down, FALSE when it comes up. In WinCC that is the SetBitWhileKeyPressed system function; in FactoryTalk View it is a momentary push button object; every HMI package has one. Between the two writes the bit is TRUE in your PLC, and nothing in the PLC knows whether the second write is ever coming.

Often it is not. The panel loses its connection while the finger is down. The runtime crashes or reboots. The operator slides a gloved finger off the edge of the button and some panels never fire the release event. A Wi-Fi tablet drops off the network. In each case the PLC is left with a bit that says "the operator is pressing Start" for ever, until somebody writes it back by hand.

The opposite also happens. A quick tap on a busy panel can produce the TRUE and the FALSE inside one communication cycle, and on a slow task the PLC may never execute a scan while the bit is TRUE. The press simply vanishes. Operators learn to press harder, which does not help.

The consequences of a stuck bit depend entirely on how your program reads it. Read it as a level, the way you would read a hardwired start button in a seal-in circuit, and a stuck start bit turns every stop into a pause.

The cure is the one from chapter 8: act on the rising edge of the HMI bit with an R_TRIG. A stuck bit then produces one edge, one start, and nothing more. The program below runs two identical conveyors side by side, one reading the HMI start as a level, the other as an edge.

PROGRAM Main
VAR
    HMI_Start : BOOL;    (* input: HMI momentary button, may stick *)
    HMI_Stop  : BOOL;    (* input: HMI momentary button *)
    MotorA    : BOOL;    (* start read as a level *)
    MotorB    : BOOL;    (* start read as an edge *)
    StartEdge : R_TRIG;
END_VAR

(* Conveyor A: start as a level -- do not ship this *)
IF HMI_Start THEN
    MotorA := TRUE;
END_IF;
IF HMI_Stop THEN
    MotorA := FALSE;
END_IF;

(* Conveyor B: start as an edge *)
StartEdge(CLK := HMI_Start);
IF StartEdge.Q THEN
    MotorB := TRUE;
END_IF;
IF HMI_Stop THEN
    MotorB := FALSE;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: HMI_Start, HMI_Stop.

Switch HMI_Start on and leave it on — that is the lost release. Both motors run. Now switch HMI_Stop on and off. MotorB stays stopped; MotorA restarts the instant Stop drops. Only a fresh press, off and on again, starts MotorB.

The edge handles the restart, but the stuck bit itself is still a fault worth reporting. A TON on the raw bit that times out after a few seconds tells you a button has been "held" far longer than any human holds a button, and the HMI or its connection needs attention. You will build that in the first challenge.

Stop commands are the exception to the edge rule. A network stop is read as a level and wins over everything, because a stop that sticks TRUE leaves the machine stopped, which is safe. And none of this is how you build an emergency stop: an E-stop on a touch screen is a contradiction. Real emergency stops are hardwired red mushroom buttons into a safety relay or a safety PLC, and the HMI only displays their state.

The second style deserves a word. The HMI writes TRUE on press and never writes FALSE; the PLC resets the bit after it has acted. A short tap cannot vanish, because the bit stays TRUE until the PLC has seen it, and a lost release cannot stick, because the HMI never sends a release. Many WinCC and FactoryTalk projects use it for every non-jog button, and if your HMI supports a plain "set bit" event it is usually the better choice.

Request, acknowledge, done

Commands from a person are forgiving: if a start gets lost, the operator presses again. Commands between machines are not. When a filler tells a capper "bottle 4,217 is in position, cap it", the capper must cap that bottle once — not zero times, not twice — and the filler must know it has happened before it indexes the starwheel. That needs a handshake: an agreed sequence of signal changes in which each side only moves when it has seen the other side's last move.

The two-step version is the minimum. The requester sets Req. The responder sees it, does the job and sets Ack. The requester sees Ack and drops Req. The responder sees Req drop and drops Ack. Both signals are now FALSE and the next cycle can start. Each signal is owned by exactly one side, and each side only changes its own signal in response to a change of the other's.

That sequence has four edges, which is why it is usually called a four-step handshake: Req up, Ack up, Req down, Ack down. Production interfaces normally split the responder's answer into Busy (I have the request and I am working on it) and Done (finished, result valid), and add Error for a job that failed. The requester then knows the difference between "the partner never saw my request" and "the partner is still working", which matters when you write the timeout.

The most important step is the last one. A responder that clears Done after a fixed time instead of waiting for Req to fall, or a requester that raises Req again before Done has dropped, will sooner or later run a job twice or skip one, because on a slow link the two sides briefly disagree about which cycle they are in. Waiting for the other side's edge is what makes the handshake independent of timing.

Here is the responder side as a small state machine (chapter 12). Req is the partner's signal; you play the partner.

PROGRAM Main
VAR
    Req      : BOOL;    (* input: written by the partner *)
    Step     : INT;     (* 0 idle, 10 working, 20 done *)
    Busy     : BOOL;    (* written by us *)
    Done     : BOOL;    (* written by us *)
    Work     : TON;     (* stands for the real job: 1.5 s *)
    JobCount : DINT;
END_VAR

Work(IN := Step = 10, PT := T#1500ms);

CASE Step OF
    0:  (* idle: Done is low, wait for a new request *)
        IF Req THEN
            Busy := TRUE;
            Step := 10;
        END_IF;
    10: (* working *)
        IF NOT Req THEN             (* partner withdrew: abandon *)
            Busy := FALSE;
            Step := 0;
        ELSIF Work.Q THEN
            Busy     := FALSE;
            Done     := TRUE;
            JobCount := JobCount + 1;
            Step     := 20;
        END_IF;
    20: (* done: hold Done until the partner drops Req *)
        IF NOT Req THEN
            Done := FALSE;
            Step := 0;
        END_IF;
END_CASE;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Req.

Switch Req on: Busy comes on, the job runs for 1.5 s, then Done replaces Busy and JobCount goes to 1. Now leave Req on. Nothing else happens — no second job, however long you wait. Only when you switch Req off does Done drop and the responder return to step 0. Switch Req off in the middle of a job instead and the job is abandoned without being counted.

Compare that with the shortcut most first attempts take: IF Req AND NOT Busy THEN start the job. With Req held high after the job, that line starts the job again on the very next scan. Holding Done until Req falls is what turns a level into exactly one job.

Every wait in a handshake needs a timeout. If the partner never answers — powered off, in a different mode, stuck in its own fault — the requester must not hang silently in "waiting for Done". After a time that comfortably exceeds the partner's worst-case response, typically two to five times its normal cycle, raise an alarm with a message that names the step: "Capper did not acknowledge request within 2 s" is a fault a technician can fix; "Sequence timeout step 30" is a phone call.

The responder you ran above is half of a handshake. The harder half is the requester, because it has to wait for the other side at every step and give up cleanly if the other side never answers.

Heartbeats and watchdogs

A handshake catches a partner that stops answering during an exchange. Most of the time there is no exchange going on, and you still need to know whether the other side is alive. A frozen HMI looks exactly like a healthy HMI in which nobody is pressing anything. A partner PLC that went to STOP may leave its last outputs on the Profinet link frozen at their final values; depending on the I-device configuration, your side may keep reading "conveyor ready" from a controller that is no longer running any code.

The standard answer is a heartbeat: each side changes a value continuously, and the other side checks that it keeps changing. The checker is a watchdog — a timer that is restarted every time the heartbeat changes, and that expires if it stops.

Two conventions are common. A toggle bit flips every second. A counter increments every cycle or every few hundred milliseconds. Prefer the counter. A toggle that freezes looks the same as a toggle you sampled twice at the wrong moment, so the watchdog time has to be generous; a counter that has not moved is unambiguous, and the size of the jump tells you about communication delays when you are troubleshooting. Overflow does not matter: an INT counter that wraps from 32767 to −32768 has still changed, and the watchdog only ever asks "is it different from last scan?"

Then decide what loss of each partner means, before somebody decides for you on site:

Partner lostTypical reactionWhy
HMI panelStop jog and other hold-to-run motion, keep production running, alarmThe operator cannot see or stop the machine from that panel, but a running batch should not be scrapped
SCADA / MESKeep running on the current order, buffer production data, alarmSupervisory systems go down for patching; the machine must not
Upstream PLCFinish the product in the machine, accept nothing new, alarmProduct arriving without data is worse than a short stop
Downstream PLCStop discharging, hold the product, alarmPushing product onto a conveyor that may not be running jams the transfer

The live example has two simulated partners in one program. The first block is the HMI: it increments HMI_Heartbeat every 500 ms unless you freeze it. The rest is the PLC: a watchdog on the HMI heartbeat, its own heartbeat counter for the HMI to watch, and a jog output that is only allowed while the HMI is alive.

PROGRAM Main
VAR
    HmiFrozen     : BOOL;   (* input: freeze the simulated panel *)
    JogPB         : BOOL;   (* input: HMI hold-to-run jog button *)
    HmiTick       : TON;
    HMI_Heartbeat : INT;    (* written by the HMI *)
    LastHB        : INT;
    HbStale       : TON;
    HmiAlive      : BOOL;
    JogMotor      : BOOL;   (* output *)
    PlcTick       : TON;
    PLC_Heartbeat : INT;    (* written by the PLC, watched by the HMI *)
END_VAR

(* ---- HMI side, simulated: count every 500 ms unless frozen ---- *)
HmiTick(IN := NOT HmiTick.Q AND NOT HmiFrozen, PT := T#500ms);
IF HmiTick.Q THEN
    HMI_Heartbeat := HMI_Heartbeat + 1;
END_IF;

(* ---- PLC side: the watchdog restarts whenever the counter moves ---- *)
HbStale(IN := HMI_Heartbeat = LastHB, PT := T#2s);
LastHB   := HMI_Heartbeat;
HmiAlive := NOT HbStale.Q;

(* Hold-to-run only while the panel is provably alive *)
JogMotor := JogPB AND HmiAlive;

(* Our own heartbeat for the HMI: once per second *)
PlcTick(IN := NOT PlcTick.Q, PT := T#1s);
IF PlcTick.Q THEN
    PLC_Heartbeat := PLC_Heartbeat + 1;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: HmiFrozen, JogPB.

Switch JogPB on: the motor jogs. Now switch HmiFrozen on while the jog is held. The counter stops, HbStale.ET starts climbing, and after 2 s HmiAlive drops and so does JogMotor, even though the jog bit is still TRUE — which is exactly the state a frozen panel leaves behind. Unfreeze and the motor resumes, because JogPB is still on. On a real machine you would make that resumption impossible: once the watchdog trips, jog stays off until the button has been seen released.

Notice the order of the two watchdog lines. The comparison HMI_Heartbeat = LastHB runs first, and only then is LastHB updated. That order is the whole trick.

Choose the watchdog time from the partner's heartbeat period and the network, not from a round number. With a 500 ms heartbeat over a busy plant network, 2 s tolerates three lost updates. For jog and other hold-to-run functions you want it much shorter — a 100 ms heartbeat and a 300–500 ms watchdog — because the watchdog time is how long an axis keeps moving after the panel dies. That is also why serious jog-over-HMI designs add a maximum jog duration, and why the enabling switches on teach pendants and mobile panels (a KUKA smartPAD, a Siemens Mobile Panel) are safety-rated and handled by the safety system — hardwired, or over PROFIsafe on the wireless Siemens panels — rather than trusted to a heartbeat in the standard program.

Status words and state codes for SCADA

Commands flow in; status flows out. A SCADA overview with 40 machines on it does not want 40 × 25 individual BOOLs; it wants a compact, stable description of each machine it can poll cheaply and draw consistently. Two fields carry most of that:

  • A state code: one integer saying what the machine is doing — stopped, starting, running, held, faulted. The SCADA screen turns it into a colour and a word; historians trend it to calculate availability.
  • A status word: individual condition bits packed into a WORD — running, faulted, auto mode, guard open, ready to start — so one Modbus register or one OPC UA tag carries them all.

Add a heartbeat counter, an active fault code, and the current setpoints echoed back (more on that below), and you have a status block that works for any supervisory system. Keep it in one STRUCT or one data block per machine, so the SCADA integrator gets one address range to map.

PROGRAM Main
VAR
    Running      : BOOL;   (* inputs: what the machine is doing *)
    Faulted      : BOOL;
    AutoMode     : BOOL;
    GuardOpen    : BOOL;
    ReadyToStart : BOOL;
    StatusWord   : WORD;   (* published: one register for SCADA *)
    StateCode    : INT;    (* published: one number for the overview *)
END_VAR

ReadyToStart := AutoMode AND NOT Faulted AND NOT GuardOpen AND NOT Running;

(* Bit layout is a contract: document it, never renumber it *)
StatusWord   := 16#0000;
StatusWord.0 := Running;
StatusWord.1 := Faulted;
StatusWord.2 := AutoMode;
StatusWord.3 := GuardOpen;
StatusWord.4 := ReadyToStart;

(* State codes in priority order: the worst condition wins *)
IF Faulted THEN
    StateCode := 90;          (* faulted *)
ELSIF GuardOpen THEN
    StateCode := 80;          (* stopped, guard open *)
ELSIF Running THEN
    StateCode := 50;          (* producing *)
ELSIF ReadyToStart THEN
    StateCode := 20;          (* ready *)
ELSE
    StateCode := 10;          (* stopped *)
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: Running, Faulted, AutoMode, GuardOpen.

Switch AutoMode on: bit 2 and bit 4 set, and the watch table shows StatusWord as 20 — the engine displays words in decimal, so 20 is 2#0001_0100, bits 2 and 4. Add Running and the word becomes 5 (bits 0 and 2: no longer "ready", because it is already running) with state 50. Open the guard and state 80 overrides everything except a fault. On a real engineering tool you would switch the watch display to hexadecimal or binary for words like this; TIA Portal, Studio 5000 and CODESYS all have a display-format option in the watch table.

The rules for status interfaces are about stability more than code:

  • The bit layout and the code numbers are a contract. Once the SCADA integrator has mapped bit 3 to "guard open" on 40 screens and in the historian, moving it to bit 5 because it looks tidier breaks every one of them. Add new bits at the end; retire old ones by leaving them permanently FALSE.
  • Leave gaps in state codes. 10, 20, 50, 80, 90 leaves room for "starting" at 30 or "held" at 60 without renumbering.
  • Write every bit every scan. Start the word at zero and rebuild it, as above. A status word built with IF Faulted THEN StatusWord.1 := TRUE and no ELSE latches stale bits for ever.
  • Publish what the machine is doing, not what was requested. "Running" should come from the contactor feedback or the drive status, not from the run command, otherwise SCADA shows a motor running that tripped on overload a minute ago.

Never trust a value from the network

The HMI has input fields with minimum and maximum limits configured, so it is tempting to assume the values that arrive are already valid. They are not. The limits on a WinCC I/O field or a FactoryTalk numeric input only protect that one screen. A second panel added two years later may not have them. The SCADA system writes the same tag without any limits. An OPC UA client — a Python script, an MES, a well-meaning process engineer with UaExpert — can write anything the server allows. A Modbus master can write any register in the mapped range, including the ones you meant to be read-only.

So the PLC validates every value it accepts from outside, every time. Three questions, in this order:

  1. Permission. Is the source allowed to change this value? Many HMIs pass the logged-in user's level to the PLC (WinCC user management and FactoryTalk Security can both be configured to do so). A line operator may change a conveyor speed; only a technician may change a drive's acceleration ramp.
  2. Mode. Is the machine in a state where this change is allowed? Changing a cut length in the middle of a cut, or a temperature setpoint while a batch is being held for quality approval, may be forbidden even for a technician.
  3. Range. Is the number physically and procedurally sensible? A conveyor drive rated 200–1800 rpm should never receive 2500 or −50.

What to do with a bad value is a design decision, and the right answer differs by value:

StrategyWhat happens to 2500 rpm when the limit is 1800Use it when
RejectActive value unchanged, reason code set, HMI shows the rejectionMost setpoints: the operator must know the value was not taken
ClampActive value becomes 1800, HMI shows 1800A slider or a value written by another program, where "as close as allowed" is what was meant
Reject and alarmUnchanged, alarm raisedValues from machine-to-machine links, where a bad value means a bug somewhere

Silent clamping of an operator entry is the one to avoid: the operator typed 2500, the machine runs at 1800, the screen still shows 2500 because nobody wrote it back, and an hour later someone wonders why the line is slow. Whatever you choose, the HMI must display the value the machine is actually using, which means the PLC publishes it.

PROGRAM Main
VAR CONSTANT
    SPEED_MIN : REAL := 200.0;     (* rpm, from the drive datasheet *)
    SPEED_MAX : REAL := 1800.0;
END_VAR
VAR
    NewSpeed    : REAL := 900.0;   (* input: value typed on the HMI *)
    Apply       : BOOL;            (* input: HMI apply button *)
    Mode        : INT := 1;        (* input: 1 = manual, 2 = auto *)
    UserLevel   : INT := 1;        (* input: 0 viewer, 1 operator, 2 technician *)
    ApplyEdge   : R_TRIG;
    ActiveSpeed : REAL := 900.0;   (* the value the drive really gets *)
    RejectCode  : INT;             (* 0 ok, 1 permission, 2 mode, 3 range *)
    Accepted    : DINT;
END_VAR

ApplyEdge(CLK := Apply);

IF ApplyEdge.Q THEN
    IF UserLevel < 1 THEN
        RejectCode := 1;                        (* viewers change nothing *)
    ELSIF Mode = 2 AND UserLevel < 2 THEN
        RejectCode := 2;                        (* in auto: technicians only *)
    ELSIF NewSpeed < SPEED_MIN OR NewSpeed > SPEED_MAX THEN
        RejectCode := 3;
    ELSE
        ActiveSpeed := NewSpeed;
        RejectCode  := 0;
        Accepted    := Accepted + 1;
    END_IF;
END_IF;

END_PROGRAM
Live example — open this page with JavaScript to run and edit it. Inputs you can drive: NewSpeed, Apply, Mode, UserLevel.

Type 1200 into NewSpeed and toggle Apply: accepted. Type 2500 and apply: RejectCode 3, ActiveSpeed stays at 1200. Set Mode to 2 and apply a valid 1000: rejected with code 2, until you raise UserLevel to 2. Set UserLevel to 0 and nothing passes. The checks run in a fixed order, so the reason code reports the most fundamental problem first — telling a viewer that 2500 is out of range would be answering the wrong question.

Note what the program does not do: it never writes the typed value directly into the drive. NewSpeed is a request; ActiveSpeed is the truth. The HMI shows both, side by side, and the operator can see at once whether the request was taken.

Validation also covers values that are not numbers. A mode request of 7 on a machine that has modes 1 to 3 should be rejected, not fall through a CASE with no ELSE. A recipe number must point to a recipe that exists (the next chapter does that properly). A string — a batch ID, an operator name — must fit the declared length; most PLCs truncate silently, and a truncated batch ID in a pharmaceutical record is a deviation report.

Who owns the setpoint

A dosing pump's flow setpoint can come from three places. The operator types it on the HMI. The active recipe defines it. The program calculates it, for example ramping it up over 30 seconds after a start, or trimming it from a downstream analyser. All three are legitimate. If all three write the same variable, the value the pump receives depends on which one wrote last, and you have built a fight.

The classic version: SCADA has a faceplate that writes the setpoint tag every second, because the integrator configured the field as cyclic rather than on-change. The program ramps the same tag from 0 to 120 l/h on a start. Every second SCADA writes back the value it read a moment ago; the ramp stutters, sometimes jumps back to zero, and the trend looks like a saw blade. Nobody's code is wrong in isolation. The design is wrong, because the tag has two owners.

The fix is the rule from chapter 21 — one owner per output — applied to data. Each external source gets its own request tag. The program owns the active value and decides, by mode, which request it listens to:

ModeActive setpoint comes fromHMI fieldProgram
ManualOperator request, validatedEditableValidates and copies
Auto, recipeActive recipeRead-only, shows the recipe valueCopies from recipe, ramps
Auto, cascadeCalculated (analyser trim, upstream rate)Read-onlyCalculates, limits
MaintenanceTechnician request, wider limitsEditable for level 2+Validates against maintenance limits

Rockwell's PlantPAx process objects formalise exactly this with "Operator" and "Program" command sources and a set of lock bits that decide which one is in charge; many Siemens and CODESYS libraries have the same idea under different names. Whatever the library, the program publishes which source is active, so the faceplate can grey out the field the operator cannot use.

Two smaller ownership problems catch people every year:

  • Bumpless switching. When the machine goes from Auto to Manual, copy the active setpoint into the operator's request tag first. Otherwise the pump jumps to whatever the operator last typed, possibly hours ago.
  • HMI start values. Some HMI configurations write a configured start value to the PLC when the runtime starts or reconnects. A panel rebooting after a power dip can then quietly reset a setpoint the operator changed this morning. Request tags plus validation make this harmless; a panel writing straight into the active value does not.

PackML: a shared language for machine states

Every interface in this chapter so far was invented locally: your command codes, your state numbers, your bit layout. That works inside one plant. It fails when a bottling line has a filler from one OEM, a labeller from another and a case packer from a third, and the line controller has to talk to all three. PackML exists for that situation.

PackML is published as ISA-TR88.00.02 and maintained with the OMAC packaging group. It defines a standard state model for a machine, a standard set of commands to move between states, and a standard set of interface tags, the PackTags, so a line controller or MES can drive any compliant machine the same way. Food, beverage and pharmaceutical end users increasingly write it into their specifications.

The state model has 17 states. The ones you meet daily are Stopped, Idle, Execute (producing), Held (stopped by an internal condition, such as an upstream starvation the machine can see), Suspended (stopped by an external condition, such as a full downstream conveyor) and Aborted (stopped by an emergency or a serious fault). The states whose names end in -ing — Starting, Stopping, Holding, Resetting, Aborting and so on — are "acting" states, in which the machine runs a short sequence and then moves on by itself when that sequence reports state complete.

Commands arrive as numbers in a command word, exactly like the earlier example. A minimal declaration of the codes, as you might find them in a vendor's PackML library, looks like this:

(* PackML command codes (PackTags Command.CntrlCmd) *)
VAR CONSTANT
    CMD_RESET     : INT := 1;
    CMD_START     : INT := 2;
    CMD_STOP      : INT := 3;
    CMD_HOLD      : INT := 4;
    CMD_UNHOLD    : INT := 5;
    CMD_SUSPEND   : INT := 6;
    CMD_UNSUSPEND : INT := 7;
    CMD_ABORT     : INT := 8;
    CMD_CLEAR     : INT := 9;
END_VAR

(* PackML state numbers (PackTags Status.StateCurrent) *)
VAR CONSTANT
    ST_CLEARING  : INT := 1;    ST_STOPPED      : INT := 2;
    ST_STARTING  : INT := 3;    ST_IDLE         : INT := 4;
    ST_SUSPENDED : INT := 5;    ST_EXECUTE      : INT := 6;
    ST_STOPPING  : INT := 7;    ST_ABORTING     : INT := 8;
    ST_ABORTED   : INT := 9;    ST_HOLDING      : INT := 10;
    ST_HELD      : INT := 11;   ST_UNHOLDING    : INT := 12;
    ST_SUSPENDING : INT := 13;  ST_UNSUSPENDING : INT := 14;
    ST_RESETTING : INT := 15;   ST_COMPLETING   : INT := 16;
    ST_COMPLETE  : INT := 17;
END_VAR

The PackTags are grouped into Command (what the line asks: Command.CntrlCmd, Command.CmdChangeRequest, Command.UnitMode, Command.MachSpeed), Status (what the machine reports: Status.StateCurrent, Status.UnitModeCurrent, Status.CurMachSpeed) and Admin (counters and reasons for OEE: Admin.ProdProcessedCount, Admin.ProdDefectiveCount, Admin.StopReason). Note CmdChangeRequest: the command number is written first, then a separate change-request bit tells the machine to act on it — the same "value, then apply" pattern that fixed the torn Modbus write.

You do not have to implement PackML to benefit from it. Even on a machine that will never see a line controller, its model answers questions people otherwise argue about at commissioning: what the difference is between held and suspended, whether reset from aborted goes straight to idle (it does not; it goes through Clearing and Stopped), which states accept a stop. Rockwell, Siemens, Beckhoff and B&R all ship free PackML templates or libraries, and reading one is a good exercise in state machine design at industrial scale. The design method behind interfaces like this — deciding what each state means before writing it — is covered in the design book.

Next: values that come in sets

The pattern under every section of this chapter is the same: the outside world sends requests, your program decides, and your program publishes the truth. Network buttons become edges and timeouts. Machine-to-machine commands become handshakes that wait for the other side. Silent partners are caught by heartbeats. Every value is checked for permission, mode and range before it touches the process, and every value that matters has exactly one owner.

The values operators change most often rarely come alone. A product change on a filler means a new fill volume, a new speed, new valve timings and a new capping torque, all at once, all valid together, and all applied at a moment when the machine can take them. That is a recipe, and the next chapter applies everything here — requests, validation, ownership — to whole sets of parameters stored in arrays of structures.

Practise this chapter