John Bradnam
Created January 6, 2022 © GPL3+

Micro Word Clock

A 3D printed Micro Word Clock based on a 8x8 LED matrix. Time is shown as words or and as scrolling text.

IntermediateFull instructions provided8 hours1
Micro Word Clock

Things used in this project

Hardware components

Microchip Technology ATtiny1614 Microprocessor
×1
Real Time Clock (RTC)
Real Time Clock (RTC)
DS1307 SOIC-8 variant
×1
32.768 kHz Crystal
32.768 kHz Crystal
×1
Tactile Switch, Top Actuated
Tactile Switch, Top Actuated
17mm shaft with button tops
×1
Battery Holder, Coin Cell
Battery Holder, Coin Cell
CR1220 SMD + battery
×1
MAX7219 LED matrix
30mmx30mm (see text)
×1

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

3D Printer (generic)
3D Printer (generic)
Soldering iron (generic)
Soldering iron (generic)

Story

Read more

Custom parts and enclosures

STL Files

Files required to 3D print case

Word clock text mask

Schematics

Schematic

PCB

Eagle Files

Schematic & PCB in Eagle format

Code

PrintedWordClockV2.ino

C/C++
/**
 * ATtiny1614 Micro Word Clock
 * John Bradnam (jbrad2089@gmail.com)
 * 
 * Based on "Tiny Word Clock" by gfwilliams
 * https://www.instructables.com/Tiny-Word-Clock/
 * 
 * 2021-05-09 - Initial Code Base
 *
 * ---------------------------------------
 * ATtiny1614 Pins mapped to Ardunio Pins
 *
 *             +--------+
 *         VCC + 1   14 + GND
 * (SS)  0 PA4 + 2   13 + PA3 10 (SCK)
 *       1 PA5 + 3   12 + PA2 9  (MISO)
 * (DAC) 2 PA6 + 4   11 + PA1 8  (MOSI)
 *       3 PA7 + 5   10 + PA0 11 (UPDI)
 * (RXD) 4 PB3 + 6    9 + PB0 7  (SCL)
 * (TXD) 5 PB2 + 7    8 + PB1 6  (SDA)
 *             +--------+
 *             
 *             
 * BOARD: ATtiny1614/1604/814/804/414/404/214/204
 * Chip: ATtiny1614
 * Clock Speed: 20MHz
 * millis()/micros(): "TCD0 (1 series only, default there)"
 * Programmer: jtag2updi (megaTinyCore)
 * ----------------------------------------
 */
#include <LedControl.h>
#include <Wire.h>
#include <RTClib.h>

//MAX7219
#define CLK 1          //PA5
#define LOAD 2         //PA6
#define DIN 3          //PA7

//DS1307
#define _SCL 9         //PA2
#define _SDA 8         //PA1

//Switches
#define SWITCHES 0     //PA4

enum SwitchEnum { NONE, SET, UP, DOWN };

enum WordEnum {
  TEN_M, HALF, QUARTER, TWENTY, 
  FIVE_M, TO, PAST, ONE, 
  TWO, THREE, FOUR, FIVE, 
  SIX, SEVEN, EIGHT, NINE, 
  TEN, ELEVEN, TWELVE
};
#define H 0
#define M 7
#define S 15
#define R 10
#define W 2
const byte letters[19][7] PROGMEM = { 
  { 1, 3, 4, 0, 0, 0, 0}, {20,21,22,23, 0, 0, 0}, { 8, 9,10,11,12,13,14}, { 1, 2, 3, 4, 5, 6, 0},
  {16,17,18,19, 0, 0, 0}, {28,29, 0, 0, 0, 0, 0}, {25,26,27,28, 0, 0, 0}, {57,62,63, 0, 0, 0, 0},
  {48,49,57, 0, 0, 0, 0}, {43,44,45,46,47, 0, 0}, {56,57,58,59, 0, 0, 0}, {32,33,34,35, 0, 0, 0},
  {40,41,42, 0, 0, 0, 0}, {40,52,53,54,55, 0, 0}, {35,36,37,38,39, 0, 0}, {60,61,62,63, 0, 0, 0},
  {39,47,55, 0, 0, 0, 0}, {50,51,52,53,54,55, 0}, {48,49,50,51,53,54, 0}
};

#define VSHIFT 2
#define SPACE 10
const byte numbers[11][5] PROGMEM = {
  {7,5,5,5,7}, {2,2,2,2,2}, {7,1,7,4,7}, {7,1,7,1,7}, {5,5,7,1,1},
  {7,4,7,1,7}, {7,4,7,5,7}, {7,1,2,2,2}, {7,5,7,5,7}, {7,5,7,1,1},
  {0,0,0,0,0}
};

