Gauge detail screen up and running

Thanks to the code from Ivan Memruk at Mind The Robot, and refinements done by Freddy Martens at atstechlab, I made some additional tweeks to get a reasonable detail gauge for the weather.

I still have more work to do on the details, the current code, although very flexible, makes a lot of implicit assumptions about the values the gauge will have, and the resulting geometry of the face. So, for instance, you can’t get a 300 degree thermometer face, and have the hand point at the right spot, just by changing upper/lower values, and tick mark settings. I’ll remove some of general flexibility, instead adjusting things to let me use more of the gauge face, and define where on the gauge face things are displayed, making overlapping gauges easier.

This will also give me a chance to get visual object cached, so I don’t have to redraw everything on a device rotate. Right now, any orientation change simply calls the main “get weather” again. I’m going to split that into an asynchronous “get weather”, and a “show weather” that simply displays the last received values, when I get some time.

But, before I do that last bit of cleanup, I have one more detail screen I want to do. I’d like to be able to use the Nook as a desktop weather station that I can see from a distance. Gauges are pretty, but not that good for seeing the detail at a distance, so I’ll do a more attractive weather screen with a clock, and likely a rotation of the information from all (or a subset of) the weather stations we’re looking at.

Here’s a picture of the phone screen with the overlapping gauges, unfortuantely, Android doesn’t provide a built-in screen capture, I can do it on my Nook, since I’m running a custom OS which runs on that device, but I’m keeping my DroidX unrooted and stock (for now):

Android Weather Display, updated

VBSP WeatherMade a bunch of progress on the Weather Display. I started with Weather Underground data, since I send data from several of my SensorPacks there. I don’t send information from all my SensorPacks there, only ones actually measuring outdoor conditions. In addition to those outdoor weather stations, I have a sensor I move around the house just to test xbee signals from different places. I also have one in a closet in the garage where we keep wine, which has two accessory sensors as well, one in a wine bottle filled with water on the shelf, and one in the wine chiller. That’s so you can see the room temp, the in-bottle temp, and the in-chiller temp (project description coming soon!).

Also bought an inexpensive Android tablet (a Barnes and Nobel Nook), and have the app running on my DroidX, as well as the tablet, displaying up to 4 stations on the Droid, and up to 8 on the Nook.

The basic functionality works well enough, next big step is to add a per-station detail page, but thought I’d post the app in it’s current state now. It’s my first non-trival Android app, so still learning my way around the system, and how to get things working with Android. App can be downloaded here, and the source code, such as it is, can be found here.

Android weather display

First step, grabbing data sent to Weather Underground from several of my SensorPacks. You’ll note the humidity sensor went out on one sensor, and the second is tracking a slightly higher than realistic humidity, compared to “03”, which is a reference commercial weather station I have. Next step will be a nicer graphical display, then a way to get data for SensorPacks that aren’t sending data to Weather Underground, likely via the network/bluetooth from a local PC that’s already gathering the data. Here’s a screen snap of the emulator, although it runs great on my DroidX too…


Sensor display on a TFT Touchscreen

Here’s a demo of data from the SensorPacks being displayed on a TFT Touchscreen/shield I picked up from Adafruit. Writing a simple UI via graphics primitives reminded me of building UI’s back in the day, where all you have are lines, circles, and x/y coordinates to play with. There wasn’t a lot of data space left over to build functionality, but I managed to get a quick-and-dirty analog clock, as well as the ability to store data for 4 individual SensorPacks. The XBee listens for data, and when it recognizes a packet from a SensorPack, it either updates the entry, or adds it to the list.

I ended up with about 200 bytes of storage left, after porting and streamlining my sensor reading code from the other displays I built, so adding more functionality will require a bit of code rework, but I’m pretty happy with this so far. Will likely add gauges, and the ability to show history at some point.


#include <NewSoftSerial.h>
#include "TFTLCD.h"
#include "TouchScreen.h"

boolean firstRun = true;

//#define DEBUG 2
//#define DEBUG 0

#if not defined USE_ADAFRUIT_SHIELD_PINOUT 
 #error "For use with the shield, make sure to #define USE_ADAFRUIT_SHIELD_PINOUT in the TFTLCD.h library file"
