The goal was to read their current state, get temperatures from glue heads, hoses, and the tank, and later safely change setpoints through a regular REST API.

According to the spec, everything looked simple:

There is a glue unit
        ↓
It is controlled through a PLC
        ↓
Using Modbus TCP
        ↓
Some service on our side
        ↓
REST API

So, in theory, you spin up FastAPI, write the right requests to the PLC, and you're done.

But writing the API itself turned out to be the easy part. All the fun was hidden inside the PLC logic.

So the question how do I control the glue heads? automatically turned into a much more annoying one:

are we actually sure which register controls which glue head?

First: just check everything

The first stage was basic connectivity testing and, more importantly, understanding what actually controls what.

The edge computer — in our case, some old MikroTik units were enough for this role — had to connect through the industrial network to the glue unit controllers over Modbus TCP.

When you're dealing with real physical equipment, you first need to check the boring stuff:

  1. Is the network equipment reachable?
  2. Can we open a TCP connection to the controller?
  3. Does Modbus answer?
  4. Can we read a known register?
  5. Does the value match what we see on the HMI?
    Yes, yes — this turned out to be the most important part.

What happened in practice:

The network was reachable, but initially the Modbus endpoint returned Connection refused.

So first I had to deal with the network side and the MikroTik connection itself — and no, this was not exactly "plug a LAN cable into your PC and you're done."

After that was fixed, I could read registers from all the units normally and compare the values with what the operator panel showed.

At the same time, I found another quirk of these specific controllers: they responded properly only with one fixed Transaction ID = 0.

Reading a register is easy. Understanding what it controls is harder

The next problem appeared after Modbus itself was already working.

The PLC controller was perfectly happy to return some numbers.

But the PLC does not write a friendly note next to them saying:

this is the temperature of hose #2, and this one is the pump temperature

You get an address and some numeric value.

And then you have to figure out what physically sits behind it.

Why?

Because in real production, quite often nobody is going to hand you the original PLC project with all labels and all internal logic neatly documented.

And asking the equipment manufacturer to explain 5, 10, or 100 registers can take literally forever.

For example, some temperature channels returned a special large value which, after the usual scaling, turned into almost two thousand degrees.

Obviously, the glue had not turned into plasma.

But just hardcoding:

this value means "channel disabled"

would also be wrong.

So we went back to the HMI and started checking those positions there.

Only after the field check did it become clear that those temperature zones were indeed unused or physically absent.

So in the API they ended up looking like this:

{
  "value": null,
  "quality": "unavailable"
}

instead of some completely insane temperature.

That became a pretty good rule for the whole project:

Modbus gives you a register. The meaning of that register is confirmed by the real equipment.

Literally: me and the commissioning technician playing "guess the melody."

He's on the HMI changing values.

I'm in the console checking what moves in the PLC.

And then it turns out that the same address can mean different things

The most interesting part started when I began building the map of glue heads and hoses.

The logic looked obvious to us.

Example: there is a sequence of temperature slots.

So you make one mapping for all the glue heads inside one unit, because in theory they should all be the same:

slot 1 → HEAD1
slot 2 → HEAD2
slot 3 → HEAD3
slot 4 → HEAD4
slot 5 → HOSE1
...

and then reuse that everywhere.

Except during validation we discovered that the actual unit configurations were different.

Some machines had more glue heads and hoses.

Others had fewer.

Because of that, the same PLC slot could refer to different physical equipment depending on the configuration.

On one unit, a particular position could really be a glue head.

On another unit, the same position could already be a hose.

And technically everything still worked perfectly.

PLC returns a value.

API returns JSON.

No errors.

The only small problem is that the API confidently tells the user:

this is the head temperature

while physically it's a hose.

Why?

Good question.

Ask the Chinese guy who assembled the machine.

I had to throw away the idea of one global map

After validation, it became obvious that one shared file like:

register_map.py

for all units simply wouldn't work.

So instead I created a separate profile for each individual unit.

Something like:

profiles/
    unit_01.yaml
    unit_02.yaml
    unit_03.yaml
    unit_04.yaml

Each profile contains the actual configuration of that particular unit:

HEAD1
HEAD2
HOSE1
HOSE2
TANK
...

I also added channel activity information and positions whose purpose had not yet been confirmed:

CONFIRMED
CANDIDATE
UNKNOWN

Yes, there are also these weird candidates inside the PLC.

I can see them.

But we couldn't find them on the HMI.

And since the task was to control the glue heads, and we already knew everything we needed about them, we simply don't touch the UNKNOWN equipment.

Only after that came the API

Once the map had been validated, the software part became much easier.

I built the service with FastAPI + asyncio.

At the same time, HTTP clients do not talk to the PLC directly.

This is not obvious at first, but it becomes very important when you're dealing with equipment worth hundreds of thousands of dollars.

I deliberately did not build this:

GET /telemetry
      ↓
Modbus request
      ↓
PLC
      ↓
HTTP response

Because then the number of PLC requests depends directly on how many API clients you have.

Do I know how many clients there will be?

What if somebody accidentally hammers the endpoint and takes down the PLC?

Instead, every unit gets its own worker.

PLC
 ↓
worker
 ↓
polling roughly once per second
 ↓
snapshot in RAM
 ↓
REST API

The worker continuously reads the current state and updates a ready-to-use snapshot in memory.