//Scrolling text variables
#define SCROLL_SPEED 40                //Speed at which text is scrolled
String scrollText;                     //Used to store scrolling text
volatile int8_t scrollDelay;           //Used to store scroll delay
volatile int8_t scrollCharPos;         //Current character position in text being scrolled
volatile int8_t scrollCharCol;         //Next column in character to display
volatile int8_t scrollOffScreen;       //Extra columns required to scroll last character off screen
volatile bool scrollFinished;          //Set when scrolling is complete
byte displayBuffer[8];                 //Double buffer for fast updates
char timeBuffer[16];                   //Used to create time string

//Small font
#define COLON_3X5 10
#define SLASH_3X5 11
#define A_3X5     12
#define P_3X5     13
#define M_3X5     14
#define SPACE_3X5 15
#define VSHIFT 1    //Pixels up from bottom
const byte font3x5[16][3] PROGMEM = {
  {0x1F,0x11,0x1F}, {0x00,0x1F,0x00}, {0x1D,0x15,0x17}, {0x15,0x15,0x1F}, {0x07,0x04,0x1F},
  {0x17,0x15,0x1D}, {0x1F,0x15,0x1D}, {0x01,0x1D,0x03}, {0x1F,0x15,0x1F}, {0x07,0x05,0x1F},
  {0x00,0x0A,0x00}, {0x18,0x04,0x03}, {0x1E,0x05,0x1E}, {0x1F,0x05,0x07}, {0x1F,0x06,0x1F},
  {0x00,0x00,0x00} 
};

// Because the MAX7219 digit and segment pins are not connected to the
// correct row/column pins on the display to simplify PCB routing, these
// tables map the logical row/columns to their physical locations.
uint8_t rowMap[8] = {0,1,2,3,4,5,6,7};
uint8_t colMap[8] = {7,6,5,4,3,2,1,0};

//Secondary menus
enum ClockEnum { WORD_TIME, SET_HOUR, SET_MINUTE };
ClockEnum clockMode = WORD_TIME;

#define FLASH_TIME 200          //Time in mS to flash digit being set
#define STEP_TIME 350           //Time in mS for auto increment or decrement of time

int lastHour = -1;              //Used to store current hour displayed
int lastMinute = -1;            //Used to store current minute displayed
int setH = 0;                   //Hour being set
int setM = 0;                   //Minute being set

long flashTimeout = 0;          //Flash timeout when setting clock or alarm
bool flashOn = false;           //Used to flash display when setting clock or alarm
long stepTimeout = 0;           //Set time speed for auto increment or decrement of time

LedControl lc = LedControl(DIN, CLK, LOAD, 1);
RTC_DS1307 rtc;

//----------------------------------------------------------------------
// Hardware Setup
void setup() 
{
  //Tell Wire handler that we are using the alternative SDA and SCL pins
  Wire.pins(PIN_PA1,PIN_PA2);       // SDA pin, SCL pin
  Wire.usePullups();                // Use pullup resistors on SDA and SCL pins
  
  pinMode(SWITCHES,INPUT);
  
  lc.shutdown(0, false); //The MAX7219 is in power-saving mode on startup,
  lc.setIntensity(0, 4); //Set the brightness to a medium values 
  lc.clearDisplay(0);    //Clear the display

  if (!rtc.begin())
  {
    showLetter(R, true);            //Cannot find RTC
    delay(2000);
  }
  else if (!rtc.isrunning()) 
  {
    showLetter(W, true);            //RTC lost power
    rtc.adjust(DateTime(2022, 1, 4, 6, 30, 0));
    delay(2000);
  }

  //Show current time
  DateTime newTime = rtc.now();
  showTimeInWords(newTime.hour(), newTime.minute(), newTime.second(), true);

  //Set up display refresh timer
  //CLK_PER = 3.3MHz (303nS)
  TCB1.CCMP = 49152;   //Refresh value for display (67Hz)
  TCB1.INTCTRL = TCB_CAPT_bm;
  TCB1.CTRLA = TCB_ENABLE_bm;
  scrollFinished = false;
  scrollDelay = 0;
}

//-------------------------------------------------------------------------
//Timer B Interrupt handler interrupt each mS - output segments
ISR(TCB1_INT_vect)
{
  //Handle scrolling of text
  if (scrollDelay != 0)
  {
    scrollDelay--;
    if (scrollDelay == 0)
    {
      scrollDelay = SCROLL_SPEED;
      scrollTextLeft(); 
    }
  }

  //Clear interrupt flag
  TCB1.INTFLAGS |= TCB_CAPT_bm; //clear the interrupt flag(to reset TCB1.CNT)
}