#endif

// These are the pins for the shield!
#define YP A1  // must be an analog pin, use "An" notation!
#define XM A2  // must be an analog pin, use "An" notation!
#define YM 7   // can be a digital pin
#define XP 6   // can be a digital pin

#define TS_MINX 150
#define TS_MINY 120
#define TS_MAXX 920
#define TS_MAXY 940

// For better pressure precision, we need to know the resistance
// between X+ and X- Use any multimeter to read it
// For the one we're using, its 300 ohms across the X plate
TouchScreen ts = TouchScreen(XP, YP, XM, YM, 300);

// The control pins can connect to any pins but we'll use the 
// analog lines since that means we can double up the pins
// with the touch screen (see the TFT paint example)
#define LCD_CS A3    // Chip Select goes to Analog 3
#define LCD_CD A2    // Command/Data goes to Analog 2
#define LCD_WR A1    // LCD Write goes to Analog 1
#define LCD_RD A0    // LCD Read goes to Analog 0

// you can also just connect RESET to the arduino RESET pin
#define LCD_RESET A4

/* For the 8 data pins:
Duemilanove/Diecimila/UNO/etc ('168 and '328 chips) microcontoller:
D0 connects to digital 8
D1 connects to digital 9
D2 connects to digital 2
D3 connects to digital 3
D4 connects to digital 4
D5 connects to digital 5
D6 connects to digital 6
D7 connects to digital 7

For Mega's use pins 22 thru 29 (on the double header at the end)
*/

// Color definitions
#define	BLACK           0x0000
#define	BLUE            0x001F
#define	RED             0xF800
#define	GREEN           0x07E0
#define CYAN            0x07FF
#define MAGENTA         0xF81F
#define YELLOW          0xFFE0 
#define WHITE           0xFFFF

TFTLCD tft(LCD_CS, LCD_CD, LCD_WR, LCD_RD, LCD_RESET);

#define MINPRESSURE 10
#define MAXPRESSURE 1000

#define BOXSIZE 40
#define PENRADIUS 4

int tftRotation = 1;

#define MENU 1
#define DETAIL 2
#define CLOCK 3
#define DETAILGRAPH 4

unsigned int displayMode = MENU;
unsigned int detailID = 0;

NewSoftSerial xbSerial(2,3);

// a id t T h p c dd rt reset

#define COMMANDS "aidtTt0t1t2t3hpcddrtreset" // commands we know about, mushed together

#define MAXTOKEN 32     // Maximum number of parsable tokens per packet
#define MAXTOKENLEN 32  // No one token's command or data should exceed this
#define MAXSTRING 80    // SoftSerial buffer is 64 bytes long, so we can't get more than that at once.
                        // This leaves a little buffer in case we get a couple packets in a row.

#define INVALID -99

void sClear(char *theString, int len=MAXSTRING);   // Clears a string, optionally a different length than MAXSTRING                       
//void tftPrint(char *text, uint16_t color=GREEN);

// Define sensor IDs and their relative index positions 
#define SENSORONE 0
#define SENSORTWO 1
#define MAXSENSORS 4
uint8_t nsid = SENSORONE; // Which sensor do we want to display data for?
char *sid = {"VoltBarn Default SensorID"}; // Until we get packets with sensor ID's, use a default

// Globals
uint16_t mpt[MAXSENSORS] = {INVALID,INVALID,INVALID,INVALID}; // Temperature, per sensor
uint16_t mph[MAXSENSORS] = {INVALID,INVALID,INVALID,INVALID}; // Humidity, per sensor
uint16_t mpp[MAXSENSORS] = {INVALID,INVALID,INVALID,INVALID}; // Pressure, per sensor
uint16_t mpu[MAXSENSORS] = {INVALID,INVALID,INVALID,INVALID}; // Time of last update per sensor
uint16_t mpc[MAXSENSORS] = {CYAN, GREEN, YELLOW, MAGENTA};
char *mpi[MAXSENSORS] = {"VoltBarnSensorID1", "VoltBarnSensorID2", "VoltBarnSensorID3", "VoltBarnSensorID4"};
float mpat[MAXSENSORS][4] = { {-99, -99, -99, -99},
                            {-99, -99, -99, -99},
                            {-99, -99, -99, -99},
                          //  {-99, -99, -99, -99},
                            {-99, -99, -99, -99} }; // Auxillary temp sensors per SensorPack

