Skip to content

ModBridge

A Modbus TCP to REST API bridge that polls live register values, serves them over HTTP, logs to CSV, and ships with a built-in device simulator. Available in both Rust and Python with identical feature sets.

Rust Python Modbus TCP REST API Tokio FastAPI pymodbus
// the problem

Getting data out of industrial devices shouldn't require a SCADA license

Modbus TCP is the lowest common denominator of industrial communication. Nearly every PLC, VFD, power meter, and sensor that speaks Ethernet speaks Modbus. But reading Modbus data from a modern application means either licensing a proprietary driver, writing your own polling loop, or using a SCADA platform that's overkill for most use cases.

For projects like Argus and Power Dashboard, I needed a way to get live register data into a Python or JavaScript application without dealing with Modbus directly in the application code. A small sidecar process that polls Modbus and exposes the data over a clean REST API was the obvious answer. ModBridge is that sidecar.

I built the Python version first because it was the fastest path to something working. Then I built the Rust version as a way to learn Rust properly, applying it to a problem I already understood deeply. The two implementations ended up being a useful comparison study in how the same problem feels in both languages.


// how it works

Two modes, one contract

ModBridge has two modes: serve and simulate. In serve mode, it connects to a real Modbus TCP device, polls the configured register range on a set interval, caches the values in memory, and serves them at a REST endpoint. In simulate mode, it runs a fake Modbus device on localhost that generates random-walking register values, so you can test without physical hardware.

The REST API returns register values with addresses, raw integer values, and hex representations. The response structure is identical between the Rust and Python implementations, so you can swap between them without changing the consuming application.

The REST response structure is identical between the Rust and Python versions: register address, raw integer value, hex representation, and poll timestamp. Swapping implementations requires no changes in the consuming application. That contract was the design goal, not an accident.

Configuration is handled via command-line flags. You pass the Modbus host, the register range to poll, the polling interval, and the port for the REST server. No config file required for basic usage, which keeps it easy to stand up quickly.


// the technical details

Rust vs Python, and what the comparison taught me

The Rust implementation was my first real Rust project beyond tutorials. The borrow checker forced a level of explicitness about ownership and threading that took some getting used to, but the result is a binary with no runtime dependency that starts in milliseconds and uses negligible memory. Dropping it onto a low-power embedded Linux device is straightforward.

Async I/O in Rust with Tokio is conceptually similar to asyncio in Python, but the type system makes the concurrency explicit in a way Python doesn't. Getting the Modbus polling loop and the HTTP server running on the same Tokio runtime without blocking took some trial and error, but the final structure is clean. Shared state between the poller and the HTTP handler uses an Arc and a RwLock, which is the canonical Rust pattern for this.

The Python version has a different strength: it's easy to modify. Adding a new endpoint, changing the polling logic, or switching the Modbus function code is a matter of editing a few lines. For use cases where you want to prototype quickly or adapt the behavior, the Python version is more practical.

The simulator implementations differ in an interesting way. The Python simulator uses a TOML configuration file that lets you define register ranges, walk speeds, and value distributions. The Rust simulator uses command-line flags. Both serve the same purpose but reflect the different ergonomics of the two ecosystems.