CuPID Remote Enhanced : Flexible IO Mote

Introduction

So recently, we’ve been down the road of creating a versatile harem of remotes. Of particular interest is our small form factor unit, which eschews any front-panel indicators or controls for low power and size.

We want this unit to be small, flexible, attractive, and powerful. We want to be able to have it battery or wall-powered. For inputs, we want 1Wire, digital, analog, and anything we can do on Arduino digital pins. For outputs, logic-level digital, as well as open-drain transistor outputs would be nice. We also want to monitor battery power, which we typically do with a voltage divider, as we did before.

Previously, we showed we can package this all up in a little box we like in our CuPID remote:

CuPID Remote

Since we’ve nailed down our design, we decided to get it onto a board, clean up the box, and make it pretty.

The Board

Let’s introduce the board:

Our CuPID Remote board: flexible and capable, and fits just right. Really, just barely.
Our CuPID Remote board: flexible and capable, and fits just right. Really, just barely.

The Fit

It’s tight. I mean, really tight. If we do nothing, we can wedge the board in there, and we wouldn’t even need to screw it down. The round corners above are gently filed, giving a free fit. Here’s how it fits:

It fits, but barely. But with so little room, we need to make it all count.
It fits, but barely. But with so little room, we need to make it all count.

We’ve left room at the top and bottom, where we’ll drill and fit cable entries. They need a bit of room for the sealing nut and for the cable bend.

Putting it together

So here, we’ll put together one configuration of this remote: a USB-powered, remote sensor node. This one will go into a system with a powered sensor, so our power output screw terminal will come in handy. Here’s our parts layout:

Our parts layout. We don't need to use the female headers, but it's nice to be able to pull the RF unit out if we want.
Our parts layout. We don’t need to use the female headers, but it’s nice to be able to pull the RF unit out if we want.

After some soldering, we get this:

Our assembled board, and how it fits. Looks good!
Our assembled board, and how it fits. Looks good!

Finally, we put our holes in our enclosure, and feed in some USB power into our terminal blocks. We’re ready for whatever sensor we decide to use.

Our completed USB mote, ready for sensor.
Our completed USB mote, ready for sensor.

CuPID Remote : Wireless RF Temperature Controller

The Project

After our remote temperature sensor project, we wanted to work on another of the basic unit operations of a typical controls systems network: the temperature controller. Building the basic CuPID Remote, we wanted to incorporate a number of features:

  • Local indication of control variables
  • Local control of control parameters and current system status
  • RF transmission to a CuPID base unit
  • Flexible IO to allow addition of sensors and local control of relays
  • Encapsulation within a waterproof enclosure

Basically, we wanted a fully functional, waterproof industrial temperature controller, with the added features that it would communicate with RF to our CuPID controller, and then to the world over the internets.

For this project, we’re going to use a 5V usb power supply (although the regulator built in to our Moteino microcontroller could take less or more), and we’ll send out a few logic-level (3.3V) signals to control a solid-state relay. The idea, however, is that we can power it directly using the same power supply that runs something like a solenoid, if for example you were controlling the temperature on a jacketed fermenter by controlling the flow of glycol. In that case, we’d throw in a switching regulator to bring the input voltage down for the microcontroller, and put in a miniature relay or power MOSFET.  We’ll get to this shortly.

The Parts

For the project, we’ll use the following parts:

  • Moteino R4 with RFM69W Receiver
  • 4-digit 7-segment common-anode display, e.g. this
  • Encoder with button, e.g. this guy
  • Polycase WC22F waterproof enclosure
  • MAX 7221 8-digit LED Display Driver
  • PG Cable Glands, e.g. HEYCO 3207
  • Assorted connectors, ribbon cables, and a prototyping board. We won’t list these because they’ll change when we get our PCBs made

Construction

So the really unfortunate part about prototyping embedded electronics is trying to get a bunch of stuff into a small box without a PCB … as you shall see. At first layout, we wanted to shove everything above into our teensy box, along with a couple shift registers to drive a 12-bar LED graph, like we did before. We’ll end up putting this option into the PCB, but as you can see, the prospect of hand-soldering all of these components is a bit mind-numbing.

Layout of all components within our enclosure. We don't use all of these for the prototypes.
Layout of all components within our enclosure. We don’t use all of these for the prototypes.