And the important part is that 1 Hz is safe for the Mitsubishi PLC in our setup.

The REST API simply returns that snapshot to clients, no matter how many of them there are.

If tomorrow two clients connect — or twenty — the PLC does not suddenly receive ten times more requests and stop the equipment because of some unexpected software stupidity.

Why every unit gets its own worker

I intentionally isolated the units from each other.

If one loses connection or its controller stops responding, the others should continue working.

Simple example:

Tomorrow production decides to shut down one glue unit because of cost savings, lower production volume, whatever.

We should not have to redesign the whole API because of that.

Inside each worker, Modbus requests are strictly sequential.

One request.

One response.

Then the next request.

Then the next response.

If there is a timeout or protocol error, the connection is closed and reopened.

The snapshot is also replaced as one complete object.

So the client should never see inconsistent state like:

HEAD1 - already from the new polling cycle
HEAD2 - still from the previous one
HOSE1 - even older

From the API point of view, the unit always has one consistent current snapshot.

This becomes especially important if we're planning to control anything later.

The final structure looked roughly like this:

                  ┌→ worker 1 → PLC 1
                  ├→ worker 2 → PLC 2
FastAPI + RAM ←───┼→ worker 3 → PLC 3
                  └→ worker 4 → PLC 4
        ↓
     REST API

Plus a small history buffer in memory, events, and an audit log for important actions.

Writing is a completely different story

Reading PLC state is relatively safe.

Writing is not.

Technically, I could have built an API like this:

POST /write

register = 1234
value = 1650

And congratulations — now we have a universal remote tool that can write arbitrary values into basically any accessible PLC register.

I think you can see yourself why this is a terrible idea.

The external client should not know PLC addresses at all, let alone set random values there.

It should work with physical entities.

In simple terms: you need to idiot-proof the whole thing.

For example:

{
  "type": "set_temperature_setpoint",
  "zone": "HEAD1",
  "value_c": 165.0
}

And only inside the service does that turn into:

HEAD1
 ↓
profile of this specific unit
 ↓
confirmed PLC address
 ↓
validation checks
 ↓
Modbus write

Before writing anything, the service should at least check:

  • does HEAD1 exist on this unit;
  • is its address confirmed;
  • is writing to this zone allowed;
  • is the requested temperature inside the allowed range;
  • is the setpoint jump too large;
  • is the PLC state fresh enough;
  • does the profile match the current unit configuration.

If anything doesn't match — no command.

I'd much rather listen to an annoyed commissioning technician who configured something wrong in the MES than break the machine.

The worst case: you don't know whether the write actually happened

There is another interesting thing here that you don't run into very often in normal backend development.

Imagine this:

API sent a command
        ↓
PLC may have received it.
Or at the exact same time it may have received another command from the HMI
        ↓
connection times out or returns an error

What do you do?

Retry automatically?

What if the first command actually succeeded?

Then you send the physical action twice.

So operations like this should not be treated as a normal retryable error.

They need a separate state:

uncertain

Meaning:

we cannot reliably say whether the command was executed or not.

And no automatic retry.

For a regular HTTP API, this feels a bit unusual.

For physical equipment, it makes perfect sense.

And then there is the operator with the HMI

There is one more participant in the whole process that you cannot ignore:

the operator.

They continue working through the normal HMI.

They have their own job.

They probably don't know anything about our API and MES system.

And honestly, they may not care.

So now one setpoint can potentially be changed by two different sources:

HMI ──┐
      ├── PLC
API ──┘

For example:

API reads: 165 °C

operator sets on the HMI: 170 °C

API almost simultaneously writes: 160 °C

Last write wins.

So technical validation alone is not enough.

You also need normal operating rules.

The API should not silently fight the operator for control of a glue head.

Eventually the machines will win anyway.

We've all seen The Matrix.

Kidding.

That is exactly why, during the first stage, API-based control is physically disabled.

Reading works.

The command architecture is ready.

The checks are in place.

But writing is enabled only after specific zones and scenarios are separately validated and the system is gradually introduced to operators on the production floor.

What I ended up with

So from this seemingly simple task:

build an API for the glue units

I ended up with a small industrial service with a fairly non-trivial architecture:

glue units
       ↓
PLC
       ↓
Modbus TCP
       ↓
separate polling workers
       ↓
one-second snapshots in RAM
       ↓
FastAPI
       ↓
business logic and semantic commands

Plus:

  • a separate profile for every configuration;
  • a validated map of heads, hoses, and other zones;
  • explicit UNKNOWN entries for unconfirmed positions — we still don't know what to do with those;
  • failure isolation between units;
  • no direct client access to PLC registers;
  • validation before every write;
  • control disabled by default.

FastAPI itself was probably the easiest part of the whole project.

Most of the work was the network, controller quirks, Modbus, register validation, HMI checks, and physically mapping PLC addresses to glue heads and hoses.

I genuinely thought the operator was going to kill me while we were trying to match my PLC slots with his HMI slots.

Conclusion

This project showed me very clearly the difference between a normal API and an API connected to real physical equipment.

In regular backend development, a wrong mapping usually ends with a wrong field in JSON or a database error.

Here, a wrong mapping means you wanted to change the temperature of a glue head, but potentially sent the command to a completely different physical zone.

In the end, there is no magic here.

Just several slightly weird PLCs, several glue units, a validated address map, and a service that turns all of that into an understandable and reasonably safe API.