MPP-Finder mit ESP in MicroPython - Teil 2 - Strom- und Leistungssensor INA219A - AZ-Delivery

This article is also available as PDF file. It deals with the creation of a MicroPython module for the device INA219.

Figure 1: INA219 under test

Image 1: INA219 under test

The IC INA219 is a multi-talent when it comes to recording voltage, current, and power. These quantities must be recorded in order to locate the MPP, the maximum power point, i.e. the point of the greatest power of a solar panel. The INA219 works in the background and delivers the three values via the I2C bus. To control the IC, I will introduce a MicroPython module in this post and demonstrate its use with an example program. So welcome to a new episode from the series

MicroPython on the ESP32 and ESP8266

today

The ESP32 and the module INA219A

For testing the circuit of the buck converter from the last article is used. Therefore only one INA219 is added to the list of hardware. The key data from the Datasheet:

Operating voltage

3.3V to 5V

Idle current

0.7mA

Bus voltage

-26V to 26V

Current

-3.2A to +3.2A

I use the IC in form of a Break-Out-Board (aka BOB), on which all necessary further components are already present. The links to it can be found abundantly with the help of Uncle Google.

Figure 2: The INA219 module

Image 2: The module INA219

From the table, you can already see that the module works bidirectional. Positive values for current and voltage result when the positive pole of the bus voltage is connected to the Vin+ terminal. The minus pole of the bus voltage is not connected to Vin-, but to GND, like the supply voltage. The bus voltage is not the I2C bus, but the positive line of the input voltage. The circuit diagram makes this clear.

Figure 3: Circuit of the INA219

Image 3: Circuit of the INA219

The measuring principle for the current is based on the measurement of the voltage drop at the shunt resistor, which has a typical value of 100mΩ. A solar panel is drawn in here as the voltage source. The measuring resistor is therefore located in the bus line between the positive pole of the voltage source and the positive pole of the load. The current through the shunt may be up to 3.2A. The voltage drop is then 320mV, and the power consumed is approx. 1W.

The value for the bus voltage is measured at Vin- against GND. The voltage value at the voltage source is obtained by adding the bus voltage at Vin- with the voltage drop at the shunt. Both values can be queried via corresponding registers of the IC. The connection is shown in figure 4, which is taken from the Data sheet, page 10.

Figure 4: Block diagram of the INA219

Image 4: Block diagram of the INA219

Hardware


1

ESP32 Dev Kit C unsoldered

or ESP32 NodeMCU Module WLAN WiFi Development Board

or NodeMCU-ESP-32S Kit

1

0.96 inch OLED SSD1306 display I2C 128 x 64 pixel

1

INA219

2

Resistor 47 Ω

various

Jumper cable

1

MB-102 breadboard plug-in board with 830 contacts 3er

The software

For flashing and programming the ESP32:

Thonny or

µPyCraft

Used firmware for the ESP8266/ESP32:

v1.19.1 (2022-06-18) .bin

The MicroPython programs for the project:

ssd1306.py Hardware driver to the OLED display

oled.py API for the OLED display

ina219.py Driver module for the INA219

MicroPython - Language - Modules and programs

For the installation of Thonny you find here a detailed manual (english version). In it there is also a description how the Micropython firmware (state 05.02.2022) on the ESP chip. burned is burned.

MicroPython is an interpreter language. The main difference to the Arduino IDE, where you always and only flash whole programs, is that you only have to flash the MicroPython firmware once at the beginning to the ESP32, so that the controller understands MicroPython instructions. You can use Thonny, µPyCraft or esptool.py to do this. For Thonny, I have described the process here described here.

Once the firmware is flashed, you can casually talk to your controller one-on-one, test individual commands, and immediately see the response without having to compile and transfer an entire program first. In fact, that's what bothers me about the Arduino IDE. You simply save an enormous amount of time if you can do simple tests of the syntax and the hardware up to trying out and refining functions and whole program parts via the command line in advance before you knit a program out of it. For this purpose, I also like to create small test programs from time to time. As a kind of macro, they summarize recurring commands. From such program fragments sometimes whole applications are developed.

