Today we will explore building a voltage and current monitor with an MCU. This project will be using a WiFi-capable MCU, the Seeed Studio ESP32C3, but you could just as easily use an Arduino Uno R3 with an LCD display, or a Raspberry Pi Pico 2 W with a touchscreen, etc.
Let’s start with the problem we’re trying to solve: we want to be able to measure the current flowing between two points, and the voltage at one of those points. It’s reasonable to assume that we want to do something with that information; log it in a file, send it to some MQTT server, or over BlueTooth, or display it on an LCD, and so on.
Measuring voltage is easy enough, but to measure current we will need to “inject” ourselves into the current flow somewhere. From our perspective, where current comes from is the “source” and where it goes is the “sink”. We will sit in the middle and watch the current go by.
It’s worth noting that this writeup exists because I already had something to do this, but I fried it. I had INA219 I purchased from Adafruit that I damaged accidentally. Pro Tip: Do not reverse the polarity of the battery you are using. :-(
Design criteria - an itemized description of the goals of our project - is a useful way to start. It sets the boundaries and guides the development process. The design criteria for this project is:
Now that the design criteria has been set, we can discuss various high-level approaches, and evaluate them to see how they stack up against these design criteria.
At a high level, measuring voltage is relatively easy. While there are one or two problems here, for now let’s assume we can solve them easily enough and instead we’ll focus on the more interesting problem: measuring current.
Measuring current can be done in several ways. Effectively, all of them work by measuring the “voltage drop” or voltage difference across a so-called “sense resistor”. However, the approaches all have pros and cons.
Before we dive in to the pros and cons of each of these approaches, it’s worth discussing sense resistors.
A sense resistor is differentiated from other resistors by purpose, and often by the low resistance used. When a current passes over a resistor, it causes a voltage drop that is proportional to the current and the value of the resistor. We use Ohm’s Law to calculate the current. Ohm’s Law states V = IR where V is the Voltage (in Volts), I is the current (in Amps), and R is the resistance (in Ohms). The voltage in this situation is referred to as “voltage drop”. What it means is that some of the energy was consumed by the resistor (and turned into heat) and as a consequence there is less voltage on one side of the resistor than on the other.
As an example, let’s assume our “source” has 15V and that current flows through a 1.5 Ohm resistor. There is 2.5A of current. We can calculate the voltage drop in this situation as V = 2.5A*1.5Ohm = 3.75V. That does not mean that the either side of the sense resistor would be at 3.75V, it means that the “sink” side of the resistor would be 3.75V less than the source side, or 15V − 3.75V = 11.25V ! The 3.75V was “dropped”.
How is that helpful, though? Aren’t we looking to calculate current? It’s helpful because we can re-arrange the equation to calculate current from Voltage (voltage drop) and Resistance ($I = :raw-latex:`\frac{V}{R}`). We measure the voltage at the “source” (in the example, 15V) and at the other end of the sense resistor, at the input to the “sink” (11.25V from the example), and we calculate the difference (3.75V). We can re-arrange the equation above as I = (V)/(R) and plug those values in to get 2.5 Amps.
That voltage drop comes with a price: heat. The unit used here is is Watts, and the equation we will use is Watts = I2*R. From the prior example, (2.5Amps)2*1.5Ohm = 9.375Watts. Ouch! Unless your resistor is a so-called “power” resistor, that is more than enough heat to “Let the magic smoke out”. So we are therefore encouraged to use a low value resistor, but as it is with all things, not too low (or the voltage drop won’t be easily readable, and the results will suffer).
There is another wrinkle, however: let’s say we wanted to measure 100mA of current, but our input is 20V. Let’s say our sense resistor is 50 mOhm. What is the “sink” side voltage? It will be the “source” side minus the voltage drop, or 20V − 0.1A*0.05Ohm = 19.995V, or 5mV. That doesn’t sound like very much. What’s more, some devices can’t measure 20V directly, so we use a voltage divider (more on that later) to “divide” the voltage; let’s say we divide it by 4 to get 5V. Now it’s 5V − 0.00125V = 4.99875. Ouch! I doubt my multimeter could measure with that level of precision.
So use a larger sense resistor you say? Well, now we are back to heat. Sometimes a balance can be struck. Large enough to measure, but not so large as to turn into a space heater (and cause excessive voltage drop).
We have enough of a foundation to start evaluation solutions.
The built-in ADC is a sensible thing to use under certain conditions: the input voltage range is fairly well-constrained and precision isn’t a priority. In particular, many built-in ADCs are known to have noise or “non-linearity” (meaning: they behave differently at different points in their range) issues.
The ADC in the ESP32C3 is capable of 12 bits, but if using the analogRead function in the Arduino ecosystem, there are …. challenges. I have fought mightily with ESP ADCs and I have not won many battles. There are some workarounds that help, but let’s start with a simpler example: An Arduino Uno. It has a 10-bit ADC and despite the low precision it’s relatively well behaved.
The Uno has a scale of 0 to 5V and as noted it has a 10-bit ADC. That means it divides the 5V range up into 1024 (0 through 1023 inclusive) “steps”. If we divide 5V (expressed as mV) by 1024, we get about 4.9mV. This means that our best case scenario is 5mV, and in practice it is probably twice that.
However, let’s assume a resolution of about 5mV. From our Design Criteria of 20V max and 20mV min resolution we should be able to calculate the size of sense resistor. First, we have to “divide” the 20V down to 5V (or less, but we should try to stay as close to 5V as possible). More on voltage dividers later, but we would have to do that twice - once to sense the voltage at the source and once again at the sink, and then subtract the difference.
If we’ve divided by 4 and then we have a resolution of 5mV, that means that we actually have a resolution of 20mV before we divide. That is right on the threshold of our design criteria, but let’s allow it for now.
Our design criteria also specifies a current resolution of 10mA. What size resistor would we need to detect a change of 10mA when we have a resolution of 5mV? Reminder that the 5mV is after the voltage divider, so it’s really 20mV. We can use Ohm’s Law to calculate the minimum sense resistor needed: 0.02V = R*0.01A and solving for R gives us 2 Ohms! A 2 Ohm resistor at 3A would generate 32*2 = 18W) 18W! You’d need a pretty large heatsink just for the sense resistor, much less the voltage drop (3A*2Ohm = 6V), rendering the approach all but unworkable.
One might assume that the biggest problem we experienced was a lack of resolution. A 10-bit ADC is just not enough. Well, how much would be enough?
We can take what we learned before and apply it to this new problem. Let’s start with a 12-bit ADC. A 12-bit ADC (assuming 5V range for this example) has a resolution of (5000mV)/(212) = 1.22mV Using the same equation as above, 0.00122*4 = R*0.01A and solving for R gives us 0.488Ohms. That is the minimum resistor that we could use. Let’s use 0.5 Ohms (a convenient size) and see how much heat it might generate: 32A*0.5Ohm = 4.5W. Ouch. This is possible, with a so-called “power” resistor, or a bunch of smaller resistors in parallel, but it’s not great. What about the voltage drop at max current? 3A*0.5Ohm = 1.5V. Not… ideal.
OK, what about 16-bits? There are 16-bit ADCs. They aren’t inexpensive, but let’s run the math anyway. The resolution is (5000mV)/(216) = 0.076mV And using the same equation again we get a minimum resistor size of 0.076÷1000*4 = R*0.01A = 0.0304 Ohms, or 3 mOhm. How can that be? Let’s double-check our work. 20mA across 0.0304 Ohms = 0.000608V (.608mV), and .608mV divided by 4 = .152mV
OK, so an external ADC that has enough precision would work, such as the 4-channel 16-bit ADS1115 from Adafruit, however, I don’t have one of those (yet?)
This project exists because I fried an INA219 I purchased from Adafruit by effectively giving it negative voltage. The I2C portion still worked, but it no longer sensed voltage and the current sensing was completely broken.
However, the INA219 is quite nice to use, and I’ve ordered some more to replace the one I fried. Using these is really easy, and avoids the need for voltage dividers or the use of an ADC at all; the communication is over I2C. I found the results to be excellent.
However, I had also previously purchased a few INA180A1 from DigiKey on impulse. It is SOT-23 form, which is not particularly fun to solder, and was $0.44 at the time of purchase. The INA180 is a low- and high-side current sense amplifier that provides an output voltage. The ratio of the part that I purchased (the INA180A1) is 20V/V. What that means is that it amplifies the difference between two inputs in Volts by 20 (up to the voltage rail). Of particular note is that the input voltages can go up to 26V, even though the IC itself only requires 2.7-5.5V! No need to use an voltage divider! It’s built in, and very precise.
I painstakingly soldered the IC to a breakout board and tested it with a single 150mOhm resistor. I worked up a quick test using an external power supply and it worked great. With 10V input and a single 150mOhm resistor as the sense resistor, I ran a small sequence of tests to verify function, and the results are in the table below.
To convert from the Output to an “Error”, we first divide the output (in Volts) by 20 (which is what the INA180A1 uses) to get a “voltage drop”, and then divide that by the resistor size used (150 mOhm) to get a current. We then divide the current we get from the current we put in, divide the former by the latter, and convert to a percent.
| Current | Output | Calculated Current | Error |
|---|---|---|---|
| 1000mA | 2.995V | 0.9983A | 0.16% |
| 100mA | 301.8mV | 100.6mA | -0.6% |
| 25mA | 76.9mV | 25.6mA | -2.4% |
As far as accuracy goes, that’s well more than good enough.
This approach still requires the use of an ADC. We will discuss two scenarios here, the first is an Arduino Uno (10-bit ADC, 5V range) and the second is the ESP32C3 (12-bit ADC, but 2.5V range).
The INA180 multiplies the difference by a fixed amount (20 in this case), so we have to first determine if we need a voltage divider, and if so how large.
Let’s start with the Uno. The Uno’s ADC range is 0-5V, which means after dividing by 20 (5000mV÷20 = 250mV) we get 250mV as a maximum differential that the INA180A1 can measure. At ~3A (max current), what size resistor produces 250mV? Ohm’s Law to the rescue again: 0.25V = 3A*R. Solving for R gives us 0.083 Ohms, or 83mOhm. That’s… actually pretty handy because I happen to have a set of 150 mOhm (milli-ohm) Yageo KNP100JR-73-0R15 from DigiKey, and they are rated at 1 Watt. Two of these in parallel is 75mOhm (less than the minimum!), and as far as power goes: 3A2*0.075Ohm = 675mW. That 675mW is divided between two 1W resistors, so it’s well within a sensible range. What about voltage drop? 3A*0.075Ohm = 0.225V. Not terrible. And, again, a reminder that 3A is our maximum.
However, we’re not done evaluating! We still have to see how this might work with the ESP32C3’s ADC. The ESP32C3’s ADC normally operates from 0 to 3.3V, but in reality it’s 0 to 1.1V and it goes through attenuation. What’s more, it’s not particularly linear above about 2.5V. Despite being a 12-bit ADC, it’s more like an 11 bit ADC when the reduced range is accounted for. For now, let’s assume 11 bits and a range of 0-2.5V ; 1/2 of the Uno’s range.
We could use a 2:1 voltage divider and not actually lose much in the way of accuracy or precision. Without experimenting to see precisely how the ESP32C3’s ADC behaves (I have), it can be difficult to quantify. But the reduced range (no more than 3.3V in theory, no more than 2.5V in practice) means we lose at least 1 bit of advantage that we gained going from 10 bit to 12 bit.
An even better approach might be to use four of the 150mOhm resistors in parallel, giving us 150mOhm÷4 = 37.5mOhm. 37.5 mOhm at maximum current (3A) drops (3A*37.5mOhm = 112.5mV) 112.5mV, and 112.5mV multiplied by 20 is 2.25V. This is just under our 2.5V range. What’s more, heat is a non-issue and it drops less voltage. What about the lower-bound for current, though? Remember, our design criteria is for 20mA. So 20mA across 37.5 mOhms = 0.75mV, and 0.75mV multiplied by 20 is 15mV. 15mV is above our minimum resolution, so it’s just fine.
Let’s say that we had an MCU with a full 3.3V ADC range. Would three of those 150mOhm resistors work? Let’s assume a worst-case of a 10-bit ADC, so our resolution is about 3.2mV. 20mA across 50mOhms = 1.0mV, multiplied by 20 is 20mV. The ratio of current to output voltage is 1:1. That’s especially convenient. It means a 3.3V ADC has a range of 3.3A. Voltage drop at 3A would be 3A*0.05Ohm = 150mV, which is not terrible, either.
The topic of MCUs and ADCs is an immensely deep and complex one, and we’ve only barely scratched the surface here.
Summary: Using a differential amplifer (like the INA180) and some sense resistors can work. For a 5V device like an Uno, 2x 150 mOhm resistors in parallel works out about right, but the cost is higher voltage drop (about 225mV at max current of 3A). For a 3.3V device like an ESP32C3, or for which the ADC voltage range is reduced (like the ESP32C3), then 4x 150 mOhm resistors would work, dropping less voltage (half as much; 112.5mV).
One approach might be to combine approaches: a specialized differential amplifer together with an external, high precision ADC. For example, an INA180 feeding an Adafruit ADS1105 external 12-bit ADC (a 12-bit ADC with full 5V range and excellent linearity), which then communicates with any MCU over I2C, avoids a bunch of issues. It is more components, and the total price is no better than (and likely worse than) the next solution.
The ideal current sensor would be completely non-intrusive. That’s why those fancy magnetic field sensing units are so nice. However, they are not cheap and are primarily oriented around larger currents. One example is the ACS724, available in breakout board form from Pololu for about $10. Pololu has many examples that handle quite a bit more current. The listed example has a range of 0-5A, and produces 800mV/A (with 5V VCC), or 0.8mV/mA.
If we assume the Uno, again, we have access to a 5V range. 5V range at 800mV/A gives us 5000mV÷800mV = 6.25A. For the ESP32C3 it would be half of that, or 6.25A÷2 = 3.125A, still within specifications.
In terms of heat and voltage loss, the (approximately) 1.2mOhm resistor used in the ACS724 breakout board listed above would produce 32*1.2 ⁄ 1000 = 0.0108W of power and 3A*1.2mOhm = 3.6mV of voltage drop (at 3A). That’s so low as to be meaningless in this context.
What about the resolution, though? We specified 20mA min resolution. At 0.8mV/mA (from the datasheet), the limiting factor is the Uno’s ADC - by far (more than 5x). Even the ESP32C3’s ADC would probably be fine, because it’s effective resolution is only about twice that of the Uno’s which still means it’s the limiting factor.
However, the ACS724 is $10 and still requires an ADC.
A summary of the solutions presented above is warranted.
Using a built-in ADC is basically a non-starter because the minimum resistor needed would be so large as to be impractical.
Using an external ADC is also largely a non-starter. We would require a fairly precise external ADC (probably 14-bit) to be useful for the same reasons as above.
We did not discuss building our own differential amplifier, because that requires even more sophistication than this writeup warrants.
Using a specialized IC (non-magnetic) such as an INA180 is workable. It has some downsides:
but it’s also inexpensive and easy to use.
However, four 150 mOhm resistors results in a resolution of 0.75mV/mA.
Finally, using a specialized IC (magnetic) such as the ACS724 from Pololu for about $10 seems to be among the easiest to use; it has a resolution of 0.8mV/mA, slightly worse than the four-resistor + INA180 solution above. However, it’s voltage drop is insanely low.
We deferred the “sensing voltage” part of the problem until the end. By far, current sensing is a more difficult problem to evaluate a design for.
Still, that doesn’t mean that sensing voltage is zero steps. We have to account for the fact that there is a mismatch between our input voltage range (0-20V) and the ADC voltage range (from our examples, 0-5V for an Uno, 0-2.5V for the ESP32C3). This requires the use of a voltage divider (aka “potential divider”).
As noted earlier, we cannot connect any of our ADCs directly to the voltages we wish to measure because those voltages may exceed the design specifications for the ADC. To solve this problem we will use a potential divider. A potential divider works by “dividing” the voltage. If you think about it, it’s just Ohm’s law again. There are far better writeups on how a potential divider (also known as a “voltage divider”, among other names) works, so I won’t go into too much detail here, however, basically we have:
Between R1 and R2, where they connect, is where we will sense the voltage.
Potential dividers use resistors, however we shouldn’t use resistors that are too large and we shouldn’t use resistors that are too small. If we use resistors that are too big, we might starve the ADC from enough current to do it’s job (and really large resistors are more significantly impacted by other issues, such as noise). If we use resistors that are too small, we’ll simply waste current and generate heat, and that is inefficient (and heat causes resistors to get more resister-y, which is an entirely separate topic).
So what should we do? The “too small” resistor problem is easier to solve ironically because it’s largely subjective. We can perform some very quick back-of-the-napkin math to get “close” and then zero in from there.
The quick-n-dirty way is to assert that more than a few mA (say, 10mA) is “too much”. Since we know the current and voltage ranges, we can re-arrange Ohm’s law to get R = (V)/(I), and thus calculate a rough lower bound for the size of the resistor. Using 10mA and 20V, we get: R = (20V)/(0.01A) = 2000Ohm, or 2K.
Cool. What about the upper bound? Well, as noted earlier, we can’t go too large or we’ll starve the ADC. How do we determine an appropriate size? The ADC does consume a small bit of current. How much? Well, it varies based on the configuration. This is expressed as impedance. We could try to figure out what the ADC we’re using consumes for current (impedence, in Ohms) from the datasheet. Ideally, the amount of current available to the ADC should be at least 100 times larger than the amount of current it consumes, otherwise we run the risk of the ADC drawing so much current that it pulls down the voltage, causing errors. As an example, let’s see what the datasheet for the ADS1015 says:
| Full-Scale Resolution | Input Impedence in Ω |
|---|---|
| FSR = ±6.144V | 22M |
| FSR = ±4.096V | 15M |
| FSR = ±2.048V | 4.9M |
| FSR = ±1.024V | 2.4M |
| FSR = ±0.512V, ±0.256V | 710k |
Cool. At any of the voltages we’re likely to be using this (anything above about 0.5V) we’re above 2.4M Ohm impedance. 1/100th of 2.4M Ohm is 24K. So now we have a range: no smaller than 2K, no larger than 24K.
The final stage of selecting resistors for the potential dividers consists of selecting resistors that result in a nice dovetail of the maximum voltages we expect to see into the maximum input voltages of our device. In other words, we wouldn’t want to arrange the voltage divider to handle 150 Volts when our maximum input is 20V (or vise versa). There is not an easy way to optimize this process.
According to the datasheet for the ADS1015, the ADS1015 can only handle inputs up to VDD + 0.3V max input. Let’s assume the VDD will be very close to 5V. A reminder of the formula for a potential divider:
where R1 is the “upper” resistor and R2 is the “lower” resistor.
If we assume 20V as Vin and 5V as Vout and we re-arrange the equation to solve for R2, we get R2 = (Vout*R1)/(Vin − Vout). With our values we get: R2 = (5V*R1)/(20V − 5V) or R2 = (R1)/(3). That’s pretty useful! R2 is ideally 1/3 the size of R1 (and R1 is 3x the size of R2).
The process I used is probably not terribly efficient, but it didn’t take long. I looked at the resistors I have in the 2K to 20K range and since we are looking for a second resistor that is 1/3 in size, I simply did some quick math to determine if I also had a resistor of approximately that size. For example, starting at 20K, do I have a resistor “a bit under” 7K? Yes. 6.8K would do nicely. The next size smaller I have from 20K is 10K, and do I have a resistor that is approximately 1/3 of 10K? Yes I do! 3.3K is another great choice. I could continue, but I stopped here.
Let’s compare and contrast these choices.
I’m not aware of criteria that might be used to be more selective about which choice is better, however, I like the color bands on 6.8K resistors so I’ll use that combo.
It’s worth noting that we don’t want to hyper fixate here: it doesn’t really matter all that much, and hyper fixating on this isn’t likely to be beneficial. We’ll simply plug into the software what the values are and move on with life. We don’t even have to worry too much about the precise range - if we found a combination that we liked that resulted in an input range of 0-19V instead of 0-20V, if that is an acceptable compromise to make, then do it.
We have learned about a number of different approaches to measuring current, sense resistors, voltage dividers, and more. Primarily due to parts-on-hand, I will prototype with an INA180A1 and - because the WiFi-capable devices I have all have built-in ADCs that have ranges between 2.5V and 3.3V, three or four 150 mOhm sense resistors.
However, given the issues with the built-in ADCs, I may go on a sidequest to characterize those ADCs. Stay tuned!
That said, here are the parts required:
The software will basically use the built-in ADC, twice, to read two different channels:
Software will be used to calculate the appropriate voltage and current and then send it to an MQTT broker, take a 0.3s nap, and repeat. Forever. More fanciness to the software is an exercise for the reader.
Roughly, I expect to do something like this (this is sort-of pseudo code, untested or checked for syntax errors, etc.):
// for the voltage sensing voltage divider const uint16_t voltage_divider_r1 = 20000; const uint16_t voltage_divider_r2 = 6800; const float voltage_divider_undo_ratio = (voltage_divider_r2 + voltage_divider_r1) / voltage_divider_r2; const uint8_t voltage_channel = A0; const uint16_t adc_bitsize = 4096; // if ESP32C3 const uint8_t current_channel = A1; uint16_t readVoltage() { uint16_t ret = analogRead(voltage_channel) * voltage_divider_undo_ratio / adc_bitsize; return ret; } // since this goes through (4) 150mOhm resistors, and the INA180 // has a multiplier of 20, we see a ratio of 0.75mV/mA. // Thus, to convert from mV to mA, multiply by 1/0.75, or by 4 and then divide by 3 uint16_t readCurrent() { uint16_t ret = analogRead(voltage_channel) * 4 / (3 * adc_bitsize); return ret; }