Idea

After building my first Neopixel Clock, I decided I needed one for myself. There was no way I was going to solder 90 lengths of wire onto 180 tiny pads again, though, so I knew I needed to design a custom PCB. This necessitated a redesign of the entire clock, focused around making it as easy as possible to assemble.

Design

Having been unsatisfied with Kicad the last time I designed a PCB, I looked for new options, and found EasyEDA. They are a PCB design web app plus manufacturing service all in one. I found the web app to work quite nicely for my needs, and their PCBs were both high quality and cheap, so I’m happy with them so far.

Custom PCB populated with Neopixel strip segments

Digit diffuser in CAD

To minimize the cost and the size of the finished product, I designed a single circuit board that could hold Neopixel strips in a 7-segment arrangement, plus all of the electronics needed to drive the clock. Each digit uses the same circuit board, with the holes for the microcontroller and clock module left unpopulated in all but the first digit. Power and data come in from the left and exit out the right.

After designing the circuit boards, I designed a new light diffuser around the board shape. The circuit board slides and snaps in from the back of the diffuser, forming a separate enclosure around each of the seven Neopixel strip segments. Each digit is a separate 3D print measuring 76mm wide by 120mm tall.

Hardware

Like the previous version, the clock is powered by an Arduino Pro Mini clone (purchased from eBay). It communicates with a DS3231 Real Time Clock module (also from eBay) via I2C. Power comes over USB, via a micro USB breakout board (eBay). The Neopixel strips (from Amazon) are 60-per-meter WS2812B LEDs. There are two LEDs per digit segment, for a total of 14 on each digit and 58 individually-addressable lights on the entire clock.

Software

The software (full source code and download link below) was reused from my original Neopixel clock. I updated the arrays to fit a clock with a different number and arrangement of pixels, and it worked straight away.

Result

 

Downloads

Code

#include <Adafruit_NeoPixel.h>
#ifdef __AVR__
  #include <avr/power.h>
#endif

#define PIN 9

#define SEGMENTS_PER_DIGIT 7
#define LEDS_PER_SEGMENT 2
#define LEDS_PER_DIGIT 14
#define GRID_WIDTH 17
#define GRID_HEIGHT 7

#define NUMPIXELS 58

#include <Wire.h>
#include "RTClib.h"
#include <Encoder.h>

RTC_DS3231 rtc;

Adafruit_NeoPixel strip = Adafruit_NeoPixel(NUMPIXELS, PIN, NEO_GRB + NEO_KHZ800);

int mode = 0;
bool justEnteredMode = true;

double timeOfLastInput = 0;
double timeOfLastRefresh = 0;
int inactivityTimeoutDuration = 3000;

uint32_t startColor,endColor;

uint16_t startHue = 0;
uint16_t endHue = 80;

int brightness = 10;


int buttonPin = 10;
bool oldButtonState = 1;

Encoder myEnc(3, 2);
long oldPosition = -999;
int inputHours = 1;
int inputTenMinutes = 0;
int inputMinutes = 0;

int inputColor = 0;
int inputColorShift = 12;
int inputColorDirection = 0;


// Defines the light number for each light by its position in a grid covering the whole clock face
// 999 indicates positions where no light exists

const int PROGMEM pixelNumber[7][17] = 
{
  {999,  0,  1,999,999, 14, 15,999,999,999, 30, 31,999,999, 44, 45,999},
  { 11,999,999,  2, 25,999,999, 16,999, 41,999,999, 32, 55,999,999, 46},
  { 10,999,999,  3, 24,999,999, 17, 29, 40,999,999, 33, 54,999,999, 47},
  {999, 12, 13,999,999, 26, 27,999,999,999, 42, 43,999,999, 56, 57,999},
  {  9,999,999,  4, 23,999,999, 18, 28, 39,999,999, 34, 53,999,999, 48},
  {  8,999,999,  5, 22,999,999, 19,999, 38,999,999, 35, 52,999,999, 49},
  {999,  7,  6,999,999, 21, 20,999,999,999, 37, 36,999,999, 51, 50,999},
};


const int pixelValues[][14] = {
  {1,1,1,1,1,1,1,1,1,1,1,1,0,0}, // Number 0
  {0,0,1,1,1,1,0,0,0,0,0,0,0,0}, // Number 1
  {1,1,1,1,0,0,1,1,1,1,0,0,1,1}, // Number 2
  {1,1,1,1,1,1,1,1,0,0,0,0,1,1}, // Number 3
  {0,0,1,1,1,1,0,0,0,0,1,1,1,1}, // Number 4
  {1,1,0,0,1,1,1,1,0,0,1,1,1,1}, // Number 5
  {1,1,0,0,1,1,1,1,1,1,1,1,1,1}, // Number 6
  {1,1,1,1,1,1,0,0,0,0,0,0,0,0}, // Number 7
  {1,1,1,1,1,1,1,1,1,1,1,1,1,1}, // Number 8
  {1,1,1,1,1,1,1,1,0,0,1,1,1,1}, // Number 9
  {0,0,0,0,0,0,0,0,0,0,0,0,0,0}  // Digit off
};


