29 Jan 2021

Sensing Temperature with a RaspberryPi - pt2

In my previous post I started a conversation around the details of sensing a temperature with a RaspberryPi. That post discussed using the cheapest temperature sensor available: a thermistor. Using a thermistor with an RPi is cheap, but not very straight-forward: an ADC is required, we need to communicate with that ADC (over SPI in my particular case), a voltage divider circuit is required to convert a varying resistance into a varying voltage, and we need some funky math in order to account for the sensor's non-linearity with respect to resistance versus temperature over a range of temperatures. Not to mention the further adjustments we need to make in order to account for the self-heating of the thermistor device itself.

However, learning how to work with one resistive sensor opens the door to working with a whole host of other resistive sensors (light sensors, pressure sensors, potentiometers, sliders, joysticks, etc). So while reading temperature in this way is challenging, the tricks learned along the way are useful in other common electronic designs as well.

My examples use a RaspberryPi specifically because I wanted to show an end-to-end example of reading an actual temperature with an actual SBC. From evaluating various temperature devices, choosing and buying one… all the way to connecting it to the RaspberryPi and reading the temperature in user-space. However, any of a large number of SBCs could be used. Although these posts are RaspberryPi-specific, many of the steps and concepts can be applied to a wide range of Linux-capable SBCs.

Continuing along with the discussion of reading a temperature with a RaspberryPi, this post will discuss a different class of temperature sensor.

Digital Thermometer

At the polar opposite end of the spectrum from the hard-to-work-with thermistor are a class of temperature-sensing devices I'm calling "digital thermometers". I've seen that phrase used in a couple places, but the term isn't universal.

Whereas simple devices, such as the thermistor, require you to add various other components to your design and perform a complicated conversion from resistance to temperature, a digital thermometer is usually wired directly to the RPi and all that you need to do is read a temperature value directly from the device itself. There are no hoops to jump through to account for self-heating, or 3rd-order transfer functions to implement to adjust for the non-linear relationship between changing resistance versus changing temperature, no extra components to add to your design, and no circuitry you need to figure out and measure to improve the accuracy of your calculation.

The digital thermometer handles all of these things itself and simply provides a temperature (or something that is easily translated into a temperature). Conceptually it's as if all the pieces that were discussed in the previous post (except for the inter-chip communication) were encapsulated together into the sensor (including some of the software that would have needed to be written for the RPi):


NOTE: I'm not trying to imply that digital thermometers are based on thermistors; in fact according to their datasheets they are not. But I'm trying to relate back to case of the thermistor to show how much more advanced these devices are.

Examples of digital thermometers include:

  • TMP102 from Sparkfun
  • Si7021 from Adafruit
  • TC72
  • DS18B20

There are dozens (or hundreds?) of digital thermometers from which to choose. Select the accuracy, the range of temperatures you want to measure, your favourite inter-IC communication bus, and you're mostly done. Many of these devices have come about thanks to the maker movement. Taking sophisticated ICs, putting them on breadboard-friendly break-out boards, and adding any required circuitry makes successful projects easier. In addition to the hardware, most of these devices also come with (mostly Python) libraries, just in case neither hardware nor software are your strong suits when working with electronics ;-)

TMP102

The TMP102 temperature break-out board from Sparkfun has the following characteristics:

  • a resolution of: 12 bits
  • a range of: -25°C to +85°C
  • an accuracy of: ±0.5°C
  • and communicates over: I2C

If you decide to forgo the Python library you'll need to know the format of the data you'll receive from the device. Simply refer to the datasheet and you'll find:


The upper-case Ds represent the value to the left of the decimal point, the lower-case ds represent the fractions. If a whole-number calculation of the device's temperature will suffice, simply take the first byte that is given and ignore the second.

Si7021

The Si7021 is an example of a digital thermometer from Adafruit. It's specs are:

  • configurable from 11-bit to 14-bit resolution
  • -10°C to +85°C
  • ±0.4°C
  • I2C

The Si7021 provides an example of a digital thermometer that doesn't, exactly, give you a temperature, but it gives you something (a "Temp_Code") that is easy to convert to a temperature:


TC72

I can't find any examples of the TC72 on a maker-friendly break-out board but there are generic break-out boards to which it could be attached.

  • 10-bit
  • -40°C to +85°C
  • ±2°C
  • SPI

Data format:


DS18B20

The DS18B20 is a very popular temperature-sensing device, and with good reason. In addition to its great specs, it is available both as a discrete component, as well as in a water-proof temperature-appropriate housing.


  • configurable 9-bit to 12-bit resolution
  • -55°C to +125°C
  • ±0.5°C accuracy
  • 1-wire communication protocol

It's data format is as follows, and is very similar to a 16-bit signed 2's complement representation:


Designing for Multiple Sensors

In many situations the goal is to put one temperature sensor in an area (e.g. to measure the temperature of a room). For these situations, hooking up one temperature sensor to one SBC and distributing one set of these in each area will suffice. However, in other situations, the goal is to distribute a lot of sensors in a small area (e.g. monitoring the supply and return temperatures of a multi-zone HVAC system). Hooking up 1 or 2 sensors via I2C or SPI to one RaspberryPi isn't too hard, but trying to connect a dozen or more devices on these same buses can be a challenge. Another option would be to use a dozen RaspberryPis with 1 or 2 sensors each, but that's getting silly.

The problem lies with how these buses were designed, and the assumptions that were used when designing them.

SPI

The SPI bus consists of 1 clock line and 2 data lines (one for each direction):

The specific slave device to which the master wants to talk is selected using a physical chip select line. When a design calls for multiple SPI devices, multiple chip select lines need to be used:

The above diagram shows a master with one SPI bus that has 3 chip selects on it. The standard RaspberryPi 40-pin expansion header has two SPI buses. The first SPI bus (SPI0) exposes 2 chip selects, and the second SPI bus (SPI1) exposes 3 chip selects.

Pins 19 and 21 are the 2 data lines for SPI0, pin 23 is the SPI0 clock pin, and pins 24 and 26 are the 2 chip selects for SPI0.

