Close

Firmware 1: how to stop worrying and reinvent the wheel

A project log for IuT voltmeter for a breadboard

Measure several voltages on a breadboard and display results on a smartphone for less than $10 for parts

alexandernAlexanderN 06/04/2017 at 20:070 Comments

I was not afraid of writing driver code for the ADC module, and I did. When I later built the NodeMCU firmware, I found that this driver was already written and available for inclusion. Nevertheless using such a driver would be difficult without reviwing the ADC datasheet, and I did not worry too much for reinvenbting the wheel. Hence the title of this log.

Note 1 : why NodeMCU firmware (Lua interpreting language) was selected for this development

The ADS1x15 driver was developed on top of the i2c module available from the NodeMCU firmware build.

The code checks the presence of the i2c module in the build first

-- check presence of the i2c driver
if require('i2c') then
    print("i2c driver present; execution continue")
    -- otherwise the LUA interpreter flags an error
end

After that some variables are initialised with particular values to refer to these values by name (interpreting languages do not support C-like #define)

-- DEFINE section - constants to use
-- NodeMCU specific
id_i2c = 0  -- not to get confused with compulsory 0 in the code 
-- WEMOS mini specific
sda = 2 -- SDA line on the i2c WEMOS mini shields
scl = 1 -- SCL line on the i2c WEMOS mini shields
-- ADS breakout board default
ads1x15=0x48 -- connection on the breakout board
-- ADS operating ranges (datasheet table 8 p. 26)
ranges = {6.144,4.096,2.048,1.024,0.512,0.256}

Three high level functions operate the ADC as specified in its datasheet; these must be presented to the Lua interpreter before the first call

-- THREE ADS DRIVE FUNCTIONS
-- ADS driver function - start a singe conversion
-- ch 0..3 (MUX), ran 0..5 (PGA)
-- datasheet ADS1x15 p.26, table 8
function ads1x15_single_start(id_i2c,dev_addr,ch,ran)
    i2c.start(id_i2c)
    tmp=i2c.address(id_i2c, dev_addr, i2c.TRANSMITTER)
--    
    i2c.write(id_i2c, 0x1) -- select the CONFIG reg then write to it
--
    -- 1xxx xxxx  start a single conversion = 8 (0 for continuous)
    -- x1xx xxxx  single ended mode = 4
    -- xx?? xxxx  channel from the user
    -- xxxx ???x  measurement range
    -- xxxx xxx1  single shot mode
    i2c.write(id_i2c, (8+4+ch)*16+(ran*2+1) ) -- MSB
--    
    -- 000x xxxx  lowest data rate (applicable for continuous mode)   
    -- xxx0 xxxx  comparator mode - traditional (default)
    -- xxxx 0xxx  comparator polarity - active low (default)
    -- xxxx x0xx  non-latching comparator action (default)
    -- xxxx xx11  keep RDY pin in high-impedance state 
    i2c.write(id_i2c, 0x03 ) -- LSB
 --        
    i2c.stop(id_i2c)
--       
    return tmp -- the device should acknowledge its address
end
-- ADS driver function - check that singe conversion is completed
function ads1x15_not_ready(id,dev_addr)
    i2c.start(id_i2c)
    tmp=i2c.address(id_i2c, dev_addr, i2c.TRANSMITTER)
    i2c.write(id_i2c, 0x1) -- select the CONFIG reg
    i2c.stop(id_i2c)
-- read it
    i2c.start(id_i2c)
    tmp = i2c.address(id_i2c, dev_addr, i2c.RECEIVER)
    tmp = i2c.read(id_i2c,2)  
--    
    i2c.stop(id_i2c)
    if (string.byte(tmp,1)<128) then 
        return true
    else
        return false
    end
end
-- ADS driver function - read the last conversion's result
function ads1x15_read(id,dev_addr)
    i2c.start(id_i2c)
-- select the CONVERSION register
    tmp=i2c.address(id_i2c, dev_addr, i2c.TRANSMITTER)
    i2c.write(id_i2c, 0x0) -- pointer to the CONFIG reg
    i2c.stop(id_i2c)
-- read it
    i2c.start(id_i2c)
    tmp = i2c.address(id_i2c, dev_addr, i2c.RECEIVER)
    tmp = i2c.read(id_i2c,2)  
--    
    i2c.stop(id_i2c)
    return tmp -- string of two bytes is returned
end
-- ADS driver function - init the device
function ads1x15_reset(id,dev_addr)
    i2c.start(id_i2c)
-- select the CONVERSION register
    tmp=i2c.address(id_i2c, 0, i2c.TRANSMITTER)
    i2c.write(id_i2c, 0x06) -- code for power down mode p.21
    i2c.stop(id_i2c)
--
    return tmp -- string of two bytes is returned
end

Next comes the code to initialise the driver and the ADC ( what is called setup() in Arduino programming )

-- setup and check the soft i2c peripheral
i2c.setup(id_i2c, sda, scl, i2c.SLOW)
print("SDA = ",gpio.read(sda)) -- check it is 1 (line pulled up whilst idle)
print("SCL = ",gpio.read(scl)) -- check it is 1 (line pulled up whilst idle)
print("ADS reset = ",ads1x15_reset(id_i2c,ads1x15))
-- test case
ran=1
ch=1
-- when the code executes, change at will in the terminal

Finally, the following code, when presented to the interpreter, causes voltage measurement on a single defined CH pin using set RAN every 3 seconds with proper ADC readiness and presence checks. In addition, it measures the conversion time, prints raw bytes received from the ADC, and handles the issue of negative measured voltages (MSB is set in the returned data; NodeMCU/Lua seems not to support signed 16-bit integers).

-- measure each 3 seconds
tmr.alarm(0,1000,tmr.ALARM_AUTO,
    function ()
        if ads1x15_not_ready(id_i2c,ads1x15) then
            print("ADS1x15 has not finished a conversion")
            return
        end
        tmp=ads1x15_single_start(id_i2c,ads1x15,ch,ran)
        if tmp then
            print("")
        else
            print("ADS1x15 does not respond")
            return
        end
        t1=tmr.now()
        while ads1x15_not_ready(id_i2c,ads1x15) do
            tmr.delay(1000)
        end
        print("Convertion took > ",tmr.now()-t1," us")
        rez=ads1x15_read(id_i2c,ads1x15)
        print("Raw bytes = ",string.byte(rez,1,2))
        if string.byte(rez,1)<128 then
            print("Reading, V = ",(string.byte(rez,1)*256+string.byte(tmp,2))*ranges[ran+1]/32768)
        else
            print("Reading, V = ",((string.byte(rez,1)-255)*256+string.byte(tmp,2)-255)*ranges[ran+1]/32768)
        end
     end  
)

When the code is running, one can change CH and RAN from the console, getting instant measuremnent results without the need to recompile the whole code. The code starts ADC measurement and checks whether it is completed every 1 ms; the typical value was found around 140 ms which was commensurate with the slowest sampling rate of the ADC of 8 samples per second.

Using the built in ADS1015 module makes the code more compact (but some of the constants may still look cryptic)

-- check presence of the ads1115 driver
if require('ads1115') then
    print("ads1115 driver present; execution continue")
    -- otherwise the LUA interpreter flags an error
end  

-- DEFINE section - constants to use
-- NodeMCU specific
id_i2c = 0  -- not to get confused with compulsory 0 in the code 
-- WEMOS mini specific
sda = 2 -- SDA line on the i2c WEMOS mini shields
scl = 1 -- SCL line on the i2c WEMOS mini shields
-- ALERT pin of the ADC is not used at present
-- module's constants were put into arrays for ease of alterations
ranges={ 
         ads1115.GAIN_6_144V, -- 2/3x Gain
         ads1115.GAIN_4_096V, -- 1x   Gain
         ads1115.GAIN_2_048V, -- 2x   Gain
         ads1115.GAIN_1_024V, -- 4x   Gain
         ads1115.GAIN_0_512V, -- 8x   Gain
         ads1115.GAIN_0_256V  -- 16x  Gain
}
channels={
        ads1115.SINGLE_0, -- channel 0 to GND
        ads1115.SINGLE_1, -- channel 1 to GND
        ads1115.SINGLE_2, -- channel 2 to GND
        ads1115.SINGLE_3, -- channel 3 to GND
        ads1115.DIFF_0_1, -- channel 0 to 1
        ads1115.DIFF_0_3, -- channel 0 to 3
        ads1115.DIFF_1_3, -- channel 1 to 3
        ads1115.DIFF_2_3  -- channel 2 to 3        
}


-- setup section
i2c.setup(id_i2c, sda, scl, i2c.SLOW) -- set up the i2c controller
ads1115.setup(ads1115.ADDR_GND) -- ADR to GND connection for the i2c address 0x48
ran=0
ch=0
rez=0.
cnt=0

tmr_adc = tmr.create()
-- measure each 200 ms
tmr_adc:register(200, tmr.ALARM_AUTO, 
    function()
        -- single shot setup
        -- data rate stated for correct timer callback setting
        ads1115.setting(ranges[ran+1], ads1115.DR_8SPS, channels[ch+1], ads1115.SINGLE_SHOT)
        -- start adc conversion and get result in callback after conversion is ready
        ads1115.startread(
            function(volt, volt_dec, adc) 
                rez = math.floor(volt)/1000
                cnt = cnt + 1
                if cnt>10 then
                    cnt = 0
                    print('Voltage = ' .. rez .. ' V')
                end
             end
        )
    end
)
tmr_adc:start()
--tmr_adc:stop()
This code was used for the complete design - it starts mesuring voltage from the single ended channel A0 of the ADC every 200 ms then reads the ADC converted voltage using callback mechanism after a particular delay. It outputs to the console one in ten measurements only in order to give a chance to other pieces of firmware outputting their diagnostic messages. (Note 2: how do the drivers handle delays for ADC conversions.)

The latter code was used for the first prototype.



Note 1: why NodeMCU firmware (Lua interpreting language) was selected for this development

A few words regarding the choice for firmware development. At the moment one can develop for ESP using Arduino ecosystem, AT commands, Basic, native C (C++), Espruino (JavaScript), microPython, NodeMCU (Lua) - this alphabetical list may not be exhaustive. All of these options were developed, supported and used for projects by a number of smart and inspirational people. My background is with embedded development and not computer networking. For this reason I personally found it easier to use NodeMCU as this was the first open and comprehensive interpreting system with zero cost software toolset. (Espruino and microPython came later, I supported development of both through Kickstarter as I do believe people with other background would benefit from these.) The advantage of interpreting environment is that you do not need to wait a couple of minutes to compile the code then some time to program the flash code memory; instead you can type your instructions and get instant feedback. I did require this option when I tried to figure out how to operate, for example, a UDP listener. The drawback of interpreters is the need to store a substantial code that taxes resources of the computer system, and slower execution because the interpreter needs to process any repeated instructions every time they need to be executed. A perfect combination would be to use an interpreter for debugging then compiler for deployment but this combination does not seem to exist for the ESPs. For the above reason this development was conducted using NodeMCU firmware (Lua interpreter built on top of the Espressif SDK + optional custom modules).

An added complication for using NodeMCU/Lua is its RTOS-like behavior. Howevre this feature may became a serious advantage when developing a real application.

Of course there are other compliucations, four of which are discussed here.

Note 2: how do the drivers handle delays for ADC conversions

My driver checks whether the last conversion was completed then reads the data. ADS1115 driver simply reads the ADC's output register after a set delay. For this reason it is important to state the data rate that sets a delay which is long enough to complete the conversion, i.e. ADS115.DR_8SPS .

TODO: the ADC can report completion of the present conversion via its ALERT pin. By connecting this pin to an unused Wemos mini pin one can trigger an interrupt then the callback function will simply need to read the converted value.

Discussions