uint16_t rx1[MAXSENSORS], rx2[MAXSENSORS], ry1[MAXSENSORS], ry2[MAXSENSORS];
  
uint8_t maxID = 0;

short int tempSid = 0;
uint16_t ctime = 1200; // current time
uint16_t   pt = 500, ph = 500, pp = 2950; // last temp, humidity, pressure received
float ft = INVALID, fh = INVALID, fp = INVALID; // Set to "no data yet" values (> 9999) for temp, humidty, pressure

char thePacket[MAXSTRING]; // Primary info, the packet received
char tempString[MAXSTRING]; // used in subString
char t[MAXSTRING]; // used in sTrim

uint16_t idx, idx1; // Loop variable for routines that don't call others

long int lastUpdate;

uint8_t * heapptr, * stackptr;
void check_mem() {
  stackptr = (uint8_t *)malloc(4);          // use stackptr temporarily
  heapptr = stackptr;                     // save value of heap pointer
  free(stackptr);      // free up the memory again (sets stackptr to 0)
  stackptr =  (uint8_t *)(SP);           // save value of stack pointer
  if (heapptr > stackptr) {
    Serial.print("ERROR**************************************************heap: ");
    Serial.print((int)heapptr);
    Serial.print(" > stackptr: ");
    Serial.println((int)stackptr);
  }
  #ifdef DEBUG >= 3
  Serial.print("**************************************************heap: ");
  Serial.print((int)heapptr);
  Serial.print(" < stackptr: ");
  Serial.println((int)stackptr);
  #endif
}

void setup(void) {
  char incomingByte;
  
  xbSerial.begin(9600);  // XBee
    
  //Serial.begin(9600);
  Serial.begin(115200);
   
  tft.reset();
  
  uint16_t identifier = tft.readRegister(0x0);
  if (identifier == 0x9325) {
    Serial.println("Found ILI9325");
  } else if (identifier == 0x9328) {
    Serial.println("Found ILI9328");
  } else {
    Serial.print("Unknown driver chip ");
    Serial.println(identifier, HEX);
    while (1);
  }  
 
  tft.initDisplay();
    
  //tft.setRotation(tftRotation); 
  tft.fillScreen(BLACK);
  tft.setCursor(0, 0);
  tft.setRotation(tftRotation);
  tft.setTextColor(RED);
  tft.setTextSize(2);
  tft.println("VoltBarn SensorPack");
  
  delay(1500);
  xbSerial.print("+++");
  delay(1500);
  if (xbSerial.available() == 0)
    xbSerial.print("+++");
  delay(1000);
  while (xbSerial.available()) { // Clear out the "OK"
    incomingByte = xbSerial.read();
  }
  /*
  tft.println("XBee info:");
  tftXbee("ATMY");
  tftXbee("ATID");
  tftXbee("ATDL");
  delay(1000);
  */
  xbSerial.print("ATCN\r");
 
  lastUpdate = millis();
  check_mem();
}

/*
void tftXbee(String command) {
  char incomingByte;
  int b;
  
  xbSerial.print(command);
  xbSerial.print('\r');
  tft.print(command);
  tft.print(":");
  delay(1000);
  while (xbSerial.available() > 0) {
    incomingByte = xbSerial.read();
    tft.print(incomingByte);
  }
  tft.println("");
  delay(1000);
}
*/

// ***********************************
// ************** loop
// ***********************************