Pins 38 and 35 are the 2 data lines for SPI1, pin 40 is the SPI1 clock pin, and pins 12, 11, and 36 are the chip selects for SPI1.

All of the pins for SPI0 are readily available on the 40-pin header. The data lines and clock pins are available for SPI1 as well. However, the 3 chip selects for SPI1 are not the primary functions of those pins, so extra setup is required to set those chip select pins for SPI mode.

If you wanted to hook up more than 2 devices to the RaspberryPi's SPI0 bus you would need more chip select lines; the same is true for the Pi's SPI1 bus as well. Setting up more chip select lines requires some hardware work and tweaks to the driver software. It's possible, but it's getting complicated due to the nature of how the SPI bus works.

Another way to connect multiple devices to an SPI bus is as follows:

This is an example of connecting multiple SPI devices in a daisy-chain topography. One chip select always selects all devices at the same time, but the data from the first device travels to the next device down the chain, and so on, until the last device connects back to the master. This is the topology used for JTAG. In theory a large number of devices can be connected to one SBC using this topology. However, with this design it's not possible for the master to query any one device at a time, it must always clock enough times to get all the data from all the devices with each request. The slaves themselves must also support a pass-through mode which enables them to save up the received bits and send them along after they've put their own data on the data line.

In other words, this topology is also complicated and not always possible.

Whether you are using the first topology (multiple chip select lines) or have daisy-chained your devices together, there is no way for your software to know how you've wired everything together. Give 10 people the same two SPI devices to connect to the RaspberryPi's SPI0 bus and half of them will connect device A to chip-select 0 and the other half of them will connect device B to chip-select 0. Neither arrangement is wrong, but these differences need to be taken into account. As a result, SPI buses are not discoverable. There is no way for Linux to know, a-priori, the devices to which it is connected or how they've been wired up. There's no unique ID that can be sent to let Linux know exactly what's connected to where. Therefore, there's no way Linux can do all the work for you; you need to write some code for your specific arrangement, and therefore are responsible for interpreting the data you receive from each device. Plus this code needs to be flexible enough to allow the user to specify the wiring so your code knows where to look for its data.

I2C

With I2C there is 1 data line and 1 clock line:

Notice that there are no chip select lines. The master specifies the slave with which it wishes to communicate by providing an address as part of the message preamble. This has the added side-effect of reducing the data throughput rate since some part of every communication is taken up specifying an address. The original I2C spec specifies 7 bits to be used for these addresses; meaning there are only 128 unique address possibilities. With 1000s and 1000s of I2C devices on the market, you can be assured the possibility for address collisions is more than probable! This address isn't based on topology or related in any way to a device's position on the bus. This address is a number that is burned into the device, and when the device sees this number in the preamble of a message, it responds. Each I2C device has to know the address to which it is to respond. For many I2C devices an address is chosen by the manufacturer and simply burned into its circuitry. This address is specified in the device's datasheet. There is no central repository or authority dispensing these addresses, so manufacturers are free to use whatever address they want. Fancier I2C devices use from one to three external pins to allow the user to modify some part of the I2C address; the rest of the address is burned into the device. When the device is placed into its circuit these pins are either grounded or tied high to change the address; at which point their address becomes static. In recent years there have been new revisions of the I2C spec that allow for 10-bit addresses, but the adoption rate has been slow.

Therefore, trying to attach multiple of the exact same device to a RaspberryPi becomes tricky. If you're lucky you might be able to change 3 of the 7 address pins, giving you the ability to add up to 8 of the same device to each I2C bus your SBC exposes… but only if you're lucky and your specific device provides these address pins. Otherwise each I2C bus on your SBC can only accommodate up to 1 instance of a specific device if that device uses a fixed address.

The standard 40-pin expansion header on the RaspberryPi includes 1 I2C bus: I2C1. Pin 3 is the I2C1 data line, and pin 5 is the I2C1 clock pin.

This situation (regarding I2C device addressing) means that the I2C bus is not discoverable. You can't connect a random bunch of I2C devices to your SBC and have Linux magically know which devices you have connected, and therefore automatically know how to interpret the data it receives from those devices. Any address could conceivably be used by any number of non-related devices, and with devices where the user can modify parts of the address itself, those devices could have any of a range of addresses. Therefore when using I2C Linux can help you with the minutiae of sending and receiving individual data packets, but knowing which devices are in use and how to interpret their data is up to you.

1-Wire

As we've already seen, the DS18B20 is one of the simplest devices to use with the RaspberryPi; wire it up, and read its temperature. No extra circuitry required, no fancy conversions. But the fact it uses the 1-wire protocol gives us a couple additional benefits as well.

As part of the 1-wire protocol, any device that uses the 1-wire protocol must have a 64-bit unique serial ID (address) burned into it from the factory. The first 8-bits of this 64-bit number identify the device class, which is centrally administered in order to avoid address collisions. A centrally-administered database of device classes means that OSes, like Linux, can uniquely identify what type of device is connected, and can therefore know how to present its data. The DS18B20's class ID is 0x28, therefore any time Linux detects a 0x28 device connected to it via 1-wire, it knows this is a DS18B20 and can dynamically create a file in sysfs that presents user-space with a simple temperature reading. 1-wire buses are discoverable.

The other advantage of the 1-wire protocol (made possible as a consequence of the unique 64-bit number burned into every device at the factory) is its ability to enumerate all such devices attached to a single bus. Due to the low-speed nature of the bus, and various other electrical properties, 100s (if not 1000s) of devices can be connected together on 1 GPIO pin; all uniquely addressable, and all able to report their unique temperature reading to the user.

Multiple Sensors Conclusion

If you want to connect only one sensor to your device, any sensor using any bus will do. Once you get between 2 and 8, you'll probably want to drop SPI and try either I2C (if your device has 3 address pins) or 1-wire. But once you getting up into the 6 range, and certainly past 8, you'll want to switch to 1-wire and use the DS18B20. Also, SPI and I2C are buses that are designed for on-device, short range communication; they are not buses that can accommodate placing sensors a significant distance away from the SBC itself.