void setup () {

  Serial.begin(9600);


  if (! rtc.begin()) {
    Serial.println("Couldn't find RTC");
    while (1);
  }
    
  if (rtc.lostPower()) {
    Serial.println("RTC lost power, lets set the time!");
    // following line sets the RTC to the date & time this sketch was compiled
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
    // This line sets the RTC with an explicit date & time, for example to set
    // January 21, 2014 at 3am you would call:
    // rtc.adjust(DateTime(2014, 1, 21, 3, 0, 0));
  }

  // Uncomment below to set time to compile time.
  // THIS WILL KEEP RESETTING THE TIME EVERY BOOT UNTIL CODE WITH THIS DISABLED IS UPLOADED
  //rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));


  strip.begin(); // This initializes the NeoPixel library.


  pinMode(buttonPin, INPUT_PULLUP);      // sets the digital pin as output

}

void loop () {

  if (digitalRead(buttonPin) == 0 && oldButtonState == 1) {
    mode++;
    justEnteredMode = true;
    timeOfLastInput = millis(); // Count mode switching as an input
  }

  oldButtonState = digitalRead(buttonPin);

  int delayNum = 500;

  DateTime now = rtc.now();
  

  int convertedHour;

  if (now.hour() > 12) {
    convertedHour = now.hour() - 12;
  }
  else {
    convertedHour = now.hour();
  }


  int num0 = (convertedHour /10) % 10;
  int num1 = convertedHour % 10;
  int num2 = (now.minute() /10) % 10;
  int num3 = now.minute() % 10;


  // Time display and brightness adjust mode
  if (mode == 0) {
    // Run only on first instance of loop
    if (justEnteredMode == true) {
      Serial.println("Mode 0, display time");
    }
    justEnteredMode = false;

    adjustVariable(&brightness, 1, 1, 20);

    updateDigits(num0,num1,num2,num3);

    inputColor += 5;
  }

  // Color set mode
  else if (mode == 1) {
    // Run only on first instance of loop
    if (justEnteredMode == true) {
      Serial.println("Mode 1, set color 1");
      updateDigits(1, 10, 10, 10);
      delay(500);
    }
    justEnteredMode = false;


    adjustVariable(&inputColor, 10, 0, 360);
    
    resetModeAfterInactivity();

    updateDigits(8, 8, 8, 8);

  }
  
  // Color shift amount set mode
  else if (mode == 2) {
    // Run only on first instance of loop
    if (justEnteredMode == true) {
      Serial.println("Mode 2, set color shift amount");
      updateDigits(2, 10, 10, 10);
      delay(500);
    }
    justEnteredMode = false;

    adjustVariable(&inputColorShift, 1, 0, 30);
    
    resetModeAfterInactivity();
        
    updateDigits(8, 8, 8, 8);

  }  
  
  // Gradient direction set mode
  else if (mode == 3) {
    // Run only on first instance of loop
    if (justEnteredMode == true) {
      Serial.println("Mode 3, set gradient direction");
      updateDigits(3, 10, 10, 10);
      delay(500);
    }
    justEnteredMode = false;

    adjustVariable(&inputColorDirection, 1, 0, 2);
    
    resetModeAfterInactivity();

    updateDigits(8, 8, 8, 8);

  }

  // Hour set mode
  else if (mode == 4) {
    // Run only on first instance of loop
    if (justEnteredMode == true) {
      Serial.println("Mode 4, set hour");
    }
    justEnteredMode = false;
    
    adjustVariable(&inputHours, 1, 1, 12);

    int digitOne = (inputHours / 10) % 10;
    int digitTwo = inputHours % 10;
    
    resetModeAfterInactivity();

    updateDigits(digitOne, digitTwo, 10, 10);

    rtc.adjust(DateTime(now.year(), now.month(), now.day(), inputHours, now.minute(), now.second()));
    
  }

  //Ten minute set mode
  else if (mode == 5) {
    // Run only on first instance of loop
    if (justEnteredMode == true) {
      Serial.println("Mode 5, set ten minutes");
    }
    justEnteredMode = false;

    adjustVariable(&inputTenMinutes, 1, 0, 5);
    
    resetModeAfterInactivity();

    updateDigits(10, 10, inputTenMinutes, 10);

    rtc.adjust(DateTime(now.year(), now.month(), now.day(), now.hour(), inputTenMinutes*10, 0));
    
  }

  //Minute set mode
  else if (mode == 6) {
    // Run only on first instance of loop
    if (justEnteredMode == true) {
      Serial.println("Mode 6, set minute");
    }
    justEnteredMode = false;
    
    adjustVariable(&inputMinutes, 1, 0, 9);
    
    resetModeAfterInactivity();

    updateDigits(10, 10, 10, inputMinutes);

    rtc.adjust(DateTime(now.year(), now.month(), now.day(), now.hour(), (inputTenMinutes*10) + inputMinutes, 0));
    
  }
  
  else {
    mode = 0;
  }
  
}