void loop() { 

  char incomingByte;
  char cmd[MAXTOKENLEN], data[MAXTOKENLEN];
  byte ntok = 0;
  //char displayItem;
  boolean tokenFound;
  uint16_t i;
  char xtime[6];
  uint16_t tftWidth, tftHeight; 
  boolean touched = false;
  uint8_t bytesA = 0, bytesR = 0;

#ifdef DEBUG >= 2
  // Sample data
  if (firstRun) {
    //sCopy(thePacket,":id:test1-01:t:32.0:p:26.02:h:27.0:");
    //splitTokens();
    sCopy(thePacket,":id:foo1-01:t:32.0:p:26.02:h:27.0:");
    splitTokens();
    sCopy(thePacket,":id:bar1-01:t:32.0:p:26.02:h:27.0:c:0250:");
    splitTokens();
    firstRun = false;
  }
#endif
  
  tftWidth = tft.width();
  tftHeight = tft.height();
  
  // First thing, check for a touch
  
  tft.setRotation(0); // make sure we're at the default rotation to read the input
  digitalWrite(13, HIGH);
  Point p = ts.getPoint();
  digitalWrite(13, LOW);
  
  pinMode(XM, OUTPUT);
  pinMode(YP, OUTPUT);
  
  if (p.z > MINPRESSURE && p.z < MAXPRESSURE) {
    
    // turn from 0->1023 to tft.width
    p.x = map(p.x, TS_MINX, TS_MAXX, tft.width(), 0);
    p.y = map(p.y, TS_MINY, TS_MAXY, tft.height(), 0);
    
    // x, y input are absolute, regardless of rotation
    // x, y output changes, depending on rotation
      
    Serial.print("[");Serial.print(tftWidth);Serial.print(",");Serial.print(tftHeight);Serial.print("] ");
    Serial.print("X = "); Serial.print(p.x); 
    Serial.print("\tY = "); Serial.print(p.y);
    Serial.print("\tPressure = "); Serial.println(p.z);
    
    if (displayMode == CLOCK || displayMode == DETAIL) {
      displayMenu();
      displayMode = MENU;
    } else {
      for (i = 0; i < maxID; i++) {
        if (p.x > rx1[i] && p.x < rx2[i] && p.y > ry1[i] && p.y < ry2[i]) {
          //Serial.println("In bounding box");
          if (displayMode == MENU) {
            tft.fillRect(rx1[i], ry1[i], BOXSIZE, (BOXSIZE*8)-10, mpc[i]);
            detailID = i;
            displayDetail(detailID);
            displayMode = DETAIL;
            touched = true;
          } else {
            displayMenu();
            displayMode = MENU;
            touched = false;
          } // else
        } // if
     } // for
     if (touched == false && p.x > tftWidth-40) {
       Serial.println("Touched the clock");
       displayClockAnalog();
       displayMode = CLOCK;
       touched = true;
      } // if
    } // else
    
   if (p.y < (TS_MINY-5)) {
      Serial.println("erase");
      // press the bottom of the screen to erase 
      //tft.fillRect(0, BOXSIZE, tft.width(), tft.height()-BOXSIZE, BLACK);     
    }
  }

  // Now, we grab packets and process them.
  sClear(thePacket); // Null out the packet when the loop starts
  tokenFound = false;
  while (bytesA = xbSerial.available()) {
    bytesR++;
#if DEBUG >= 1
    Serial.print((int)bytesA);
    Serial.print(" Byte(s) available (");
    Serial.print((int)bytesR);
    Serial.println("):");
#endif
    if (bytesR > MAXSTRING) { // Too much data coming in at once, bail and process what we've got
      thePacket[MAXSTRING-2] = ':';
      thePacket[MAXSTRING-1] = '\0';
      break;
    }
    // read the incoming byte:
    incomingByte = xbSerial.read();
    // Ignore any cruft we get until we get our first token separator. ACK errors cause havok
    // and resetting
    if ((incomingByte == ':') || (tokenFound)) {
      tokenFound = true;
      sAppend(thePacket,incomingByte);
#if DEBUG >= 1
      Serial.print("I received: ");
      Serial.print(incomingByte);
      Serial.print("[");
      Serial.print(incomingByte,HEX);
      Serial.println("]");
#endif
      if (incomingByte == 0xD) // Started getting a corrupted packet with a 0xD in the middle, bail
        break;
    } else {
      Serial.print("#");
    }
  }
  if (sLength(thePacket) > 0) {
    check_mem();
    lastUpdate = millis();
    Serial.println(thePacket);
    splitTokens(); // break into an array of tokenized strings    
    switch (displayMode) {
      case MENU: displayMenu();
      break;
      case CLOCK: displayClockAnalog();
      break;
      case DETAIL: displayDetail(detailID);
      break;
    }
  }
}

