Raspberry-Pi controlled robot for a school robotics project

For a school project, several teams constructed and programmed small robots to navigate on uneven terrain and complete various tasks. The required tasks included being able to drive around, measure the position of the "sun" in the sky, measure the wind speed, measure the salinity of a puddle of water, measure the temperature of a block of ice, and plant a flag into the ground. Each team was also required to pick several additional tasks, according to the number of people in the team. We chose to also collect gravel from a pile.

Most teams controlled their robots using the Vex Cortex and programmed their robots with RobotC. However, I didn't like using RobotC, so as the team's programmer, I chose to control our robot with a Raspberry Pi and program it with C. However, the Raspberry Pi only has two hardware pulse-width modulation (PWM) outputs, and no analog inputs, so it would not have been able to control all of the motors or use the light and salinity sensors. Thus, I had to write a program that generates the necessary PWM outputs using one of the PWM channels as a timer and the direct memory access (DMA) hardware of the Raspberry Pi. This method of generating the PWM outputs is mostly reliable, but changing one channel often results in jitter in other channels.

To read the analog sensors, an external circuit is necessary. The program reads how long it takes for a capacitor to charge through the analog sensor by sampling the capacitor at regular intervals using the serial peripheral interface (SPI) hardware of the Raspberry Pi.

The Raspberry Pi is connected to my computer and phone via my phone's wireless hotspot. The robot can be controlled by the program's command line interface, where the user can press certain keys to make the robot do something or read a sensor (for example, lower the gravel scoop, measure the temperature, plant the flag). The program also runs a web server that serves a single page with two sliders through which the user can drive the robot. This page is meant to be opened on a smartphone, so that the user can control the speed of both motors in addition to the direction.

The code for this project can be found at https://www.github.com/mnigmann/mission-possible-robot/

Creating additional PWM outputs for the Raspberry Pi

A single PWM channel will have one PWM_RNG register and one PWM_DAT register. The PWM_RNG1 register controls the period of the output of PWM channel 1, and the PWM_DAT1 register controls the on-time of the output of PWM channel 1. However, the PWM_DAT1 register is actually the input to a first-in-first-out buffer (FIFO), so it is possible to write several values to this register, and then configure the PWM channel to output each one in sequence.

The additional PWM outputs were created using the DMA hardware and one of the Pi's hardware PWM channels as a timer. The purpose of DMA is to load bytes from one memory address to another independently of the CPU. The DMA hardware is configured using a sequence of control blocks. Each control block (CB) contains a source address, a destination address, control information, and the address of the next CB. A CB can be configured to wait for the PWM FIFO to become empty before continuing to the next CB.

Since all of the PWM outputs will have the same frequency, all channels can be turned on together, and each channel can be turned off individually after the appropriate delay. This can be seen in the diagram below:

There are two CBs per edge: one to turn on or off the appropriate pin and another to load the appropriate delay into the PWM hardware and wait for the time to elapse. If two channels turn off at the same time, they share a CB. There are an additional two CBs that turn on all of the channels and wait, respectively. For every CB pair, there is a data block (DB) consisting of three 4-byte integers: a bitmask of the pins controlled by that CB, the delay, an empty value. The DBs are stored in the pwm_data array. The following table shows the configuration that yields the above waveform:

Address (example) Source location Value of source Destination location Next control block
0xDEAEE000 pwm_data[0] 0x00E00000 GPIO_SET0 0xDEAEE020
0xDEAEE020 pwm_data[4] 100 PWM_RNG1 0xDEAEE040
0xDEAEE040 pwm_data[3] 0x00C00000 GPIO_CLR0 0xDEAEE060
0xDEAEE060 pwm_data[7] 3600 PWM_RNG1 0xDEAEE080
0xDEAEE080 pwm_data[6] 0x00200000 GPIO_CLR0 0xDEAEE0A0
0xDEAEE0A0 pwm_data[1] 300 PWM_RNG1 0xDEAEE000
0xDEAEE0C0 pwm_data[9] 0x00000000 GPIO_CLR0 0xDEAEE0E0
0xDEAEE0E0 pwm_data[1] 0 PWM_RNG1 0xDEAEE000