//----------------------------------------------------------------------
// Main program loop
void loop() 
{
  wordTimeMode();
  switch (clockMode)
  {
    case SET_HOUR: hourMode(); break;
    case SET_MINUTE: minuteMode(); break;
  }
}

//----------------------------------------------------------------------
// In word clock display mode
void wordTimeMode()
{
  bool updateTime = scrollFinished;
  scrollFinished = false;
  if (isScrollComplete())
  {
    SwitchEnum s = getButtonState();
    if (s != NONE)
    {
      delay(10);
      if (getButtonState() == s)
      {
        if (s == SET)
        {
          DateTime currentTime = rtc.now();
          clockMode = (clockMode == SET_MINUTE) ? WORD_TIME : (ClockEnum)((int)clockMode + 1);
          switch (clockMode)
          {
            case SET_HOUR: 
              setH = currentTime.hour();
              setM = currentTime.minute(); 
              flashTimeout = millis() + FLASH_TIME;
              flashOn = false;
              lc.clearDisplay(0);    //Clear the display 
              displayNumber(setH, true, flashOn);
              showLetter(H, flashOn);
              break;
    
            case SET_MINUTE:
              flashTimeout = millis() + FLASH_TIME;
              flashOn = false;
              lc.clearDisplay(0);    //Clear the display 
              displayNumber(setM, true, flashOn);
              showLetter(M, flashOn);
              break;
    
            case WORD_TIME:
              //Set RTC
              rtc.adjust(DateTime(currentTime.year(), currentTime.month(), currentTime.day(), setH, setM, 0));
              //force update
              updateTime = true;
              break;
          }
          //Wait until button is released
          while (getButtonState())
          {
            delay(10);
          }
        }
        else if (s == DOWN && clockMode == WORD_TIME)
        {
          //Scroll current time
          DateTime currentTime = rtc.now();
          setH = currentTime.hour();
          int hours = currentTime.hour();
          int h = (setH == 0) ? 12 : (setH > 12) ? setH - 12 : setH;
          char c = (setH < 12) ? 'A' : 'P';
          sprintf(timeBuffer,"%d:%02d_%cM",h,currentTime.minute(),c);
          drawString(String(timeBuffer));
        }
      }
    }
    if (clockMode == WORD_TIME && isScrollComplete());
    {
      DateTime newTime = rtc.now();
      showTimeInWords(newTime.hour(), newTime.minute(), newTime.second(), updateTime);
    }
  }
}

//----------------------------------------------------------------------
// In word clock set hour  mode
void hourMode()
{
  if (millis() > flashTimeout)
  {
    flashTimeout = millis() + FLASH_TIME;
    flashOn = !flashOn;
    displayNumber(setH, true, flashOn);
    showLetter(H, flashOn);
  }
  if (millis() > stepTimeout)
  {
    if (getButtonState() == UP)
    {
      setH = (setH + 1) % 24;
      displayNumber(setH, true, flashOn);
      stepTimeout = millis() + STEP_TIME;
    }
    else if (getButtonState() == DOWN)
    {
      setH = (setH + 23) % 24;
      displayNumber(setH, true, flashOn);
      stepTimeout = millis() + STEP_TIME;
    }
  }
}

//----------------------------------------------------------------------
// In word clock set minute  mode
void minuteMode()
{
  if (millis() > flashTimeout)
  {
    flashTimeout = millis() + FLASH_TIME;
    flashOn = !flashOn;
    displayNumber(setM, true, flashOn);
    showLetter(M, flashOn);
  }
  if (millis() > stepTimeout)
  {
    if (getButtonState() == UP)
    {
      setM = (setM + 1) % 60;
      displayNumber(setM, true, flashOn);
      showLetter(M, flashOn);
      stepTimeout = millis() + STEP_TIME;
    }
    else if (getButtonState() == DOWN)
    {
      setM = (setM + 59) % 60;
      displayNumber(setM, true, flashOn);
      showLetter(M, flashOn);
      stepTimeout = millis() + STEP_TIME;
    }
  }
}

