Friday, 31 March 2017

Resurrecting more old posts - word clock with Arduino and TinyRTC DS1307 module

I've always loved the idea of a word clock; a few of us have even built a couple over the years (though I don't actually manage to ever keep hold of the ones I've made). Mostly they're a fun way to prove our multiplexing/charlie-plexing is working properly. Sometimes just to demonstrate how to use a MAX7219 chip.

Recently we had to convert some old code over to Arduino, to use a MAX7219 LED driver. We'd had some trouble getting a 4-way 7-segment LED display to work properly with the MAX7219 chips - mostly because we were using common anode displays and the driver chips are best suited to common cathode (or was it the other way around...?). So we thought it'd be a good idea to strip the bigger project back to individual pieces; and for this task we focussed on just making the MAX7219 chips work.

A few months back we'd done some playing around with Arduino and MAX7219. This time we cascaded two chips and connected each MAX7219 to a single LED matrix. A bit of copy-n-paste coding later and we had a working example using the Arduino MAX Library


Having just moved a load of stuff into the new workshop bungalow, we had boxes and boxes of components hanging around - to hand were some RTC modules from a few years back; this gave us a perfect opportunity to put one to use


The RTC Library had us reading the current date/time off the TinyRTC module in just minutes!

#include <Wire.h>
#include "RTClib.h"
RTC_DS1307 RTC;

void setup () {
      Serial.begin(9600);
      Wire.begin();
      RTC.begin();
   if (! RTC.isrunning()) {
      Serial.println("RTC is NOT running!");
      // following line sets the RTC to the date & time this sketch was compiled
      RTC.adjust(DateTime(__DATE__, __TIME__));
   }
}
void loop () {
      DateTime now = RTC.now();
      Serial.print(now.year(), DEC);
      Serial.print('/');
      Serial.print(now.month(), DEC);
      Serial.print('/');
      Serial.print(now.day(), DEC);
      Serial.print(' ');
      Serial.print(now.hour(), DEC);
      Serial.print(':');
      Serial.print(now.minute(), DEC);
      Serial.print(':');
      Serial.print(now.second(), DEC);
      Serial.println();
      delay(1000);
}


After putting the two matrix boards side-by-side all we needed to do was read the current time over I2C and decide which LEDs needed to light up when. Here's the pattern of letters/words we came up with:


As we're effectively using a larger 16x8 matrix, we had plenty of extra letters after writing out the hours and minutes, so went to town with improving the precision of our clock.

Normally a word clock  might say something like "it is ten past two" and display this until the time had changed to the next five-minute segment (in this case, "it is quarter past two"). We decided that we'd improve the precision by adding in "nearly" and "just gone" elements to each five-minute segment.

So, within two minutes of a time, we'd describe it as "nearly". For example at 14:04 we'd say "it is nearly five past two". Similarly, for up to two minutes after a time, we'd describe it is "just gone". So at 14:31 we'd say "it has just gone half past two".

This gives us the ability to tell the time to within two minutes, using language that many of us commonly use every day (although, as Nick pointed out, a margin of error of two minutes still leaves plenty time to miss your bus if you're not careful!)

Here's the code we came up with.
It's written to be readable/understandable rather than particularly "clever" (for example, we'd normally use an array for multi-line variables, like R[1][3] rather than multiple "single" variables, like R1C3 but as we've been getting a few questions of late on things we'd consider to be pretty simple, we tried to keep the code as understandable as possible!)


#include "Wire.h"
#define DS1307_I2C_ADDRESS 0x68

int dataIn = 2;
int load = 3;
int clk = 4;
int maxInUse = 2;      //change this variable to set how many MAX7219's you'll use

// define max7219 registers
byte max7219_reg_noop         = 0x00;
byte max7219_reg_digit0         = 0x01;
byte max7219_reg_digit1         = 0x02;
byte max7219_reg_digit2         = 0x03;
byte max7219_reg_digit3         = 0x04;
byte max7219_reg_digit4         = 0x05;
byte max7219_reg_digit5         = 0x06;
byte max7219_reg_digit6         = 0x07;
byte max7219_reg_digit7         = 0x08;
byte max7219_reg_decodeMode      = 0x09;
byte max7219_reg_intensity       = 0x0a;
byte max7219_reg_scanLimit       = 0x0b;
byte max7219_reg_shutdown      = 0x0c;
byte max7219_reg_displayTest    = 0x0f;

int e = 0;                // just a variable


// these are used to run in test mode
bool test_mode = false;
int curr_min = 0;
int curr_hr = 0;


// Convert normal decimal numbers to binary coded decimal
byte decToBcd(byte val){
   return ( (val/10*16) + (val%10) );
}

// Convert binary coded decimal to normal decimal numbers
byte bcdToDec(byte val){
   return ( (val/16*10) + (val%16) );
}

void setDateDs1307(byte second, byte minute, byte hour, byte dayOfWeek, byte dayOfMonth, byte month, byte year){
   Wire.beginTransmission(DS1307_I2C_ADDRESS);
   Wire.write(0);
   Wire.write(decToBcd(second)); // 0 to bit 7 starts the clock
   Wire.write(decToBcd(minute));
   Wire.write(decToBcd(hour)); // If you want 12 hour am/pm you need to set
                                             // bit 6 (also need to change readDateDs1307)
   Wire.write(decToBcd(dayOfWeek));
   Wire.write(decToBcd(dayOfMonth));
   Wire.write(decToBcd(month));
   Wire.write(decToBcd(year));
   Wire.endTransmission();
}

// Gets the date and time from the ds1307
void getDateDs1307(byte *second, byte *minute, byte *hour, byte *dayOfWeek, byte *dayOfMonth, byte *month, byte *year){
   // Reset the register pointer
   Wire.beginTransmission(DS1307_I2C_ADDRESS);
   Wire.write(0);
   Wire.endTransmission();

   Wire.requestFrom(DS1307_I2C_ADDRESS, 7);

   // A few of these need masks because certain bits are control bits
   byte b;
   b=Wire.read();
   Serial.print(b,HEX);
   *second = bcdToDec(b & 0x7f);

   b=Wire.read();
   Serial.print(b,HEX);
   *minute = bcdToDec(b);

   b=Wire.read();
   Serial.print(b,HEX);
   *hour = bcdToDec(b & 0x3f); // Need to change this if 12 hour am/pm

   b=Wire.read();
   Serial.print(b,HEX);
   *dayOfWeek = bcdToDec(b);

   b=Wire.read();
   Serial.print(b,HEX);
   *dayOfMonth = bcdToDec(b);

   b=Wire.read();
   Serial.print(b,HEX);   
   *month = bcdToDec(b);

   b=Wire.read();
   Serial.print(b,HEX);
   *year = bcdToDec(b);

   Serial.print(" ");
   
}