Note that the last pair of control blocks is excluded from the main cycle, as nothing points to 0xDEAEE0C0. Since three channels are used, four CB pairs are configured, but only three are used because two channels have the same on-time and are assigned to the same CB pair.

The first CB in each pair is configured to load a bitmask into the GPIO_SET0 or GPIO_CLR0 register. For example, the CB with address 0xDEAEE00 loads the value 0x00E00000, (which has the 21st, 22nd, and 23rd bits set), into the GPIO_SET0 register, setting pins 21, 22, and 23 high.

The second CB in each pair is configured to load a delay into the PWM_RNG1 register and then wait for the delay to elapse before continuing to the next CB in the chain. However, for some reason, the actual measured delay is always the delay loaded in by the previous CB pair, so the delay that is loaded into the PWM_RNG1 register is always one DB ahead of the corresponding CB. For example, instead of loading pwm_data[1] into PWM_RNG1, CB 0xDEAEE020 loads pwm_data[4], which is in the following DB. The reason that the last integer in every DB is zero is that the second CB will load both pwm_data[3*n+1] into PWM_RNG1 and pwm_data[3*n+2] into PWM_DAT1, ensuring that the PWM FIFO is refilled and the DMA waits. When the value of a PWM channel is changed, care must be taken to ensure that the chain does not have any blocks with a delay of less than 4. It may be necessary to rearrange some of the CBs to ensure that this does not happen. I observed that if the delay was less than four, the behavior of the outputs would become unpredictable. The algorithm for setting a PWM channel is as follows:

  1. If the channel is not currently a PWM channel, then the channel must be added to an existing CB (if possible) or a new CB must be allocated and inserted into the chain.
    1. If any of the CBs have an offset (time between turning on all channels and turning off the channels associated with that CB) that is close to the desired offset of the new channel, then the pin bitmask in the corresponding DB is ORed with the bitmask corresponding to the new channel.
    2. If there are no CBs with a close offset, then a new CB is created with the bitmask corresponding to the new channel in its DB. The program finds the CBs that would be executed immediately before and immediately after the new CB. The CB immediately before the new CB is changed to point to the new CB, and the new CB is configured to point to the CB immediately after it, inserting it into the CB cycle. The delays of the nearby CBs are adjusted so that their offsets are not affected by the insertion. For example, consider adding pin 24 to the example described above with an offset of 250:
      Address Source location Value of source Destination location Next control block
      0xDEAEE000 pwm_data[0] 0x01E00000 GPIO_SET0 0xDEAEE020
      0xDEAEE020 pwm_data[13] 50 PWM_RNG1 0xDEAEE100
      0xDEAEE040 pwm_data[3] 0x00C00000 GPIO_CLR0 0xDEAEE060
      0xDEAEE060 pwm_data[7] 3600 PWM_RNG1 0xDEAEE080
      0xDEAEE080 pwm_data[6] 0x00200000 GPIO_CLR0 0xDEAEE0A0
      0xDEAEE0A0 pwm_data[1] 250 PWM_RNG1 0xDEAEE000
      0xDEAEE0C0 pwm_data[9] 0x00000000 GPIO_CLR0 0xDEAEE0E0
      0xDEAEE0E0 pwm_data[1] 0 PWM_RNG1 0xDEAEE000
      0xDEAEE100 pwm_data[12] 0x01000000 GPIO_CLR0 0xDEAEE120
      0xDEAEE120 pwm_data[4] 100 PWM_RNG1 0xDEAEE040
      The two green rows are the new CB pair that was allocated for the new channel. Since the offset of the new channel (250) is less than the offsets of the other channels, the new CB pair is inserted after the first CB pair, which has an offset of 0. The first CB pair's pointer to the next CB has been changed from 0xDEAEE040 to 0xDEAEE100, and the source pointer to the delay was changed from pwm_data[4] to pwm_data[13]. The delay after the first CB was also changed from 300 to 250, and the delay after executing the new CB pair is 50. Since the total delay is still 300, the timing of the following CBs is not affected.
  2. If the channel is currently a PWM channel, and its value is being changed, then the program looks for the CB corresponding to that channel. If the only channel controlled by that CB is the channel the function was called with, then the CB is removed from the chain, and the delays are adjusted accordingly. Otherwise, the channel is simply removed from that CB by unsetting the corresponding bit in its pin bitmask. Then, see 1.