So we pulled off the shift registers, the power supply, the relay, and soldered up the rest. These are all options for the future. The soldering was still pretty ugly. Here is the wiring diagram for what was left:

Pin layout for the prototype
Pin layout for the prototype

First, we create our holes, insert our cable glands and cables, and insert them into our screw terminals. From left to right, we have USB (5V, GND), Cat5 (outputs 1,2,3, 5V, and GND), and our 1Wire sensor (Data, 5V, GND).

Our cable entries. From left to right: USB, IO, and 1Wire temperature sensor.
Our cable entries. From left to right: USB, IO, and 1Wire temperature sensor.

Now we solder it all up. We add vertical headers to the Moteino board so we can still use an FTDI cable with the lid off. Here is everything all soldered up and inserted:

Assembly of our components into our little box.
Assembly of our components into our little box.

Alright, so let’s mount her and plug her in. Here’s what we get all plugged in and running:

Live action shot of our controller. If you look closely, you can see how we tried a number of adhesives we weren't terribly happy with, which left remnants. They don't affect the visibility once the LEDs are on.
Live action shot of our controller. If you look closely, you can see how we tried a number of adhesives we weren’t terribly happy with, which left remnants. They don’t affect the visibility once the LEDs are on.

One of the nice parts we mentioned previously is the mounting of the headers on the board so we can open it up and easily connect our FTDI cable:

A nice part about this design is that we can easily attach our FTDI connector cable to the headers once we open the top. In the future, we'll install an FTDI chip on-board and use the USB cable for data transfer.
A nice part about this design is that we can easily attach our FTDI connector cable to the headers once we open the top. In the future, we’ll install an FTDI chip on-board and use the USB cable for data transfer.

 

The Code Bits

 7-segment LCD

The tough part of the LCD is handled by a neat little chip, the MAX7221, which will handle up to eight digits, four of which we’re using here. You don’t, of course, need to have this chip, using shift registers, as we demonstrated with a bar segment display previously. If you have a common anode display, however, this chip is a great way to offload the processing and not worry about the timing of the remainder of the program. Here is the code. We use the LedControl library, which has built-in functions for numerical digits, and the remaining characters can be pieced together with binary addition of the digits and the set row command:

void handledisplay(){
  byte displaymode = 0;
  float newlcdvalue;
  
  if (menuy) {
    blinky = 1;
  }
  else {
    blinky = 0;
  }
  
  //  Serial.println(testvalue);
  if (blinky) {
    byte blinkinterval = (millis()/200) % 2;
    if (!blinkinterval) {
      displayon = 1;
    }
    else {
      displayon = 0;
    }
  }
  else {
    displayon = 1;
  }
  
  if (menux == 0){
    if (menuy == 0){
      if (tempmode == 'F'){
        newlcdvalue = CtoF(chanpv[0]);
      }
      else{
        newlcdvalue = chanpv[0];
      }
    }
    if (menuy == 1) {
//      Serial.println("I am in the menu");
      displaymode = 1;  // display characters
      lcdvalue = chartoint(tempmode);
    }
  }
  else if (menux == 1) {
    if (tempmode == 'F'){
        newlcdvalue = CtoF(chansv[0]);
      }
      else{
        newlcdvalue = chansv[0];
      }
  }
  
  if (displayon){
    if (newlcdvalue != lcdvalue) {
      if (displaymode == 0){
        lcdvalue = newlcdvalue;
        setdigits(newlcdvalue);
      }
      else if (displaymode == 1){
        setchars('-','-','-',tempmode);
      }
    }
  }
  else {
    lc.clearDisplay(0);
    lcdvalue = -9999; // ensure refresh
  }
}
void setchars(char char1, char char2, char char3, char char4){
  lc.clearDisplay(0);
  lc.setRow(0,0,chartoint(char1));
  lc.setRow(0,1,chartoint(char2));
  lc.setRow(0,2,chartoint(char3));
  lc.setRow(0,3,chartoint(char4));
}
byte chartoint(char mychar){
  byte charbyte = 62;
  switch (mychar) {
    case '-':
      charbyte = 0;
      break;    
    case 'F':
      charbyte = 71;
      break;
    case 'C':
      charbyte = 78;
      break;
    case 'V':
      charbyte = 62;
      break;
    case 'S':
      charbyte = 91;
      break;
    case 'P':
      charbyte = 104;
      break;
  }
  return charbyte;
}
void setdigits(float lcdvalue) {
   
  int dig1 = lcdvalue / 10;
  float leftovers = lcdvalue - (dig1 * 10);
  int dig2 = leftovers;
  leftovers -= dig2;
  int dig3 = leftovers * 10;
  leftovers -= dig3 / 10;
  int dig4 = leftovers;
    
  if (lcdvalue < 0) {
    
    // display value
    lc.clearDisplay(0);
    
    
    // this should work for the negative sign
    lc.setRow(0,0,1);
    lc.setDigit(0,1,dig1,false);
    lc.setDigit(0,2,dig2,true);
    lc.setDigit(0,3,dig3,false);
  }
  else {
    // display value
    lc.clearDisplay(0);
    lc.setDigit(0,0,dig1,false);  
    lc.setDigit(0,1,dig2,true);
    lc.setDigit(0,2,dig3,false);
    lc.setDigit(0,3,dig4,false);
  }
}