Autostart

If you want the program to start autonomously when the controller is switched on, copy the program text into a newly created blank file. Save this file as boot.py in the workspace and upload it to the ESP chip. The program will start automatically at the next reset or power-on.

Test programs

Manually, programs are started from the current editor window in the Thonny IDE via the F5 key. This is quicker than clicking on the Start button, or via the menu Run. Only the modules used in the program must be in the flash of the ESP32.

In between times Arduino IDE again?

If you want to use the controller together with the Arduino IDE again later, simply flash the program in the usual way. However, the ESP32/ESP8266 will then have forgotten that it ever spoke MicroPython. Conversely, any Espressif chip that contains a compiled program from the Arduino IDE or the AT firmware or LUA or ... can easily be flashed with the MicroPython firmware. The process is always like here described.

Signals on the I2C bus

How a transmission on the I2C bus works and what the signal sequence looks like, you can read my article mammutmatrix_2_ger.pdf to read. I use there a interesting little toolwith which you can get the I2C bus signals to your PC and analyze them.

The module ina219.py

As already mentioned, the INA219 BOB is accessed via I2C. Because I also run an OLED display on the I2C bus in addition to this module, I wrote the MicroPython module in such a way that the constructor of an INA219 object has to be passed an I2C instance to be defined in advance, which is then also used for the display. So programs that are supposed to serve both features always start as follows.

from machine import Pin,SoftI2C
i2c=SoftI2C(Pin(22),Pin(21))

In the module ina219.py I define a class INA219, which for better readability starts with the definition of various constants and variables so that the registers and bits can be addressed with names instead of numbers. At the beginning is the 7-bit base hardware address (HWADR) of the INA219, 0x40 or 64 decimal. To this address, the I2C driver routines append an eighth bit as LSBit (Least Significant Bit), 0 for write and 1 for read operations. According to the datasheet, the INA219 chip could be set to one of 15 other hardware addresses. However, the 0x40 is hardwired into the module.

class INA219:
   HWADR=const(0x40)

Of the six registers of the INA219, four are used to query shunt voltage, bus voltage, current, and power. Register 0x00 contains the configuration bits of the device. Here, mainly the operating mode, the resolution for the measurement of the bus voltage and the voltage at the shunt, as well as the factor for the measuring range of the shunt voltage measurement are set. Furthermore, one of two ranges for the bus voltage can be specified. The MSBit of the register is the reset bit, which sets the chip to the same state as after booting if it is set to 1.

    # Register
   RegConfig=const(0x00)
   RegUShunt=const(0x01)
   RegUBus=const(0x02)
   RegPower=const(0x03)
   RegCurrent=const(0x04)
   RegCalibrate=const(0x05)
   
   # RST bit
   RST=const(1<<15)

   # Umax at bus
   Ubus16V=const(0)
   Ubus32V=const(1<<13)
   ubus=["16V","32V"]

The list ubus contains the plain text names for configuration state feedback.

    # Attenuator
   PGA1=const(0) # 40mV
   PGA2=const(1) # 80mV
   PGA4=const(2) # 160mV
   PGA8=const(3) # 320mV
   PGAShift=const(11)
   PGAMask = const(3<<11)
   pga=["PGA1: 40mV","PGA2: 80mV","PGA4: 160mV","PGA8: 320mV",]

