Saturday 2 January 2021

HC-SR04 - Every Microsecond Counts

In Pi Wars 2019 we had a lot of success with using HC-SR04 sonar distance sensors on our Sputnik robot. 

We liked them so much we put 5 on the robot - one on the front - 2 on the sides, and 2 mounted at 45 degrees at the front. 

These were connected to an Arduino, the distance calculated using the Arduino "NewPing" library, and the results sent to the Pi over a USB cable. 

Some teams struggled with ToF sensors because of the black walls, but we had no such issues with the sonar sensors, so I was keen to use the same sensor in Nanny McPi. 

However, I didn't want to use an Arduino for Nanny McPi, so considered connecting the sensor directly to the Raspberry Pi.  

To discover the issues with connecting the sensor directly to the Pi, we need to look at the HC-SR04 in a bit more detail. 

How Does It Work? 



The HC-SR04 is powered with 5V through the Vcc and Gnd pins. To measure the distance you need to first send a trigger signal down the Trig pin (3.3V is fine for this). A high frequency sound is sent out, and the echo detected. The time between sending the sound and receiving the echo is sent back to the Pi by raising the Echo pin high for this amount of time. 

The echo pin uses the 5V Vcc voltage - this is issue number one - send 5V to a Raspberry Pi GPIO pin and it won't be happy.

Sound travels at 343 m/s. So you can use the time taken for the echo to be received to convert to a distance, and then divide by 2 (since we're measuring the time for the sound to go out and back). 

Consider that in 1ms, sound will travel  34.3cm - so a 1ms pulse means an object is about 17cm in front of you. So measuring the pulse in milliseconds isn't going to be very accurate (+/- 17cm) - you need to measure the returned pulse in microseconds to get a decent distance measurement.  

This is issue number two - trying to measure a pulse in a python application to microsecond resolution is not very easy - as Raspbian is not a real time operating system.

Don't Fry The Pi

So - issue number one is that we need to handle 5V coming back through the echo pin. In theory you could try and power the sensor with 3.3V - or get an equivalent sensor designed to work with 3.3V, but my understanding is you need to sacrifice some range for this. 

Also we have a lot of these sensors around so we might as well use them rather than buying some more. 

Actually this is an easy issue to solve - you can just use a Potential divider circuit to reduce the voltage from the echo pin from 5V to 3V. The diagram below shows how this works. 



I'm using 2.2k and 3.3k resistors to pull 5V down to 3V - but you can use 1k and 2k resistors which gives an output voltage closer to 3.3V. (Note that the track on the stripboard is cut on the echo pin, so the current has to pass through the potential divider to get to the other side). 

Measuring the pulse

So - now issue number 2 - how to measure the pulse width in microseconds. I didn't think python would be able to do the job - so considered writing a C/C++ program to access the GPIO pins and measure the pulse for me. 

But then I discovered pigpiod. This is a C program that accesses the GPIO pins directly through DMA, offers microsecond level timing accuracy, and has a python interface. (Actually by default it polls every 5 microseconds - but this is fine for the HC-SR04 - as 5 microseconds is less than a millimeter, so I've left it at the default). So this seemed ideal. 

Actually, I've since discovered that the GPIO Zero has an option to use the pigpiod daemon, so I presume this would also produce the same level as accuracy. But I didn't realise this at the time, so coded my own implementation. 

The pigpiod interface works by specifying a callback for a particular pin which is called when the Pin changes state. The pin status and time in microseconds that the state change occurred are provided in the call to your callback function. So by attaching a callback to the echo pin, you can determine the length of the pulse in milliseconds. 

Once I coded this and tested it, I really haven't had to touch it since. I rely on this sensor for moving around the arena and for measuring a distance to a block to pick it up - or drop it down in the Tidy The Toys challenge, and just take it for granted that an accurate distance measurement will always be available. 

This is the class I wrote to measure the distance.  This uses a condition variable so that the distance can be fetched synchronously. The condition variables are held in a dictionary keyed by the echo pin number.  So you just create an instance of the 'SingleSonarSensor' class, providing the trigger pin and echo pin in the constructor, and call getDistance() to return the distance in cm. 


import pigpio 
import threading
import time
'''
SonarContext provides locked data about the last sonar trigger. 
'''
class SonarContext:

    def __init__(self):
        self.condition = threading.Condition()
        self.lastDistance = 0 
        self.isSet = False 


class SingleSonarSensor:

    # Static variables used by the static callback method 
    tickStartTimes = dict()     
    sonarContextByEchoGpio = dict() 

    def __init__(self, triggerGpio, echoGpio):

        self.triggerGpio = triggerGpio
        self.echoGpio = echoGpio 
        self.sonarContext = None 
        self.pi = pigpio.pi()
        self.pi.set_mode( triggerGpio, pigpio.OUTPUT )
        self.pi.set_mode( echoGpio, pigpio.INPUT )

        if ( echoGpio in SingleSonarSensor.sonarContextByEchoGpio ):
            # Another sensor is using this - but I guess we can use the same context. 
            self.sonarContext = SingleSonarSensor.sonarContextByEchoGpio[echoGpio] 
        else:
            self.sonarContext = SonarContext() 
            SingleSonarSensor.sonarContextByEchoGpio[echoGpio] = self.sonarContext 
            self.pi.callback(echoGpio, pigpio.EITHER_EDGE, SingleSonarSensor.echoGpioCallback) 

    def getDistance(self):

        self.sonarContext.condition.acquire() 
        self.sonarContext.isSet = False 

        self.pi.gpio_trigger( self.triggerGpio, 20, 1 )

        distance = 0  

        while not self.sonarContext.isSet:
            self.sonarContext.condition.wait() 

        distance = self.sonarContext.lastDistance

        self.sonarContext.condition.release()

        return distance 

    @staticmethod
    def echoGpioCallback(gpio, level, tick):
        if ( level == 1 ):
            # just record the tick when the echo pin goes high 
            SingleSonarSensor.tickStartTimes[gpio] = tick 

        if ( level == 0 ):
            # Calculate the distance in cm
            if (    (gpio in SingleSonarSensor.tickStartTimes) 
                and (gpio in SingleSonarSensor.sonarContextByEchoGpio) ):
                diff = pigpio.tickDiff(SingleSonarSensor.tickStartTimes[gpio], tick)
                """ Convert microseconds to cm. Sound travels at 343 m/s so need to multiply
                    time in microseconds by 0.0343 to get cm - and then divide by 2 since we're 
                    measuring the time there and back """ 
                cm = (diff * 0.0343) / 2 
                context = SingleSonarSensor.sonarContextByEchoGpio[gpio] 
                context.condition.acquire() 
                context.isSet = True 
                context.lastDistance = cm 
                context.condition.notifyAll() 
                context.condition.release()








 



2 comments:

  1. Nice work, I have a handful of HCSR04s that I had intended to use for PiWars 2018 but ran out of time and never fitted them. May have the same problem this time :-) Team Barum Jam Collective

    ReplyDelete
    Replies
    1. Thanks. The main problem, which I should have mentioned, is that they are useless if approaching something at an angle. I was thinking of using your method of judging distance using the camera but went for the hc-sr04 in the end.

      Delete