Therefore, for designs where multiple sensors need to be spread out in an area but preferably connected to one SBC, the DS18B20 has many advantages:

  • the 1-wire bus is a long-distance bus that can let you connect sensors that are several metres away from your SBC
  • the DS18B20 has a wide temperature range, and high accuracy
  • the DS18B20 comes in several form-factors, one of which is in a temperature-appropriate and water-proof housing
  • you can connect 100s of them up reliably to a single RaspberryPi or other SBC
  • due to their unique serial numbers with a centrally-managed device prefix, Linux (and other OSes) are able to know what devices are connected to them (they're discoverable) and therefore do all the conversions internally for you, presenting user-space with a simple file to read containing each device's temperature (one file per device) automatically
Specific details of using a DS18B20 can be found in another one of my posts. Using multiple DS18B20s at a time is discussed in the post at this link.

17 Jan 2021

Sensing Temperature with a RaspberryPi

Devices for sensing temperature in electronics projects come in 3 basic varieties:

  1. resistive sensors
  2. thermometer ICs
  3. digital thermometers
In this blog post I'll discuss the first variety. Future posts will discuss the others.

Resistive Sensors

Thermistor

The thermistor is the most basic, and cheapest, way to detect temperature. A thermistor is a 2-lead device whose resistance at any given point in time is directly related to its temperature at that time.

Thermistors come in 2 varieties:

  • PTC (positive temperature coefficient)
    • resistance increases with increasing temperature
  • NTC (negative temperature coefficient)
    • resistance decreases with increasing temperature

NTC-type thermistors are the most common.

In reality all electronic components (especially resistors) are affected by temperature, but ideally the effects due to temperature are minimal. In most cases the effects are so small that trying to use a regular resistor to measure temperature would require sensitive measuring devices and amplification in order to detect the variances. It could only be used as a very crude temperature sensing device with little resolution. A thermistor is specifically designed to highlight the effects of temperature and to make these effects noticeable in response to relatively small temperature changes.

One major challenge with using a thermistor is that the relationship between resistance and temperature is not linear across a temperature range. Pretend you have a thermistor that at 0°C was 1kΩ and at 10°C was 2kΩ, meaning there's a 1kΩ change for every 10°C. That doesn't mean that if the resistance at 40°C is XkΩ that the resistance at 50°C will be (X+1)kΩ. The change might be (X+7) or (X+0.4).

Figuring out how to translate between measured resistance and temperature can require a tricky bit of math! A commonly accepted relationship between resistance and temperature is given by the Steinhart-Hart Equation:

For this equation the temperature is specified in Kelvins, and you need to know the a, b, and c coefficients for your specific thermistor in order to use this equation. The datasheet for a thermistor will go into a lot of detail to help explain how to perform the translation and will often provide these coefficients for you (or coefficients for other equations it provides). This third-order equation wouldn't be too hard for a RaspberryPi to perform, but a lookup table with linear interpolation would probably work better in practice (especially for math-challenged SoCs such as 8-bitters).

Another issue faced with thermistors is their susceptibility to self-heating. Any electronic circuit will tend to heat up when it is powered; thermistors are no exception. However, given that the job of this device is to measure temperature, heating up as a result of being powered on will cause a thermistor to report a slightly higher temperature than its surroundings. Again, a datasheet provides the details for how to account for this effect mathematically.

Although we're now aware of various issues that can cause our measurement to require correcting, we still haven't figured out how to use a RaspberryPi to measure the resistance of a thermistor.

Analog-to-Digital Converters

If we take a step back, we'll realize that a varying resistance is an analog signal. One of the best ways to measure an analog signal is to use an analog-to-digital converter (ADC): a circuit whose job it is to measure analog signals and convert them into 1s and 0s for digital processing.

The problem is that an ADC measures voltage, not resistance. Therefore we need a way to convert a varying resistance into a varying voltage. Luckily this problem isn't too hard to solve: some sort of voltage divider circuit can be easily constructed for this purpose.

A second issue is that the RaspberryPi doesn't have (or doesn't expose) any ADCs for our use. Most SoCs have at least one ADC which the SBC exposes through its interface header. This is especially true of boards that are targeted at this market (i.e. the "maker" space or educational electronics). The standard Arduino, for example, has 6 analog pins. It's possible, however, that not having an available ADC might be a good thing. There are tens of thousands of discrete ADC ICs from which to choose (hundreds that are through-hole alone). One can choose the resolution (i.e. the number of bits) from 6 to 32, the communication protocol (e.g. I2C, SPI, QSPI, parallel, 1-wire, serial, etc and combinations thereof), the sampling rate (from as low as a couple samples a second up to over 10G S/s), the voltage ranges you want to sample, the signal-to-noise ratio, and the type/architecture of the conversion circuitry. When an ADC is integrated into the SoC, the ADC that is chosen will be a general-purpose one with middle-of-the-road (or below) specs. Being able to choose the right ADC for your application might be a good thing.

Now we have all the pieces we need in order to use a thermistor with a RaspberryPi to read temperature:

Note that the components in the drawing are not drawn to scale, and that the voltage divider drawing is meant to be a generic representation of a voltage divider circuit, although perhaps not the exact circuit that is needed. I chose the MCP3001 as my ADC simply because when I looked through the drawer of ADC components I have on my workbench, that's one of the ones I found. It happens to be a 1-channel ADC that uses SPI for communication, has 10 bits of resolution, and has a sampling rate of 75kS/s at 2.7V (or 200kS/s at 5V).

The thermistor forms one of the loads of the voltage divider. The output of the divider is fed to the IN+ pin of the ADC (pin 2). Pins 5, 6, and 7 of the ADC are the SPI pins that are hooked up to the RaspberryPi. +3.3V and grounds are hooked up as appropriate.

Our circuit is ready to read temperature. Now on to the software.

Communication Buses

Before we can get on with the job of reading the converted voltage values from the ADC over SPI, we need to take a quick look at communication buses in general.