Eventually, we’ll wrap up all the digits for setRow, using our chartoint() function, so we can use the same subfunction for all set functions.  In the meantime, here are the binary values for the segments, using the notation A-H shown above in the wiring schematic:

LCD segment binary value
a 64
b 32
c 16
d 8
e 4
f 2
g 1
h 128
Encoder

Although the way the encoder works is a bit bizarre and archaic, the code is pretty simple. We attach an interrupt one of the pins so that the handler function is called whenever the encoder is activated. It looks a bit like this:

pinMode (encoder0PinA,INPUT);
  pinMode (encoder0PinB,INPUT);
  attachInterrupt(1, doEncoder, CHANGE);

void doEncoder() {
  int pinAval = digitalRead(encoder0PinA);
  int pinBval = digitalRead(encoder0PinB);
  byte direction;
  if ((pinAval == HIGH) && (lastpinAval == LOW)) {
    if (pinBval == HIGH) {
      direction = 0; // CCW
    } else {
      direction = 1; // CW
    }
    // PV menu
    if (menux == 0){
      if (menuy == 0){
        if (direction == 1) {
          menux = 1;
        }
      }
      else if (menuy == 1){
        if (tempmode == 'F'){
          tempmode = 'C';
        }
        else if (tempmode == 'C'){
          tempmode = 'F';
        }
      }
    } // menu top  level
    // SV menu
    else if (menux == 1){
      if (menuy == 0){
        if (direction == 0) {
          menux = 0;
        }
      }
      else if (menuy == 1) {
        if (direction == 1) {
          chansv[0] += 0.1;
        }
        else if (direction == 0) {
          chansv[0] -= 0.1;
        }
      }
    } // menu top  level
  } // 
  lastpinAval = pinAval;
}

You can see our simple menu structure handling here as well, a simple 2D array of states and handling.

The Web UI

Clips coming. Looks like this.

Testing

In production. Here’s a screenshot:

Temperature cycling of our Beer Freezer with a deadband of 2C.
Temperature cycling of our Beer Freezer with a deadband of 2C.

Next

Add segment display for output indication

We’d like to shove one of these in a box. We’ve already prepared one, but we’ll definitely need to put it on a PCB to make the connections. Stand by.

Put Moteino interface on power USB

We’ve already got USB going into our enclosure. No reason to require opening the enclosure lid, if we’ve already got data lines headed in. Trick is, we’ll have to put a permanent FTDI in place to do so. We’ll at least create pads on our PCB to populate if we want to. IF we have room.

Moteino / Arduino and 1Wire : Optimize your read for speed

Hello all,

I’ve been picking apart my code to do some optimization, and an obvious target was the read of 1Wire devices, the DS18B20 in particular. Currently, I use the OneWire library, without the DallasTemperature library, which I didn’t much care for.

What all 1Wire read routines have in common is that they issue the Convert command prior to reading a measured value. For the DS18B20, this is the Convert T command, 0x44. Depending on the resolution, the conversion takes from 75-750ms, per the datasheet.