// ***********************************
// ************** displayDetail
// ***********************************
void displayDetail (uint16_t i) {
  tft.fillScreen(BLACK);
  tft.setCursor(0, 0);
  tft.setRotation(tftRotation);
  tft.setTextColor(WHITE); 
  tft.setTextSize(3);
  tft.print("ID:");
  tft.println(mpi[i]);
  tft.print("Temp:");
  tft.print(mpt[i]/((mpt[i] > 999) ? 100: 10));
  tft.print(".");
  tft.print(mpt[i]%((mpt[i] > 999) ? 100: 10));
  tft.println("F");
    for (idx = 0; idx < 4; idx++) {
    if (mpat[i][idx] != -99) {
      tft.print("Temp");
      tft.print(idx);
      tft.print(":");
      tft.print(mpat[i][idx]);
      tft.println("F");
    }
  }
  tft.print("Pressure:");
  tft.print(mpp[i]/((mpp[i] > 999) ? 100: 10));
  tft.print(".");
  tft.print(mpp[i]%((mpp[i] > 999) ? 100: 10));
  tft.println("Hg");
  tft.print("Humidity:");
  tft.print(mph[i]/((mph[i] > 999) ? 100: 10));
  tft.print(".");
  tft.print(mph[i]%((mph[i] > 999) ? 100: 10));
  tft.println("%");
  tft.print("Last update:");
  displayTime(mpu[i], 3);
  tft.print("Time: ");
  displayTime(ctime, 3);
}
  
// ***********************************
// ************** displayMenu
// ***********************************
void displayMenu () {
  char xtime[6];
  
  // Print tft info:
  tft.fillScreen(BLACK);
  tft.setCursor(0, 0);
  tft.setRotation(tftRotation);
  tft.setTextSize(4);
  tft.setTextColor(RED);
  tft.print("Time: ");
  displayTime(ctime, 4);
  tft.setRotation(0);
  
  // 0,0 is bottom left and 240, 0 is upper right in this orientation 180,120,60
  for (idx = 0;idx < maxID; idx++) {
    for (uint8_t ii = 0; ii < 8; ii++) {
      tft.drawRect(((BOXSIZE*(4-idx))-(ii/2)), (ii/2), BOXSIZE, (BOXSIZE*8)-10, WHITE);
    }
    rx1[idx] = (BOXSIZE*(4-idx));
    ry1[idx] = 0;
    rx2[idx] = rx1[idx] + BOXSIZE;
    ry2[idx] = BOXSIZE * 8;      
  }
  
  tft.setRotation(tftRotation);
  for (idx = 0; idx < maxID; idx++) { 
    tft.setTextColor(mpc[idx]); 
    tft.setTextSize(2);
    tft.print("\n "); // left hand padding  
    tft.setTextSize(3);
    tft.print(mpi[idx]);
    Serial.println(mpi[idx]);
    Serial.print(" Temp: ");
    Serial.println(mpt[idx]);
    tft.print(":");
    tft.print(mpt[idx]/((mpt[idx] > 999) ? 100: 10));
    tft.print(".");
    tft.print(mpt[idx]%((mpt[idx] > 999) ? 100: 10));
    tft.println("F");
  }  
}

// ***********************************
// ************** displayClock
// ***********************************
/*
void displayClock () {
  tft.fillScreen(BLACK);
  tft.setCursor(0, 0);
  tft.setRotation(tftRotation);
  tft.setTextSize(10);
  tft.println("");
  displayTime(ctime,11);
  tft.setTextSize(2);
  tft.setRotation(0);
}
*/