If you want to connect two or more computers together in the same building, chances are you'd use Ethernet, WiFi, or maybe TokenRing: these are the common communication buses used between personal computing devices. Expansion cards on a motherboard are often connected via ISA, PCI, and/or PCIe buses. External devices are often connected to a personal computer with some version of USB.

In the embedded world, two of the most common inter-chip communication buses are I2C and SPI; these are two (mostly) standardized ways of connecting individual components together on a PCB. Like all good specifications, the design of the I2C and SPI buses define aspects such as the signaling, what to do in case of a collision, the algorithm of which devices get to speak when, the data rate(s), if/how multiple devices can be connected at the same time, and how to physically connect devices together (how many pins and wires are needed).

As we've just seen, we found ourselves in the position where we needed to add a discrete IC to our design (i.e. the ADC). In theory every chip could define its own communication method(s), or every company could define its own communication protocols for the devices it manufactures. There is much benefit to the end user if manufacturers standardize on a small set of common protocols.

Some buses are discoverable. This means there is a mechanism devices can use to identify themselves uniquely and/or specify which generic service they provide. The PCIe and USB buses are discoverable. Without any intervention from the user, the OS is able to identify each connected device, and get them ready to be used. For example, if the user plugs an external hard-drive into a computer's USB bus, the OS (if instructed to do so) can automatically mount one or more of the hard-drive's partitions into the filesystem. This requires anyone wanting to create and sell PCIe or USB devices to buy, and pay a yearly fee, in order to own a unique vendor ID.

I2C and SPI buses are not discoverable. There is no way for an OS (or RTOS) to probe these buses and be sure they've uniquely found all connected devices. Therefore it is not possible for Linux (for example) to generate sysfs entries for devices connected on these buses and perform various conversions automatically. Which means that if you want to read an ADC over SPI (or I2C) you'll need to write your own software (or find ready-to-use libraries from someone who has already written them).

SPI

Before you can write software to interact with an SPI-connected device, you have to understand a few things about how SPI works. SPI defines a clock line, and 2 data lines (one in each direction). It uses a physical chip select line to select which slave device can respond to the master.

When a slave device writes to the bus, it will make its data valid on either the rising or falling edge of the clock. The clock line (which is controlled by the master) can either idle high or idle low when not in use. The master and slave have to agree on these parameters of the communication in order for them to communicate successfully. Defining which way the clock line idles is generally referred to as the CPOL, or clock polarity. Defining whether the data is valid on the rising or falling clock edge is generally referred to as the CPHA, or clock phase.

The datasheet for your specific SPI device will tell you which combinations of (CPOL, CPHA) the device supports. The MCP3001, for example, only supports configurations (0, 0) and (1, 1).

The SPI bus supports a range of data rates, but not all devices support all possible data rates. The data rate is related to the sampling rate; there wouldn't be much use in a high sampling rate if there was no way to send that data to the master in time. Again, consult the datasheet for your device to know which data rates can be safely used; the MCP3001 supports data rates up to 2.8MHz.

The last piece of information you need to know is the data format. The MCP3001 is a single-channel, 10-bit ADC. In theory 10 bits of data could be transferred using 10 bits. However Linux seems to prefer to communicate using 8-bit bytes. My attempts to change the bit size from 8 to 10 weren't successful; possibly a driver issue. In any case I was forced to communicate with the MCP3001 using 8-bit packets. For reasons that aren't entirely clear, when the MCP3001 detects that 8-bit bytes are being used for communication, it encodes its 10 bits of data as follows:


The act of initiating a read from the device is what causes the conversion to commence. Therefore the first three bits from the device are two unknowns (X) and a 0 due to the time required to perform a conversion. Next the device starts writing the 10 bits starting with the MSb. Once it reaches the LSb it then writes these same bits out again going from the LSb to the MSb. Any bits that are read after these 22 bits, are read as zeros.

Knowing these parameters for your specific SPI device, you can now write the code to communicate with your device over SPI.

OpenEmbedded/Yocto

Before you can use SPI on your RaspberryPi, you have to enable it. Enabling SPI on the RaspberryPi requires the user to modify the contents of the config.txt file (correctly) and reboot. If you're using any images or distros that are not based on OpenEmbedded/Yocto then there are a bunch of different tools and ways to modify this file (including using a simple text editor). If you don't make your edits correctly you could end up with a device that refuses to boot until you correct any problems.

If you are using OpenEmbedded/Yocto to generate your images, enabling SPI is as simple as adding the following line to your configuration (most likely your conf/local.conf file):

I can't imagine anything simpler. Rebuild your image, flash it to your µSD card, and boot.

SPI in Linux on RPi

Writing code for Linux to read an SPI device in user-space is very straight-forward, provided you've read the datasheet of your device and you know how it needs to be configured (i.e. which SPI mode(s) it uses, the bit size, and speed) and the format of the data it will give you.

If your image is setup correctly with SPI enabled, after your device boots you'll find the following device nodes in your filesystem:

# ls -l /dev/spidev*
crw-------    1 root     root      153,   0 Jan  1  1970 /dev/spidev0.0
crw-------    1 root     root      153,   1 Jan  1  1970 /dev/spidev0.1

Your device will be connected to one of these nodes. Simply write a program that opens the correct one, uses ioctl(2)s to configure it, and read(2)s from it to get data from your SPI device.

The RaspberryPi's 40-pin GPIO Header exposes one SPI bus with two chip selects:

Connect the MCP3001's Pin-6 (DOUT) to the RPi's Pin-21 (SPI0_MISO), connect MCP3001's Pin-7 (CLK) to RPi's Pin-23 (SPI0_SCLK). If you connect MCP3001's Pin-5 (/CS) to RPi's Pin-24 (SPI0_CE0_N) then your device will be at /dev/spidev0.0. If, instead, you connect the MCP3001's chip-select to RPi's Pin-26 (SPI0_CE1_N) then you'll find your device at /dev/spidev0.1. This is how you know which device to use in your code. In the string "spidevX.Y" the X refers to the SPI bus, and the Y refers to the chip-select for SPI bus X.