There are three main issues I had with the implementation using OneWire as in the examples:

  • There was no provision for setting the resolution. While this is available in the DallasTemperature library, it just didn’t make sense to me to rewrite all of my lean code around this library to get this one feature.
  • Wait times are hardcoded. Per the datasheet, it is possible to check with the device for status after issuing the Convert T command to know exactly when the temperature conversion is complete. In most implementations, however, this wait time is hard-coded, and typically VERY generously. For example, in a read operation at 12 bits that actually takes <600ms, the hardcoded wait time is 1000ms. This is a 40% savings, ladies and gentlemen. Futhermore, most test code does not even adjust the delay time for different resolution. This is just insane. A ready that should take 60-75ms will implement a wait of 1000ms (?!). I mean, I understand if speed is not that important to you, but this is crazy.
  • Everything is done synchronously. Nothing else can happen while we are waiting for this conversion This is very inefficient, that is, in the case that your program needs to do anything else. This is  the real reason I dug into this. I wanted to read sensors, but be able to update a display in real-time. The moral of the story: if anything else in your program depends on timing, forget using the default 1Wire read code. So to easily work out the first two items, I created the example below in code box 1. Run it after substituting your 1Wire pin, and you’ll get something like this for reading 9, 10, 11, and 12 bit resolutions:
dsaddress:2838FF400500004E,
 Conversion took: 76 ms
 Raw Scratchpad Data:
 50 1 0 0 1F FF 10 10 21
 Temp (C): 21.00

dsaddress:2838FF400500004E,
 Conversion took: 150 ms
 Raw Scratchpad Data:
 50 1 0 0 3F FF 10 10 51
 Temp (C): 21.00

dsaddress:2838FF400500004E,
 Conversion took: 298 ms
 Raw Scratchpad Data:
 50 1 0 0 5F FF 10 10 C1
 Temp (C): 21.00

dsaddress:2838FF400500004E,
 Conversion took: 596 ms
 Raw Scratchpad Data:
 4F 1 0 0 7F FF 1 10 37
 Temp (C): 20.94

A HUGE improvement over stock wait times. With the accuracy of the DS18B20, it really doesn’t make much sense to use 12 bits, so 10 bits saves me loads in timing. I left in a bunch of original code that’s been commented out so you can see how it was done previously.

Now, we can also separate conversion commands and reading the data back. If you have multiple sensors, you actually want to use the Skip ROM and Convert T commands to tell all devices on the bus to convert simultaneously, but that’s another topic. In the meantime while conversion is taking place, we can do other stuff.

So we separate our read DS18B20 routine into find, set resolution, send conversion command, and finally read temperature. Between the last two, we just continually check in our loop to see that data is ready, and when it is, we read it. Pretty simple, but SUPER EFFECTIVE. You can see we lose a little due to overhead, but still, plenty fast, and we can do other stuff at the same time.

Enjoy!
C

Temp (C): 21.50
Elapsed time (ms): 99
Temp (C): 21.50
Elapsed time (ms): 170
Temp (C): 21.62
Elapsed time (ms): 321
Temp (C): 21.56
Elapsed time (ms): 618

Code box 1:

#include <OneWire.h>

#define LED 9
#define SERIAL_BAUD   115200

void setup(void) {
Serial.begin(SERIAL_BAUD);
}

void loop(void) {
for (int i=9;i<13;i++){
handleOWIO(6,i);
Serial.println();
}

delay(1000);
Blink(LED,3);
}

void handleOWIO(byte pin, byte resolution) {
int owpin = pin;

// Device identifier
byte dsaddr[8];
OneWire myds(owpin);
getfirstdsadd(myds,dsaddr);

Serial.print(F("dsaddress:"));
int j;
for (j=0;j<8;j++) {
if (dsaddr[j] < 16) {
Serial.print('0');
}
Serial.print(dsaddr[j], HEX);
}
// Data

Serial.println(getdstemp(myds, dsaddr, resolution));

} // run OW sequence