void putByte(byte data) {
   byte i = 8;
   byte mask;
   while(i > 0) {
      mask = 0x01 << (i - 1);         
      digitalWrite( clk, LOW);   
      if (data & mask){                  
         digitalWrite(dataIn, HIGH);
      }else{
         digitalWrite(dataIn, LOW);
      }
      digitalWrite(clk, HIGH);
      --i;                                    
   }
   
}

void maxAll (byte reg, byte col) {      // initialize   all   MAX7219's in the system
   int c = 0;
   digitalWrite(load, LOW);   // begin      
   for ( c =1; c<= maxInUse; c++) {
   putByte(reg);   // specify register
   putByte(col);//((data & 0x01) * 256) + data >> 1); // put data
      }
   //digitalWrite(load, LOW);
   digitalWrite(load,HIGH);
}

void maxOne(byte maxNr, byte reg, byte col) {      
//maxOne is for addressing different MAX7219's,
//while having a couple of them cascaded

   int c = 0;
   digitalWrite(load, LOW);   // begin      

   for ( c = maxInUse; c > maxNr; c--) {
      putByte(0);      // means no operation
      putByte(0);      // means no operation
   }

   putByte(reg);   // specify register
   putByte(col);//((data & 0x01) * 256) + data >> 1); // put data

   for ( c =maxNr-1; c >= 1; c--) {
      putByte(0);      // means no operation
      putByte(0);      // means no operation
   }

   //digitalWrite(load, LOW); // and load da stuff
   digitalWrite(load,HIGH);
}

void showClockOutput(byte hour, byte minute, byte second, byte dayOfMonth, byte month, byte year){
   Serial.print(hour, DEC);
   Serial.print(":");
   Serial.print(minute, DEC);
   Serial.print(":");
   Serial.print(second, DEC);
   Serial.print(" ");
   Serial.print(dayOfMonth, DEC);
   Serial.print("/");   
   Serial.print(month, DEC);
   Serial.print("/");
   Serial.print(year, DEC);   
   Serial.println();
}


void setup () {
   byte second, minute, hour, dayOfWeek, dayOfMonth, month, year;
   
   pinMode(dataIn, OUTPUT);
   pinMode(clk,   OUTPUT);
   pinMode(load,    OUTPUT);

   //initiation of the max 7219
   maxAll(max7219_reg_scanLimit, 0x07);         
   maxAll(max7219_reg_decodeMode, 0x00);   // using an led matrix (not digits)
   maxAll(max7219_reg_shutdown, 0x01);      // not in shutdown mode
   maxAll(max7219_reg_displayTest, 0x00); // no display test
   for (e=1; e<=8; e++) {      // empty registers, turn all LEDs off
      maxAll(e,0);
   }
   maxAll(max7219_reg_intensity, 0x0f & 0x0f);      

   // initialise the RTC module
   // (inc setting the time if necessary)

   Wire.begin();
   bool set_date=true;
   if(set_date==false){

      // change these values as necessary
      second = 10;
      minute = 46;
      hour = 16;
      dayOfWeek = 4;
      dayOfMonth = 30;
      month = 3;
      year = 17;
      setDateDs1307(second, minute, hour, dayOfWeek, dayOfMonth, month, year);
   }
                                                                           
   Serial.begin(9600);
   Serial.println("Let's go");
                                                                           
}   