You'll notice that since the MCP3001 is a 1-channel ADC, it doesn't receive any commands from the master. In fact, it doesn't even have a MOSI data line (master out, slave in), it only has a DOUT data line (aka MISO). Therefore there's nothing to connect to the RPi's Pin-19 (SPI0-MOSI) pin. Most other SPI devices would have something connected to this pin.

Now that you've open(2)ed the correct device, you can proceed to configure it with various SPI ioctl(2)s. You can set the mode with SPI_IOC_WR_MODE32, you set the number of bits with SPI_IOC_WR_BITS_PER_WORD, and you set the speed with SPI_IOC_WR_MAX_SPEED_HZ. The Linux kernel's SPI documentation provides lots of information, or you can just read the source code itself.

I've written a small MCP3001 program (which you can find here) to demonstrate. The defaults are all setup to work "out-of-the-box" (assuming you're using /CS0). Otherwise the program takes a range of optional cmdline arguments that can be used to tweak the configuration.

# mcp3001 -h
mcp3001 1.0.0
usage: mcp3001 [options]

  where:
    options:
      -D|--device <d> SPI device to use (default: /dev/spidev0.0)
      -s|--speed  <s> max speed (Hz)
      -b|--bpw <b>    bits per word
      -O|--cpol       clock polarity, idle high (default: idle low)
      -H|--cpha       clock phase, sample on trailing edge (default: leading)
      -L|--lsb        least significant bit first
      -C|--cs-high    chip select active high
      -3|--3wire      SI/SO signals shared
      -N|--no-cs      no chip select
      -R|--ready      slave pulls low to pause
      -v|--verbose    add verbosity
      -V|--version    print version


A Thermistor and a RaspberryPi

At this point we have everything we need to read a temperature with a RaspberryPi using a thermistor: in addition to the RPi and a thermistor we have an ADC which can read the analog signal and convert it to binary, and we have a voltage divider circuit which converts a varying resistance to a varying voltage which the ADC can measure. We have SPI enabled in our image, we have our ADC device hooked up to our RPi, and we even have some code to read the values being generated by the ADC over SPI.

As you can see, reading a temperature of a thermistor with a RaspberryPi is not very straight-forward; despite the information in this post, more work remains. I glossed over the math parts because, frankly, it's not worth it. Also, I didn't dig very deeply into the details of the voltage divider circuit needed to convert the varying resistance to a varying voltage. As we'll see in subsequent posts, there are much easier ways (and, ironically, much more accurate ways) to read a temperature.

Nothing Wasted

The good news is: time spent learning something new is never wasted.

Although the details needed to use a thermistor aren't really worth the effort, the things we've learned are applicable to a great many other situations. There are a number of sensors that measure something in the physical world, and describe it using a varying resistance.

For example: a user rotating a dial on a potentiometer changes the amount of resistance. A photocell changes its resistance based on how much light falls on it. Force sensitive resistors change their resistance based on how hard they are being pressed. Also, 2-axis analog joysticks don't just report that their toggle has been moved (for example) to the left, they provide a set of resistors that are read to indicate how far along each of the X and Y planes the toggle has been moved.

Everything we've learned hooking up a thermistor to a RaspberryPi can be used to read a whole host of other sensors!


8 Jan 2021

Device Enumeration on a 1-Wire Bus

Introduction

In my last two posts I discussed using a DS18B20 to obtain the temperature. In digital electronics, most IC devices would use either the I2C protocol or the SPI protocol for communication. The DS18B20 temperature sensor, however, uses the 1-wire protocol; a standard, though less common, protocol. While reading the datasheet for the DS18B20, one thing that fascinated me about the 1-wire protocol is its algorithm for correctly detecting an arbitrary number of connected devices despite having only one, bi-directional data wire and despite all these data wires being connected together in one big node.

On twitter I jokingly lamented the fact that the Linux kernel already has full support for the 1-wire protocol as well as the DS18B20, thus "robbing" me of the opportunity to learn much about either in detail. Therefore in order to "buy back" some of this opportunity, I decided to write my own code (in userspace) implementing the 1-wire device discovery algorithm. This exercise also forced me to write the code for a "testing" application which mimics the behaviour of an arbitrary number of connected devices.

The project consists of 2 parts:

  • the ROMsearch program which acts as the "master" side of the algorithm, and implements the ROMsearch feature of the 1-wire protocol
  • a tester component which either reads in pre-crafted files or generates random data and acts as a representation of an arbitrary number of connected "devices"

The two programs communicate with each other over a pair of fifos, which act as the "wiring" of the circuit. The code for this project can be found in the ROMsearch repository of my github profile.

Algorithm

I encourage you to read the specification yourself for the exact details. The datasheet I'm using can be found here. There are also other places where the 1-wire device discovery algorithm is described such as here and here.

Every specification sits atop a set of assumptions and facts in order for it to function correctly. Here are the relevant facts and assumptions related to this algorithm:

  • Each device comes from the factory with a unique 64-bit serial number encoded into its ROM.
  • All data is transmitted LSb first.
  • The master initiates all conversations.
  • Multiple devices are permitted to write to the bus at the same time.
  • The data line (DQ) has a weak pullup, as a consequence of the weak pullup on DQ:
    • if all devices write a 1 at the same time, the master reads 1 on DQ
    • if all devices write a 0 at the same time, the master reads 0 on DQ
    • if 1 or more devices write a 1, and 1 or more devices write a 0 on DQ at the same time, the master reads a 0 on DQ (regardless of the number of devices writing a given value versus the other)
    • if there are no devices trying to write anything to DQ, the master will read a 1

Interestingly enough, this algorithm and the circuitry of the 1-wire bus, are designed to expect multiple devices to write to the bus at the same time. Usually much care is taken in the design of a shared bus to make sure that no two devices ever try to write to the bus at the same time; and to detect when such inevitable events occur. In the case of a 1-wire system, having multiple devices write to the bus at the same time is a feature, and an integral part of the algorithm's success.