//----------------------------------------------------------------------
// Displays the current time in words
//  hours = current hour (0 to 23) 
//  minutes = current minute (0 to 59)
//  second = current second (0 to 59)
void showTimeInWords(int hours, int minutes, int secs, bool forceShow)
{
  //Convert to all seconds
  unsigned int h = (hours % 12) * 3600;
  unsigned int hm = h + (minutes / 5) * 300;
  unsigned int hms = h + minutes * 60 + secs;
  //Since only 5 minute intervals are displayed, each position is +- 150 seconds (2.5min)
  if ((hms - hm) >= 150)
  {
    hm = hm + 300;    //Add 5 min to take it to the closest word
  }
  hours = hm / 3600;
  minutes = (hm / 60) % 60;
  //After half past the hour, other prefixes are TO the next hour
  if (minutes > 30)
  {
    hours++;
  }

  if (forceShow || hours != lastHour || minutes != lastMinute)
  {
    int hourWord;
    switch (hours % 12)
    {
      case 0: hourWord = TWELVE; break;
      case 1: hourWord = ONE; break;
      case 2: hourWord = TWO; break;
      case 3: hourWord = THREE; break;
      case 4: hourWord = FOUR; break;
      case 5: hourWord = FIVE; break;
      case 6: hourWord = SIX; break;
      case 7: hourWord = SEVEN; break;
      case 8: hourWord = EIGHT; break;
      case 9: hourWord = NINE; break;
      case 10: hourWord = TEN; break;
      case 11: hourWord = ELEVEN; break;
    }
  
    //Show the words that make up the time
    int five = minutes / 5;
    switch (five)
    {
      case 0: showWords(1, hourWord); break;
      case 1: showWords(3, FIVE_M, PAST, hourWord); break;
      case 2: showWords(3, TEN_M, PAST, hourWord); break;
      case 3: showWords(3, QUARTER, PAST, hourWord); break;
      case 4: showWords(3, TWENTY, PAST, hourWord); break;
      case 5: showWords(4, TWENTY, FIVE_M, PAST, hourWord); break;
      case 6: showWords(3, HALF, PAST, hourWord); break;
      case 7: showWords(4, TWENTY, FIVE_M, TO, hourWord); break;
      case 8: showWords(3, TWENTY, TO, hourWord); break;
      case 9: showWords(3, QUARTER, TO, hourWord); break;
      case 10: showWords(3, TEN_M, TO, hourWord); break;
      case 11: showWords(3, FIVE_M, TO, hourWord); break;
    }
    lastHour = hours;
    lastMinute = minutes;
  }
}

//----------------------------------------------------------------------
// Show a variable number of words
//   words - the number of word indexes that follow in the parameter list
void showWords(int words, ...)
{
  int wordIndex;
  byte pixel;
  
  lc.clearDisplay(0);    //Clear the display 
  
  va_list valist;
  va_start(valist, words);
  for (int i = 0; i < words; i++) 
  {
    wordIndex = va_arg(valist, int);
    for (int letter = 0; letter < 7; letter++)
    {
      pixel = pgm_read_byte_near(&letters[wordIndex][letter]);
      if (pixel == 0)
      {
        break;
      }
      else
      {
        showLetter(pixel, true);
      }
    }
  }
  va_end(valist);
}

//-----------------------------------------------------------------------------------
// Display a character
//   letter - character index to show
//   flash - true to show digit, false to show blank
void showLetter(int letter, bool flash)
{
  lc.setLed(0, colMap[letter & 0x07], rowMap[letter >> 3], flash);
}

//-----------------------------------------------------------------------------------
// Display a number as two 7 segment digits
//   num - 0 to 99
//   leadingZeros - true to have leading zeros
//   flash - true to show digit, false to show blank
void displayNumber(long num, bool leadingZeros, bool flash)
{
  num = max(min(num, 99), 0);
  for (int i = 0, shift = 0; i < 2; i++, shift+=4)
  {
    if (flash && (num > 0 || i == 0 || leadingZeros))
    {
      displayDigit(num % 10, shift, VSHIFT);
    }
    else
    {
      displayDigit(SPACE, shift, VSHIFT);
    }
    num = num / 10;
  }
}

//-----------------------------------------------------------------------------------
// Display a digit
//   num - 0 to 10
//   hshift - columns to shift left
//   vshift - rows to shift down
void displayDigit(int num, int hshift, int vshift)
{
  byte pixel;
  for (int row = 0; row < 5; row++)
  {
    pixel = pgm_read_byte_near(&numbers[num][row]);

    uint8_t mask = 0x01;
    for (int col = 0; col < 3; col++)
    {
      lc.setLed(0, colMap[7-(col + hshift)], rowMap[row + vshift], (pixel & mask));
      mask = mask << 1;
    }
  }
}

//-----------------------------------------------------------------------------------
// Return current button state
SwitchEnum getButtonState()
{
  //S4 (SET) - 0V 0
  //S3 (UP) - 2.5V 512
  //S3 (DOWN) - 3V 614
  SwitchEnum result = NONE;
  int value = analogRead(SWITCHES);
  if (value < 450)
  {
    result = DOWN;
  }
  else if (value < 550)
  {
    result = UP;
  }
  else if (value < 650)
  {
    result = SET;
  }
  return result;
}

