DEV Community

Phil Yeh
Phil Yeh

Posted on

I Built an LLM Agent That Reads Real PLCs — Here's What Nobody Tells You About Tool Calling with Industrial Protocols

Six months ago I wrote about how a "viral" Dev.to article promoting a Local RAG tool sold exactly one copy. The lesson I took from that: I'd been building from what looked trendy, not from a problem I actually had.

This time I did it differently. I use Modbus every week at work — reading data off PLCs, inverters, sensors. So when I started learning agentic AI, I asked a narrower question: can an LLM agent actually operate real industrial hardware, not just call web APIs?

Most agent tutorials wire an LLM up to a weather API or a search tool. That's a reasonable place to start, but it skips the part that makes industrial protocols genuinely hard: the data isn't semantically labeled. A Modbus register is just two bytes. Whether those two bytes mean "1500" or "-3.2°C" or half of a 32-bit float depends entirely on a device's register map — usually buried in a PDF nobody reads twice.

This is what I built, and what I learned building it.

Tool calling looks easy until the data isn't clean

The standard agent tutorial pattern is: define a tool, decorate it, let the LLM call it.

@tool
def get_weather(city: str) -> str:
    """Get current weather for a city."""
    return weather_api.get(city)
Enter fullscreen mode Exit fullscreen mode

Clean input, clean output. The LLM doesn't need to know anything about how weather data works internally.

Modbus doesn't hand you clean output. A single "read holding registers" response might contain:

  • A uint16 (0–65535)
  • An int16 (signed, two's complement)
  • A float32 split across two registers — and the two possible byte orders (AB CD vs the word-swapped CD AB, common on Schneider and Siemens gear) produce completely different numbers from the same four bytes

Here's the actual decode logic, pulled from a Modbus logging tool I maintain:

def decode_registers(reg_bytes: bytes, fmt: str) -> str:
    if fmt == "float32 (AB CD)":
        return ", ".join(
            f"{struct.unpack('>f', reg_bytes[i:i+4])[0]:.4f}"
            for i in range(0, len(reg_bytes) - 3, 4)
        )
    if fmt == "float32 (CD AB)":
        results = []
        for i in range(0, len(reg_bytes) - 3, 4):
            # Swap the two 16-bit words, then decode as big-endian float
            swapped = reg_bytes[i+2:i+4] + reg_bytes[i:i+2]
            results.append(f"{struct.unpack('>f', swapped)[0]:.4f}")
        return ", ".join(results)
    # ... uint16, int16, uint32, hex, ascii
Enter fullscreen mode Exit fullscreen mode

I tested this against a real (simulated) device holding a value of 8190.5 stored in word-swapped order. Decoded correctly on the first real end-to-end run. That's not a coincidence — it's the same decode path I've been using in production logging tools, just wrapped differently.

The decision that mattered more than the code: none of this complexity should ever reach the LLM. The model should never see raw hex and be asked to "figure out" the encoding. LLMs are unreliable at precise byte-level arithmetic — that's not a training gap, it's an architectural mismatch. A regex or a struct.unpack call will get it right every time; an LLM doing the same math token-by-token won't.

So the tool function does 100% of the decoding. What the agent sees is:

Flow rate: 12.75 L/min
Enter fullscreen mode Exit fullscreen mode

Not 41 4C 00 00. Not "here's a float32 in AB CD order, please interpret it." Just the answer.

A translation layer between "what the LLM sees" and "how the wire protocol actually works"

Once decoding is handled, the second design decision is what vocabulary you expose to the model. I didn't give the agent raw Modbus primitives (read_register(addr, qty, fmt)). I gave it named devices:

DEVICE_MAP = {
    "pump_rpm": {"addr": 0, "qty": 1, "fmt": "uint16", "label": "Pump RPM"},
    "flow_rate": {"addr": 3, "qty": 2, "fmt": "float32 (AB CD)", "label": "Flow rate"},
    "total_volume": {"addr": 5, "qty": 2, "fmt": "float32 (CD AB)", "label": "Total volume"},
}

@tool
def read_device_value(device_name: str) -> str:
    """Read the current value of a named device."""
    device = DEVICE_MAP[device_name]
    result = read_holding_registers(host, port, unit_id=1,
                                     start_addr=device["addr"],
                                     quantity=device["qty"],
                                     decode_fmt=device["fmt"])
    return f"{device['label']}: {result['value']}"
Enter fullscreen mode Exit fullscreen mode

This means deploying the agent against a different physical device is a config change, not a rewrite — swap the register addresses in DEVICE_MAP, the agent logic doesn't move. It also means the LLM's job is simplified to "match the user's question to a device name," which is exactly the kind of fuzzy matching LLMs are actually good at, instead of the kind of precise decoding they're bad at.

Local models are usable for tool calling now, but the model choice matters more than the tutorials suggest

I ran this on Ollama with llama3.1:8b — no cloud API, so the "no data leaves your network" story that matters for a lot of industrial deployments is actually true, not just a marketing line.

It works. Three sequential questions ("pump rpm?" → "flow rate?" → "total volume?") each resolved to the correct tool call with the correct arguments, and the model correctly carried context between them.

But it's worth being honest about the edges I hit testing this:

  • Tool count matters. Local 8B models start missing or misapplying tools somewhere past 3–4 distinct tools in one session. I kept the initial toolset small (read_device_value, list_available_devices) rather than exposing one tool per register, partly because of this — fewer, more general tools beat many narrow ones for small-model reliability.
  • Speed is a real trade-off, not a footnote. On a laptop with no dedicated GPU, each response took 20–30 seconds. That's the cost of "100% offline," and if your use case needs sub-second responses, a local 8B model on CPU is the wrong tool regardless of how well the agent logic works.
  • Temperature 0, always, for tool calling. This isn't a creative writing task. Any temperature above 0 measurably increased malformed tool calls in my testing.

Why read-only, on purpose

The current version only queries — it doesn't write setpoints or toggle coils. That's not a missing feature, it's a design boundary. An LLM deciding what to read and getting it wrong means a wrong answer. An LLM deciding what to write to a live PLC and getting it wrong means an actuator does something. Those failure modes are not the same severity, and I'm not comfortable collapsing that distinction just to make a more impressive demo.

If you're building something similar and considering write access, I'd think hard about whether the agent actually needs autonomous write authority, or whether a human-confirms-before-write pattern gets you 90% of the value at a fraction of the risk.

What this connects to

I've spent years working with Modbus, OPC-UA, and MQTT in industrial automation — protocols that, unlike REST APIs, were never designed with an LLM (or honestly, with much external tooling at all) in mind. Most agentic AI content right now is about connecting models to web services. Connecting them to a 1979-era serial protocol running on a factory floor is a different problem, and I think it's a more interesting one — the constraints are real, the failure modes have physical consequences, and "just retry the API call" isn't always a safe answer when the API call is a write to a live device.

If you're coming from a software background and curious what the industrial side of agentic AI actually looks like, or you're an automation engineer curious what the AI side actually requires — happy to go deeper on either in the comments.


The tool built in this post — including the Modbus decode logic, the device-map translation layer, and a Docker setup that runs the whole thing (Ollama + a simulated Modbus device to try it against, no real hardware required) — is packaged as Industrial Agent on Gumroad. Read-only queries, one-command setup, and the same decode logic used in Modbus Logger Pro.

By Phil Yeh — Senior Automation Engineer specializing in Industrial Python and developer tools. I write post-mortems and technical deep-dives, not polished tutorials. If you want more of this in your inbox: Phil's Industrial Notes.

Top comments (0)