The configuration group for the attenuator consists of a series of bits that together give the values 0 to 3. PGAShift specifies by how many places this value must be shifted to the left. PGAMask is used to mask the bits in the 16-bit register value. The same is true for the resolution and the mode.

    # Resolution
   Res9=const(0)  # 9-bit @ 84µs
   Res10=const(1) # 10-bit @ 148µs
   Res11=const(2) # 11-bit @ 276µs
   Res12=const(3) # 12-bit @ 532µs
   Samp2=const(9) # 12-bit 2x @ 1060µs
   Samp4=const(10) # 12-bit 4x @ 2.13ms
   Samp8=const(11) # 12-bit 8x @ 4.26ms
   Samp16=const(12) # 12-bit 16x @ 8.51ms
   Samp32=const(13) # 12-bit 32x @ 17.02ms
   Samp64=const(14) # 12-bit 64x @ 34.05ms
   Samp128=const(15) # 12-bit 128x @ 68.10ms
   BusShift=const(7)
   ShuntShift=const(3)
   BusMask=const(15<<7)
   ShuntMask=const(15<<3)
   res={
       0 :"Res9: 9-bit @ 84µs",
       1 :"Res10: 10-bit @ 148us",
       2 :"Res11: 11-bit @ 276us",
       3 :"Res12: 12-bit @ 532us",
       9 :"Samp2: 12-bit 2x @ 1060us",
       10 :"Samp4: 12-bit 4x @ 2.13ms",
       11 :"Samp8: 12-bit 8x @ 4.26ms",
       12 :"Samp16: 12-bit 16x @ 8.51ms",
       13 :"Samp32: 12-bit 32x @ 17.02ms",
       14 :"Samp64: 12-bit 64x @ 34.05ms",
       15 :"Samp128: 12-bit 128x @ 68.10ms",
      }

The plain text messages are coded as Dictionary (Dict) at the resolution and mode because not all bit patterns occur continuously.

    
   ModePowerDown=const(0)
   ModeADCoff=const(4)
   ModeUShunt=const(5)
   ModeUBus=const(6)
   ModeBoth=const(7)
   ModeMask=const(7)
   mode={
       0:"Power down",
       4:"ADC off",
       5:"Shunt voltage",
       6:"Bus voltage",
       7:"Bus and shunt voltage",
      }
   
   CnvRdy=const(1)
   Ovfl=const(0)
In addition to the value of the bus voltage, register 0x02 contains the status bits 0 and 1. The latter is 1 when a measurement is finished, bit 0 indicates an overflow of the current measurement.

The method __init__() represents the constructor of an INA219 instance. Except for the position parameter which must be passed an I2C object as an argument, all other parameters are keyword parameters and thus optional and can also be omitted during instantiation. In this case, the respective default value is used.

    def __init__(self, 
                i2c,
                mode=ModeBoth,
                busres=Samp4,
                shuntres=Samp4,
                shuntpga=PGA1,
                ubus= Ubus16V,
                Imax=0.4,
                Rshunt=0.1):
       self.i2c=i2c
       print("Constructor of INA219")
       self.imax=Imax
       self.rshunt=Rshunt
       self.configure(mode=mode,
                      busres=busres,
                      shuntres=shuntres,
                      shuntpga=shuntpga,
                      ubus=ubus,
                      Imax=Imax,
                      Rshunt=Rshunt)

So that the configuration can be changed during the program run, there is the method configure(), which is also called by the constructor.

From the constructor, arguments are passed for all parameters. This is important so that all properties (aka properties or attributes) of the INA219 instance can be set.

On the other hand, it is annoying if you have to pass the whole list of arguments to change just one parameter. Therefore I have coded all these parameters optionally and set them with None by default. Now I can use the method configure() on it and react only to really existing parameters.

First, the existing configuration is read in and the local variable change to False is set. For each argument passed, the corresponding parameter is not Noneso the appropriate method is executed which performs the desired property change.

    def configure(self,
                 mode=None,
                 busres=None,
                 shuntres=None,
                 shuntpga=None,
                 ubus=None,
                 Imax=None,
                 Rshunt=None):
       self.readConfig()
       change=False
       if ubus is not None:
           self.setBusrange(ubus)
       if shuntpga is not None:
           self.setPGAShunt(shuntpga)
       if busres is not None:
           self.setBusResolution(busres)
       if shuntres is not None:
           self.setShuntResolution(shuntres)
       if mode is not None:
           self.setMode(mode)
       if Imax is not None:
           self.imax=Imax
           change=True
       if Rshunt is not None:
           self.rshunt=Rshunt
           change=True
       if change:
           self.currentLSB=self.imax/32768
           self.cal=int(0.04096/  \
          (self.currentLSB*self.rshunt))
           self.writeReg(RegCalibrate,self.cal)
           print("Calibration factor: {}".format(self.cal))
       print("Configuration bits:")
       self.printData(self.config)