//---------------------------------------------------------------
// Test if text scroll has completed
bool isScrollComplete()
{
  return (scrollDelay == 0);
}

//---------------------------------------------------------------
//Draw string
// s = String to display
// f = font to use
void drawString(String s)
{
  while (!isScrollComplete())
  {
  }
  s.toUpperCase();
  scrollText = s;
  scrollCharPos = 0;
  scrollCharCol = 0;
  scrollOffScreen = 0;
  scrollDelay = SCROLL_SPEED;     //Starts scrolling
  scrollFinished = false;
}

//---------------------------------------------------------------
//Scroll text left
void scrollTextLeft()
{
  uint8_t bits;
  uint8_t mask;
  int8_t colMax;
  int8_t rowMax;
  char ch;
  
  //Scroll screen buffer left
  for (int8_t c = 0; c < 8; c++)
  {
    for (int8_t r = 0; r < 8; r++)
    {
      displaySetPixel(r, c, (c != 7 && displayGetPixel(r, c + 1)));
    }
  }

  colMax = 3;
  rowMax = 5;
  //character column after last column is blank for letter spacing
  if (scrollOffScreen == 0 && scrollCharCol < colMax)
  {
    ch = scrollText[scrollCharPos];
    if (ch >= '0' && ch <= '9' || ch == ':' || ch == ';' || ch == 'A' || ch == 'P' || ch == 'M' || ch == ' ' || ch == '_')
    {
      uint8_t c = ch - 48;
      switch(ch)
      {
        case ' ': c = SPACE_3X5; break;
        case ':': c = COLON_3X5; break;
        case ';': c = SLASH_3X5; break;
        case 'A': c = A_3X5; break;
        case 'P': c = P_3X5; break;
        case 'M': c = M_3X5; break;
        case '_': c = SPACE_3X5; colMax = 0; break;
      }
      bits = pgm_read_byte(&font3x5[c][scrollCharCol]);
      mask = 0x10;
      //Get bits in the next column and output to buffer
      for(int8_t r = 0; r < rowMax; r++)
      {
        if (bits & mask)
        {
          displaySetPixel(7 - r - VSHIFT, 7, true);
        }
        mask = mask >> 1;
      }
    }
  }

  if (scrollOffScreen > 0)
  {
    scrollOffScreen--;
    if (scrollOffScreen == 0)
    {
      //Stop scrolling
      scrollDelay = 0;
      scrollFinished = true;
    }
  }
  else
  {
    scrollCharCol++;
    if (scrollCharCol == (colMax+1))
    {
      scrollCharCol = 0;
      scrollCharPos++;
      if (scrollCharPos == scrollText.length())
      {
        //All text has been outputted, just wait until it is scrolled of the screen
        scrollOffScreen = 8;
      }
    }
  }
  displayRefresh();
}

//---------------------------------------------------------------
//Transfers the display buffer to the matrix
void displayRefresh()
{
  byte row = 0;
  byte rowMask = 0;
  byte colMask = 0;
  colMask = 0x01;
  for(int c = 0; c < 8; c++)
  {
    row = 0;
    rowMask = 0x80;
    for (int r = 0; r < 8; r++)
    {
      if (displayBuffer[r] & colMask)
      {
        row = row | rowMask;
      }
      rowMask = rowMask >> 1;
    }
    lc.setRow(0,c,row);
    colMask = colMask << 1;
  }
}

//---------------------------------------------------------------
//Sets the bit in the 8 byte array that corresponds to the physical column and row
// r = row (0 - top row to 7 - bottom row)
// c = column (0 to 7) (0 is far left)
// on = true to switch bit on, false to switch bit off
void displaySetPixel(int8_t r, int8_t c, bool on)
{
  byte mask = 1 << (7-c);
  if (on)
  {
    displayBuffer[r] = displayBuffer[r] | mask;
  }
  else
  {
    displayBuffer[r] = displayBuffer[r] & ~mask;
  }
}

//---------------------------------------------------------------
//Get the color of the bit that corresponds to the physical column and row
// r = row (0 - top row to 7 - bottom row)
// c = column (0 to 7)
// Returns true if on
bool displayGetPixel(int8_t r, int8_t c)
{
  byte mask = 1 << (7-c);
  return (displayBuffer[r] & mask);
}

Credits

John Bradnam

John Bradnam

90 projects • 80 followers
Thanks to gfwilliams.

Comments