void loop () {
   
   byte second, minute, hour, dayOfWeek, dayOfMonth, month, year;
   getDateDs1307(&second, &minute, &hour, &dayOfWeek, &dayOfMonth, &month, &year);
   showClockOutput( hour, minute, second, dayOfMonth, month, year);

   int r;
   int r1c1, r1c2;
   int r2c1, r2c2;
   int r3c1, r3c2;
   int r4c1, r4c2;
   int r5c1, r5c2;
   int r6c1, r6c2;
   int r7c1, r7c2;
   int r8c1, r8c2;
   
   if(test_mode==true){
      curr_min++;
      if(curr_min > 59){ curr_hr++; curr_min=0;}
      if(curr_hr > 23 ){ curr_hr=0; }

      hour = curr_hr;
      minute = curr_min;
   }

      // from the time, work out whether we start with its or it has
      // (its nearly or it has just past)
      r=0;
      
      r1c1 = B11100000; r1c2 = 0x00;
      r2c1 = 0x00; r2c2 = 0x00;
      r3c1 = 0x00; r3c2 = 0x00;
      r4c1 = 0x00; r4c2 = 0x00;
      r5c1 = 0x00; r5c2 = 0x00;
      r6c1 = 0x00; r6c2 = 0x00;
      r7c1 = 0x00; r7c2 = 0x00;
      r8c1 = 0x00; r8c2 = 0x00;
      
      if(minute==1 || minute==2 || minute==6 || minute==7 || minute==11 || minute==12 || minute==16 || minute==17){ r=1; }
      if(minute==3 || minute==4 || minute==8 || minute==9 || minute==13 || minute==14 || minute==18 || minute==19){ r=2; }
      if(minute==21 || minute==22 || minute==26 || minute==27 || minute==31 || minute==32 || minute==36 || minute==37){ r=1; }
      if(minute==23 || minute==24 || minute==28 || minute==29 || minute==33 || minute==34 || minute==38 || minute==39){ r=2; }
      if(minute==41 || minute==42 || minute==46 || minute==47 || minute==51 || minute==52 || minute==56 || minute==57){ r=1; }
      if(minute==43 || minute==44 || minute==48 || minute==49 || minute==53 || minute==54 || minute==58 || minute==59){ r=2; }

      // just before or just after major clock minutes, light up the
      // word "nearly" or "just past"
      if(r==1){ r1c1 = B11011101; r1c2 = B11101111; }
      if(r==2){ r1c1 = B11100000; r1c2 = B00000000; }
      if(r==2){ r2c1 = B11111100; }

      // maybe we could use the word "just gone" for one minute past and drop the "just"
      // for two minutes past?
      r=0;
      if(minute==2 || minute==7 || minute==12 || minute==17){ r=1; }
      if(minute==22 || minute==27 || minute==32 || minute==37){ r=1; }
      if(minute==42 || minute==47 || minute==52 || minute==57){ r=1; }
      if(r==1){
         // mask out the word "just"
         r1c1 = r1c1 & B11111110;
         r1c2 = r1c2 & B00011111;
      }
      
      // at quarter past and quarter to the hour, light up the word quarter
      if(minute >=13 && minute<=17){ r2c1+=1; r2c2 = B11111100; }
      if(minute >=43 && minute<=47){ r2c1+=1; r2c2 = B11111100; }

      // at twenty to and twenty past the hour, light up the word "twenty"
      // (also at twenty-five to and twenty-five past)
      if(minute >=18 && minute<=27){ r3c1 = B11111100; }
      if(minute >=33 && minute<=42){ r3c1 = B11111100; }

      // light up the word "five" at not only five to/past but also
      // twenty-five to and twenty-five past
      if(minute >=3 && minute<=7) {   r3c1 +=1; r3c2 = B11100000;}
      if(minute >=53 && minute<=57){ r3c1 +=1; r3c2 = B11100000;}
      if(minute >=23 && minute<=27){ r3c1 +=1; r3c2 = B11100000;}
      if(minute >=33 && minute<=37){ r3c1 +=1; r3c2 = B11100000;}

      // at ten past/to the hour, light up the word ten
      if(minute >=8 && minute <=12){ r3c2 = B00001110;}
      if(minute >=48 && minute <=52){ r3c2 = B00001110;}

      // at around half past...
      if(minute >=28 && minute <=32){ r4c1 = B11110000;}

      // display the either the word "past" or "two"
      if(minute > 2   && minute <= 32){ r4c1 += B00000011; r4c2 = B11000000; }
      if(minute > 32 && minute < 58) { r4c1 += B00001100; }

      // this is for on the hour, o'clock
      if(minute >=58 || minute <=2) { r8c1 = B11111110; }

      // show the correct hour(s)
      // (why compare to 32 and not 30 for half past the hour?
      //   because at 11:32 we want it still to read "it has just
      //   gone half past 11" - not half past twelve!
            
      // just before/after one o'clock
      if((hour==0   && minute > 32)   || (hour == 1   && minute <=32)){ r7c2 = B00000111; }
      if((hour==12   && minute > 32) || (hour == 13 && minute <=32)){ r7c2 = B00000111; }

      // just before/after two o'clock
      if((hour== 1   && minute > 32) || (hour == 2   && minute <=32)) { r5c1 = B11100000; }
      if((hour==13   && minute > 32) || (hour == 14 && minute <=32)) { r5c1 = B11100000; }

      // just before/after three o'clock
      if((hour== 2   && minute > 32) || (hour == 3   && minute <=32)) { r4c2 += B00011111; }
      if((hour==14   && minute > 32) || (hour == 15 && minute <=32)) { r4c2 += B00011111; }

      // just before/after four o'clock
      if((hour== 3   && minute > 32) || (hour == 4   && minute <=32)) { r5c1 = B00011110; }
      if((hour==15   && minute > 32) || (hour == 16 && minute <=32)) { r5c1 = B00011110; }
      
      // just before/after five o'clock
      if((hour==4   && minute > 32) || (hour == 5   && minute <=32)){ r5c1 += 1; r5c2 = B11100000; }
      if((hour==16 && minute > 32) || (hour == 17 && minute <=32)){ r5c1 += 1; r5c2 = B11100000; }

      // just before/after six o'clock
      if((hour==5   && minute > 32) || (hour == 6   && minute <=32)){ r6c1 = B11100000; }
      if((hour==17 && minute > 32) || (hour == 18 && minute <=32)){ r6c1 = B11100000; }

      // just before/after seven o'clock
      if((hour==6   && minute > 32) || (hour == 7   && minute <=32)){ r5c2 = B00011111; }
      if((hour==18 && minute > 32) || (hour == 19 && minute <=32)){ r5c2 = B00011111; }

      // just before/after eight o'clock
      if((hour==7   && minute > 32) || (hour == 8   && minute <=32)){ r6c1 = B00011111; }
      if((hour==19 && minute > 32) || (hour == 20 && minute <=32)){ r6c1 = B00011111; }

      // just before/after nine o'clock
      if((hour==8   && minute > 32) || (hour == 9   && minute <=32)){ r6c2 = B11110000; }
      if((hour==20 && minute > 32) || (hour == 21 && minute <=32)){ r6c2 = B11110000; }

      // just before/after ten o'clock
      if((hour==9   && minute > 32) || (hour == 10 && minute <=32)){ r6c2 = B00001110; }
      if((hour==21 && minute > 32) || (hour == 22 && minute <=32)){ r6c2 = B00001110; }

      // just before/after eleven o'clock
      if((hour==10 && minute > 32) || (hour == 11 && minute <=32)){ r7c1 = B11111100; }
      if((hour==22 && minute > 32) || (hour == 23 && minute <=32)){ r7c1 = B11111100; }

      // this is just before/after twelve (noon)
      if((hour==11 && minute > 32) || (hour == 12 && minute <=32)){ r7c1 = B00000011; r7c2 = B11110000; }

      // this is just before/after midnight
      if((hour==23 && minute > 32) || (hour == 0   && minute <=32)){ r8c1 = B00000001; r8c2 = B11111110; }


      // now light up the LEDs
      maxOne(1,1,r1c1);
      maxOne(1,2,r2c1);
      maxOne(1,3,r3c1);
      maxOne(1,4,r4c1);
      maxOne(1,5,r5c1);
      maxOne(1,6,r6c1);
      maxOne(1,7,r7c1);
      maxOne(1,8,r8c1);
   
      maxOne(2,1,r1c2);
      maxOne(2,2,r2c2);
      maxOne(2,3,r3c2);
      maxOne(2,4,r4c2);
      maxOne(2,5,r5c2);
      maxOne(2,6,r6c2);
      maxOne(2,7,r7c2);
      maxOne(2,8,r8c2);
   
      delay(2000);
   
}



Normally, when telling the time, anything before 30 minutes is described as "past the hour" and anything after is "to the following hour". But because we're allowing for "a few minutes past a specific time point" we had to allow for up to 32 minutes to be described as "past" (so 15:32 would be written as just gone half past three). Similarly, up to two minutes before the o'clock position would be described as "nearly x o'clock" so any checks against minutes are 0-32 and 33-58 (not against 30 and 59 as might be expected).

