all posts

esp32

4-20 mA into an ESP32 — the 165 Ω trick (and the dead zone you must avoid)

Why 165 Ω is the correct shunt value for reading 4-20 mA industrial sensors on an ESP32, how to wire it, how to calibrate it into NVS, and why you must never use a 250 Ω shunt with a stock ESP32 ADC.

By Akshay Sarode· October 22, 2025· 9 min readesp32analog4-20maindustrial

4-20 mA into an ESP32 — the 165 Ω trick

If you Google "ESP32 4-20 mA" you will find roughly fifteen tutorials that tell you to use a 250 Ω shunt resistor "because that gives you 1-5 V". Every one of those tutorials is wrong, and following them will produce a measurement chain with 15% non-linearity in the bottom quarter of the range. Here is the correct value, why it works, and how to calibrate it.

The short answer: use a 165 Ω 1% precision metal-film resistor, not 250 Ω, not 100 Ω. 4 mA across 165 Ω gives 0.66 V. 20 mA across 165 Ω gives 3.30 V. That is exactly the maximum range of the ESP32 ADC at 11 dB attenuation — no clipping at the top, no dead zone at the bottom.

Why 250 Ω fails on an ESP32

The 250 Ω value comes from PLC-land where the ADC reference is 1-5 V or 0-10 V and 250 Ω gives you a clean 1 V at 4 mA, 5 V at 20 mA. That works fine on a Schneider Electric Modicon or an Allen-Bradley CompactLogix. It does not work on an ESP32, for two reasons:

  1. The ESP32 ADC tops out at 3.3 V. A 5 V signal will be clipped — anything above 16.5 mA on a 250 Ω shunt is invisible. You lose the top 22% of your sensor's range.

  2. The ESP32 ADC has a non-linear "dead zone" from roughly 0 V to 800 mV. Below 800 mV the ENOB drops sharply and the readings drift with temperature and supply voltage. With a 250 Ω shunt and a 4-5 mA reading, you're sitting right in that dead zone.

Both problems disappear with 165 Ω.

Why 165 Ω specifically

The arithmetic:

V_min = 4 mA  × 165 Ω = 0.660 V
V_max = 20 mA × 165 Ω = 3.300 V
ESP32 ADC max (11 dB att) = ~3.30 V

That places the entire 4-20 mA span comfortably inside the ESP32's linear ADC region (Espressif's ADC calibration guide lists the linear range as roughly 100 mV to 3.0 V at 11 dB attenuation, with usable readings out to 3.3 V if you use the calibration table). 0.66 V is well above the 100 mV non-linear floor. 3.30 V uses the full top end without clipping.

The other contender — 150 Ω — gives you 0.60 V to 3.00 V, which is fine but wastes 9% of your dynamic range at the top. The other one — 100 Ω — gives 0.40 V to 2.00 V, which puts the bottom in the dead zone and wastes 39% of the dynamic range. 165 Ω is the sweet spot.

Wiring it up

The 4-20 mA "loop" is current-driven. The sensor (a pressure transmitter, temperature transmitter, level probe, whatever) sits in series with a 24 V DC loop power supply. The current through the loop encodes the measurement. To turn that current into a voltage the ESP32 can read, you put the shunt resistor between the loop's signal-return line and ground, then take the ADC tap at the top of the shunt.

A few wiring rules that matter:

  • Tie the ESP32 GND to the loop power supply GND. The shunt resistor is referenced to that common ground. If the grounds float, you'll see noise that looks like 50 Hz mains pickup but is actually a ground loop.
  • Use a 1% metal-film resistor, not a carbon-film or 5% ceramic. A 5% tolerance gives you 8.25 Ω of error at the top of the range, which is 1% of full-scale. Metal film 1% drift over the industrial temperature range is roughly 100 ppm/°C — over a 40 °C swing, that's 0.4% drift, which is well below the inherent transmitter accuracy.
  • Don't add a series capacitor for "filtering". People do this hoping to suppress noise. It introduces a low-pass with a sloppy time constant that wrecks step-response. If you need filtering, do it in software with a 10-sample boxcar.

Calibrating into NVS

The ESP32-S3 ships with eFuse-stored ADC calibration constants. The Arduino-ESP32 core surfaces these via analogReadMilliVolts() — use that, not the raw analogRead() which gives you uncalibrated 0-4095 counts.

But factory calibration only gets you to about ±2% at any given point. For better accuracy you do a two-point field calibration with a current source (a Fluke 705 calibrator or a Hart 375 — but a $30 RD6006 bench supply with a current limit also works for a bench setup) and store the offset/gain in NVS.

#include <Preferences.h>

Preferences prefs;

struct AdcCal {
  float offset_mv;   // measured at 4 mA
  float scale;       // (20mA - 4mA) / (V_at_20mA - V_at_4mA)
};

AdcCal cal;

void loadCal() {
  prefs.begin("420ma", true);
  cal.offset_mv = prefs.getFloat("offset", 660.0);
  cal.scale     = prefs.getFloat("scale",  16.0 / (3300.0 - 660.0));
  prefs.end();
}

void saveCal(float offset_mv, float scale) {
  prefs.begin("420ma", false);
  prefs.putFloat("offset", offset_mv);
  prefs.putFloat("scale",  scale);
  prefs.end();
}

float readMilliamps(int pin) {
  float mv = analogReadMilliVolts(pin);
  return 4.0f + (mv - cal.offset_mv) * cal.scale;
}

The calibration ritual: inject 4.000 mA from your calibrator, read analogReadMilliVolts() ten times and average — that is offset_mv. Inject 20.000 mA, average again — call that mv_20. Compute scale = 16.0 / (mv_20 - offset_mv). Save to NVS. The next boot loads it.

This brings the chain to roughly ±0.3% of full-scale at the ADC, which is below the typical industrial transmitter spec of ±0.5%. You're now limited by the sensor, which is the right place to be limited.

Avoiding the dead zone

The ESP32-S3 ADC at 11 dB attenuation is non-linear below about 100 mV in the SAR core's response, and Espressif's eFuse calibration helps but doesn't fully fix it. Below 800 mV — depending on board, supply, temperature — you can see ±15 mV of repeatability error. With a 165 Ω shunt, the lowest signal voltage at the legitimate 4 mA sensor floor is 660 mV, comfortably above the danger region.

If you ever see the loop current dip below 3.5 mA you're either looking at a broken sensor (the LED on most 4-20 mA transmitters trips a fault current of ~3.6 mA when the sensor is faulted, by the NAMUR NE 43 standard) or a wiring issue. Either way that signal is data: emit a device_fault event upstream and let your alerting handle it. Don't try to "rescue" it with software interpolation.

Does it really matter?

Yes. We deploy this in real plants now via the bench kit and the pillar build tutorial. The difference between a 250 Ω chain and a 165 Ω chain on the same Endress+Hauser pressure transmitter is roughly 8% measurement error in the bottom third of the range and outright clipping in the top quarter. 165 Ω makes the chain match the sensor's stated accuracy. 250 Ω makes it look like the sensor is broken.

There's a related trick for 0-10 V signals into the ESP32 — a 1:3 voltage divider — that solves the same kind of clipping problem at the top end. Same math, different circuit.

External references