Taming legacy PLCs: bridging SCADA data to modern cloud dashboards

The machine on the factory floor was installed in 2009. It works perfectly. It has outlived three plant managers, two ERP systems, and one company rebrand. Nobody wants to touch it, let alone replace it - and they’re right not to. But the operations manager wants something the machine’s designers never imagined: a dashboard she can glance at from her phone while she’s standing in line for coffee.
That tension - priceless old iron that runs flawlessly but says nothing - is the central drama of industrial digitization. This is the story of bridging that exact gap for one client, and the protocols, design choices, and genuine surprises we hit along the way. If you’ve got aging hardware and a modern visibility problem, you’ll recognize every beat.
Why this is hard (a primer for the uninitiated)
If you’ve never worked on a factory floor, the obvious question is: why not just plug it into the cloud? Three reasons, and understanding them is most of the job.
First, the machines don’t speak internet. A modern industrial controller - a PLC, or Programmable Logic Controller - talks in protocols designed in an era when “the network” meant a cable to the control room. They speak S7comm, EtherNet/IP, Modbus, Profibus. None of them speak REST or MQTT. They were built to be reliable for thirty years, not to be friendly to a web developer.
Second, you are not allowed to touch them. A PLC running a production line is, functionally, a piece of safety equipment. Changing its program risks the line, the product, and sometimes the people standing next to it. The plant’s safety team treats any modification as guilty until proven innocent - as they should.
Third, there’s a wall down the middle of the building. Industrial networks (OT - Operational Technology) are deliberately isolated from office networks (IT - the regular internet-connected world). This OT/IT divide is a security boundary, not an accident. The whole point of digitization is to get data across that wall without poking a hole in it. Get this wrong and you’ve connected a 2009 PLC with no security model directly to the internet, which is how factories end up in the news.
So the real brief wasn’t “connect the machine to the cloud.” It was: get data out of untouchable legacy hardware, across a wall you’re not allowed to breach, to a phone - and do it without anyone in safety or security losing sleep.
The hardware landscape
The client ran a mix of Siemens S7-300 PLCs and Allen-Bradley MicroLogix controllers across two production lines. Each spoke its own dialect: S7comm on the Siemens side, EtherNet/IP on the Allen-Bradley side. Both talked to their own SCADA systems over the plant’s isolated OT network, and there they stayed.
Our constraints were carved in stone before we wrote a line of code:
- Don’t touch the PLC configurations. Safety team’s hard line, non-negotiable.
- Don’t bridge OT and IT directly. Security requirement - no routed path between the two networks.
- Don’t install anything on the SCADA workstations. They’re validated machines; adding software means re-validating them.
Read those three again and you’ll see they rule out almost every off-the-shelf “IoT gateway” product, all of which want to either reconfigure the PLC or sit on both networks at once. We needed something quieter.
The edge gateway: a polite eavesdropper
The answer was a purpose-built edge gateway - an Intel NUC running a hardened Ubuntu image, installed in the plant’s control cabinet as a passive listener on the OT network. Think of it as a respectful guest who only ever asks the PLCs questions they’re already happy to answer, and never rearranges the furniture.
It did three jobs.
1. Protocol translation (reading without writing)
The key insight is that both PLC families support read access to their tag values over the existing Ethernet connection - no program change required. You’re not reprogramming the controller; you’re asking it “what’s the value of Temperature_Tank_3 right now?” the same way its own SCADA system does. We used python-snap7 for Siemens and pylogix for Allen-Bradley.
The polling loop was deliberately, almost aggressively simple. Different tags matter at different rates, so each tag group got its own interval - fast-moving values like temperatures every second, slow ones like shift production counts every ten:
# Each tag group polls on its own cadence and lands in a local ring buffer.
SCHEDULE = {
"fast": {"interval_s": 1, "tags": ["Temp_Tank_3", "Pressure_Line_A", "Flow_Rate"]},
"slow": {"interval_s": 10, "tags": ["Units_Produced", "Shift_Counter"]},
}
def poll_group(plc, group):
for tag in group["tags"]:
value = plc.read(tag) # passive read - never writes to the PLC
ring_buffer.append(tag, value, timestamp=now()) That plc.read() is the whole trick: it’s the same request the SCADA system already makes thousands of times a day, so the PLC neither knows nor cares that a second, quieter observer joined the conversation.
2. Edge compression and forwarding (crossing the wall, once)
Every five seconds, the gateway serialized its ring buffer into a compact binary payload and published it to our cloud MQTT broker through a TLS tunnel over the IT network. This is the single, deliberate point where data crosses from OT to IT - and crucially, it’s outbound only. The plant firewall permits the gateway to make one kind of connection out, and nothing in. There is no inbound path to the OT network from the internet, which is exactly what kept the security team comfortable.
We chose MQTT specifically for its store-and-forward behavior, and that choice paid off (see the next section). MQTT QoS 1 gave us guaranteed-at-least-once delivery without us hand-rolling retry logic into the gateway.
3. Local redundancy (assume the link will die)
The plant’s WiFi was, to put it generously, moody - fine in the control room, unreliable near the metal. So the gateway was designed to treat cloud connectivity as a luxury, not a given. When the link dropped, it kept writing to a local SQLite ring buffer with seven days of capacity, and replayed the entire backlog automatically when the tunnel returned.
The operations team stress-tested this the honest way: they walked over and yanked the gateway’s LAN cable, left it unplugged for two days, then plugged it back in. Recovery was automatic and complete - every sample from those two dark days streamed up in order, and the dashboard backfilled as if nothing had happened. That test did more to earn trust than any architecture diagram we could have drawn.
The cloud side
The ingest service was a small Node.js process subscribed to the MQTT topic, unpacking payloads into TimescaleDB - a PostgreSQL extension built for time-series data. Each reading became a row keyed by device_id, tag_name, value, and timestamp. A few decisions earned their keep:
Hypertables. Timescale automatically partitions data by time, so a query scoped to “last 24 hours” never scans the full history. After three months of accumulating data, dashboard queries were still landing under 50ms.
Continuous aggregates. We pre-computed 5-minute, 1-hour, and 1-day rollups. The dashboard’s “week view” renders from those pre-aggregated tables instead of scanning millions of raw rows - the difference between a chart that snaps into place and one that spins.
Tag metadata as config, not code. Rather than hardcoding which raw tag maps to which human label, we kept a shared YAML file that both the gateway and the cloud loaded. Onboarding a new sensor meant editing YAML and redeploying - no code change, no developer required:
tags:
Temp_Tank_3:
label: "Tank 3 Temperature"
unit: "°C"
group: "fast"
alarm: { high: 85, low: 10 } # thresholds drive the alarm engine That single file turned “add a sensor to the dashboard” from a sprint ticket into a five-minute config change - which, on a factory that keeps finding new things worth measuring, mattered more than any clever query.
The dashboard, and the feature nobody predicted
The frontend was an unremarkable SvelteKit app with Recharts for the time-series visuals. We say “unremarkable” as a compliment - all the interesting engineering lived in the pipeline, and the UI’s job was to stay out of the way.
But one feature surprised us with how much the operations team came to depend on it: the alarm timeline. Whenever a tag crossed a threshold from that YAML config, we generated an event and stored it alongside the telemetry, then rendered those events as markers directly on the charts.
The payoff was correlation at a glance. The manager could now see that the temperature alarm at 14:23 lined up with the production-rate dip at 14:25 - a connection that previously required cross-referencing two separate logs on two separate systems, assuming anyone thought to look. Overlaying events and metrics on one timeline turned forensic archaeology into a five-second glance, and that, more than any latency number, was what made people actually open the dashboard every morning.
What we’d do differently
No honest project story ends without scar tissue.
MQTT topic structure. We organized topics as /{plant}/{line}/{device}/{tag} - clean and intuitive at 50 tags. At 500+ tags across multiple sites, that granularity becomes a subscription headache; you end up wildcarding broadly and filtering in the app anyway. Next time we’d publish to /{plant}/{line}/{device}/telemetry with all tag values packed into one payload, and let the consumer fan out.
The SQLite ring buffer. It worked, but under concurrent load - the backlog replay running at the same time as the normal polling cycle - SQLite occasionally hit write contention. For a single-writer log on a busy edge device, an embedded key-value store like LMDB or RocksDB is the better-fit tool, and we’d reach for one if we built this again.
Neither was a fire. Both were the kind of “fine now, painful at 10×” decision that you only see clearly once the system is real and growing.
The bottom line
The client now watches both production lines, in real time, from a phone - and nobody touched a PLC configuration, breached the OT/IT boundary, or installed a thing on the validated SCADA workstations. The machine from 2009 still works perfectly, still says nothing to anyone but its own controller. We just taught the building to listen.
If you’ve got aging industrial hardware and a modern visibility requirement - and a safety team who’ll (rightly) veto anything reckless - we’d like to hear about it.