Saturday, January 28, 2012

Interfacing Sensirion temperature / humidity sensors to the XBee using 'bit-banging' technique

This article documents an technique to interfacing digital sensors such as the Sensirion SHT21 to an XBee Series 2 radio module running ZigBee end device firmware without using any additional hardware components. The same technique should apply to similar digital sensors.

The XBee Series 2 radio modules from Digi are ideal for developing prototype and low production run wireless sensors. The modules are pre-certified in US, Europe and other regions eliminating the requirement to under go RF certification. In addition to a UART port for communications the XBee module also provides  several IO lines which can be configured in digital input, digital output, ADC and PWM modes.

Digi provide several protocol options including DigiMesh (their own proprietary protocol) and ZigBee. For interoperability with other vendors equipment and security I use ZigBee. Each protocol family has several firmware options which can be loaded on the module: usually Coordinator, Router and End Device.  For battery powered applications the End Device firmware must be used to achieve reasonable battery life. In this mode the module spends most of its time asleep using less than 1µA current. Periodically it will wake (briefly consuming 15 - 40mA), query its parent router to see if there are any waiting packets. If there are none it will go back to sleep. In this mode a set of AAA batteries can last a year or more.

Analog sensors can be coupled to XBee IO lines in ADC mode (10 bits resolution ranging from 0 - 1.2V) and read remotely using Digi's remote sampling API. However analog sensors will probably require signal conditioning circuitry (op amps, filters etc) to make the best use of the ADC voltage range. This conditioning circuitry is likely to require individual calibration to achieve good results.
The headache of analog design can be conveniently side-stepped by using a sensor with a digital IO interface. Sensirion produce a range of temperature / humidity sensors with an impressive resolution (14 bit temperature, 12 bit humidity) and accuracy (less than ±0.3°C for some versions). These sensors talk I2C protocol (or a variant of I2C). But there is a catch: the XBee firmware from Digi does not directly support I2C.

A common solution is to use a low cost MCU as a bridge between the sensor and the XBee's UART. The MCU waits for a command from the network (via the XBee's UART), queries the sensor and relays the result back to the UART for transmission on the ZigBee network. By monitoring the XBee's SLEEP pin the MCU can also spend most of its time in a low power sleep, waking only when the XBee wakes.
There is an alternative... a common solution in a situation where there is no direct hardware support for a serial protocol: "bit banging". Each of the XBee's IO lines can be set high, low or in high impedance (input) state by remote control: everything needed to realize the I2C and SPI protocols. But there is a catch: unlike a MCU bit banging its own IO lines which can happen at clock speeds exceeding 100kHz, remotely bit banging XBee IO lines is a very slow process. Fortunately the Sensirion sensor datasheets do not place any lower bound on the protocol clock speed. So a clocking speed of just 1Hz will work just as well as 100kHz... it's just going to take a while to complete a query.
This is what the test setup looks like. A SHT75 sensor is at the bottom of the photo. A XBee board (the Grove XBee Carrier board from SeeedStudio) with DIO1 and DIO2 connected to the SHT75 clock and data pins respectively. The 3.3V power supply from the board powers the sensor.




Implemenation details:

The details of how to communicate with an XBee module are beyond the scope of this article. A good introduction to the topic is the book Building Wireless Sensor Networks from O’Reilly.

It’s important to note that the I2C protocol requires pull-up resistors on the clock and data lines. Fortunately the XBee has configurable 30k internal pull-up resistors for digital inputs which are configured with the ATPR command. They are enabled by default.

A sensor query begins by first issuing a remote ATIR command to the XBee to initiate frequent IO sampling. I found 100ms sampling period gave good results. ATIR must be followed by ATAC (commit) for the change to take effect. The sampling will keep the (normally sleeping) XBee awake. Also each time a sample packet is transmitted the acknowledgement will let the XBee know if there are incoming packets waiting for it. So it helps keep packet latency relatively low. Samples packets comprise the high/low state of any digital input pins and the ADC value of any pins configured as ADC.

Before proceeding to the next step wait until the first sample arrives. This may take several seconds depending on where the XBee is in its sleep cycle. When it starts to transmit samples you know it is awake and will stay awake.

Now issue ATD commands to set the clock and data lines into the necessary state. For example, ATD13 sets DIO1 into input (or high impedance) state, ATD14 sets DIO1 into a output low (0V) and ATD15 sets DIO1 into output high (3.3V). Any ATD command will need to be followed by an ATAC before the change will take effect.

To read a data line during the read phase of an I2C conversation, wait for a IO sample to arrive after the low-to-high transition of the clock.

Finally when the I2C conversation is complete set ATIR=0 to stop automatic sampling, followed by ATAC. On reception the XBee should go back to sleep.

I found that a short delay (about 100ms) between each AT command was required for acceptable results.