The gist of the algorithm is as follows:

  • the master issues a RESET on the line, this causes all devices to enter their reset state
  • the master sends the ROMsearch command on the line putting all devices into ROMsearch mode; this causes all devices to get ready to send their serial numbers starting with the LSb
  • the master issues a read on the line, all devices put the first bit of their respective serial numbers on the data line simultaneously; the master records this value
  • the master then issues a second read on the line, all devices put the complement of the first bit of their respective serial numbers on the line, the master records this value too
  • the master then writes either a 0 or a 1 on the data line; any device whose current actual bit matches the value put on the line by the master will continue being part of the search; any device whose current actual bit doesn't match the value put on the line by the master will stop responding to the master until a RESET is seen
  • all of the devices that are still part of the search get ready to put the next bit, and its complement, on the data line as the master issues two more reads
  • this pattern (2 reads, 1 write) continues until an entire serial number is read, then the whole sequence starts again in an effort to find the next device's serial number (if any are left to discover)

The algorithm assumes that all devices are able to stay in sync. In other words, for any given pass through the search algorithm (i.e. the set of 2 reads and a write from the master) all slave devices are working on the same bit position. In other words, in the first pass they are all sending their 0th bit… on the 11th pass they're all sending their 10th bit, etc. The algorithm wouldn't work if one device was sending its 12th bit while another was sending its 5th.