void getfirstdsadd(OneWire myds, byte firstadd[]){
byte i;
byte present = 0;
byte addr[8];
float celsius, fahrenheit;

int length = 8;

//Serial.print("Looking for 1-Wire devices...\n\r");
while(myds.search(addr)) {
//Serial.print("\n\rFound \'1-Wire\' device with address:\n\r");
for( i = 0; i < 8; i++) {
firstadd[i]=addr[i];
//Serial.print("0x");
if (addr[i] < 16) {
//Serial.print('0');
}
//Serial.print(addr[i], HEX);
if (i < 7) {
//Serial.print(", ");
}
}
if ( OneWire::crc8( addr, 7) != addr[7]) {
//Serial.print("CRC is not valid!\n");
return;
}
// the first ROM byte indicates which chip

//Serial.print("\n\raddress:");
//Serial.print(addr[0]);

return;
}
}

float getdstemp(OneWire myds, byte addr[8], byte resolution) {
byte present = 0;
int i;
byte data[12];
byte type_s;
float celsius;
float fahrenheit;

switch (addr[0]) {
case 0x10:
//Serial.println(F("  Chip = DS18S20"));  // or old DS1820
type_s = 1;
break;
case 0x28:
//Serial.println(F("  Chip = DS18B20"));
type_s = 0;
break;
case 0x22:
//Serial.println(F("  Chip = DS1822"));
type_s = 0;
break;
default:
Serial.println(F("Device is not a DS18x20 family device."));
}

// Get byte for desired resolution
byte resbyte = 0x1F;
if (resolution == 12){
resbyte = 0x7F;
}
else if (resolution == 11) {
resbyte = 0x5F;
}
else if (resolution == 10) {
resbyte = 0x3F;
}

// Set configuration
myds.reset();
myds.select(addr);
myds.write(0x4E);         // Write scratchpad
myds.write(0);            // TL
myds.write(0);            // TH
myds.write(resbyte);         // Configuration Register

myds.write(0x48);         // Copy Scratchpad

myds.reset();
myds.select(addr);

long starttime = millis();
myds.write(0x44,1);         // start conversion, with parasite power on at the end
while (!myds.read()) {
// do nothing
}
Serial.print("Conversion took: ");
Serial.print(millis() - starttime);
Serial.println(" ms");

//delay(1000);     // maybe 750ms is enough, maybe not
// we might do a ds.depower() here, but the reset will take care of it.

present = myds.reset();
myds.select(addr);
myds.write(0xBE);         // Read Scratchpad

//Serial.print("  Data = ");
//Serial.print(present,HEX);
Serial.println("Raw Scratchpad Data: ");
for ( i = 0; i < 9; i++) {           // we need 9 bytes
data[i] = myds.read();
Serial.print(data[i], HEX);
Serial.print(" ");
}
//Serial.print(" CRC=");
//Serial.print(OneWire::crc8(data, 8), HEX);
Serial.println();

// convert the data to actual temperature

unsigned int raw = (data[1] << 8) | data[0];
if (type_s) {
raw = raw << 3; // 9 bit resolution default
if (data[7] == 0x10) {
// count remain gives full 12 bit resolution
raw = (raw & 0xFFF0) + 12 - data[6];
} else {
byte cfg = (data[4] & 0x60);
if (cfg == 0x00) raw = raw << 3;  // 9 bit resolution, 93.75 ms
else if (cfg == 0x20) raw = raw << 2; // 10 bit res, 187.5 ms
else if (cfg == 0x40) raw = raw << 1; // 11 bit res, 375 ms
// default is 12 bit resolution, 750 ms conversion time
}
}
celsius = (float)raw / 16.0;
fahrenheit = celsius * 1.8 + 32.0;
Serial.print("Temp (C): ");
//Serial.println(celsius);
return celsius;
}

void Blink(byte PIN, int DELAY_MS)
{
pinMode(PIN, OUTPUT);
digitalWrite(PIN,HIGH);
delay(DELAY_MS);
digitalWrite(PIN,LOW);
}

Code box 2:

#include <OneWire.h>

#define LED 9
#define SERIAL_BAUD   115200

OneWire myds(6);
byte readstage;
byte resolution;
unsigned long starttime;
unsigned long elapsedtime;
byte dsaddr[8];

void setup(void) {
Serial.begin(SERIAL_BAUD);
readstage = 0;
resolution = 12;
}