Additionally, when creating a new channel, it is necessary to ensure that the pin bitmask in the first DB is ORed with the correct bitmask for that channel.

However, the DMA continues to run while the function is changing the CBs, which could cause jitter and lost cycles. Therefore, in a later revision of the program, I created a duplicate copy of the CB list and the DB list, which were then copied into the "live" versions by the pwm_update function. One could then update several channels at once and then call pwm_update to write them all at once.

I noticed that sometimes, the above algorithms would assign a negative delay to some of the CBs. If this happens, the DMA chain is interrupted and the program must be restarted.

Measuring resistance with the Raspberry Pi

The Raspberry Pi has no analog inputs, and I did not have an analog-to-digital converter available. However, neither the light sensor or the salinity sensors actually output a voltage. Typically, these sensors were used with a resistor divider to create an analog output. However, it was possible to just measure the resistance of the sensor and get a reasonably precise value as well. This was done by charging a known capacitance through the unknown resistance of the sensor and measuring the time it takes for the capacitor to charge. The capacitor is connected via a 470 ohm resistor to the master-in-slave-out (MISO) pin of the Raspberry Pi, which is connected to the Pi's SPI hardware. The light sensor was connected between the Pi's chip enable 0 (CE0) pin and the capacitor, and the salinity sensor was connected between the Pi's CE1 pin and the capacitor.

The program measures a resistive sensor in the following manner:
  1. The CE0 (or CE1) output is switched on.
  2. At the same time, the program loads 64 bytes into the SPI hardware's FIFO buffer. The SPI would normally transmit these bytes and receive new ones at the same time, but the output pin was disconnected and used for other purposes. The Pi still "receives" 64 bytes (or 512 bits), which correspond to 512 samples of the voltage on the capacitor. If the voltage is above the threshold voltage needed for the Pi to detect the input, this will be represented as a 1 in the received data. Thus, the "received" data starts as all zeros, and then at some point transitions to all ones. The sampling frequency is given by the formula f0=512/t0, where t0 is the sampling interval (initially 10 milliseconds)
  3. The program counts the number of zeros in the received data, and computes the time it took for the capacitor to charge by dividing the number of zeros by the sampling frequency.
  4. The program discharges the capacitor by configuring the MISO pin as an output and setting it low. The capacitor discharges through the 470 ohm resistor. After a delay, the pin is configured as the MISO pin again.
  5. The program computes the second sampling frequency with the formula f1=512/(1.5ts), where ts is the time computed in step 3. This gives a sampling interval 50% larger than the previous measured value, which makes the size of the sampling interval much closer to the actual value. The closer the sampling interval is to the actual value, the more precise the result.
  6. Steps 2 and 3 are repeated to give the final measurement.
  7. The CE0 or CE1 pin is switched off again and put in a high-impedance state.

Anemometer and sun sensor

The anemometer was attached to a rotary encoder, which outputs a certain number of pulses per rotation. To measure the wind speed, the program would sample the number of pulses received in 500 millisecond intervals. To get a more stable result, the program also kept track of the last 16 measurements in a circular buffer, and performed an average of these last 16 results.

The sun sensor (above) consists of one servo that rotates a second servo horizontally. The second servo rotates the light sensor vertically. First, the program sweeps horizontally in 5 degree increments. Then, it returns to the brightest spot and sweeps the vertical servo. Then, the program displays the brightest horizontal and vertical angles and returns to the starting position.

Driving the robot

The program runs a primitive web server, and the user can access the site to drive the robot using the Pi's IP address. An image of the site is shown below:

The left bar controls the left motor, and the right bar controls the right motor. This interface works reliably, but there were sometimes problems when connecting with the website after the program was started.

Controlling the robot's attachments

The user can control the robot and read back sensor data via a command line interface, which may be accessed over an ssh connection. The user can then press certain keys to make the robot do something (such as plant the flag, get a temperature reading, or raise/lower the shovel). An image of the interface is shown below:

Comments

Popular posts from this blog

Improving and calibrating the capacitive water sensor

Controlling a ceiling fan and light with an Arduino

Turn a buck converter module into a buck-boost converter with only two components