Introduction: Wooden LED Clock - Analog Style

About: Atheist, Feminist, Critical Thinker. Maker of things, user of opensource.

It's an analog style wooden LED clock. I don't know why I haven't seen one of these before..even though the digital types are very common. Anyhoo, here we go!

Step 1:

The plywood clock project started as a simple starter project for the CNC router. I was looking at simple projects online and found this lamp(image above). I had also seen digital clocks that shine through wood veneer(image above). So, combining the two projects was an obvious idea. Looking to challenge myself, I decided not to use veneer but only a piece of wood for this project.

Step 2: Design

I designed the clock in Inkscape (image above). The design is very simple by choice. I decided against routing traces for the wires because at this point I was unsure if I wanted to go with radial or perimeter wiring. (I decided to go with perimeter wiring finally.) One neopixel goes in each one of the small circular holes for showing the minute and hour time, with five-minute precision. The circle in the middle will be routed out to accommodate the electronics.

Step 3: CNCing

I designed the toolpaths on MasterCAM, and used a technoRouter to mill out the clock from 3/4 inch plywood. I use a 15"x15" piece for this, with minimal wastage. The trick is to route out as much of the wood as possible without breaking through the wood. Leaving 0.05"-0.1" is a good choice for light wood. If you are unsure, it's better to leave more wood in, because you can always sand the other face. I ended up removing a little too much wood from some parts, but thankfully the results don't suffer too much because of this.

Note for users without access to a CNC:

This project can easily be done with a drill press. You just need to set the stop at a point where you leave around 0.1" of wood remaining at the base. You'll have to be precise, but not too precise. After all, ideally no one will see all the LEDs light up at the same time, so you can get away with a little slop.

Step 4: Electronics

The electronics are fairly simple. There are 24 neopixels, twelve for showing the hours and twelve for showing the minutes, with five-minute precision. An Arduino pro mini controls the neopixels and it gets accurate time through a DS3231 real time clock(RTC) module. The RTC module has a coin cell as backup, so it doesn't lose time even when the power is off.

Material:

Arduino pro mini (or any other Arduino for that matter)

DS3231 breakout board

Neopixels in individual breakout boards

Step 5: Electronics Assembly

I connected the neopixels in a string, using 2.5" wires for the first twelve leds and four-inch wire for the next twelve. I could have used slightly smaller wire lengths. After making the string, I tested it out, making sure the solder joints were good. I added a momentary switch to turn on all the leds, just to show off.

Step 6: Dry Run

After experimenting, putting LEDs in the holes and turning them all on, I was satisfied with the results. So I sanded the front face a bit and applied a PU coat. I ended up sanding the coat off later on, but it's a good idea to leave it on if you don't find it aesthetically displeasing.

Step 7: Epoxy

After some testing with the led position within the holes, I figured that the best discussion is achieved when the LEDs are around 0.2" away from the end of the hole. When you try this yourself, the brightness of the LEDs will be very different in each hole. Don't worry about this; we'll fix it in code. This is because of the type of drill bit I used. If I were to do this again, I'd use a ball end drill bit for the holes. But, in any case, to get the distance I mixed some epoxy and put a little bit in each hole.

Step 8: Putting It All Together

The LEDs will be placed starting from the 12 o'clock hour hand position moving anti-clockwise through all the hour hand positions and then to the minute hand, again moving from the 60 minute mark moving anti-clockwise. This is so that when we view from the front the LED pattern appears going clockwise.

After the epoxy cured for an hour, I put in some more epoxy. This time, I placed the LEDs in the holes, making sure to cover the wires and solder joints with the epoxy. This makes for good light diffusion and secures the wires.

Step 9: Code

The code is on GitHub, feel free to modify it for your use. When you switch on all the LEDs to the same level, the brightness of light shining through will be very different in each hole. This is because of the different thickness of wood in the holes and the difference in the shade of the wood, As you can see the wood color varies quite a bit in my piece. To remedy this difference in brightness, I made a matrix of led brightness levels. And decreased the brightness of the brighter LEDs. It's a trial and error process and can take several minutes, but the results are well worth it.