Because both a change of Rshunt as well as Imax causes a change in the calibration factor, in addition to the property value self.rshunt or self.imax also change on True set. This leads to a recalculation of the calibration factor. For control, the configuration bits are output in the terminal.

For reading out a register the method readReg(), is passed the register number. All registers have 16-bit width. Therefore 2 bytes must be read in and then assembled to a 16-bit value.

    def readReg(self, regnum):
       buf=bytearray(2)
       buf[0]=regnum
       self.i2c.writeto(HWADR,buf[0:1])
       buf=self.i2c.readfrom(HWADR,2)
       return buf[0]<<8 | buf[1]

The communication over the I2C bus is done by variables, which fulfill the so-called buffer protocol. Therefore I use here the byte array buf to send the registered address and to receive the register value. The integer value of the register number is first written to position 0 of the byte array. Then I send the zero position of the buffer to the INA219. This sends the two bytes of the register content in the order MSB, and LSB, which I assign to the same byte array. I shift the MSB in position 0 by 8 bits to the left, which corresponds to a multiplication by 256. The lower 8 bits of this value were filled with zeros during shifting. For this, I oriere the LSB in buffer position 1 and thus get the register content that the method returns.

The counterpart to readReg() is writeReg(). The method is passed the register number and registers content. This is used to write the byte array buf is filled. The LSB of the registered content is sent last. I get it by undoing the value with 0xFF = 0b111111. I get the MSB by shifting to the right by 8-bit positions, which corresponds to a division by 256. The register number comes in buffer position 0 because it must be sent before the data.

    def writeReg(self, regnum, data):
       buf=bytearray(3)
       buf[2]=data & 0xff #
       buf[1]=data >> 8   # HIGH byte first (big endian)
       buf[0]=regnum
       self.i2c.writeto(HWADR,buf)

All other methods of the module used for communication with the INA219 readReg() and writeReg() are returned.

According to the rules of OOP (Object Orient Programming), the attributes of an object should not be accessed directly by reading or even writing from the outside. Therefore there are appropriate routines in the class INA219, which read or set attribute values. The former then looks like this.

    def readConfig(self):
       self.config = self.readReg(RegConfig)
    def getPGAShunt(self):
       self.readConfig()
       c=(self.config & PGAMask) >> PGAShift
       return c

The getXYZ() methods first read the configuration register from INA219. The value ends up in the attribute self.config. The group value is obtained by undoing the configuration with the group mask (PGAMask) and then right shifting by the position value (PGAShift). The group value is returned. It also serves as index into the list or into the dictionary with plain text messages. These are generated by the tellXYZ() methods.

    def tellPGA(self,val):
       return self.pga[val]

getXYZ()- and tellXYZ() methods are available for each of the groups PGA, bus voltage range, bus voltage resolution, shunt voltage resolution, and mode.

A summary of the whole configuration gives tellConfig() in the terminal.

    def tellConfig(self):
       print("Voltage range:",
             self.tellUbus(self.getBusrange()))
       print("PGA weakening:",
             self.tellPGA(self.getPGAShunt()))
       print("Bus resolution:",
             self.tellResolution(self.getBusResolution()))
       print("Shunt resolution:",
             self.tellResolution(self.getShuntResolution()))
       print("Mode:",self.tellMode(self.getMode()))

Any getXYZ() method also corresponds to a setXYZ() method. It checks the passed argument for validity and throws an assertion exception if an invalid value was passed.

    def setBusrange(self,data=Ubus16V):
       assert data in [0,1,16,32]
       c=self.config & (0xFFFF - Ubus32V)
       if data in [1,32]:
           c=c | Ubus32V
       self.config=c
       self.writeConfig()