// ***********************************
// ************** displayTime
// ***********************************
void displayTime (uint16_t time,uint8_t tsize) {
  uint16_t ttime;
  char xtime[6];
  short int idx; // Override the global with one that can go negative
  
  #ifdef DEBUG >= 3
  Serial.print("In displayTime with: ");
  Serial.println(time);
  #endif
  
  if (millis() > lastUpdate + 120000)
    tft.setTextColor(YELLOW);
  else
    tft.setTextColor(RED);
  tft.setTextSize(tsize);

  // Make sure we print a 4 digit time
  for (idx = 4, ttime=time; idx >= 0; idx--) {
    if (idx == 2)
      xtime[idx] = ':';
    else {
      xtime[idx] = 48 + (ttime % 10);       
      ttime /= 10;
    }
  }
  xtime[5] = '\0';
  tft.println(xtime);
  Serial.println("Exiting displayTime");
}

// ***********************************
// ************** displayClockAnalog
// ***********************************
void displayClockAnalog () {

  // Set rotation, so everything is drawn in perspective
  tft.setRotation(tftRotation);

  uint16_t x2, y2;
  short int i;
  
#define HOUR (ctime / 100) % 12
#define MINUTE (ctime % 100) % 60
#define MIDPX tft.width()/2
#define MIDPY tft.height()/2

  Serial.print("Displaying hour: ");
  Serial.print(HOUR);
  Serial.print(" minute: ");
  Serial.println(MINUTE);
  
  // Rotate hands 90 degrees, 0,0 bottom left
  //tft.setRotation(0);
  //hour = (hour + 3) % 12;
  //minute = (minute + 15) % 60;
  
  // Or, reset rotation
  // tft.setRotation(tftRotation);
  
  tft.fillScreen(BLACK);
  tft.setCursor(0, 0);
  for (idx = 0; idx < 5; idx++) {
    tft.drawCircle(MIDPX, MIDPY, (tft.height() < tft.width()) ? MIDPY - idx: MIDPX - idx, RED);
  }

    // Draw hour hand, compensating for how many minutes past the hour, so the hand doesn't just "jump" every hour

    for (i = -2; i < 3; i++) {    
      x2 = MIDPX - 75 * sin((3.141596 / 180.0 * -((HOUR + (MINUTE/60.0)) * 30.0)));
      y2 = MIDPY - 75 * cos((3.141596 / 180.0 * -((HOUR + (MINUTE/60.0)) * 30.0)));
      tft.drawLine(MIDPX+i, MIDPY+i, x2+i, y2+i, BLUE);
      
    }

  for (i = -2; i < 3; i++) {    
    x2 = MIDPX - 100 * sin((3.141596 / 180 * -(MINUTE * 6)));
    y2 = MIDPY - 100 * cos((3.141596 / 180 * -(MINUTE * 6)));
    tft.drawLine(MIDPX+i, MIDPY+i, x2+i, y2+i, GREEN);
  }
}

// ***********************************
// ************** parseToken
// ***********************************

void parseToken(char *tok, char *val, int wackaSid) {
  Serial.print("Parsing for token: ");
  Serial.print(tok);
  Serial.print(" with value: ");
  Serial.println(val);

  if (sEquals(tok,"c")) { // clock time
    ctime = atoi(val);
    Serial.print("Time: ");
    Serial.println(ctime);
  }
  if ((sEquals(tok,"a")) || (sEquals(tok,"id"))) { // sensor id
    sid = val;
    tempSid = pickSensor(sid);
    if (tempSid < 0)
      return;
  }
  Serial.print("Parse token, sid is ");
  Serial.println(tempSid);
  mpu[tempSid] = ctime; // Set the last-updated time for this sensor to now
  if (sEquals(tok,"t")) { // temperature
    ft = atof(val); // set temp to null terminated string
    mpt[tempSid] = int(ft * 10);
    pt = mpt[tempSid];
  } else if (sEquals(tok,"T")) { // alternate temperature
    //ft = atof(val);
  } else if (sEquals(tok,"h")) { // humidity
    fh = atof(val);
    mph[tempSid] = int(fh * 10);
    ph = mph[tempSid];
  } else if (sEquals(tok,"p")) { // pressure
    fp = atof(val);
    mpp[tempSid] = int(fp * 100);
    pp = mpp[tempSid];
  } else if (sEquals(tok,"reset")) {
    if (sEquals(val,"reset")) {
      //resetDefaults(); // we got a "reset/reset" token, undo any customizations
    }
  } else if (sEquals(tok, "t0")) {
    mpat[tempSid][0] = atof(val);
  } else if (sEquals(tok, "t1")) {
    mpat[tempSid][1] = atof(val);
  } else if (sEquals(tok, "t2")) {
    mpat[tempSid][2] = atof(val);
  } else if (sEquals(tok, "t3")) {
    mpat[tempSid][3] = atof(val);
  } 
}

