all posts

esp32

ESP32 industrial monitoring — the 30-minute end-to-end build

Box-open to first datapoint on a real dashboard in half an hour. ESP32-S3 with three input classes — digital GPIO, analog ADC (4-20 mA + 0-10 V), and I2C — over USB-C, WiFi, MQTT to an EU-resident broker. Full BOM, wiring, code, and deployment.

By Akshay Sarode· September 18, 2025· 18 min readesp32industrialmqttmodbus

ESP32 industrial monitoring — the 30-minute end-to-end build

If you've ever specced an industrial PoC, you know the depressing maths. A "starter" Schneider Electric Modicon M221 with a couple of analog input cards is $1,800 before you've thought about a HMI. An Advantech WISE-4051 wireless module is $400 per node. A Phoenix Contact mGuard router is $700 if you want a real VPN to the cloud. By the time you've justified the rollout, the project has been dead for two months because nobody can get a meaningful signal on a screen without a budget approval.

This is the post that fixes that. It's a complete, reproducible build of a real industrial signal chain — three input types, one ESP32, MQTT to an EU-resident broker, a dashboard you don't have to host — that costs about $28 in parts, reaches first-datapoint in 30 minutes, and uses only off-the-shelf, single-vendor-free components. It's the bench kit that we ship as $99 turnkey, but here you build it yourself.

What you're going to end up with

A USB-powered ESP32-S3 reading:

  • 4 digital GPIO lines (dry contacts via a 4N25 optoisolator — door switches, e-stop relays, run/idle status of legacy machines).
  • 2 analog ADC lines — one configured for 4-20 mA sensors via a 165 Ω precision shunt, one configured for 0-10 V sensors via a 1/3 voltage divider (so 10 V maps to 3.30 V at the pin).
  • An I2C bus with two real industrial-grade peripherals: a Sensirion SHT45 for temperature/humidity, and a Texas Instruments INA226 for high-side bus voltage and current measurement.

All of those publish over WiFi to a Sutrace MQTT broker in europe-west3. The dashboard auto-discovers the channels on first publish and gives you a chart per channel within seconds.

Bill of materials — $28 total