Due to the rules of boolean logic, the "bit and complement" nature of the algorithm, and the fact that all the writes from the devices are logically ANDed together (due to the pull-up circuitry of the 1-wire bus, as described above) the master can interpret each pair of values it reads on the line as follows:

    • 01: all devices still part of the search have a 0 in the current bit position
    • 10: all devices still part of the search have a 1 in the current bit position
    • 00: some of the devices have a 1, others have a 0 in the current bit position
    • 11: there are no devices responding as part of the search (we've reached the end of the serial number)

Example

It's time to look at a concrete example. For this example we're going to limit the length of the serial number to 8 bits. In this example there are 4 devices on the bus whose serial numbers are:


Starting with a reset and ROM-search command, all devices are in ROM-search mode and are ready to send the 1st bit of their respective serial numbers. The master performs a read on the data line and all devices send their 1st bit at the same time. This results in a 0 being read by the master.


The master performs a second read and all devices write the complement of their 1st bit at the same time. The master records a 1:


The master has no idea how many devices are connected on the bus, but it does know that this bit of all devices is a 0, because it did not encounter a fork. The master adds a 0 to the serial number it is currently creating:


The master writes a 0 on the data line, all devices who have a 0 bit in the current position remain part of the search (which in this case is all of them).

Having written a 0 on the data line causes all the devices to move on to their 2nd bit. The same thing happens for the 2nd bit. The master performs two reads on the data line which causes all devices still part of the search to write their 2nd bit and its complement (respectively) on the bus in response to each read pulse. Since all devices, coincidentally, have a 0 in their 2nd bit as well, the master sees another 01, adds a 0 to the serial number it is building, writes a 0 on the data line, and all devices continue to be part of the search:


Now we move to the 3rd bit. The first read yields the following bits. When they're all written to the bus at the same time the master reads a 0:


When all devices write the complement of the 3rd bit of their serial number on the bus the master reads another 0:


Now that the master has seen a 00 it knows that at this bit position (i.e. the 3rd bit position) there is a fork: there is at least one device with a serial number of (in part) "000" and there is at least one device with a serial number that begins with "100". The master needs to continue down one path, while remembering to come back and finish the other path. Which path it chooses to finish now is irrelevant.

Let's arbitrarily decide to finish the 0 path now. In doing so, we need to save the partial serial number "100" for later, add a 0 to the current serial number we're building, and write a 0 to the data line.


Writing a 0 to the data line at this point eliminates devices #1 and #4 from the search. With these two devices eliminated, the next set of reads yields:


A 01 lets the master know that all remaining devices have a 0 at the current position. Therefore it writes a 0 to the bus, which keeps both remaining devices in the search, and adds a 0 to the serial number it is building:


This process continues with the 5th bit of the remaining devices. Seeing a 10 in response to a set of reads lets the master know the next bit of all remaining devices is a 1:


The 6th bit:


At the 6th bit position of all the devices that are remaining along the 0 fork from the last time we encountered a 00, we have found another fork. If we arbitrarily decide to take the 0 fork again at this junction then, in addition to remembering to finish the "100" fork we found earlier, we also have to remember to finish the "110000" that we've just found now.


Taking the 0 fork again removes device #3 from the list:

Performing 2 reads yields:


Which we know means that all remaining devices have a 1 in the current position. Writing a 1 to the bus keeps all the current devices and moves us to the next bit:


At this point the master knows that all remaining devices have a 0 in this position. It records the 0 and writes a 0 to the bus. Since device #2 has reached the end of its serial number, it takes itself out of the search in response to the write the master has performed (which would normally cause the device to get ready to send its next bit).

Now, with no devices left in the search, and due to the pull-up on the bus, when the master performs its next 2 reads it gets:


When the master receives a 11 it knows that it has found the unique serial number of one of the devices attached to its bus: 01010000. We know this as the serial number of device #2.

Remember: the master is blind! We can see that there are 4 devices in total and that their serial numbers all consist of 8 bits, but the master doesn't know either of these things. However, the master is not dumb. It does know that of all the serial numbers present, none of them have 1 bits in either the 1st or 2nd positions. The master doesn't have to perform an exhaustive search of the entire bit-space to find all the devices, it only needs to follow-up with all of the forks it encounters each time through.

During the last pass the master encountered a fork; in fact it encountered 2 forks. Each time it found a fork it saved the state of the serial number up until that point, plus the "other path". I.e. at the 3rd bit position a fork was found, this indicated to the master that there were two valid paths at this point "000" and "100". We decided, arbitrarily, to follow the "000" path, so we saved the "100" path for later. The same thing happened at bit position 6. When examining the 6th bit position of the devices that were part of the search up until that point we found another 2 paths: "010000" and "110000". Now we need to go back to find what exists along the "100" and "110000" paths.

At this point the master sends a RESET to all devices and issues the ROM-search command. This puts all devices back into the search (including any ones we've already found) and gets all devices ready to send their 1st bits again.

However, the difference this time is that the master is not starting from scratch; it already knows that something exists along the "100" and "110000" paths and that no "1"s can possibly exist in bit positions 1 and 2.

We arbitrarily decide to investigate the "100" partial serial number. All devices are ready to send their first bits, so the master performs 2 reads. There's no need to process the result since we already know how it turns out, so the master can simply write a 0 to the bus (which is the value of the 1st bit in the partial serial number "100" that we are currently following). All devices that have a 0 in the 1st position get ready to send the next bit. The master performs 2 reads, ignores this result as well (since we're at the 2nd position of a known partial serial id "100"), and writes a 0 to the bus again. A third set of 2 reads is performed and the master ignores this value too.

This time, however, a different path is taken than before. Previously at the 3rd bit position we arbitrarily decided to take the "0" path and save the "1" path for later. We are now at "later". This time we're following up on the known partial serial number of "100", so this time we're going to write a 1 for the 3rd bit of the search pattern. When the master writes a 1 on the data bus, devices #1 and #4 will continue on as part of the search, and devices #2 and #3 will drop out.


The number of forks, so far, along this path is zero (although there is a fork to come).

Keeping track of all the forks that are found, and the partial serial numbers that go along with them, will lead to finding all the serial numbers of all the devices attached to the bus.

Notice that when the RESET and ROMsearch were issued most recently, device #2 became part of the search again. But we will never "find" device #2 again because we've already found it by arbitrarily following all the "0" paths the first time, and all subsequent passes through the algorithm will start with already-known partial serial IDs. Given that all serial numbers are assumed to be unique and of the same length, we won't ever "find" an already-found device again.

As a result, all devices are only found once, and alleys that don't lead to valid serial numbers are never explored.

Implementation Details

As mentioned above, this algorithm is implemented as two programs which communicate over a pair of named fifos.

The testing program needs to be started first. It will create a pair of fifos. The ROMsearch program can then be started. It will use these fifos to communicate with the testing program. As the ROMsearch program performs reads and writes to the fifos the testing program will send back appropriate responses based on its list of devices.

Alternatively, you could interact with the testing program "by hand" by writing commands into the "toTesterFifoFd" fifo (echo -n R > toTesterFifoFd) and seeing what comes out the "fmTesterFifoFd" fifo (cat fmTesterFifoFd). In this way you could perform an interactive ROMsearch if you wished. All communication done through the fifos is done using ASCII characters.

Note that in the following example run, the replies coming back from the testing program in response to the reads being performed by the master (cat fmTesterFifoFd) are interspersed with the output of the testing program itself which is set to verbose mode. So you have to search a little to find the two zeros that the tester is sending back to the master when the master issues its two 'r' commands in the following sequence.

$ ./tester &
[1] 9808
devices: 7
bitsize: 8
devices_pG[00] = 170 (0b10101010)  current bit pos:00 → 0
devices_pG[01] = 040 (0b00101000)  current bit pos:00 → 0
devices_pG[02] = 115 (0b01110011)  current bit pos:00 → 1
devices_pG[03] = 085 (0b01010101)  current bit pos:00 → 1
devices_pG[04] = 162 (0b10100010)  current bit pos:00 → 0
devices_pG[05] = 224 (0b11100000)  current bit pos:00 → 0
devices_pG[06] = 024 (0b00011000)  current bit pos:00 → 0
$ ls
Makefile   ROMsearch.o  common.o  data02  data04  fmTesterFifoFd  tester.o
ROMsearch  baddata      data01    data03  data05  tester          toTesterFifoFd
$ cat fmTesterFifoFd &
[2] 9862
$ echo -n V > toTesterFifoFd 

devices_pG[00] = 170 (0b10101010)  current bit pos:00 → 0
devices_pG[01] = 040 (0b00101000)  current bit pos:00 → 0
devices_pG[02] = 115 (0b01110011)  current bit pos:00 → 1
devices_pG[03] = 085 (0b01010101)  current bit pos:00 → 1
devices_pG[04] = 162 (0b10100010)  current bit pos:00 → 0
devices_pG[05] = 224 (0b11100000)  current bit pos:00 → 0
devices_pG[06] = 024 (0b00011000)  current bit pos:00 → 0
$ echo -n R > toTesterFifoFd 
fifo: 0x52 (R) cnt:1 bitPos:0

devices_pG[00] = 170 (0b10101010)  current bit pos:00 → 0
devices_pG[01] = 040 (0b00101000)  current bit pos:00 → 0
devices_pG[02] = 115 (0b01110011)  current bit pos:00 → 1
devices_pG[03] = 085 (0b01010101)  current bit pos:00 → 1
devices_pG[04] = 162 (0b10100010)  current bit pos:00 → 0
devices_pG[05] = 224 (0b11100000)  current bit pos:00 → 0
devices_pG[06] = 024 (0b00011000)  current bit pos:00 → 0
$ echo -n S > toTesterFifoFd 
fifo: 0x53 (S) cnt:1 bitPos:0

devices_pG[00] = 170 (0b10101010)  current bit pos:00 → 0
devices_pG[01] = 040 (0b00101000)  current bit pos:00 → 0
devices_pG[02] = 115 (0b01110011)  current bit pos:00 → 1
devices_pG[03] = 085 (0b01010101)  current bit pos:00 → 1
devices_pG[04] = 162 (0b10100010)  current bit pos:00 → 0
devices_pG[05] = 224 (0b11100000)  current bit pos:00 → 0
devices_pG[06] = 024 (0b00011000)  current bit pos:00 → 0
$ echo -n r > toTesterFifoFd 
fifo: 0x72 (r) cnt:1 bitPos:0
 readState:0 bitPos:0
  in search [00]  bit:0
  in search [01]  bit:0
  in search [02]  bit:1
  in search [03]  bit:1
  in search [04]  bit:0
  in search [05]  bit:0
  in search [06]  bit:0
  <= 0

devices_pG[00] = 170 (0b10101010)  current bit pos:00 → 0
devices_pG[01] = 040 (0b00101000)  current bit pos:00 → 0
devices_pG[02] = 115 (0b01110011)  current bit pos:00 → 1
devices_pG[03] = 085 (0b01010101)  current bit pos:00 → 1
devices_pG[04] = 162 (0b10100010)  current bit pos:00 → 0
devices_pG[05] = 224 (0b11100000)  current bit pos:00 → 0
0devices_pG[06] = 024 (0b00011000)  current bit pos:00 → 0
$ echo -n r > toTesterFifoFd 
fifo: 0x72 (r) cnt:1 bitPos:0
 readState:1 bitPos:0
  in search [00] ~bit:1
  in search [01] ~bit:1
  in search [02] ~bit:0
  in search [03] ~bit:0
  in search [04] ~bit:1
  in search [05] ~bit:1
  in search [06] ~bit:1
  <= 0

devices_pG[00] = 170 (0b10101010)  current bit pos:00 → 0
devices_pG[01] = 040 (0b00101000)  current bit pos:00 → 0
devices_pG[02] = 115 (0b01110011)  current bit pos:00 → 1
devices_pG[03] = 085 (0b01010101)  current bit pos:00 → 1
0devices_pG[04] = 162 (0b10100010)  current bit pos:00 → 0
devices_pG[05] = 224 (0b11100000)  current bit pos:00 → 0
devices_pG[06] = 024 (0b00011000)  current bit pos:00 → 0
$ echo -n 0 > toTesterFifoFd 
fifo: 0x30 (0) cnt:1 bitPos:0
 readState:2 bitPos:0
   removing: 02
   removing: 03

devices_pG[00] = 170 (0b10101010)  current bit pos:01 → 1
devices_pG[01] = 040 (0b00101000)  current bit pos:01 → 0
devices_pG[04] = 162 (0b10100010)  current bit pos:01 → 1
devices_pG[05] = 224 (0b11100000)  current bit pos:01 → 0
devices_pG[06] = 024 (0b00011000)  current bit pos:01 → 0

The testing program takes an optional cmdline argument. This argument names a file whose contents are the data to be used. This data represents a list of unique serial numbers for the search program to find. The first line of the data file specifies the number of devices (N), the second line specifies the bit size of the devices, and the subsequent N lines start with a unique positive integer number representing the serial IDs of each of the devices connected to the 1-wire bus. If no file is specified, the test program will simply generate random data (time-seeded) to use.

When generating random data, you can tell the testing program the bit size to use via the -b/--bitsize cmdline option, and you can specify the maximum number of devices to generate using the -m/--max-devices cmdline argument. Note that by specifying the maximum number of devices this doesn't mean that this many devices will be created; a random number of devices will be created, up to a maximum of the number given.

Note: the program has provisions to make sure that the max-devices and bitsize options make sense. You can't set the bitsize to 4, then ask for a maximum of 1000 devices.

$ ./tester -b4 -m1000
the given bit size (4) is not high enough to randomly
generate the requested number of max entries (1000)

please either increase the bit size
or reduce the number of max entries to 7 or less

The help for the testing program is as follows:

$ ./tester -h
ROMsearch 1.0.0
usage: ./tester [<options>] [<testfile>]
  where:
    <testfile>              a file from which to get serial ID data
                            (otherwise the data is generated randomly)
    <options>
      -h|--help             print information about this program and exit successfully
      -b|--bitsize <b>      set the number of bits in the serial ID to <b> (MIN:2 default:8 MAX:64)
      -m|--max-devices <m>  set the maximum number of devices (MIN:1 default:8)

Note that if you specify a testfile to use and try to specify a max-devices and/or bitsize, the max-devices and/or bitsize options will be ignored:

$ ./tester -b8 -m20 data01
WARNING: specifying the bit size and/or max entries on the cmdline
         is not compatible with using pre-generated data from a file
these cmdline options will be ignored in favour of the values from the datafile
devices: 3
bitsize: 8
devices_pG[00] = 210 (0b11010010)  current bit pos:00 → 0
devices_pG[01] = 205 (0b11001101)  current bit pos:00 → 1
devices_pG[02] = 164 (0b10100100)  current bit pos:00 → 0

The testing program understands the following commands on the "toTesterFifoFd" fifo:

  • r
    • The master is performing a read on the bus. The tester writes the logical AND of all the current bits of each of the device's serial numbers that are still part of the search as one value. If this is the second such 'r' command then the tester will AND the complement of all the device's current serial bits of the devices that are still part of the search.
  • R
    • RESET. The tester resets the current bit position back to the beginning (i.e. bit 0, LSb first) and resets the state back to expecting the ROM command (i.e. ROMsearch).
  • S
    • ROMsearch. After coming out of RESET, the devices need to know which function to perform. Currently the only supported function is ROMsearch, but I include it in order to be faithful to the 1-wire protocol.
  • 0 or 1
    • The master is choosing which devices are to continue on being part of the search, and which are go to idle until the next RESET. Any device whose current serial ID bit position contains either the '0' or the '1' that is sent by the master remain in the search.
  • Q
    • I've added this command so the master can tell the testing program that it is done, so the tester can terminate gracefully.
  • V
    • Another command I added to toggle the verbosity of the tester.

The ROMsearch program takes no arguments; it simply looks for the "toTesterFifoFd" and "fmTesterFifoFd" fifos and begins running through the ROMsearch algorithm to find all the devices represented in the testing program. As it finds devices, it prints their serial IDs to stdout.

$ ./tester &
[1] 11651
devices: 5
bitsize: 8
devices_pG[00] = 060 (0b00111100)  current bit pos:00 → 0
devices_pG[01] = 188 (0b10111100)  current bit pos:00 → 0
devices_pG[02] = 007 (0b00000111)  current bit pos:00 → 1
devices_pG[03] = 061 (0b00111101)  current bit pos:00 → 1
devices_pG[04] = 197 (0b11000101)  current bit pos:00 → 1
$ ./ROMsearch 
00011110...060
01100010...197
01011110...188
00000011...007
00011110...061
[1]+  Done                    ./tester