void resetModeAfterInactivity() {

  if (timeOfLastInput + inactivityTimeoutDuration < millis()) {
    Serial.print("Time of last input: ");
    Serial.print(timeOfLastInput);
    Serial.print(". Current milis: ");
    Serial.println(millis());
    mode = 0;
  }
  
}

void updateDigits(int num0, int num1, int num2, int num3) {

  if (timeOfLastRefresh + 100 < millis()) {

    timeOfLastRefresh = millis();
    
    // Set the segments
    for (int v=0; v<GRID_HEIGHT; v++) {
    
      for (int h=0; h<GRID_WIDTH; h++) {
    
        int currentPixelNumber = pgm_read_word_near(&(pixelNumber[v][h]));
    
        if (currentPixelNumber != 999) {
    
          // There is a pixel here, see if it should be lit
          int digitOffset = 0;
  
          // Track which number we want to draw to the current digit position
          int numberToDraw;
  
          // Track if we're on a divider pixel
          bool divider = false;
          
          // First digit
          if (h >= 0 && h < LEDS_PER_SEGMENT + 2) {
            digitOffset = 0;
            numberToDraw = num0;
            if (numberToDraw == 0) {
              numberToDraw = 10;
            }
          }
  
          // Second digit
          if (h >=LEDS_PER_SEGMENT + 2 && h < (LEDS_PER_SEGMENT * 2) + 4) {
            digitOffset = LEDS_PER_SEGMENT * SEGMENTS_PER_DIGIT;
            numberToDraw = num1;
          }
  
          // Divider
          if (h == (LEDS_PER_SEGMENT * 2) + 4) {
            digitOffset = (LEDS_PER_SEGMENT * SEGMENTS_PER_DIGIT) * 2;
            divider = true;
          }
  
          if (h >= (LEDS_PER_SEGMENT * 2) + 5 && h <= (LEDS_PER_SEGMENT * 3) + 6) {
            digitOffset = (LEDS_PER_SEGMENT * SEGMENTS_PER_DIGIT) * 2 + 2;
            numberToDraw = num2;
          }
  
          if (h >= (LEDS_PER_SEGMENT * 3) + 7 && h <= (LEDS_PER_SEGMENT * 4) + 8) {
            digitOffset = (LEDS_PER_SEGMENT * SEGMENTS_PER_DIGIT) * 3 + 2;
            numberToDraw = num3;
          }
          
          if (pixelValues[numberToDraw][currentPixelNumber-digitOffset] == 1 || divider == true) {   
            if (inputColorDirection == 0) {
              strip.setPixelColor(currentPixelNumber, HSV_to_RGB(inputColor + h*inputColorShift,100,brightness)); 
            }
            else if (inputColorDirection == 1) {
              strip.setPixelColor(currentPixelNumber, HSV_to_RGB(inputColor + v*inputColorShift,100,brightness)); 
            }
            else {
              strip.setPixelColor(currentPixelNumber, HSV_to_RGB(inputColor,100,brightness)); 
            }
          }
          else {
            strip.setPixelColor(currentPixelNumber, strip.Color(0,0,0));
          }
          
          
          strip.show();
    
        }
        else {
          // 999 means no pixel here, do nothing
        }
      }
  
    }
  
  }
    
}


void adjustVariable(int *variable, int increment, int minNum, int maxNum) {

  long newPosition = myEnc.read();
  
  if (newPosition != oldPosition) {
    if (oldPosition > newPosition) {
      *variable -= increment;
      
      timeOfLastInput = millis();
      
      if (*variable < minNum) {
        *variable = maxNum;
      }  
      Serial.print("Decrease to");
      Serial.println(*variable + increment);
    }
    if (oldPosition < newPosition) {
      *variable += increment;

      timeOfLastInput = millis();
      
      if (*variable > maxNum) {
        *variable = minNum;
      }
      Serial.print("Increase to ");
      Serial.println(*variable - increment);
    }      

    oldPosition = newPosition;
  }
  
}