// ***********************************
// ************** splitTokens
// ***********************************
int splitTokens() {
  char tmpString[MAXSTRING];
  int ntok = 0;
  char *token = ":";
  //char **outString = theTokens;
  //char *theString = thePacket;
  char cmd[MAXTOKENLEN], data[MAXTOKENLEN];
  boolean getCommand = true;
  int mySid = 0;
  
  #ifdef DEBUG
  Serial.println("Entering splitTokens...");
  Serial.println(thePacket);

  if (sContains(thePacket, token)) 
    Serial.println("Got a token");
  else
    Serial.println("no tokens");
  #endif
  
  while (sContains(thePacket, token)) {
    #ifdef DEBUG
    Serial.println("Getting token");
    #endif
    check_mem();
    if (sIndexOf(thePacket, token) > 0) {
      if (getCommand) {
        sClear(cmd);
        sCopy(cmd, sSubstring(thePacket, 0, sIndexOf(thePacket, token)));
        sTrim(cmd);
        if (sContains(COMMANDS, cmd)) // Make sure this is a command
          getCommand = false;
      } else {
        sClear(data);
        sCopy(data, sSubstring(thePacket, 0, sIndexOf(thePacket, token)));
        sTrim(data);
        getCommand = true;
        parseToken(cmd, data, tempSid);
      } 
      ntok++;
    }
    sClear(tmpString);
    sCopy(tmpString,sSubstring(thePacket, sIndexOf(thePacket, token) + 1, 
                                          sLength(thePacket)));
    
    sClear(thePacket);
    sCopy(thePacket, tmpString);
    
// This indicates a corrupt string, indicating we've run out of memory somewhere
    if (sLength(thePacket) > MAXSTRING) {
      Serial.println("");
      Serial.print(sLength(thePacket));
      Serial.println("******** CORRUPT STRING ERROR *********");
      sClear(thePacket);
    }
  }
  // Check to see if we're at the end of the string, and if so
  // make sure and grab the last token
  if (sLength(thePacket) > 0) {
    ntok++;
  }
  Serial.println("Exiting");
  sClear(thePacket);
  return(ntok);
}

// ***********************************
// ************** Supporting string functions
// ***********************************

// ***********************************
// ************** sSubstring
// ***********************************
char *sSubstring(char *theString, int s, int e) {
  int ii = 0;
  
  #ifdef DEBUG >= 3
  Serial.print("entering sSubstring");
  check_mem();
  #endif

  sClear(tempString);

  for (int i = s,ii = 0; i < e; i++, ii++) {
    tempString[ii] = theString[i];
  }

  #ifdef DEBUG >= 3
  Serial.print("leaving sSubstring");
  check_mem();
  #endif
  return(tempString);
}

// ***********************************
// ************** sAppend
// ***********************************
void sAppend(char theString[], char token) {
  theString[sLength(theString)] = token;

}

// ***********************************
// ************** sCopy
// ***********************************
void sCopy(char *theString, char *newString) {

  for (idx = 0; idx < sLength(newString); idx++)
    theString[idx] = newString[idx];
  theString[sLength(newString)] = '\0';
#ifdef DEBUG >= 3
  Serial.print("In sCopy ");
  check_mem();
  Serial.print("Copied: \"");
  Serial.print(newString);
  Serial.print("\" To: \"");
  Serial.print(theString);
  Serial.println("\"");
#endif
}

// ***********************************
// ************** sIndexOf
// ***********************************
int sIndexOf(char *theString, char *token) {
  for (idx = 0; idx < MAXSTRING; idx++) {
    if (theString[idx] == token[0])
      return(idx);
  }
  return (-1);
}

