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.
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.
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.
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.