plywoodClock.ino

// Plywood Clock
// Author: tinkrmind
// Attribution 4.0 International (CC BY 4.0). You are free to:
// Share — copy and redistribute the material in any medium or format
// Adapt — remix, transform, and build upon the material for any purpose, even commercially.
// Hurray!
#include<Wire.h>
#include"RTClib.h"
RTC_DS3231 rtc;
#include"Adafruit_NeoPixel.h"
#ifdef __AVR__
#include<avr/power.h>
#endif
#definePIN6
Adafruit_NeoPixel strip = Adafruit_NeoPixel(60, PIN, NEO_GRB + NEO_KHZ800);
int hourPixel = 0;
int minutePixel = 0;
unsignedlong lastRtcCheck;
String inputString = ""; // a string to hold incoming data
boolean stringComplete = false; // whether the string is complete
int level[24] = {31, 51, 37, 64, 50, 224, 64, 102, 95, 255, 49, 44, 65, 230, 80, 77, 102, 87, 149, 192, 67, 109, 68, 77};
voidsetup () {
#ifndef ESP8266
while (!Serial); // for Leonardo/Micro/Zero
#endif
// This is for Trinket 5V 16MHz, you can remove these three lines if you are not using a Trinket
#if defined (__AVR_ATtiny85__)
if (F_CPU == 16000000) clock_prescale_set(clock_div_1);
#endif
// End of trinket special code
Serial.begin(9600);
strip.begin();
strip.show(); // Initialize all pixels to 'off'
if (! rtc.begin()) {
Serial.println("Couldn't find RTC");
while (1);
}
pinMode(2, INPUT_PULLUP);
// rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
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(2017, 11, 06, 2, 49, 0));
}
// rtc.adjust(DateTime(2017, 11, 06, 2, 49, 0));
// lightUpEven();
// while (1);
lastRtcCheck = 0;
}
voidloop () {
if (millis() - lastRtcCheck >2000) {
DateTime now = rtc.now();
Serial.print(now.hour(), DEC);
Serial.print(':');
Serial.print(now.minute(), DEC);
Serial.print(':');
Serial.print(now.second(), DEC);
Serial.println();
showTime();
lastRtcCheck = millis();
}
if (!digitalRead(2)) {
lightUpEven();
}
if (stringComplete) {
Serial.println(inputString);
if (inputString[0] == 'l') {
Serial.println("Level");
lightUpEven();
}
if (inputString[0] == 'c') {
Serial.println("Showing time");
showTime();
strip.show();
}
if (inputString[0] == '1') {
Serial.println("Switching on all LEDs");
lightUp(strip.Color(255, 255, 255));
strip.show();
}
if (inputString[0] == '0') {
Serial.println("Clearing strip");
clear();
strip.show();
}
// #3,255 would set led number 3 to level 255,255,255
if (inputString[0] == '#') {
String temp;
temp = inputString.substring(1);
int pixNum = temp.toInt();
temp = inputString.substring(inputString.indexOf(',') + 1);
int intensity = temp.toInt();
Serial.print("Setting ");
Serial.print(pixNum);
Serial.print(" to level ");
Serial.println(intensity);
strip.setPixelColor(pixNum, strip.Color(intensity, intensity, intensity));
strip.show();
}
// #3,255,0,125 would set led number 3 to level 255,0,125
if (inputString[0] == '$') {
String temp;
temp = inputString.substring(1);
int pixNum = temp.toInt();
int rIndex = inputString.indexOf(',') + 1;
temp = inputString.substring(rIndex);
int rIntensity = temp.toInt();
intgIndex = inputString.indexOf(',', rIndex + 1) + 1;
temp = inputString.substring(gIndex);
intgIntensity = temp.toInt();
int bIndex = inputString.indexOf(',', gIndex + 1) + 1;
temp = inputString.substring(bIndex);
int bIntensity = temp.toInt();
Serial.print("Setting ");
Serial.print(pixNum);
Serial.print(" R to ");
Serial.print(rIntensity);
Serial.print(" G to ");
Serial.print(gIntensity);
Serial.print(" B to ");
Serial.println(bIntensity);
strip.setPixelColor(pixNum, strip.Color(rIntensity, gIntensity, bIntensity));
strip.show();
}
if (inputString[0] == 's') {
String temp;
int hour, minute;
temp = inputString.substring(1);
hour = temp.toInt();
int rIndex = inputString.indexOf(',') + 1;
temp = inputString.substring(rIndex);
minute = temp.toInt();
Serial.print("Showing time: ");
Serial.print(hour);
Serial.print(":");
Serial.print(minute);
showTime(hour, minute);
delay(1000);
}
inputString = "";
stringComplete = false;
}
// delay(1000);
}
voidserialEvent() {
while (Serial.available()) {
char inChar = (char)Serial.read();
inputString += inChar;
if (inChar == '\n') {
stringComplete = true;
}
delay(1);
}
}
voidclear() {
for (uint16_t i = 0; i < strip.numPixels(); i++) {
strip.setPixelColor(i, strip.Color(0, 0, 0));
}
}
voidshowTime() {
DateTime now = rtc.now();
hourPixel = now.hour() % 12;
minutePixel = (now.minute() / 5) % 12 + 12;
clear();
// strip.setPixelColor(hourPixel, strip.Color(40 + 40 * level[hourPixel], 30 + 30 * level[hourPixel], 20 + 20 * level[hourPixel]));
// strip.setPixelColor(minutePixel, strip.Color(40 + 40 * level[minutePixel], 30 + 30 * level[minutePixel], 20 + 20 * level[minutePixel]));
strip.setPixelColor(hourPixel, strip.Color(level[hourPixel], level[hourPixel], level[hourPixel]));
strip.setPixelColor(minutePixel, strip.Color(level[minutePixel], level[minutePixel], level[minutePixel]));
// lightUp(strip.Color(255, 255, 255));
strip.show();
}
voidshowTime(int hour, int minute) {
hourPixel = hour % 12;
minutePixel = (minute / 5) % 12 + 12;
clear();
// strip.setPixelColor(hourPixel, strip.Color(40 + 40 * level[hourPixel], 30 + 30 * level[hourPixel], 20 + 20 * level[hourPixel]));
// strip.setPixelColor(minutePixel, strip.Color(40 + 40 * level[minutePixel], 30 + 30 * level[minutePixel], 20 + 20 * level[minutePixel]));
strip.setPixelColor(hourPixel, strip.Color(level[hourPixel], level[hourPixel], level[hourPixel]));
strip.setPixelColor(minutePixel, strip.Color(level[minutePixel], level[minutePixel], level[minutePixel]));
// lightUp(strip.Color(255, 255, 255));
strip.show();
}
voidlightUp(uint32_t color) {
for (uint16_t i = 0; i < strip.numPixels(); i++) {
strip.setPixelColor(i, color);
}
strip.show();
}
voidlightUpEven() {
for (uint16_t i = 0; i < strip.numPixels(); i++) {
strip.setPixelColor(i, strip.Color(level[i], level[i], level[i]));
}
strip.show();
}
view rawplywoodClock.ino hosted with ❤ by GitHub