// ***********************************
// ************** sContains
// ***********************************
boolean sContains(char *theString, char *token) {
  uint8_t ii = 0;
  if (sLength(theString) == 0)
    return false;
  for (idx = 0; idx < sLength(theString); idx++) {
    if (theString[idx] == token[ii]) {
      for (ii = 1; ii < sLength(token); ii++, idx++) {
        if (theString[idx] != token[ii])
          break;
      }
      return(true);
    }
  }
  return(false);
}

// ***********************************
// ************** sLength
// ***********************************
byte sLength(char theString[]) {
  for (idx1 = 0; idx1 < MAXSTRING; idx1++) {
    if (theString[idx1] == '\0') {
      return(idx1);
    }
  }
  return (-1);
}

// ***********************************
// ************** sClear
// ***********************************
void sClear(char *theString, int len) {
  for (idx = 0; idx < len; idx++)
    theString[idx] = '\0';
}

// ***********************************
// ************** sEquals
// ***********************************
boolean sEquals (char *theString, char *token) {
  if (sLength(theString) != sLength(token))
    return(false);
  for (idx = 0; idx < sLength(theString); idx++) {
    if (theString[idx] != token[idx]) {
      return(false);
    }
  }
  return(true);
}

// ***********************************
// ************** sTrim
// ***********************************
void sTrim(char *theString) {

  boolean s = false;
  uint8_t ix = 0, ii;
  uint8_t b;
  
  sClear(t);
  ii = sLength(theString);
  #ifdef DEBUG >= 2
  Serial.print("In sTrim");
  check_mem();
  /*
  Serial.print("in: i - ");
  Serial.print((int)ix);
  Serial.print(" ii - ");
  Serial.println((int)ii);
  */
  #endif
  while ((ix < ii) && (theString[ix] == ' '))
    ix++;
    
    //Serial.print("{");
    b = theString[ii];
    //Serial.print(b,HEX);
    //Serial.println("}");  
  while ((ii > 0) && ((theString[ii] == 0x20) || (theString[ii] == 0x00))) {
    theString[ii] = 0x00;
    ii--;
  }
#if DEBUG >=3
  Serial.print("trim ");
  Serial.print((int)ix);
  Serial.print(" to ");
  Serial.print((int)ii);
  Serial.print(" with '");
  Serial.print(theString);
  Serial.print("' to '");
#endif
  sCopy(t, sSubstring(theString,ix,sLength(theString)));
#ifdef DEBUG >= 3
  Serial.print("Trimmed to '");
  Serial.print(t);
  Serial.println("'");
  check_mem();
#endif
  sClear(theString);
  sCopy(theString, t);
}


// ***********************************
// ************** Other support functions
// ***********************************

// ***********************************
// ************** pickSensor
// ***********************************

short int pickSensor(char *sid) {
  
/* Update this to return a sensor ID if there is a map to any we've ever seen, 
   or create a new ID if it's not found. Need to change the fixed strings to an array that
   gets filled as sensors check in, and then use the array index as the ID */
   
  for (uint8_t idx = 0; idx < maxID; idx++) { // Override the idx global
    if (sContains(sid,mpi[idx])) { 
      #ifdef DEBUG >= 0
      Serial.print("I know this sensor '");
      Serial.print(sid);
      Serial.print("' is id: ");
      Serial.println(idx);
      #endif
      return idx;
    }
  }
  if (maxID < MAXSENSORS) {
    sCopy(mpi[maxID],sid);
    maxID++;
    #ifdef DEBUG > 0
    Serial.print("I've never seen this sensor '");
    Serial.print(sid);
    Serial.print("' is id: ");
    Serial.println(maxID-1);
    #endif
    return maxID-1;
  }
  else {
    return -1;
  }
}

Big LED display in action

I realized I’d never gotten around to posting a picture or video of the display station actually working. I’ve had this running for nearly a year now, first on a breadboard, and then via the shift register circuit board I had made. I mounted the LED’s to some smoked plexiglass, then build a simple box to put the entire mechanism in. A single higher output 5V power supply attaches to the Arduino and to the LED’s providing sufficient current for everything.

Here’s the display with an iPhone on the right for scale..

And finally some video of it in action!