With the LEDs wired up and the code working, we fired up the laser cutter to create a fascia. We cut the protective film from one side of some clear perspex and sprayed it with black paint....


....then laser-etched the letters onto the paint. As this was to be the back of the display, the lettering had to be mirrored.



Amazingly, the clock booted up and worked first time.


So we added a test mode to increase the minutes every couple of seconds, so we can see it cycle through all available times in just a few minutes instead of having to stay awake (and gawp at the clock face without a break) for 24 hours or more!


Maybe if we build another one we might try smoked acrylic so the unlit letters are less prominent when they're not in use?

Monday, 27 March 2017

Recreating MAX7219 functionality with an Arduino

In recent weeks a few of our older projects have been resurrected and we've had a few emailed questions about them. Number one tends to be "you built this with a PIC can you send me the Arduino code?"

The short answer is "no". The longer answer is "well, maybe, one day, when one of us needs something similar for some project".

The latest project  to garner interest is our "dartsboard scorer" using some massive 7-segment LEDs. It's been in a box for the last six months, but having recently got the workshop bungalow into some kind of useable state, I thought I might set up the dartboard and get it out again.

One thing that always bugged me (and everyone else who used it if I'm honest) is that the brightness of the LEDs fluctuates depending on the number of segments lit up. It's all because we couldn't use our MAX7219 chips as the supply voltage needs to be in the region of 9v-12v and the LEDs are common anode types (the max7219 chips work best with common cathode displays).

So I figured it's time we put the display right. And in doing so, maybe answering a couple of questions about the darts scorer - mostly "can you do it on an Arduino?" So here goes - we're going to be multiplexing the segments of the display(s) so that each segment draws the same amount of current and stays lit for the same duration; in theory that should make each segment appear with the same brightness.



As before, we're using a ULN2803A sink array. Each 7-segment display will get it's own (Arduino) controller chip (bare ATMega328 chips) and we'll tell it which number to display by sending data to it over a simple three-wire SPI/I2C connector.

We'll store the value we want to display in a variable. Then create a "pattern" to display on the 7-segment LED. So if we wanted to show the value 4:
We'd want to light up segments 2, 3, 6 and 7.
So our pattern (reading right-to-left) would be 01100110.
Similarly to display the number 7 we'd light up 1,2,3 and 6.
The pattern for number seven would be 00100111.

So in the main loop of our code, we'll look at bits 0-7 of our pattern variable and illuminate the appropriate LED segment (or not as necessary). All other segments will be turned off - so only one segment is lit at any one time and all active segments are illuminated for the same duration.


int k=2;
int last_k=2;
int mask_pattern = B00001101;
int value_to_display=0;

int byte_received=0;
int spi_cs=10;
int spi_data=11;
int spi_clk=12;

int last_cs=1;
int last_data=1;
int last_clk=1;
int byte_buffer=0;
int bits_received=0;

int a;
int b;
int c;
int d;


void setMaskPattern(int p){
   switch(p){
      case 0:
      mask_pattern = B00111111; break;

      case 1:
      mask_pattern = B00000110; break;

      case 2:
      mask_pattern = B01011011; break;
                           
      case 3:
      mask_pattern = B01001111; break;

      case 4:
      mask_pattern = B01100110; break;

      case 5:
      mask_pattern = B01101101; break;

      case 6:
      mask_pattern = B01111101; break;

      case 7:
      mask_pattern = B00100111; break;

      case 8:
      mask_pattern = B01111111; break;

      case 9:
      mask_pattern = B01101111; break;

      case 99:
      mask_pattern = 0x00; break;
      
   }

}

void clearBuffer(){
   byte_buffer=0;
   bits_received=0;
}

void setup() {
   for(int i=2; i<=8; i++){
      pinMode(i,OUTPUT);
      digitalWrite(i,LOW);
   }

   pinMode(spi_cs,INPUT_PULLUP);
   pinMode(spi_data,INPUT_PULLUP);
   pinMode(spi_clk,INPUT_PULLUP);

   clearBuffer();
   setMaskPattern(99);
   
}

void loop() {
   
   
   // --------------------------------------------------
   // multiplex the LEDs as fast as we can
   // (up to 3ms is ok, 5ms creates a visible flicker)
   // --------------------------------------------------
   digitalWrite(last_k,LOW);
   k=k+1;
   if(k>8){ k=2;}

   b=1 << (k-2);
   if(mask_pattern & b){
      digitalWrite(k,HIGH);
   }      
   last_k=k;
   

   // ---------------------------------------------
   // if we've received any data on the SPI bus,
   // update the number to display
   // ---------------------------------------------
   b=digitalRead(spi_cs);
   if(b==LOW){
      if(last_cs!=LOW){
         // this is a falling edge - prepare the data values      
         clearBuffer();
      }else{

         // monitor the CLK line
         a=digitalRead(spi_clk);
         if(a==LOW){
            
            if(last_clk!=LOW){
               // this is a falling edge, get the data
               c=digitalRead(spi_data);
               if(c==HIGH){               
                  d = 1;
                  d = d << bits_received;
                  byte_buffer = byte_buffer + d;
               }
               bits_received++;
               
            }                        
         }
         last_clk=a;
         
      }
      
   }else{

       if(last_cs==LOW){
         // this is releasing the CS line, so put the value
         // from the buffer onto the display
               
         if(bits_received > 3){      
            value_to_display = byte_buffer;
            if(value_to_display > 9){ value_to_display=99;}
            setMaskPattern(value_to_display);
         }
      }
      
   }

   last_cs=b;
   
}

We've also got a bit of "pin polling" going on, looking for data coming in over I2C on pins 8,9 and 10. So when our CS line goes low, we reset everything, read some incoming data and when CS drifts high, choose a new pattern to make different segments of the LED display to light up. Of course this could (should?) be put onto an interrupt, but as the controller has nothing else to do, it won't hurt to poll the pins.




Which means we need a "controller" to send data to the display - the following code simply increments a counter from zero through to ten and displays the appropriate digit on the 7-segment LED.


int byte_to_send;
int spi_cs=10;
int spi_data=11;
int spi_clk=12;

int mask=0;
int k;

void sendByte(int byte_value){
// we assert the CS line (pull it low) to tell
// the target to start listening - but first need
// to make sure that the clock line is also idle

digitalWrite(spi_clk,HIGH);
digitalWrite(spi_cs,LOW);

// give it a moment
delay(1);

// now we set the data pin to indicate each bit
// in the value we want to send
for(int i=0; i<8; i++){
   mask = 1;
   mask = mask << i;
   k = byte_value & mask;
   if(k==0){
    digitalWrite(spi_data,LOW);   
   }else{
    digitalWrite(spi_data,HIGH);   
   }

   // give it a moment
   delay(1);
   
   // drive the clock line low
   digitalWrite(spi_clk,LOW);

   // give it a moment
   delay(2);

   // return the clock line to idle
   digitalWrite(spi_clk,HIGH);
   
}

// release the CS line (send it high)
digitalWrite(spi_cs,HIGH);

}

void setup() {
// put your setup code here, to run once:
pinMode(spi_cs,OUTPUT);
pinMode(spi_data,OUTPUT);
pinMode(spi_clk,OUTPUT);

// we're using pull-ups on the other end
// so everything should idle high
digitalWrite(spi_cs,HIGH);
digitalWrite(spi_data,HIGH);
digitalWrite(spi_clk,HIGH);

}

void loop() {
// put your main code here, to run repeatedly:

byte_to_send++;
if(byte_to_send > 10){ byte_to_send=0;}
sendByte(byte_to_send);
delay(2000);

}


The result looks something like this:



Now obviously this isn't the full code for our updated Darts Scorer, but should be enough to get your own project off the ground - swapping out a MAX7219 with an atmega328 and a ULN2803A transistor array (if you don't use the decimal point you can get away with a ULN2003).