void loop(void) {

if (readstage == 0){
getfirstdsadd(myds,dsaddr);
dssetresolution(myds,dsaddr,resolution);
starttime = millis();
dsconvertcommand(myds,dsaddr);
readstage++;
}
else {
if (myds.read()) {
Serial.println(dsreadtemp(myds,dsaddr, resolution));

Serial.print("Elapsed time (ms): ");
elapsedtime = millis() - starttime;
Serial.println(elapsedtime);
readstage=0;
if (resolution == 12){
resolution = 9;
}
else {
resolution ++;
}
}
}

Blink(LED,5);
}

void getfirstdsadd(OneWire myds, byte firstadd[]){
byte i;
byte present = 0;
byte addr[8];
float celsius, fahrenheit;

int length = 8;

//Serial.print("Looking for 1-Wire devices...\n\r");
while(myds.search(addr)) {
//Serial.print("\n\rFound \'1-Wire\' device with address:\n\r");
for( i = 0; i < 8; i++) {
firstadd[i]=addr[i];
//Serial.print("0x");
if (addr[i] < 16) {
//        Serial.print('0');
}
//      Serial.print(addr[i], HEX);
if (i < 7) {
//Serial.print(", ");
}
}
if ( OneWire::crc8( addr, 7) != addr[7]) {
Serial.print("CRC is not valid!\n");
return;
}
// the first ROM byte indicates which chip

//Serial.print("\n\raddress:");
//Serial.print(addr[0]);

return;
}
}

void dssetresolution(OneWire myds, byte addr[8], byte resolution) {

// Get byte for desired resolution
byte resbyte = 0x1F;
if (resolution == 12){
resbyte = 0x7F;
}
else if (resolution == 11) {
resbyte = 0x5F;
}
else if (resolution == 10) {
resbyte = 0x3F;
}

// Set configuration
myds.reset();
myds.select(addr);
myds.write(0x4E);         // Write scratchpad
myds.write(0);            // TL
myds.write(0);            // TH
myds.write(resbyte);         // Configuration Register

myds.write(0x48);         // Copy Scratchpad
}

void dsconvertcommand(OneWire myds, byte addr[8]){
myds.reset();
myds.select(addr);
myds.write(0x44,1);         // start conversion, with parasite power on at the end

}

float dsreadtemp(OneWire myds, byte addr[8], byte resolution) {
byte present = 0;
int i;
byte data[12];
byte type_s;
float celsius;
float fahrenheit;

switch (addr[0]) {
case 0x10:
//Serial.println(F("  Chip = DS18S20"));  // or old DS1820
type_s = 1;
break;
case 0x28:
//Serial.println(F("  Chip = DS18B20"));
type_s = 0;
break;
case 0x22:
//Serial.println(F("  Chip = DS1822"));
type_s = 0;
break;
default:
Serial.println(F("Device is not a DS18x20 family device."));
}

present = myds.reset();
myds.select(addr);
myds.write(0xBE);         // Read Scratchpad

//Serial.print("  Data = ");
//Serial.print(present,HEX);
//  Serial.println("Raw Scratchpad Data: ");
for ( i = 0; i < 9; i++) {           // we need 9 bytes
data[i] = myds.read();
//    Serial.print(data[i], HEX);
//    Serial.print(" ");
}
//Serial.print(" CRC=");
//Serial.print(OneWire::crc8(data, 8), HEX);
//  Serial.println();

// convert the data to actual temperature

unsigned int raw = (data[1] << 8) | data[0];
if (type_s) {
raw = raw << 3; // 9 bit resolution default
if (data[7] == 0x10) {
// count remain gives full 12 bit resolution
raw = (raw & 0xFFF0) + 12 - data[6];
} else {
byte cfg = (data[4] & 0x60);
if (cfg == 0x00) raw = raw << 3;  // 9 bit resolution, 93.75 ms
else if (cfg == 0x20) raw = raw << 2; // 10 bit res, 187.5 ms
else if (cfg == 0x40) raw = raw << 1; // 11 bit res, 375 ms
// default is 12 bit resolution, 750 ms conversion time
}
}
celsius = (float)raw / 16.0;
fahrenheit = celsius * 1.8 + 32.0;
Serial.print("Temp (C): ");
//Serial.println(celsius);
return celsius;
}

void Blink(byte PIN, int DELAY_MS)
{
pinMode(PIN, OUTPUT);
digitalWrite(PIN,HIGH);
delay(DELAY_MS);
digitalWrite(PIN,LOW);
}