This is an oscilloscope trace of a temperature query of a SHT75 sensor. The SHT7x and SHT1x range use a protocol similar to, but not compatible with I2C. Note the time base is 3.7 seconds per division! The clock is in green, the data in yellow. The blue trace is connected to a third pin which was used for debugging (to help separate out parts of the conversation). Here it is set high while writing out the command (0x03, read temperature) .


The SHT21 and SHT25 are more recent temperature / humidity sensors from Sensirion. These sensors use standard I2C protocol. This trace is a I2C read temperature query to a SHT21 sensor:



Some results:

This is a chart of a few hours of data from a SHT21 and SHT75 connected to an XBee using this technique. For comparison a third wireless sensor, a Digi XS-Z16-CB2R sensor is included. All three sensors were enclosed in an insulated polystyrene box to ensure that all sensors were reading the same temperature and humidity. I've also included the supply voltage (a handy feature of the XBee modules).


One slightly disappointing result is that there are quite a few failed queries (compare the number of  CB2R samples in blue to those in red and green). It seems that with my current implementation of this bit banging technique the success rate is about 66%. I believe this can be significantly improved with some changes to the implementation.

Conclusion:

Bit banging serial IO protocols such as I2C, SPI with the XBee IO lines under remote control is feasible. A battery powered sensor unit can be constructed with nothing more than a XBee, a digital sensor (and some means to physically link the sensor to the XBee), a battery holder and suitable housing.  No other components are required.

However there are some down sides: it takes a long time to make a measurement (10 - 30 seconds). During this period the XBee is awake (consuming 15 - 30mA). This is not a problem if  AA or AAA cells are used and the measurements are infrequent (eg once per hour). As currently implemented, reliability is far from perfect (2 out of 3 queries succeed) however I believe this can be improved with some tweaks to the implementation. Also over 100 ZigBee packets are required to complete one measurement: this could be a problem on a congested network.

Code:

Unfortunately the test setup is too complex to package up a simple self contained ZIP file to implement this technique. However here is the source code of the main Java class file which implements the necessary XBee AT commands.

/**
 * Implement temperature and humidity queries to a SHT71 and SHT75 sensor by bit banging
 * XBee IO lines. 
 * 
 * @author Joe Desbonnet, jdesbonnet@gmail.com
 *
 */
public class SHT7x {

 public static final int CMD_TEMPERATURE_READ = 0x03;
 public static final int CMD_HUMIDITY_READ = 0x05;
 
 private XBeeSeries2 xbee;
 
 private int clockPin;
 private int dataPin;
 
 // delay=100, sampleRate=250 does not work
 // delay=150, sampleRate=250 does not work (reliably)
 // delay=200, sampleRate=250 works
 // delay=180, sampleRate=50 works
 // delay=180, sampleRate=100 works

 // delay between sending each packet to the NIC for transmission
 private int delay = 180;
 
 // ms between each sample
 private int sampleRate = 100;

 /**
  * 
  * @param xbee XBee proxy object
  * @param clockPin XBee pin used to implement SCK (0 = DIO0, 1 = DIO1 etc)
  * @param dataPin XBee pin used to implement SDA (0 = DIO0 .. etc)
  */
 public SHT7x(XBeeSeries2 xbee, int clockPin, int dataPin) {
  this.xbee = xbee;
  this.clockPin = clockPin;
  this.dataPin = dataPin;
 }

 /**
  * Implement short delay. Usually used to space ZigBee packets apart.
  */
 private void delay() {
  try {
   Thread.sleep(delay);
  } catch (InterruptedException e) {
   // TODO Auto-generated catch block
   e.printStackTrace();
  }
 }

 private void clockHigh() throws IOException {
  byte[] param = new byte[1];
  param[0] = XBeeSeries2.HIGH;
  xbee.atCommand("D" + clockPin, param);
  xbee.atCommand("AC");
  delay();
 }

 private void clockLow() throws IOException {
  byte[] param = new byte[1];
  param[0] = XBeeSeries2.LOW;
  xbee.atCommand("D" + clockPin, param);
  xbee.atCommand("AC");
  delay();
 }

 private void dataHigh() throws IOException {
  byte[] param = new byte[1];
  // Data high is achieved by high impedance state (HIGH_Z) ie digital
  // input mode
  param[0] = XBeeSeries2.HIGH_Z;
  xbee.atCommand("D" + dataPin, param);
  xbee.atCommand("AC");
  delay();
 }

 private void dataLow() throws IOException {
  byte[] param = new byte[1];
  param[0] = XBeeSeries2.LOW;
  xbee.atCommand("D" + dataPin, param);
  xbee.atCommand("AC");
  delay();
 }

 /**
  * Reset communications with sensor. Required if the previous query did not complete.
  * 
  * @param xbee
  */
 public void resetComms() throws IOException {

  int i;

  dataHigh();

  // Pulse clock 9+ times while data high
  for (i = 0; i < 10; i++) {
   clockHigh();
   clockLow();
  }
 }