Wednesday, 22 March 2017

Bluetooth with Unity for iOS and Android

In recent weeks we've had quite a bit of activity on an old "how to do bluetooth" page on the blog. A lot of people have asked for a zip file or git link so they can just clone it into their projects.

Firstly, that's not quite how this blog works; stuff posted here is a learning aid - probably just an aide-memoire for the few contributors who actually make the stuff in the first place - rather than a repository of open source code. In fact, I get quite resentful of ignorant people who post (often abusive) comments demanding the entire source code project, instead of reading the post and learning how to do it for themselves. It's not a view shared by everyone at Nerd Towers, but for me, any comment beginning "TL;DR" is meaningless drivel.

With that rant out of the way, we recently had need to create a simple bluetooth-enabled app (for a Unity Meetup event next week, demonstrating how to connect Unity/software to hardware/real-world devices). So it seemed like a good time to review the bluetooth Unity library and hopefully clarify a few things that seem to have tripped a few readers up...

The idea here is to simplify using the bluetooth library as much as possible. As the "list all devices and pick one" part of the last post caused such trouble, we've cut it out completely and tried to keep everything together in one place/script. This means we're looking for a specific named device and we'll automatically connect to it, then send and receive data via a couple of text boxes.

Once you've got this working, you should be able to modify the script as necessary to pass values to/from your own functions to make it work in your own Unity project.

Right, first up, import the BTLE library from the Asset Store.


And create a panel in the Unity IDE



Create a new, blank game object then create a script btle_controller.cs. Drag and drop the script onto the blank game object


Create a text object in the panel (this will be our debug window)


Copy and paste this code into the script:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System.Text;

public class btle_controller : MonoBehaviour {

   // -----------------------------------------------------------------
   // change these to match the bluetooth device you're connecting to:
   // -----------------------------------------------------------------
   // private string _FullUID = "713d****-503e-4c75-ba94-3148f18d941e"; // redbear module pattern
   private string _FullUID = "0000****-0000-1000-8000-00805f9b34fb";     // BLE-CC41a module pattern
   private string _serviceUUID = "ffe0";
   private string _readCharacteristicUUID = "ffe1";
   private string _writeCharacteristicUUID = "ffe1";
   private string deviceToConnectTo = "ChrisBLE";

   public bool isConnected=false;
   private bool _readFound=false;
   private bool _writeFound=false;
   private string _connectedID = null;

   private Dictionary<string, string> _peripheralList;
   private float _subscribingTimeout = 0f;

   public Text txtDebug;
   public GameObject uiPanel;
   public Text txtSend;
   public Text txtReceive;
   public Button btnSend;


   // Use this for initialization
   void Start () {
      btnSend.onClick.AddListener (sendData);
      uiPanel.SetActive (false);
      txtDebug.text+="\nInitialising bluetooth \n";
      BluetoothLEHardwareInterface.Initialize (true, false, () => {},
                                    (error) => {}
      );      
      Invoke ("scan", 1f);
   }
   
   // Update is called once per frame
   void Update () {
      if (_readFound && _writeFound) {
         _readFound = false;
         _writeFound = false;
         _subscribingTimeout = 1.0f;
      }

      if (_subscribingTimeout > 0f) {
         _subscribingTimeout -= Time.deltaTime;
         if (_subscribingTimeout <= 0f) {
            _subscribingTimeout = 0f;
            BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress (
               _connectedID, FullUUID (_serviceUUID), FullUUID (_readCharacteristicUUID),
               (deviceAddress, notification) => {
               },
               (deviceAddress2, characteristic, data) => {
                  BluetoothLEHardwareInterface.Log ("id: " + _connectedID);
                  if (deviceAddress2.CompareTo (_connectedID) == 0) {
                     BluetoothLEHardwareInterface.Log (string.Format ("data length: {0}", data.Length));
                     if (data.Length == 0) {
                        // do nothing
                     } else {
                        string s = ASCIIEncoding.UTF8.GetString (data);
                        BluetoothLEHardwareInterface.Log ("data: " + s);
                        receiveText (s);
                     }
                  }
            });
            
         }
      }
   }

   void receiveText(string s){
      txtDebug.text += "Received: " + s + " \n";
      txtReceive.text = s;
   }

   void sendDataBluetooth(string sData){
      if (sData.Length > 0) {
         byte[] bytes = ASCIIEncoding.UTF8.GetBytes (sData);
         if (bytes.Length > 0) {
            sendBytesBluetooth (bytes);
         }
      }
   }

   void sendBytesBluetooth(byte[] data){
      BluetoothLEHardwareInterface.Log (string.Format ("data length: {0} uuid {1}", data.Length.ToString (), FullUUID (_writeCharacteristicUUID)));
      BluetoothLEHardwareInterface.WriteCharacteristic (_connectedID, FullUUID(_serviceUUID), FullUUID(_writeCharacteristicUUID),
         data, data.Length, true, (characteristicUUID)=> {
            BluetoothLEHardwareInterface.Log("Write succeeded");      
         }
      );
   }
      

