ESP32 plus Modbus TCP, from sensor to cloud in 40 lines of Python
Most ESP32 tutorials wire up a DHT22 and publish to test.mosquitto.org. This is the version where the ESP32 is a Modbus TCP master polling a real PLC, and the data ends up in your production cloud through a unified API.
If you have ever stood next to a PLC at 9 PM trying to get a temperature reading into anything other than the proprietary HMI, you know the feeling. The PLC speaks Modbus TCP. Your cloud expects MQTT. And the eight-page tutorial you found is targeting an Arduino Mega running Modbus over RS-485, which is not at all the same thing.
This is what the production version looks like on an ESP32. Not "the ESP32 reads a fake register from a Modbus simulator running on the same laptop", which is what most tutorials show. The ESP32 polls a real PLC over Ethernet, validates the register types, handles the half-open socket case, and ships the data to your cloud through a unified API that does not care which protocol came in.
Total: about 40 lines of Python on the device side. Zero lines of ESP-IDF.
The setup we are aiming at
There is a Schneider Electric M221 PLC on a 192.168.1.0/24 network. It exposes holding registers starting at address 40020 over Modbus TCP, port 502. We want an ESP32 dev board on the same network to poll register 40020 (oven temperature, signed int16, scaled by 10) every two seconds, normalize it to a float in degrees Celsius, and ship it to our cloud.
The "naive ESP-IDF" version of this is roughly 600 lines of C across three files: a Modbus TCP master configured with esp-modbus, a TLS-enabled MQTT client using ESP-MQTT, a TCP socket lifecycle manager because the PLC will silently drop the connection every few hours, and an event loop that ties them together. We have written this version. It works. We then wrote it again, and again, and again, and at some point decided we were going to stop writing it.
The Python device class
Here is the entire device definition that runs on the ESP32 through SCADABLE's SDK. The runtime does the wire-level Modbus framing, the TCP reconnection logic, the upstream cloud transport, and the JSON encoding. You write the part that knows about your device.
from scadable import Device, ModbusTCP, register
class OvenLine1(Device):
"""Schneider M221, oven line 1, holding register 40020."""
transport = ModbusTCP(
host="192.168.1.42",
port=502,
unit_id=1,
timeout_ms=2000,
reconnect_backoff_s=(1, 2, 4, 8, 16),
)
temperature_c = register(
address=40020,
kind="holding",
dtype="int16",
scale=0.1,
unit="degC",
poll_interval_s=2.0,
sane_range=(-40.0, 300.0),
)
def on_out_of_range(self, name, value):
# Auditable event, ends up in service-events with full lineage.
self.alert("oven_temp_out_of_range", value=value, register=name)That is the whole device. Forty lines including imports and the out-of-range hook, which is optional. Compile it with scadable compile, flash the ESP32 with scadable deploy --target esp32 --device-id oven-line-1, and the temperature shows up in your cloud namespace within a few seconds.
What the runtime is doing for you on the ESP32
The reason this is short is not that we hid complexity, it is that the complexity is in the runtime, not in your code. Specifically, the SCADABLE gateway running on the ESP32 (Rust binary, around 380 KB compiled, fits comfortably alongside FreeRTOS in a 4 MB flash partition) is doing the following:
- Maintaining the Modbus TCP connection with a half-open detection heuristic. Modbus over TCP fails silently more often than people admit. The gateway sends a periodic register read on a known-stable address, and if it does not get a response within
timeout_ms, it closes the socket and reconnects with the configured backoff. - Validating the register kind and dtype at compile time. "Holding register, int16, scaled by 10" turns into a single Modbus function code 03 read of two bytes, which is then sign-extended and divided by 10. If you ever change
dtypefromint16touint16, the compiler refuses the build, because that is the bug that will silently send-32760to your dashboard at the worst possible moment. - Buffering the local stream when upstream is unavailable. If the cloud connection drops, the gateway holds up to a configurable buffer of readings (default 6 hours at the configured poll rate) and replays them on reconnect, in order, with the original timestamps preserved.
- Encrypting the upstream link with mTLS, using a certificate provisioned through the SCADABLE EST endpoint at flash time. We covered the cert-rotation lifecycle in Connecting ESP32 to AWS IoT Core; the same primitives apply, but you do not have to wire them yourself.
- Emitting an audit event for every out-of-range reading, which lands in our
service-eventsimmutable log. For Corvita Biomedical, this is the SOC 2 trail their auditor expects; for industrial customers, it is the difference between "the chamber overheated" being a Slack message versus a regulatory filing.
Where the naive ESP-IDF version breaks first
When we built this manually the first time, the order of failure was always the same.
- Day 0: It works. Temperature on the dashboard, you go home.
- Day 4: A reading shows up as
-32760. Someone updated the PLC firmware over the weekend and a register kind shifted. Your int16 cast did not check for that. - Day 9: The ESP32 has been showing the same temperature for three hours. The Modbus TCP socket went half-open while you slept. Your master never noticed because Modbus TCP has no keepalive at the protocol level, and your application code did not implement one.
- Day 14: The cert on the ESP32 is one day from expiry. You did not write a rotation handler because the AWS tutorial said "use Fleet Provisioning by Claim", and you skimmed past the rotation section.
- Day 21: A second ESP32 in the same factory is publishing the same temperature value as the first, because the firmware images are identical and the device IDs are baked in. You realize you need a per-device provisioning flow.
These five problems take us a couple of weeks each, the first time. We packaged the solutions because we were tired of solving them.
The factory programming flow
In production, the ESP32s are flashed at the factory with a single image and a per-device provisioning record. On first boot the device contacts SCADABLE's EST endpoint with its registration code, receives a unique mTLS certificate, and registers itself with the cloud. The Python device class is the same across the fleet; only the device ID changes. We generate the provisioning records in batches and ship them to the contract manufacturer as a CSV.
The whole device-side change between "this is one ESP32 on a workbench" and "this is 5,000 ESP32s in a fleet" is the registration code. Everything else is the runtime's problem.
Why we wrote this in Python and not C
Three reasons. First, the people who know what their device should do are usually firmware engineers, electrical engineers, or product engineers, not ESP-IDF specialists. Python is the lowest-friction syntax we could pick that is still typed enough to catch the register-kind bug at compile time. Second, the Python definition is the source of truth, which means the same device class is what shows up in our docs, our tests, our simulators, and the auditor's evidence file. Third, our compiler can emit driver code in multiple targets (Linux gateway, ESP32, eventually RP2040 and Cortex-M), and the device author should not have to know which target is downstream.
The next post
The ESP32 launch story (why this took us a year longer than Linux) is the next post in this sequence. If you want to read what we are building before we ship it, let us talk. We are looking for early hardware partners who are stuck in the same place we were, and we are ten times faster as a sounding board than as a sales pitch.