In the attribute self.config first the bit for the bus voltage range must be deleted. For this I need the negated constant Ubus32V=const(1<<13). But since there is no command for this in MicroPython, I have to reach into the bag of tricks.

 Ubus32V = 0b00100000000000

 ~Ubus32V = 0b1101111111111111


 0xFFFF = 0b11111111111111

 - Ubus32V = 0b00100000000000

= ~Ubus32V = 0b1101111111111111


If 1 or Ubus32V was passed as an argument, the range bit is set by oring with Ubus32V, the attribute self.config and sent to the INA219.

    def writeConfig(self):
       self.writeReg(RegConfig,self.config)

The other setXYZ() methods work similarly. However, several bits must be set there and shifted to the correct position. In the case of a continuous range, the valid values can be determined with range(). I check gaping ranges with the help of a list.

    def setPGAShunt(self,data=PGA1):
       assert data in range(0,4)
       self.readConfig()
       c=self.config & (0xFFFF - PGAMask)
       c=c | (data << PGAShift)
       self.config=c
       self.writeConfig()
    def setBusResolution(self,data=Samp4):
       assert data in [0,1,2,3,9,10,11,12,13,14,15]
       self.readConfig()
       c=self.config & (0xFFFF - BusMask)
       c=c | (data << BusShift)
       self.config=c
       self.writeConfig()
The resetting of the group bits happens again by difference formation. The resulting value is ored with the configuration bits after they have been shifted to the correct position.

So far it was about the configuration of the INA219. Now we want to read and process the measured values. I start with the voltage drop across the measuring resistor of 100mΩ.

    def getShuntVoltage(self):
       raw=self.readReg(RegUShunt)
       if raw & 1<<15:
           raw = -(65536 - raw)
       return raw / 100 # mV

I get the value of register 0x01, which contains the raw value of the shunt voltage in the two's complement representation. If bit 15 is set, then it is a negative value that requires special handling. In this case, the read value is subtracted from 0xFFFF and 1 is added

0xFFFF+1 = 0x10000 = 65536

The preceding minus sign makes it the correct negative voltage value. Its LSBit corresponds to 10µV according to the datasheet. If I divide the value by 100 before returning it, I get the shunt voltage in millivolts.

    def getBusVoltage(self): 
       return (self.readReg(RegUBus) >>3) * 4 / 1000# V

From the content of register 0x02 we need bits 3 to 15, which must first be shifted 3-bit positions to the right. The LSBit now has a value of 4mV. The return value of the method with the division by 1000 is the bus voltage in volts.

The value for the current is only useful if there was no overflow error during the measurement and calculation in the INA219. Therefore, I first read the bus voltage register and isolate bit 0, the overflow flag Ovfl. If it is set, the current value has no meaning, and an overflow exception is thrown, which should be caught by the calling program. If Ovfl = 0, then the contents of the current registration must be multiplied by the LSB for the current. The value for the LSB was set in the method configure() method. The formula for this can be found in the datasheet:

currentLSB = maximum to expected current / 32768

This value is also used for another quantity, the calibration factor calis required. A formula for this can also be found in the datasheet (page 12).

Cal = 0,04096 / (currentLSB * rshunt)