   void scan(){
         
      // the first callback will only get called the first time this device is seen
      // this is because it gets added to a list in the BluetoothDeviceScript
      // after that only the second callback will get called and only if there is
      // advertising data available
      txtDebug.text+=("Starting scan \r\n");
      BluetoothLEHardwareInterface.ScanForPeripheralsWithServices (null, (address, name) => {
         AddPeripheral (name, address);
      }, (address, name, rssi, advertisingInfo) => {});
                                     
   }

   void AddPeripheral (string name, string address){
      txtDebug.text+=("Found "+name+" \r\n");

      if (_peripheralList == null) {
         _peripheralList = new Dictionary<string, string> ();
      }
      if (!_peripheralList.ContainsKey (address)) {
         _peripheralList [address] = name;
         if (name.Trim().ToLower() == deviceToConnectTo.Trim().ToLower()) {
            //txtDebug.text += "Found our device, stop scanning \n";
            //BluetoothLEHardwareInterface.StopScan ();

            txtDebug.text += "Connecting to " + address + "\n";
            connectBluetooth (address);
         } else {
            txtDebug.text += "Not what we're looking for \n";
         }
      } else {
         txtDebug.text += "No address found \n";
      }
   }

   void connectBluetooth(string addr){
      BluetoothLEHardwareInterface.ConnectToPeripheral (addr, (address) => {
      },
         (address, serviceUUID) => {
         },
         (address, serviceUUID, characteristicUUID) => {
                     
            // discovered characteristic
            if (IsEqual (serviceUUID, _serviceUUID)) {
               _connectedID = address;         
               isConnected = true;

               if (IsEqual (characteristicUUID, _readCharacteristicUUID)) {
                  _readFound = true;
               }
               if (IsEqual (characteristicUUID, _writeCharacteristicUUID)) {
                  _writeFound = true;
               }
                  
               txtDebug.text += "Connected";
               BluetoothLEHardwareInterface.StopScan ();
               uiPanel.SetActive (true);

            }
         }, (address) => {

            // this will get called when the device disconnects
            // be aware that this will also get called when the disconnect
            // is called above. both methods get call for the same action
            // this is for backwards compatibility
            isConnected = false;
         });
               
   }


   void sendData(){
      string s = txtSend.text.ToString ();
      sendDataBluetooth (s);
   }

   // -------------------------------------------------------
   // some helper functions for handling connection strings
   // -------------------------------------------------------
   string FullUUID (string uuid) {
      return _FullUID.Replace ("****", uuid);
   }

   bool IsEqual(string uuid1, string uuid2){
      if (uuid1.Length == 4) {
         uuid1 = FullUUID (uuid1);
      }
      if (uuid2.Length == 4) {
         uuid2 = FullUUID (uuid2);
      }
      return (uuid1.ToUpper().CompareTo(uuid2.ToUpper()) == 0);
   }

}


... and hook up all the controls to the public variables. Starting with the debug text object, drag and drop this into the public variable slot in the Unity IDE.


Now create a second panel (we made ours dark so you can see it clearly) and link this up to the script variable by dragging and dropping it in the Unity IDE.