Step 10: Computer Vision - Calibration

I made a conscious choice to not use veneer in this project. If I had, the wood thickness would have been the same in front of all LEDs. But, because I have a different thickness of wood in front of each LED and because wood color varies a lot as well, the LED brightness is different for each LED. To make all the LEDs seem to be of the same brightness, I devised a nifty trick.

I wrote some processing code(on GitHub) which takes a photo of the clock, and analyses the brightness of each LED in turn. It then varies the power to each LED to try to get them all to have the same brightness as the dimmest LED. Now, I know this is overkill, but image processing is a lot of fun! And, I'm hoping to develop the calibration code as a library.

You can see the LED brightness before and after calibration in the photos above.

calibrateDispllay.pde

importprocessing.video.*;
importprocessing.serial.*;
Serial myPort;
Capture video;
finalint numLed =24;
int ledNum =0;
// you must have these global varables to use the PxPGetPixelDark()
int rDark, gDark, bDark, aDark;
int rLed, gLed, bLed, aLed;
int rOrg, gOrg, bOrg, aOrg;
int rTemp, gTemp, bTemp, aTemp;
PImage ourImage;
int runNumber =0;
int acceptableError =3;
int[] done;
int[] numPixelsInLed;
long[] ledIntensity;
int[] ledPower;
long targetIntensity =99999999;
voidsetup() {
done =newint[numLed];
numPixelsInLed =newint[numLed];
ledIntensity =newlong[numLed];
ledPower =newint[numLed];
for (int i=0; i<numLed; i++) {
ledPower[i] =255;
}
printArray(Serial.list());
String portName =Serial.list()[31];
myPort =newSerial(this, portName, 9600);
size(640, 480);
video =newCapture(this, width, height);
video.start();
noStroke();
smooth();
delay(1000); // Wait for serial port to open
}
voiddraw() {
if (video.available()) {
if (done[ledNum] ==0) {
clearDisplay();
delay(1000);
video.read();
image(video, 0, 0, width, height); // Draw the webcam video onto the screen
saveFrame("data/no_leds.jpg");
if (runNumber !=0) {
if ((ledIntensity[ledNum] - targetIntensity)*100/targetIntensity > acceptableError) {
ledPower[ledNum] -=pow(0.75, runNumber)*100+1;
}
if ((targetIntensity - ledIntensity[ledNum])*100/targetIntensity > acceptableError) {
ledPower[ledNum] +=pow(0.75, runNumber)*100+1;
}
if (abs(targetIntensity - ledIntensity[ledNum])*100/targetIntensity <= acceptableError) {
done[ledNum] =1;
print("Led ");
print(ledNum);
print(" done");
}
if (ledPower[ledNum] >255) {
ledPower[ledNum] =255;
}
if (ledPower[ledNum] <0) {
ledPower[ledNum]=0;
}
}
setLedPower(ledNum, ledPower[ledNum]);
delay(1000);
video.read();
image(video, 0, 0, width, height); // Draw the webcam video onto the screen
delay(10);
while (myPort.available() >0) {
int inByte = myPort.read();
//print(char(inByte));
}
String imageName ="data/";
imageName+=str(ledNum);
imageName +="_led.jpg";
saveFrame(imageName);
String originalImageName ="data/org";
originalImageName+=str(ledNum);
originalImageName +=".jpg";
if (runNumber ==0) {
saveFrame(originalImageName);
}
PImage noLedImg =loadImage("data/no_leds.jpg");
PImage ledImg =loadImage(imageName);
PImage originalImg =loadImage(originalImageName);
noLedImg.loadPixels();
ledImg.loadPixels();
originalImg.loadPixels();
background (0);
loadPixels();
ledIntensity[ledNum] =0;
numPixelsInLed[ledNum] =0;
for (int x =0; x<width; x++) {
for (int y =0; y<height; y++) {
PxPGetPixelDark(x, y, noLedImg.pixels, width);
PxPGetPixelLed(x, y, ledImg.pixels, width);
PxPGetPixelOrg(x, y, originalImg.pixels, width);
if ((rOrg+gOrg/2+bOrg/3)-(rDark+gDark/2+bDark/3) >75) {
ledIntensity[ledNum] = ledIntensity[ledNum] +(rLed+gLed/2+bLed/3) -(rDark+gDark/2+bDark/3);
rTemp=255;
gTemp=255;
bTemp=255;
numPixelsInLed[ledNum]++;
} else {
rTemp=0;
gTemp=0;
bTemp=0;
}
PxPSetPixel(x, y, rTemp, gTemp, bTemp, 255, pixels, width);
}
}
ledIntensity[ledNum] /= numPixelsInLed[ledNum];
if (targetIntensity > ledIntensity[ledNum] && runNumber ==0) {
targetIntensity = ledIntensity[ledNum];
}
updatePixels();
}
print(ledNum);
print(',');
print(ledPower[ledNum]);
print(',');
println(ledIntensity[ledNum]);
ledNum++;
if (ledNum == numLed) {
int donezo =0;
for (int i=0; i<numLed; i++) {
donezo += done[i];
}
if (donezo == numLed) {
println("DONE");
for (int i=0; i<numLed; i++) {
print(i);
print("\t");
println(ledPower[i]);
}
print("int level[");
print(ledNum);
print("] = {");
for (int i=0; i<numLed-1; i++) {
print(ledPower[i]);
print(',');
}
print(ledPower[numLed -1]);
println("};");
lightUpEven();
while (true);
}
print("Target intensity: ");
if (runNumber ==0) {
targetIntensity -=1;
}
println(targetIntensity);
ledNum =0;
runNumber++;
}
}
}
voidPxPGetPixelOrg(intx, inty, int[] pixelArray, intpixelsWidth) {
int thisPixel=pixelArray[x+y*pixelsWidth]; // getting the colors as an int from the pixels[]
aOrg = (thisPixel >>24) &0xFF; // we need to shift and mask to get each component alone
rOrg = (thisPixel >>16) &0xFF; // this is faster than calling red(), green() , blue()
gOrg = (thisPixel >>8) &0xFF;
bOrg = thisPixel &0xFF;
}
voidPxPGetPixelDark(intx, inty, int[] pixelArray, intpixelsWidth) {
int thisPixel=pixelArray[x+y*pixelsWidth]; // getting the colors as an int from the pixels[]
aDark = (thisPixel >>24) &0xFF; // we need to shift and mask to get each component alone
rDark = (thisPixel >>16) &0xFF; // this is faster than calling red(), green() , blue()
gDark = (thisPixel >>8) &0xFF;
bDark = thisPixel &0xFF;
}
voidPxPGetPixelLed(intx, inty, int[] pixelArray, intpixelsWidth) {
int thisPixel=pixelArray[x+y*pixelsWidth]; // getting the colors as an int from the pixels[]
aLed = (thisPixel >>24) &0xFF; // we need to shift and mask to get each component alone
rLed = (thisPixel >>16) &0xFF; // this is faster than calling red(), green() , blue()
gLed = (thisPixel >>8) &0xFF;
bLed = thisPixel &0xFF;
}
voidPxPSetPixel(intx, inty, intr, intg, intb, inta, int[] pixelArray, intpixelsWidth) {
a =(a <<24);
r = r <<16; // We are packing all 4 composents into one int
g = g <<8; // so we need to shift them to their places
color argb = a | r | g | b; // binary "or" operation adds them all into one int
pixelArray[x+y*pixelsWidth]= argb; // finaly we set the int with te colors into the pixels[]
}

Step 11: Parting Remarks

Pitfalls to avoid:

* With wood, you get what you pay for. So, get good quality wood. Birch plywood is a good choice; any light solid wood will do nicely as well. I cheaped out on the wood and regret my decision.

* It's better to drill less than more. A couple of the holes went too deep for my piece. And the epoxy shows through on the front face. It's very noticeable once you notice it.

* Use a ball end drill bit instead of a straight end. I've not experimented with the ball end bit, but I'm pretty sure the results will be much better.

I'm flirting with the idea of selling these on Etsy or tindie. I'd really appreciate it if you could comment below if you think it makes sense :)