DEV Community

tia-forge
tia-forge

Posted on Originally published at hubwiz.com

An AI Agent Programmed a Siemens S7-1500 Through an MCP Bridge — Here's the SCL and the Race Conditions It Caught

We built an MCP server that drives Siemens TIA Portal V19 through the Openness API, pointed an LLM agent at it, and gave it a real engineering task: implement a conveyor-sorting control program for an S7-1500 (CPU 1511-1 PN), verified in S7-PLCSIM.

Not "generate a snippet." The whole loop:

add CPU → compile → start simulator → create FB/UDTs → write SCL → compile → download → verify.

I expected the code to be the interesting part. It wasn't. The interesting part is which engineering decisions the agent made, and where it drew a line and refused to cross it.

Here's what happened, with the actual code and the failure modes it flagged.

The toolchain

  • Agent: an LLM agent (WorkBuddy) that reads the spec, plans steps, writes code and tests.
  • Bridge: an MCP server (TiaLink) exposing TIA Portal operations over MCP — add device, create block, write SCL, compile, download, read back.
  • Target: TIA Portal V19 with Openness, S7-PLCSIM for verification.

The bridge runs on the same machine as TIA Portal (an industrial PC); the agent can run elsewhere as long as it can reach the bridge.

The prompt

Hardware and network configuration were already done. The task:

  1. SCL state machine, with state numbers identical to the spec (so HMI, alarms, tests and field debugging all reference the same numbering)
  2. Logic as a reusable FB with its own instance DB (line #2 becomes a second instance DB, not copy-paste)
  3. Clear separation of i_ inputs / q_ outputs / stat_ internals
  4. Use IEC timers (TON_TIME) — no hand-rolled scan-cycle accumulation
  5. Implement: entry de-dup, quality-result latch, dual-cylinder interlock, fault latch, manual reset, normal stop
  6. Every output must have a deterministic value every scan — no reliance on last-cycle residue
  7. Emergency stop is only read as a safety-circuit status input; ordinary PLC code is not a safety function

Deliverables: FB SCL source, UDTs, state-transition doc, ≥10 automated test cases, boundary/race analysis.

And one line at the end:

"First add a CPU and check whether the simulator works."

That line is the part I actually cared about testing. Config tasks are judged from a screenshot. Programs are judged by behavior. So: does the agent build the verification environment first, or just start writing code?

The sequence it chose

  1. Check whether a CPU already existed — only then add PLC_1 (CPU 1511-1 PN)
  2. Compile, start S7-PLCSIM, confirm download/run works
  3. Create FB_ConveyorSort and write the state machine in SCL
  4. Create the needed UDTs
  5. Call the FB in OB1 with instance DB DB_ConveyorSort_1
  6. Compile + download — no errors
  7. Deliver the state transition table, 14 test cases, and a race/boundary analysis

It built the verification environment first. Good sign.

The state machine

State Name Enter when Main action Exit → target
0 INIT first scan clear latches, fault code, commands unconditional → 10
10 READY power-on / reset / after stop conveyor stopped, wait for start start → 20
20 FEED after start conveyor runs; entry de-dup; latch quality; sort timeout at sort pos → 30; timeout → 90; stop → 100
30 CHECK at sort position stop conveyor, branch on latched result OK → 50; NG → 60
50 PUSH_A judged OK cylinder A extend, push hold extended + hold done → 70
60 PUSH_B judged NG cylinder B extend, push hold extended + hold done → 70
70 RETRACT push done cylinder retract, retract timeout retracted → 80; timeout → 90
80 DISCHARGE retracted wait for part to leave sort position sort photo-eye clear → 20
90 FAULT any fault / safety circuit open all outputs off, latch fault reset + conditions OK → 10
100 STOPPED normal stop outputs off, restartable start → 20

The SCL

Here's the full function block it delivered. Two things to look at as you read:

  • Section 0 writes every output to a deterministic value at the top of the cycle (requirement #6).
  • Section 7 is the final output mapping with the interlock and the same determinism guarantee.
FUNCTION_BLOCK "FB_ConveyorSort"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1

VAR_INPUT
   i_xSafetyOK     : Bool;             // safety circuit healthy (1 = closed, run allowed)
   i_xStart        : Bool;             // start (edge-triggered)
   i_xStop         : Bool;             // normal stop (edge-triggered)
   i_xReset        : Bool;             // fault reset (edge-triggered)
   i_xPartAtEntry  : Bool;             // entry photo-eye
   i_xPartAtSort   : Bool;             // sort-position photo-eye
   i_xQualityOK    : Bool;             // quality result: 1 = OK
   i_xCylA_ExtEnd  : Bool;             // cylinder A extended limit
   i_xCylA_RetEnd  : Bool;             // cylinder A retracted limit
   i_xCylB_ExtEnd  : Bool;             // cylinder B extended limit
   i_xCylB_RetEnd  : Bool;             // cylinder B retracted limit
   i_tSortTimeout  : Time := T#5S;     // sort timeout
   i_tPushHold     : Time := T#800MS;  // push hold time
   i_tRetractTO    : Time := T#3S;     // retract timeout
END_VAR

VAR_OUTPUT
   q_xConveyor     : Bool;
   q_xCylA_Extend  : Bool;
   q_xCylA_Retract : Bool;
   q_xCylB_Extend  : Bool;
   q_xCylB_Retract : Bool;
   q_xFault        : Bool;
   q_xReady        : Bool;
   q_wState        : Word;
END_VAR

VAR
   stat_wState     : Word;             // current state
   stat_wFaultCode : Word;             // fault code
   stat_xFault     : Bool;             // latched fault
   stat_xCmdAExt   : Bool;             // cylinder A extend request
   stat_xCmdBExt   : Bool;             // cylinder B extend request
   stat_xQualityNG : Bool;             // latched quality result (1 = reject)
   stat_xPartSeen  : Bool;             // entry de-dup latch
   stat_xInTransit : Bool;             // part in transit
   stat_xStartEdge : Bool;
   stat_xStopEdge  : Bool;
   stat_xResetEdge : Bool;
   stat_xStartOld  : Bool;
   stat_xStopOld   : Bool;
   stat_xResetOld  : Bool;
   stat_tSortTO    : TON_TIME;         // sort timeout
   stat_tPush      : TON_TIME;         // push hold
   stat_tRetract   : TON_TIME;         // retract timeout
END_VAR

BEGIN
   // ---------- 0. write every output to a deterministic value each cycle ----------
   q_xConveyor     := FALSE;
   q_xCylA_Extend  := FALSE;
   q_xCylA_Retract := FALSE;
   q_xCylB_Extend  := FALSE;
   q_xCylB_Retract := FALSE;
   q_xFault        := FALSE;
   q_xReady        := FALSE;
   q_wState        := stat_wState;

   // ---------- 1. button edge extraction ----------
   stat_xStartEdge := i_xStart AND NOT stat_xStartOld;
   stat_xStopEdge  := i_xStop  AND NOT stat_xStopOld;
   stat_xResetEdge := i_xReset AND NOT stat_xResetOld;
   stat_xStartOld  := i_xStart;
   stat_xStopOld   := i_xStop;
   stat_xResetOld  := i_xReset;

   // ---------- 2. safety circuit: read-only, do NOT implement a safety function ----------
   IF NOT i_xSafetyOK THEN
      stat_wFaultCode := 16#0001;      // safety circuit open
      stat_xFault     := TRUE;
   END_IF;

   // ---------- 3. sensor plausibility checks ----------
   IF i_xCylA_ExtEnd AND i_xCylA_RetEnd THEN
      stat_wFaultCode := 16#0002;      // cylinder A contradictory position sensors
      stat_xFault     := TRUE;
   END_IF;
   IF i_xCylB_ExtEnd AND i_xCylB_RetEnd THEN
      stat_wFaultCode := 16#0003;      // cylinder B contradictory position sensors
      stat_xFault     := TRUE;
   END_IF;

   // ---------- 4. dual-cylinder interlock check (software supplement, NOT a hard interlock) ----------
   IF stat_xCmdAExt AND stat_xCmdBExt THEN
      stat_wFaultCode := 16#0010;      // both cylinders requested to extend
      stat_xFault     := TRUE;
   END_IF;

   // ---------- 5. fault reset (requires human confirmation AND recovered conditions) ----------
   IF stat_xFault THEN
      IF stat_xResetEdge
         AND i_xSafetyOK
         AND NOT (i_xCylA_ExtEnd AND i_xCylA_RetEnd)
         AND NOT (i_xCylB_ExtEnd AND i_xCylB_RetEnd) THEN
         stat_xFault     := FALSE;
         stat_wFaultCode := 16#0000;
         stat_wState     := 10;
      END_IF;
   END_IF;

   // ---------- 6. state machine ----------
   IF stat_xFault THEN
      stat_wState := 90;               // fault has priority, force fault state
   END_IF;

   CASE stat_wState OF
      0:  // INIT on power-up
         stat_xCmdAExt   := FALSE;
         stat_xCmdBExt   := FALSE;
         stat_xQualityNG := FALSE;
         stat_xPartSeen  := FALSE;
         stat_xInTransit := FALSE;
         stat_wFaultCode := 16#0000;
         stat_wState     := 10;

      10: // READY waiting for start
         q_xReady := TRUE;
         stat_xCmdAExt := FALSE;
         stat_xCmdBExt := FALSE;
         IF stat_xStartEdge THEN
            stat_wState := 20;
         END_IF;

      20: // FEED conveying
         q_xConveyor := TRUE;
         q_xReady    := TRUE;
         stat_xCmdAExt := FALSE;
         stat_xCmdBExt := FALSE;
         IF stat_xStopEdge THEN
            stat_wState := 100;
         ELSE
            // entry de-dup: latch only on "new part entered"
            IF i_xPartAtEntry AND NOT stat_xPartSeen THEN
               stat_xPartSeen  := TRUE;
               stat_xInTransit := TRUE;
               stat_xQualityNG := NOT i_xQualityOK;   // latch quality result
            END_IF;
            IF NOT i_xPartAtEntry THEN
               stat_xPartSeen := FALSE;               // part left entry, allow the next one
            END_IF;

            // sort timeout
            stat_tSortTO(IN := stat_xInTransit, PT := i_tSortTimeout);
            IF stat_tSortTO.Q THEN
               stat_wFaultCode := 16#0020;            // sort timeout
               stat_xFault     := TRUE;
               stat_wState     := 90;
            ELSIF i_xPartAtSort THEN
               stat_xInTransit := FALSE;
               stat_tSortTO(IN := FALSE, PT := i_tSortTimeout);
               stat_wState     := 30;
            END_IF;
         END_IF;

      30: // CHECK decide on latched result
         stat_xCmdAExt := FALSE;
         stat_xCmdBExt := FALSE;
         stat_tPush(IN := FALSE, PT := i_tPushHold);
         IF stat_xQualityNG THEN
            stat_wState := 60;                        // reject -> cylinder B
         ELSE
            stat_wState := 50;                        // OK     -> cylinder A
         END_IF;

      50: // PUSH_A extend A for a good part
         stat_xCmdBExt := FALSE;                      // interlock: B stays retracted
         stat_xCmdAExt := i_xCylB_RetEnd;             // only allow A after B is confirmed retracted
         stat_tPush(IN := stat_xCmdAExt, PT := i_tPushHold);
         IF i_xCylA_ExtEnd AND stat_tPush.Q THEN
            stat_xCmdAExt := FALSE;                   // push done, retract
            stat_wState   := 70;
         END_IF;

      60: // PUSH_B extend B for a reject
         stat_xCmdAExt := FALSE;                      // interlock: A stays retracted
         stat_xCmdBExt := i_xCylA_RetEnd;             // only allow B after A is confirmed retracted
         stat_tPush(IN := stat_xCmdBExt, PT := i_tPushHold);
         IF i_xCylB_ExtEnd AND stat_tPush.Q THEN
            stat_xCmdBExt := FALSE;
            stat_wState   := 70;
         END_IF;

      70: // RETRACT confirm retraction
         stat_xCmdAExt := FALSE;
         stat_xCmdBExt := FALSE;
         stat_tRetract(IN := TRUE, PT := i_tRetractTO);
         IF (stat_xQualityNG AND i_xCylB_RetEnd)
            OR ((NOT stat_xQualityNG) AND i_xCylA_RetEnd) THEN
            stat_tRetract(IN := FALSE, PT := i_tRetractTO);
            stat_wState := 80;
         END_IF;
         IF stat_tRetract.Q THEN
            stat_wFaultCode := 16#0030;               // retract timeout
            stat_xFault     := TRUE;
            stat_wState     := 90;
         END_IF;

      80: // DISCHARGE wait for the part to leave the sort position
         stat_xCmdAExt := FALSE;
         stat_xCmdBExt := FALSE;
         IF NOT i_xPartAtSort THEN
            stat_wState := 20;
         END_IF;

      90: // FAULT latched
         stat_xCmdAExt := FALSE;
         stat_xCmdBExt := FALSE;
         stat_tSortTO(IN := FALSE, PT := i_tSortTimeout);
         stat_tPush(IN := FALSE, PT := i_tPushHold);
         stat_tRetract(IN := FALSE, PT := i_tRetractTO);

      100: // STOPPED normal stop
         q_xReady    := TRUE;
         stat_xCmdAExt := FALSE;
         stat_xCmdBExt := FALSE;
         stat_xInTransit := FALSE;
         stat_tSortTO(IN := FALSE, PT := i_tSortTimeout);
         IF stat_xStartEdge THEN
            stat_wState := 20;
         END_IF;

      ELSE
         stat_wFaultCode := 16#00FF;                   // illegal state
         stat_xFault     := TRUE;
         stat_wState     := 90;
   END_CASE;

   // ---------- 7. output mapping: interlock + deterministic value each cycle ----------
   q_xCylA_Extend  := stat_xCmdAExt AND NOT stat_xCmdBExt AND i_xCylB_RetEnd;
   q_xCylB_Extend  := stat_xCmdBExt AND NOT stat_xCmdAExt AND i_xCylA_RetEnd;
   q_xCylA_Retract := NOT q_xCylA_Extend;
   q_xCylB_Retract := NOT q_xCylB_Extend;

   q_xFault := stat_xFault;
   q_wState := stat_wState;
END_FUNCTION_BLOCK
Enter fullscreen mode Exit fullscreen mode

Note: this assumes double-acting cylinders where retract is the de-energized safe direction. For spring-return cylinders, the "retract output" should be forced to 0 (de-energized = retracted) rather than commanded.

The UDTs

A parameter UDT so multiple lines can reuse the same FB:

TYPE "UDT_SortParams"
VERSION : 0.1
   STRUCT
      SortTimeout : Time := T#5S;      // sort timeout
      PushHold    : Time := T#800MS;   // push hold time
      RetractTO   : Time := T#3S;      // retract timeout
   END_STRUCT;
END_TYPE
Enter fullscreen mode Exit fullscreen mode

A status UDT for HMI display and online diagnostics:

TYPE "UDT_SortStatus"
VERSION : 0.1
   STRUCT
      State        : Word;             // current state
      FaultCode    : Word;             // fault code
      FaultLatched : Bool;             // fault latched
      QualityNG    : Bool;             // quality result latched
      PartSeen     : Bool;             // entry de-dup latch
      InTransit    : Bool;             // part in transit
   END_STRUCT;
END_TYPE
Enter fullscreen mode Exit fullscreen mode

The 14 test cases it shipped

# Name Precondition Action Expected
TC-01 Power-on init downloaded to sim first scan state 10, all outputs 0, no fault
TC-02 Normal start state 10 press start state 20, conveyor runs
TC-03 Good part sort state 20 part at entry, quality OK, reaches sort pos 20→30→50→70→80→20, cylinder A extends then retracts
TC-04 Bad part sort state 20 part at entry, quality NG 20→30→60→70→80→20, cylinder B extends then retracts
TC-05 Entry de-dup state 20 part sits at entry for 100 scans PartSeen latches once, no repeat triggering
TC-06 Sort timeout state 20 part doesn't reach sort pos within 5 s state 90, fault 16#0020 latched
TC-07 Fault latch + reset state 90, conditions recovered press start, then reset start ignored; reset + conditions OK → state 10
TC-08 Reset refused state 90, safety circuit still open press reset reset ineffective, stays in state 90
TC-09 Normal stop + resume state 20 press stop, then start stop → state 100 outputs safe; start → state 20
TC-10 Dual-cylinder interlock state 20 force both extend requests = 1 fault 16#0010, neither cylinder extends
TC-11 Contradictory position sensors any force A ext-end and ret-end = 1 together fault 16#0002 latched, state 90
TC-12 Safety circuit open state 20 i_xSafetyOK = 0 outputs drop immediately, state 90 latched; needs reset after recovery
TC-13 Retract timeout state 70 cylinder doesn't retract within 3 s fault 16#0030, state 90
TC-14 Output determinism any single-step and watch outputs every scan all outputs have defined values, no residue

The race conditions it flagged (the actually useful part)

  1. Entry de-dup vs. sensor sticking — latching until the part leaves the entry photo-eye de-dupes naturally, but only if the part actually leaves; otherwise later parts get missed. The process must guarantee single-piece spacing.
  2. When to latch the quality result — it latches at the entry, not at the sort position, because the quality system's result may not be stable by the time the part arrives, and you'd race the position signal. If quality detection is slow, use a handshake/queue instead of just delaying the read.
  3. Arrival vs. timeout in the same scan — IEC TON sets Q = TRUE on the scan it reaches PT. It checks timeout before arrival and explicitly resets the timer on arrival. If you want "arrival wins," you must reorder.
  4. Re-trigger after push — the part can still briefly cover the sort photo-eye. Going 70→20 directly would cause a double-sort. State 80 (DISCHARGE) waits for the eye to clear before re-arming.
  5. Interlock layering — software only stops "logically simultaneous requests." It does not replace pneumatic/electrical hard interlocks or mechanical limits.
  6. Button priority in the same scan — fault/safety > stop > reset > start, and this must be frozen in the requirements or implementations will diverge.
  7. "Stop" has two meanings — immediate vs. finish-current-part. It implements immediate (state 100); a controlled finish needs a StopPending flag.
  8. PT = 0 boundary — TON fires immediately, which can turn a timeout into a guaranteed fault. Validate the lower bound if the time is HMI-writable.
  9. Held reset button — edge-triggered, so a long press doesn't keep masking a recurring fault.
  10. First-scan init — state 0 does init; a proper project would also use OB100. Kept state 0 so init stays visible and testable inside the state machine.
  11. Multiple instances — timers and edge memories live in FB statics, so never share one instance DB across lines.
  12. Sim ≠ field — the simulator doesn't model photo-eye chatter, pneumatic delay, or safety-circuit response time. All times must be tuned on the real machine.

The three things it explicitly refused to do

This is the part I think matters most, and why I wrote this up at all:

  • Emergency stop must be realized by a hardware safety circuit (safety relay / safety PLC). The program only reads i_xSafetyOK.
  • Dual-cylinder hard interlock must be pneumatic or electrical. The software interlock is supplementary.
  • Time parameters and sensor tuning must be done on the real machine. The simulator can't give you real chatter or pneumatic delay.

It did the software part and handed the responsibility it can't carry back to a human. It didn't pretend to have "finished" something it hadn't.

That's the property I'd want from any code generator touching industrial control: generate the verifiable part, and be explicit about what it can't verify.

Honest caveats

  • The simulator passing does not mean it's field-ready.
  • I only checked that the code compiles, downloads, and matches the test cases at a logical level — I did not bench-test on real hardware.
  • The agent's self-reported step log is in the full write-up; treat model self-reports with the usual skepticism.

Full write-up with screenshots, the complete source, and the agent's step log: AI-powered PLC programming with WorkBuddy + TiaLink.

Disclosure: I work on TiaLink, the MCP bridge that drives TIA Portal, built by our team at 汇智网 (hubwiz.com). The agent used in this experiment was Tencent's WorkBuddy.

Top comments (0)