Create an input object (which we'll type in to send data) and a text object, as children of the second panel (this panel gets disabled until the Bluetooth device has connected). Plop a button on there too while you're about it. As before, hook up all the on-screen game objects to the script by dragging and dropping.


We tested our project using Unity Remote on an LG G3 phone and a simple Arduino/BLE-CC41a combo (pushing data onto the serial port to send/receive over bluetooth).

IMPORTANT:
If you're running the BTLE4 library on a device running Android 6.0 or later, you'll need to add an extra line to the AndroidManifest.xml file


<uses-permission: android:name="android.permission.ACCESS_COARSE_LOCATION" />


Now fire up your app (we found it only worked on an actual device, running it in test-mode on the PC did nothing- even with bluetooth enabled on the laptop) and send sending/receiving data over bluetooth!


Note:
If you're getting nothing, and no errors during compile, it's quite likely that the UUID patterns are incorrect. Use some software such as BLEGattList (for Android) on your device to query not only which bluetooth devices are available, but also which UUIDs they are using.

We found that the bottom device, listed as "unknown service" beginning 0000ffe0 with a single read/write characteristic with the leading digits 0000ffe1 gave us the values to "plug in" to our code: the service was FFE0, the read characteristic FFE1 and the write characteristic was also FFE1.


This seems pretty standard across almost every one of these cheap bluetooth modules that mimic the BLE-CC41a devices. We did find an old RedBear bluetooth module which we got working, but the FullUID string had to be changed to something completely unrecognisable, and the read and write characteristics had different values (from memory something like 0002 for reading and 0001 for writing).

Anyway, there it is - very much a cut-down, quick and dirty way of getting your device to talk to bluetooth modules, all from a single script. Please don't ask for zip files or full source code - the BTLE plugin is available on the asset store, it costs about a tenner and is well worth spending a few quid to support a fellow programmer. With that installed you can copy and paste a single script, assign some variables and off you go!

Thursday, 16 March 2017

3d printed peristaltic pump

While playing about with some ideas recently we pondered just how powerful a pump had to be to raise a column of water a set height (using, say, aquarium tube of 4.7mm diameter to a height of about 400mm).

We wondered if it would be possible to submerge a pump into a liquid and use inductive chargers (like those often used in Adafruit products to wireless charge lipo batteries) to power the pump. So the liquid could be in a container with the pump in it, which you then place on a separate base. All the clever stuff goes on in the base, and we just use inductive power to turn the pump on and off. It's probably not possible. But this was one of those "learn as you do it" type projects.

For a start, we've already discounted an impeller type design - while you can run an impeller pump off a small hobby motor, they usually have to run at quite high voltage, and they consume quite a bit of current (around half an amp or more, which is way more than any wireless system would be able to provide).

At this stage, we're not even convinced that wireless is even feasible. But we did get to thinking that some kind of peristaltic pump, driven by a small, geared, low current stepper motor might be worth investigating. Plus we've a spanky new 3d printer just sitting there waiting to be given it's first "proper" job!

The first thing was to design our peristaltic pump; for this we used Inkscape (originally with a view to making the pump from layers of laser cut acrylic).


Then after importing the svg shapes into Blender and quite a bit of buggering about (extrude, then alt+A or something to convert a spline-based extrusion into a 3d geometry mesh, applying a few boolean operations and so on) we had some pretty impressive looking models.


There was something about removing duplicates and triangulating the shapes (edit mode, control something, control + N) that we forgot to document, but then the whole thing was ready to export as an STL file. A few clicks later and our models appeared in UP! Studio


After the first export, the design appeared tiny. So we exported again from Blender, this time setting the scale setting in the export dialogue to 1000 (Blender's default units are metres but despite our shapes being described as 0.005 for 5mm for example, we still had to multiply up by 1000 to get them to the right size).


We're still learning how 3d printing works but it was still a bit of a surprise (and a  bit disappointing) to see our raft curling off the bed so early on in the first print. Stopping the print and trying again only caused the nozzle to block (which took about an hour to sort out in the end). So for the second print,  we left all the doors on the printer closed and set it running. As long as the printer was pulling filament off the spool, we were happy to leave it printing "blindly".


The final print was satisfyingly stinky when the UP! Mini beeped to tell us it had finished. The raft stayed stuck to the bed but there was still a tiny amount of warping in the print. The workshop was pretty cold (the UP! Studio said the nozzle started at 12deg C when we first booted the software up) so maybe that was it. Even so, the parts fitted together perfectly snugly.


We drew the shaft hole exactly 3mm high and 5mm wide as per the dimension drawing for the 28BYJ-48 stepper motor. The triangular part of our pump pushed over the shaft with a bit of force- it was a very snug fit; some might say perfectly scaled!

We connected up our stepper motor to a ULN2803A transistor array and threw together some simple code to make it power the coils in the correct sequence to get the motor to turn. We wrapped some 6mm aquarium pipe around the outside of the wheels and set the stepper motor spinning.


While everything appears to run fine, the 3d print holds up and it all looks good in principle, as soon as we added the (admittedly fairly stiff pipe) to the pump, it stalled the motor! Even at 12v, our little stepper just didn't have the grunt to squash the wheels against the sides of the tube. And that's without there being any fluid in the system!

So it looks like we're either going to have to use a bigger/better/stronger motor or softer/squashier tube before this goes anywhere near a bucket of water...


Saturday, 11 March 2017

Testing our UP! Mini printer calibration

This afternoon we set the UP! Mini 3d printer up in its final home, in the bungalow workshop. While the workshop isn't exactly finished, having loads of computers, tools, electronics equipment and boxes of wires hanging around the house has been getting pretty tiresome of late.

So I set up the "big computer" in the bungalow and gave the 3d printer a quick go to check it still worked ok after being joggled around. Thankfully - and as Steve insists is common with UP! printers - it just worked!

great expanses of long empty shelves - won't stay empty for long!

After calibrating our laser cutter a few days ago (and finding it wasn't cutting exactly to size without a bit of tweaking) we figured it best to see how accurately the Up! Mini was printing.


Without messing about creating a 3d model and slicing it up, I just added a simple cube in the Up! Studio software and scaled it to as close to 14mm as I could get (it ended up around 14.02mm). 10 minutes later and a little white cube was ready.


Along one side and the height of cube appeared to be within a reasonable tolerance


Our printer has a resolution of 0.2mm so even at 14.14mm we're still pretty happy that the print is within our margin of error.


Measuring the height of the cube (across the banding) showed it to also be within reasonable margin of error.


At first this reading caused a little concern, as it's less than the 14mm required. But it's also within 0.17mm of the required distance - less than the height of a single layer. So it's pretty good. After all, the variation between designed and measured values were 0.17mm, 0.06mm and 0.12mm

If we'd had that degree of accuracy from our initial laser cut testing, we'd have been more than happy! So until we print something that's completely way out of whack, it look like the UP! Mini 3d printer is producing decent prints - almost straight out of the box.


Friday, 10 March 2017

UP! Mini 3d printer

So this arrived a few days ago.


Steve has an UP! 3d printer and can't recommend it enough. We were looking for an introduction to 3d printing, but couldn't really justify throwing a grand at what would effectively be a fancy toy just to play about with.

But when five UP! Mini printers appeared on eBay for less than two hundred quid each, we figured it was time to dive in and see what this 3d printing thing is that all the cool kids are banging on about.

The printer had come from a "printer farm" (apparently) and had been maintained by a "professional". Quite what this means, we're still unsure. We were warned that the printer head might need purging and that there was no support offered. Having seen a few of the BuildBrighton lot battle for months with their 3d printers, this made us a little nervous - but Steve was insistent that out of all the 3d printers out there, UP! was a pretty pain-free entry to the 3d printing world.

So we installed the software and booted the thing up.
After hitting extrude out came a greyish streak of goo. It turns out the printer was printing with black ABS before we got it. Having pushed some white into it, the grey goo lasted about a minute before slowly turning a chewing-gum white. At least we'd loaded and managed to extrude the filament!

The UP! software makes setting up the printer pretty easy. Click "initialise" and the print head/bed goes to their home positions (thanks to a number of limit switches). Click "maintain" and you can check the bed levelling.

This involves moving the head to one of nine different points. As per the instructions, we used a folded piece of paper as a feeler gauge and moved the bed until it was just gripping the paper. Then loaded a model and hit print.


This, apparently, is the first layer raft. But after printing this much, the printer sort of gave up. Actually, that's not really fair - the printer carried on regardless, it's just that nothing came out of the nozzle! It turns out we'd managed to bung the nozzle up on the first go!


Luckily the printer came with an attachment for removing the nozzle.


Unfortunately, it didn't fit!
It turns out that our printer is using a V3 nozzle, not a V2. Ours is 8mm o/d and has an internal thread. They're far less common than the original V2 type nozzle that just about every other 3d printer uses!


With the printer up to temperature, we carefully argued about who was going to be responsible for breaking the machine before we'd even managed to get a first print off it. Rock paper scissors settled it. I lost.

The nozzle actually came off pretty cleanly - the outside was covered in black gunk and inside a white blob of ABS blocked the nozzle hole completely. It took about an hour of soaking in acetone, picking at with a pin and pulling the ABS with some super-fine tweezers and drilling with a 0.3mm bit (broke two of the buggers in the process) to get the nozzle completely clear.


With the nozzle cleared, and midnight fast approaching, we decided to give it one quick print before calling it a day, whatever the outcome. I grabbed a little dragon off Thingiverse and loaded into the UP! Studio software. Before printing, Nick insisted we should recalibrate the bed. Steve suggested that we might have crashed the bed into the nozzle (which could be the cause of the blockage).

So this time, we set the nozzle height so that it gripped the paper, then backed it off by 0.1mm on all nine points across the bed. Then I insisted that we leave all the doors closed (on the UP! Mini) and let the printer get on with its thing, instead of us crowding around it to see how it's getting on...


About half-way through the print, we couldn't resist lifting the lid and taking a peek inside. Something wasn't right.


We'd set the fill to 65% yet our model was hollow inside. And there was lots of "stringing". We had a nice solid raft, and the ABS was flowing freely but something just didn't ring true.

Nick asked why the dragon on screen was pink and red. It turns out that the model we'd downloaded had all it's normals facing the wrong way! As easy as clicking "fix" in UP! Studio and the onscreen dragon turned blue.


So we hit print, closed the printer up and left it to do it's thing. The print time went up from 6 minutes to about 19 minutes. About half-way through we peeked inside again, just to make sure everything looked as it should - things were a lot more promising this time!

At the end of the print, the UP! Mini beeped loudly and we took our first 3d model off the print bed.


Ta-da! Success!
A little 3d printed dragon.

The UP! Mini has been surprisingly simple to set up and get working. Having seen quite a few of the BuildBrighton lot fighting with their Prusa homebrew models, spewing reams and reams of spaghetti and spending hours trying to get the bed level, getting the model to stick, blocking and unblocking the nozzle, only for a slightly wonky, half-recognisable shape to appear, the UP! Mini was amazingly easy to use.

As Steve likes to remind other 3d printer users, with his UP! printer, he loads a model, hits print and out it comes - no messing about with calibration and bed sticking problems every time; his printer may have been expensive compared to some home-made/kit versions, but it "just works".

Hopefully our UP! Mini will perform as well as these early tests suggest it should!

Wednesday, 8 March 2017

Legoland Berlin

Last weekend a few of us went and had a weekend in Berlin. We didn't have time to visit all the different hackspaces/makerspaces there (at last count, about ten in just the one city!) but did end up near Legoland.

Unlike the UK counterpart, it's not a massive theme park with rides and a sprawling outdoor park (it's built in the middle of a city for a start!) but is still quite impressive and worth a visit - particularly if one of your party happens to be a five-year-old boy who has just had a birthday.


In the (underground) entrance are some very impressive city-scapes, including a recreation of the Berlin City circa 1989, complete with working underground railway system.



The whole set-up is rather impressive.
And - unlike the cliched stereotype of a typical German - it's not devoid of humour; there's even a graffitti-covered wall which - at the press of a button - falls over to the cheers of the crowds.


Moments later a tiny David Hasselhoff belts out "Looking for Freedom" atop a Lego crane, complete with twinkling LEDs. The whole thing is fantastically tongue-in-cheek - not exactly the kind of thing we've been taught to expect from our German friends!


Of course the science and engineering section held our interest far longer than the Ninja Go and Batman franchised areas; servo-driven animations, flashing LEDs and loads of real-life moments in history captured in miniature made this a trip to remember!

Tuesday, 7 March 2017

ExpressPCB acquires RobotRoom Copper Connection

This morning, David from RobotRoom sent us an email so full of exciting news, upbeat and happy smiles all around, you could almost hear Katrina and the Waves singing "Walking on Sunshine".


ExpressPCB has acquired Copper Connection.
We put our head(s) in our collective hands.

ExpressPCB - as a few of us have insisted over the years - is, far and away, the quickest and easiest software for producing quality PCBs. It's simple, quick to learn, has no fancy plugins, but the schematic and PCB layout applications integrate so nicely together that it's almost a joy to be able to lay down a circuit board from a schematic in next to no time.

The one area that ExpressPCB was lacking was output (or exporting). ExpressPCB is free software provided by a PCB manufacturing company. They don't make it easy for you to produce your own designs or to send files generated by their software to other manufacturers. And why would they - after all they've provided the software for free, it's not unreasonable that they tie the user into using them to produce the boards!

For homebrew boards, you can print your design from ExpressPCB onto toner transfer paper and make them yourself at home - the company obviously has no problem with that (it's only fair that you get to produce a prototype board before committing to a production run). Which is fine for hobbyists using through-hole components (as many hobbyists do, as they transfer designs from a breadboard to a PCB).

When designing for surface mount boards, however, things get a bit trickier. If our design was entirely SMT, we'd "print" the board to a PDF (using CutePDF) then open the pdf in inkscape, mirror, then print (so after toner-transferring the design, it would appear the "correct way around" on the copper). An alternative approach is to design a library of components that are all mirrored (with pin one at the top right instead of the top left) and we've also done this successfully in the past. But it's also all to easy to print and etch an entire SMT board only to realise - too late - that the entire design needs flipping to be useable!


One of the really nice features of RobotRoom's Copper Connection is the print facility to produce printouts either for inspection/proof (the design is printed the correct way around) or for transfer etching (the design is automatically mirrored so that it appears the right way around after doing the toner transfer process).

As mentioned, it's not an insurmountable problem to export and flip your designs from ExpressPCB if you don't have Copper Connection (or if, as we suspect might happen in the future, the print option disappears in upcoming versions of the software).

But the absolute best thing about Copper Connection - and it's whole selling point for users of ExpressPCB (and probably the first thing to go now that ExpressPCB has acquired RobotRoom) is the Gerber/Excellon export.

Without Copper Connection we'd have had to pay hundreds of pounds for "professional level" licences for Eagle or DipTrace to producer Gerber files in order to get our PCBs manufactured by factories in China. Unlike some, we've never had a problem with any of the gerber files produced by Copper Connection - our PCBs have been a doddle to create (thanks to the excellent ExpressPCB software) and a two-click process to turn into Gerbers (thanks to Robot Room's Copper Connection).

It stands to reason that export to Gerber will either be the first thing to go from Copper Connection, else maybe a "pro level" licence to cover the cost of producing the software (after all ExpressPCB aren't going to want to give software away for free that lets you take your designs to any old manufacturer).

Of course David from RobotRoom is excited that his produce has been acquired by ExpressPCB. Of course ExpressPCB are happy to have taken out a "competitor" to their software. But we can't help but feel that it'll mean the end of our cheapskate way of producing PCBs - both home-etched and pro manufactured - without quite a bit of messing about (or, worse still, learning Eagle!)