For fine tuning, the current is now measured with an ammeter (A_meter value, for example 91.7 mA) and at the same time the method getCurrent() (INA219_value for example 90.69824 mA) is called.

            self.currentLSB=self.imax/32768
           self.cal=int(0.04096/  \
                    (self.currentLSB*self.rshunt)
           self.writeReg(RegCalibrate,self.cal)

The calibration factor cal is corrected with the help of these values and entered in the method configure() as follows.

Cal = 0,04096 / (currentLSB * rshunt) * A_Meter value / INA219_value
            self.currentLSB=self.imax/32768
           self.cal=int(0.04096/  \
                    (self.currentLSB*self.rshunt)*\
                        91.7/90.69824)
           self.writeReg(RegCalibrate,self.cal)

Now, for further measurements, the A_meter value should match the INA219_value.

Now only the determination of the power is missing. Here comes the method getPower(), which closes this gap.

    def getPower(self):
       if not (self.readReg(RegUBus) & (1<<Ovfl)):
           return self.readReg(RegPower)*20*self.currentLSB
       else:
           raise OverflowError   

Also in this case the overflow flag is checked. If it is not set, the value for the power can be calculated. The content of the power register 0x03 is set to 20 times the value of the currentLSBs, that's what the datasheet wants.

As Giovanni Trapattoni said in 1998? "I'm done". You now know the module ina219.py, and it's time to run a test with it. The program ina219_test.py will query the results of the four prominent quantities shunt voltage, bus voltage, current, and power, and show them in the OLED display. In the beginning, the configuration is displayed in plain text in the terminal. Of course, we need a test circuit for this. Here is the circuit diagram.

Figure 5: INA219 - Test circuit

Image 5: INA219 - Test circuit

And this is how the implementation looks on the test board.

Figure 6: INA219 under test

Image 6: INA219 under test

Now to the test program. It is very straightforward, so there is no need to tell much about it. With the flash key on the ESP32 board, you can terminate the program, which runs in an endless loop. The key closes to GND, so it is polled to 0. To measure at different current levels, the two 47Ohm resistors can be added to the circuit individually, in series, and in parallel. Of course, you can also use the circuit of the buck converter from the previous blog sequence to measure at different voltages, for example.

# ina219_test.py
#
from machine import SoftI2C,Pin
from time import sleep
from oled import OLED
from ina219 import INA219
from sys import exit

i2c=SoftI2C(Pin(22),Pin(21))

d=OLED(i2c)
d.clearAll()
d.setContrast(255)
d.writeAt("INA219-TEST",2,0)

shunt = 0.1  # Ohm
imax=0.8 # ampere

ina=INA219(i2c,mode=INA219.ModeBoth,
          busres=INA219.Samp4,
          shuntres=INA219.Samp4,
          shuntpga=INA219.PGA2,
          ubus= INA219.Ubus16V,
          Imax=imax,
          Rshunt=shunt)

taste=Pin(0,Pin.IN,Pin.PULL_UP)
sleep(0.1)
ina.tellConfig()

while 1:
   shuntVoltage=ina.getShuntVoltage()
   busVoltage=ina.getBusVoltage()
   current=ina.getCurrent()
   power=ina.getPower()
   d.writeAt("Shunt: {:.1f} mV ".format(shuntVoltage),0,1)
   d.writeAt("Bus : {:.2f} V ".format(busVoltage),0,2)
   d.writeAt("Current: {:.3f} A ".format(current),0,3)
   d.writeAt("Power: {:.3f} W ".format(power),0,4)
   sleep(1)
   if button.value()==0:
       d.writeAt("PROG CANCELLED",1,5)
       exit()

Outlook

With the step-down converter controlled by the ESP32 from the last post and the use of an INA219 as a measurement servant, we are now able to automatically capture the power characteristic of an electrical energy source. In the next installment, we'll do this for a solar cell. You will then be able to take a close look at the parallel and series connections of several solar panels and examine them at different irradiance levels. See you then!

DisplaysEsp-32Projekte für anfängerSensoren

1 comment

Ulli

Ulli

Der Artikel ist seeeehhhrrr gut!!!!!
Weiter so
73
DC8SE

Leave a comment

All comments are moderated before being published

Recommended blog posts

  1. ESP32 jetzt über den Boardverwalter installieren - AZ-Delivery
  2. Internet-Radio mit dem ESP32 - UPDATE - AZ-Delivery
  3. Arduino IDE - Programmieren für Einsteiger - Teil 1 - AZ-Delivery
  4. ESP32 - das Multitalent - AZ-Delivery