 /**
  * Start sequence.
  * 
  * @throws IOException
  * 
  */
 private void startSequence() throws IOException {
  clockHigh();
  dataLow();
  clockLow();
  clockHigh();
  dataHigh();
  clockLow();
 }
 
 /**
  * A command is 8 bits (MSB first) followed reading an ack bit from the device. 
  * In this implementation I ignore the result of the ack bit (but there
  * still must be a 9th clock pulse). Bits are written by setting the
  * data pin to either 0V (logic 0) or high impedance (logic 1). The data is
  * read by the sensor during a low to high transition of the clock signal.
  * 
  * @param command
  * @throws IOException
  */
 private void sendCommand (int command) throws IOException {
  int i;
  boolean lastBit = true;
  boolean currentBit = false;

  // MSB first
  for (i = 0; i < 8; i++) {
   currentBit = ((command & 0x80) != 0);

   // Only change data pin if there is a change. This reduces the number
   // of ZigBee packets transmitted.
   if (currentBit != lastBit) {
    if (currentBit) {
     dataHigh();
    } else {
     dataLow();
    }
    lastBit = currentBit;
   }

   // Pulse clock
   clockHigh();
   clockLow();
   command <<= 1;
  }

  // If data currenly low bring to high_z/input mode
  if (currentBit == false) {
   dataHigh();
  }

  // I don't bother to read the ACK bit, but the clock
  // must still be pulsed for it. 
  clockHigh();
  // don't bother sampling data pin -- will assume all ok
  clockLow();

 }

 /**
  * Reference datasheet §4.3.
  * @return Temperature in °C
  * @throws IOException
  */
 public float readTemperature() throws IOException {
  int v = makeReading(CMD_TEMPERATURE_READ);
  return -39.7f + 0.01f * (float) v;  
 }
 
 /**
  * Reference datasheet §4.1.
  * 
  * @return Humidity as RH%
  * @throws IOException
  */
 public float readHumidity() throws IOException {
  int v = makeReading(CMD_HUMIDITY_READ);
  return (float)(-2.0468 + 0.0367*(double)v - 1.5955e-6*(double)v*(double)v);
 }
 
 /**
  * Reset comms, send start sequence, command and read 16 bits of data.
  * 
  * @param what One of SHT7x.CMD_TEMPERATURE_READ or SHT7x.CMD_HUMIDITY_READ
  * @return
  * @throws IOException
  */
 private int makeReading (int what) throws IOException {

  int i;

  //
  // Configure XBee to send frequent IO samples. This has two important functions.
  // First it keeps the XBee end device awake. Also an end device transmitting a 
  // packet (which must go via its parent) has the side effect of polling the 
  // parent for any incoming packets. So frequent transmission also means low
  // latency in receiving packets.
  
  byte[] params2 = new byte[2];
  params2[0] = (byte) (sampleRate >> 8);
  params2[1] = (byte) (sampleRate & 0xff);
  xbee.atCommand("IR", params2);
  xbee.atCommand("AC");
  
  // Now wait for the first sample to arrive before proceeding. At this point we'll
  // know the end device awake.
  long t0 = System.currentTimeMillis();
  while (xbee.getLastIOSampleTime() < t0) {
   delay();
  }
  
  // XBee End Device should now be awake and responsive

  // The last query may not have completed leaving the communications in an undefined
  // state. Reset communications to a known state.
  resetComms();
  delay();
  delay();

  // Start sequence ref §3.2.
  startSequence();
  delay();
  delay();

  // Send 8 bit command (and read ack bit)
  sendCommand(what);

  // Wait for measurement to complete. We could poll the DATA line. It will be pulled
  // low by the sensor when the reading is complete. However the overhead of doing
  // this makes it not worth the effort.
  try {
   Thread.sleep(200);
  } catch (InterruptedException e1) {
   // ignore
  }

  //
  // Read 16 bits
  //

  int v = 0, sample;
  for (i = 0; i < 16; i++) {

   v <<= 1;

   clockHigh();

   // wait for IO sample
   t0 = System.currentTimeMillis();
   while (xbee.getLastIOSampleTime() < t0) {
    try {
     Thread.sleep(100);
    } catch (InterruptedException e) {
     // ignore
    }
   }
   // sample = xbee.getIOSample();
   sample = xbee.getLastIOSample();
   if ((sample & 0x04) != 0) {
    v |= 1;
   }
   clockLow();

   // write ack bit
   if (i == 7 || i == 15) {
    // write ack bit (0)
    dataLow();
    clockHigh();
    clockLow();
    dataHigh();
   }
  }


  //
  // Return end device to normal sleep pattern by disabling sampling (ie by
  // setting sample period to 0).
  //
  params2 = new byte[2];
  params2[0] = 0;
  params2[1] = 0;
  xbee.atCommand("IR", params2);
  xbee.atCommand("AC");

  return v;

 }

}