PartSourceUnit price
ESP32-S3-DevKitC-1-N8R8 (8 MB flash, 8 MB PSRAM)Espressif distributors / Mouser$12.00
Sensirion SHT45 breakout (Adafruit #5665)Adafruit$5.00
INA226 breakout (CJMCU-226 or equivalent)AliExpress / Mouser$4.00
165 Ω 1% precision metal-film resistorDigi-Key$0.20
4N25 optoisolator + 4.7 kΩ pull-upDigi-Key$0.50
60-piece dupont jumper kitAliExpress$2.00
830-tie breadboard (MB-102)AliExpress$2.00
USB-C cable + 5V/2A wall plugAnker / Amazon Basics$2.30
Total$28.00

The full BOM post has the source SKUs and the rationale for each part.

Step 1 — Bench setup (5 min)

Plug the ESP32-S3-DevKitC-1 into the breadboard so it straddles the centre channel. The DevKitC-1 silkscreen labels the GPIOs cleanly — keep it visible from above, not flipped. Connect a known-good USB-C cable to a 5V/2A wall plug or a laptop port that can deliver 900 mA at 5 V (some old USB 2.0 hubs cannot, and the ESP32-S3 will brown-out under WiFi load).

You don't need a JTAG dongle. You don't need an Espressif debug probe. The DevKitC-1 has a built-in CP2102 USB-to-UART bridge and the ROM bootloader is enough for everything we're doing.

Step 2 — Wire the I2C bus (5 min)

I2C is the easy bit. The ESP32-S3 default I2C pins are configurable but sensible defaults are GPIO 8 (SDA) and GPIO 9 (SCL). Wire both the SHT45 breakout and the INA226 breakout to those two pins, share the 3.3 V rail (don't use 5 V — both breakouts are 3.3 V parts), and share GND.

Both breakouts have on-board 10 kΩ pull-ups, so daisy-chained on the same bus you have effective ~5 kΩ pull-ups, which is fine for the 100 kHz default.

The SHT45 lives at I2C address 0x44. The INA226 defaults to 0x40. They don't collide. The Sensirion datasheet lists them both clearly.

Step 3 — Wire the analog inputs (8 min)

This is where most ESP32 industrial tutorials are wrong, so pay attention.

For 4-20 mA: place a 165 Ω 1% resistor between the sensor's signal line and GND. Take the ADC reading from the top of the resistor. At 4 mA, you have 4 × 165 = 0.66 V. At 20 mA, you have 20 × 165 = 3.30 V — exactly the ESP32's max ADC range. This avoids the 0-800 mV non-linear region near zero (covered in detail in the 165 Ω trick post).

Wire that to GPIO 4 (ADC1_CH3 on the S3).

For 0-10 V: a simple resistor divider — 20 kΩ on the high side, 10 kΩ on the low side. That gives you a 1:3 ratio, so 10 V at the input is 3.33 V at the ADC pin (which gets clipped slightly above the rail, but the calibration table handles that). Use 1% resistors and pick values high enough that the input doesn't load the source — 20 kΩ + 10 kΩ presents 30 kΩ of input impedance, which is fine for any industrial 0-10 V transmitter.

Wire that to GPIO 5 (ADC1_CH4).

Step 4 — Wire the digital inputs (5 min)

For each of the four digital channels, wire a 4N25 optoisolator with a 4.7 kΩ pull-up on the ESP32 side. The dry-contact closure on the field side pulls the optoisolator's LED on, which pulls the ESP32's GPIO low. Read it as INPUT_PULLUP in software and invert.

Why the optoisolator? Because in industrial environments, "dry contact" is shorthand for "we don't actually know what voltage might be on this line if a relay is mis-wired". The 4N25 takes 1.5 V at 10 mA on the LED side and isolates 5300 V peak on the output side. That's enough to survive most install mistakes.

Wire the four optoisolator outputs to GPIO 10, 11, 12, 13.

Step 5 — Flash the firmware (5 min)

We're using the Arduino-ESP32 core (3.0+) because it has the cleanest integration with the Sensirion and INA226 libraries. ESP-IDF would be a touch more efficient but isn't worth it at this scale.

#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <SensirionI2cSht4x.h>
#include <INA226.h>

// ----- Sutrace bench config -----
const char* MQTT_BROKER = "mqtt.eu.sutrace.io";
const int   MQTT_PORT   = 8883;  // TLS
const char* DEVICE_ID   = "bench-001";
const char* GROUP_ID    = "bench";

WiFiClientSecure tls;
PubSubClient mqtt(tls);
SensirionI2cSht4x sht45;
INA226 ina(0x40);

// ADC pins
const int ADC_4_20MA = 4;
const int ADC_0_10V  = 5;

// Digital inputs (active-low via 4N25)
const int DIN[4] = {10, 11, 12, 13};

// Calibration values from NVS in production; hardcoded here for clarity
const float SHUNT_OHMS    = 165.0f;
const float DIVIDER_RATIO = 3.0f;

void setup() {
  Serial.begin(115200);
  Wire.begin(8, 9);  // SDA, SCL

  sht45.begin(Wire, 0x44);
  ina.begin();
  ina.setMaxCurrentShunt(1.0, 0.002);

  for (int p : DIN) pinMode(p, INPUT_PULLUP);

  analogReadResolution(12);
  analogSetPinAttenuation(ADC_4_20MA, ADC_11db);  // 0-3.3V range
  analogSetPinAttenuation(ADC_0_10V,  ADC_11db);

  WiFi.begin();  // commissioning UI sets credentials in NVS
  while (WiFi.status() != WL_CONNECTED) delay(200);

  tls.setCACert(SUTRACE_ROOT_CA);  // pinned in firmware image
  mqtt.setServer(MQTT_BROKER, MQTT_PORT);
  mqtt.connect(DEVICE_ID, MQTT_USER, MQTT_PASS);
}

void publishFloat(const char* metric, float v) {
  char topic[128], payload[32];
  snprintf(topic, sizeof(topic),
           "spBv1.0/%s/DDATA/%s/%s",
           GROUP_ID, DEVICE_ID, metric);
  snprintf(payload, sizeof(payload), "%.4f", v);
  mqtt.publish(topic, payload);
}

void loop() {
  if (!mqtt.connected()) mqtt.connect(DEVICE_ID, MQTT_USER, MQTT_PASS);
  mqtt.loop();

  // I2C reads
  float t = NAN, rh = NAN;
  sht45.measureHighPrecision(t, rh);
  publishFloat("temp_c", t);
  publishFloat("rh_pct", rh);
  publishFloat("bus_v", ina.getBusVoltage());
  publishFloat("bus_ma", ina.getCurrent_mA());

  // Analog reads
  float v_420 = analogReadMilliVolts(ADC_4_20MA) / 1000.0f;
  float ma    = (v_420 / SHUNT_OHMS) * 1000.0f;
  publishFloat("loop_ma", ma);

  float v_010 = (analogReadMilliVolts(ADC_0_10V) / 1000.0f) * DIVIDER_RATIO;
  publishFloat("input_010v", v_010);

  // Digital reads (invert because INPUT_PULLUP + optoisolator)
  for (int i = 0; i < 4; i++) {
    publishFloat("din[" + String(i) + "]", digitalRead(DIN[i]) ? 0.0f : 1.0f);
  }

  delay(1000);  // 1 Hz
}

The full version handles Sparkplug B birth/death certificates, NBIRTH metric metadata, and reconnection backoff — see the Sparkplug B post for the production-grade variant.

Step 6 — Captive-portal commissioning (2 min)

The first time you power the device, it doesn't have WiFi credentials. The bench firmware runs WiFiManager mode — it spins up an open AP called sutrace-bench-XXXX. Join that with your phone, the captive portal pops, you fill in your site SSID and password, and the device reboots into station mode.

iOS and Android both autodetect the captive portal correctly with iOS 15+ / Android 11+. We've tested on the four most recent majors of each.

Step 7 — First datapoint (instant)

Open app.sutrace.io/overview. The device appears in the device list within 5 seconds of joining WiFi. Click into it. The seven channels (temp_c, rh_pct, bus_v, bus_ma, loop_ma, input_010v, four din[i]) appear as charts. They start populating at 1 Hz immediately.

Things you'll trip on

A few specific gotchas that we've seen across roughly 200 bench-kit deploys:

Captive-portal on corporate WPA2-Enterprise. The default WiFiManager handles WPA2-PSK fine. Corporate WPA2-Enterprise (the one with username + password + cert) requires a different config dialog. Our bench firmware ships with both — the captive portal exposes a "WPA2-Enterprise" toggle that asks for username, password, and optionally an upload of an eap_ca.pem. If your corporate WiFi requires a client certificate as well as username/password, that's a third config step that we support but most users don't realise they need.

5 V from a USB-2.0 hub is not enough. ESP32-S3 with WiFi association can pull bursts of 700 mA at 5 V. A USB-2.0 hub providing 500 mA per port browns out. Either use the wall plug, a USB-3 port, or a powered hub. The brown-out is silent — the device just reboots into a loop.

The SHT45 needs 1.5 V to start the I2C state machine. If you've miswired the breakout to 5 V instead of 3.3 V, the SHT45 won't fail dramatically — it'll respond to address 0x44 but every reading will be NaN. Pull the 5 V wire, move to 3.3 V, the readings come back.

The INA226 default config measures only ±81.92 mV across the shunt. If you're using the breakout to measure a supply current that exceeds ~40 A on the default 2 mΩ shunt, you'll silently saturate. The library call ina.setMaxCurrentShunt(1.0, 0.002) configures the calibration register correctly for our use case (1 A max, 2 mΩ shunt). If you change the shunt resistor, change the call.

4N25 polarity matters. It's an LED on the input side; it has a forward direction. Wire the high-side of your dry contact to pin 1 (anode) and the low-side to pin 2 (cathode) through the 4.7 kΩ. Reverse that and you read a stuck-high signal regardless of contact state.

ADC pin attenuation default is wrong. The Arduino-ESP32 default is ADC_DEFAULT which gives 0-1.1 V at the pin. Our 4-20 mA chain peaks at 3.3 V — call analogSetPinAttenuation(pin, ADC_11db) or readings clip silently above 1.1 V (which corresponds to ~6.7 mA on the 165 Ω shunt).

Going from bench to plant

This is a bench. For a real install, you need: a DIN-rail-mounted enclosure (Phoenix Contact UCS or similar), a 24 V DC supply with a buck converter to 5 V, surge-protected terminal blocks on the field side, and ideally a separate WiFi VLAN. None of that is exotic. All of it is documented in the mid-market industrial monitoring use case.

The piece-by-piece transition typically goes:

  1. Enclosure: Phoenix Contact UCS-50 or UCS-62 — IP54-rated, DIN-rail-mountable, with a clear lid so the ESP32 status LED is visible without opening it. Roughly $30 each in single-unit quantities.
  2. Power: a 24 V DC supply (most plants already have one for their PLC bus) into a Mean Well DDR-15G-5 DIN-rail buck converter (24 V → 5 V, 3 A). About $25.
  3. Terminal blocks: Phoenix Contact UT 2.5-MTD-DIODE for the dry contacts (with built-in TVS for inductive-load suppression), and UT 2.5-MTD for the analog/loop wiring. ~$2.50 each.
  4. Network: most plants do not let "yet another WiFi node" onto the production network. The right answer is a separate VLAN with a managed switch (Cisco IE-3300 or similar; in tighter budgets a MikroTik RB260GS) and an AP that's locked to the VLAN. Alternatively, a Modbus RTU chain from each ESP32 to existing wired equipment, with one ESP32 acting as the WiFi gateway for a chain of devices behind it.
  5. Time sync: NTP is fine for most monitoring use-cases. For high-precision applications (sub-millisecond ordering across multiple ESP32s) the ESP-IDF supports PTPv2 over Ethernet — but that's a different hardware story.

What this bench has demonstrated is the signal chain itself: a hand-flashable ESP32 can faithfully read three industrial input classes, push them through a TLS-encrypted MQTT pipe to an EU-resident broker, and surface them on a dashboard nobody had to host. That's the hard part of the rollout decision. The rest is enclosures and cable trays.

Why this is cheaper than the alternative

A traditional industrial-IoT path of the same scope — three input classes, MQTT to a cloud dashboard — would typically be specced as: a Siemens IOT2050 ($800) or Advantech UNO-2271G ($600), plus an analog input card for 4-20 mA ($300), plus a digital input card ($150), plus TIA Portal or Wonderware licenses for the dashboard side ($2,000+ per node), plus an integrator's day-rate to wire it all up ($1,500/day). Per node, before you've seen any data, you're $5,000+ deep.

The ESP32 bench above is $28 of parts and 30 minutes of your own time. The dashboard is included in the Sutrace plan at $19/month for the Bench tier.

That difference — between $5,000 to learn whether a sensor signal is real, and $28 — is the reason the bench kit exists. The expensive options are right for permanent installs at scale; they are wrong for the question "is unified observability going to work for my plant before I commit." This is the cheap-fast version that gives you the answer.

External references