uint32_t HSV_to_RGB(float h, float s, float v) {
  int i;
  float f,p,q,t;

  uint8_t r,g,b;

  while (h >= 360) {
    h = h - 360;
  }
  while (h < 0) {
    h = h + 360;
  }
  
  h = max(0.0, min(360.0, h));
  s = max(0.0, min(100.0, s));
  v = max(0.0, min(100.0, v));
  
  s /= 100;
  v /= 100;
  
  if(s == 0) {
    // Achromatic (grey)
    r = g = b = round(v*255);
    return;
  }

  h /= 60; // sector 0 to 5
  i = floor(h);
  f = h - i; // factorial part of h
  p = v * (1 - s);
  q = v * (1 - s * f);
  t = v * (1 - s * (1 - f));
    
  switch(i) {
    case 0:
      r = round(255*v);
      g = round(255*t);
      b = round(255*p);
      break;
    case 1:
      r = round(255*q);
      g = round(255*v);
      b = round(255*p);
      break;
    case 2:
      r = round(255*p);
      g = round(255*v);
      b = round(255*t);
      break;
    case 3:
      r = round(255*p);
      g = round(255*q);
      b = round(255*v);
      break;
    case 4:
      r = round(255*t);
      g = round(255*p);
      b = round(255*v);
      break;
    default: // case 5:
      r = round(255*v);
      g = round(255*p);
      b = round(255*q);
  }

  return strip.Color(r,g,b);  
};

 

Copyright

All files should be considered Creative Commons 3.0 non-commercial licensed.


9 Comments

Steve · December 10, 2017 at 6:32 pm

This looks really cool.. I’m wondering if you could tell me if these are bright enough to see in outdoor sunlight.. I’ve been wanting to find a way to build a relatively cheap battery powered, wifi connected scoreboard for kids sports.. Something the coach would bring to the field and mount on a tripod, and have a parent control during the game with a scoring app made for it. It would all be useless if it’s not bright enough to see in daylight? What do you think?

    David Zweben · December 16, 2017 at 9:04 am

    I haven’t brought it outside, but I think they are most likely *borderline* bright enough to see in outdoor sunlight, maybe not extremely visible, but visible. At max brightness they are quite bright indoors.

    If you’re interested, I’d say buy yourself a Neopixel strip for $10 from Amazon or Ebay and just light up the bare strip outside to see if it’s bright enough. I’m sure you can come up with a fun use for it either way.

Leef_me · December 14, 2017 at 1:38 am

I came from Hackaday to see your v2 clock. Thank you for sharing information on your design.
I have a few requests. At the top of your docs, tell us the digits dimensions. Put the code in a downloadable file. Be specific about hardware model number & where: the Arduino clone, the RTC and the breakout board. Provide the files for the diffuser.

    David Zweben · December 16, 2017 at 9:07 am

    Hey Leef_me, thanks for your interest, and thanks for letting me know where you found the site, I had no idea it was on Hackaday! Those are good suggestions, so I’m going to work on those.

    David Zweben · December 16, 2017 at 9:31 am

    Hi Leef_me, I added the dimensions of each digit, the files for the diffuser, accessories and source code, and info about where I bought the parts. Hope this helps.

George · December 14, 2017 at 6:27 am

Hello David,

The led diffuser and the whole casing is 3d printed?

Thank you,
George

    David Zweben · December 16, 2017 at 9:01 am

    Correct, the case and the diffuser are the same part, all 3D-printed. The circuit board snaps into the case, which has a thin surface on the front in the shape of the 7-segment digit, with a small air gap between the circuit board and the front surface. The air gap plus the thin front surface act as the diffuser. Thanks for your interest!

Color changing clock uses PCB digits | TEKSMEK · December 13, 2017 at 7:23 pm

[…] would agree, since he wasn’t satisfied with his first Neopixel clock and proceeded to build another one. One lesson learned: soldering 180 tiny solder joints isn’t much fun. This time, [Zweben] set out […]

Color changing clock uses PCB digits – High Tech Newz · December 13, 2017 at 8:23 pm

[…] would agree, since he wasn’t satisfied with his first Neopixel clock and proceeded to build another one. One lesson learned: soldering 180 tiny solder joints isn’t much fun. This time, [Zweben] set […]

Leave a Reply

Your email address will not be published. Required fields are marked *

%d bloggers like this: