Built-in project · Arduino Uno R3

Moon phase clock on an Arduino Uno

The Moon's phase for today, computed from a DS3231 clock and drawn on a round GC9A01 display, with the days left to the next full moon. The whole build, an Arduino Uno and 3 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Advanced About 25 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $38. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Displays
  • Sensors
  • Reading a button
  • Using the Round display
  • Difficulty: Advanced
  • Time: About 25 minutes
  • Board: Arduino Uno R3
  • Parts: 5

How it works

The Moon's phase for today, computed from a DS3231 clock and drawn on a round GC9A01 display, with the days left to the next full moon.

Arduino Uno R3 runs the sketch below. The circuit around it: Round display, GC9A01, Real-time clock, DS3231, Pushbutton. pinMode sets BUTTON_PIN as an input with its pull-up on. digitalRead reads whether BUTTON_PIN is HIGH or LOW. delay waits 50 milliseconds. Serial.begin opens the serial port at 115200 baud.

Parts list

5 parts, plus the jumper wires. They retail for about $38; on Mokxi they're free.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
Round display, GC9A01 pin SCLArduino Uno R3 pin 13
Round display, GC9A01 pin SDAArduino Uno R3 pin 11
Round display, GC9A01 pin CSArduino Uno R3 pin 10
Round display, GC9A01 pin DCArduino Uno R3 pin 9
Pushbutton pin 1aArduino Uno R3 pin 2
Round display, GC9A01 pin VCCArduino Uno R3 pin 5V
Real-time clock, DS3231 pin VCCArduino Uno R3 pin 5V
Round display, GC9A01 pin GNDArduino Uno R3 pin GND
Real-time clock, DS3231 pin GNDArduino Uno R3 pin GND
Pushbutton pin 2aArduino Uno R3 pin GND
Real-time clock, DS3231 pin SDAArduino Uno R3 pin A4
Real-time clock, DS3231 pin SCLArduino Uno R3 pin A5

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Arduino Uno R3
// Moon phase clock: a DS3231 real-time clock tells the date, the sketch works
// out where the Moon is in its month, and a GC9A01 round display draws it.
//
//   GC9A01   SCL -> 13   SDA -> 11   CS -> 10   DC -> 9   VCC -> 5 V   GND -> GND
//   DS3231   SDA -> A4   SCL -> A5   VCC -> 5 V   GND -> GND
//   button   pin 2 and GND: each press moves the clock on one day, so you can
//            watch a whole month go by in half a minute
//
// # How to find the Moon's phase from a date
//
// The Moon takes about 29.53 days to go from one new moon to the next. That
// length is called the synodic month. So if we know the date of ANY new moon,
// the phase today only depends on how long ago it was:
//
//   age = (time since that new moon) modulo 29.53 days
//
// Our known new moon is 6 January 2000 at 18:14. Count the minutes from there
// to the clock's date and time, take the remainder after dividing by the
// length of a month (42524 minutes), and that is the Moon's age.
//
//   age 0 days     new moon (dark)
//   age 7.4 days   first quarter (right half lit)
//   age 14.8 days  full moon
//   age 22.1 days  last quarter (left half lit)
//
// This is the mean Moon. The real one speeds up and slows down a little on its
// elliptical orbit, so the real full moon can land up to half a day away from
// this sketch's. For a clock on a desk that is plenty.
//
// # How much of it is lit
//
// Turn the age into an angle: a whole month is a full turn, so
// angle = 360 degrees * age / month. The cosine of that angle runs from 1 at
// new moon to -1 at full moon, and it is exactly where the edge of the shadow
// (the terminator) sits, as a fraction of the Moon's radius. Row by row down
// the disc, the sketch finds how wide the Moon is at that row, multiplies by
// the cosine to place the shadow edge, and draws the lit part and the dark
// part. Waxing (growing) moons are lit on the right, waning ones on the left.
//
// # The clock
//
// The DS3231 keeps the date in seven registers as binary coded decimal, which
// means each decimal digit gets four bits: 26 is stored as 0x26. We read the
// seven bytes and turn them back into numbers with fromBcd().

#include <SPI.h>
#include <Wire.h>
#include <math.h>
#include "Adafruit_GFX.h"
#include "Adafruit_GC9A01A.h"

Adafruit_GC9A01A tft(10, 9); // CS, DC; no reset pin
const uint8_t DS3231 = 0x68;
const uint8_t BUTTON_PIN = 2;

/** Minutes in one synodic month (29.530589 days), and the reference new moon. */
const long MONTH_MIN = 42524L;
const long REF_MIN = 8294L; // 2000-01-06 18:14, counted from 2000-01-01 00:00

const int16_t CX = 120;
const int16_t CY = 112;
const int16_t R = 54;

const uint16_t SKY = 0x0000;
const uint16_t LIT = 0xF7DB;   // pale cream
const uint16_t SHADOW = 0x1082; // the dark face, just visible
const uint16_t MARE = 0xAD55;  // the grey seas

struct When {
  uint8_t sec, min, hour, date, month;
  uint8_t year; // years since 2000
};
When t;

static uint8_t fromBcd(uint8_t b) { return (uint8_t)((b >> 4) * 10 + (b & 0x0f)); }
static uint8_t toBcd(uint8_t n) { return (uint8_t)(((n / 10) << 4) | (n % 10)); }

static bool readClock() {
  Wire.beginTransmission(DS3231);
  Wire.write(0x00);
  Wire.endTransmission();
  if (Wire.requestFrom(DS3231, 7) != 7) return false;
  t.sec = fromBcd(Wire.read() & 0x7f);
  t.min = fromBcd(Wire.read() & 0x7f);
  t.hour = fromBcd(Wire.read() & 0x3f);
  Wire.read(); // day of the week, which this sketch does not need
  t.date = fromBcd(Wire.read() & 0x3f);
  t.month = fromBcd(Wire.read() & 0x1f);
  t.year = fromBcd(Wire.read());
  return true;
}

static bool isLeap(uint8_t year) { return (year % 4) == 0; } // 2000 to 2099

static uint8_t daysIn(uint8_t month, uint8_t year) {
  static const uint8_t DAYS[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
  if (month == 2 && isLeap(year)) return 29;
  return DAYS[month - 1];
}

/** Move the clock on by one day, rolling the month and year as needed. */
static void nextDay() {
  t.date++;
  if (t.date > daysIn(t.month, t.year)) {
    t.date = 1;
    t.month++;
    if (t.month > 12) {
      t.month = 1;
      t.year++;
    }
  }
  Wire.beginTransmission(DS3231);
  Wire.write(0x03);
  Wire.write(1); // day of the week: not used, any 1 to 7 will do
  Wire.write(toBcd(t.date));
  Wire.write(toBcd(t.month));
  Wire.write(toBcd(t.year));
  Wire.endTransmission();
}

/** Days from 2000-01-01 to the clock's date. */
static long dayNumber() {
  static const int16_t BEFORE[12] = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};
  long days = 365L * t.year + (t.year + 3) / 4; // whole years, with their leap days
  days += BEFORE[t.month - 1];
  if (t.month > 2 && isLeap(t.year)) days++;
  return days + t.date - 1;
}

/** The Moon's age in minutes since the last new moon, 0 to MONTH_MIN - 1. */
static long ageMinutes() {
  long total = dayNumber() * 1440L + t.hour * 60L + t.min - REF_MIN;
  long age = total % MONTH_MIN;
  if (age < 0) age += MONTH_MIN;
  return age;
}

static const char *phaseName(long age) {
  // Eight names, each one an eighth of the month wide and centred on its moment.
  uint8_t i = (uint8_t)(((age * 8L + MONTH_MIN / 2) / MONTH_MIN) % 8);
  static const char *const NAMES[8] = {"NEW MOON",    "WAXING CRESCENT", "FIRST QUARTER", "WAXING GIBBOUS",
                                       "FULL MOON",   "WANING GIBBOUS",  "LAST QUARTER",  "WANING CRESCENT"};
  return NAMES[i];
}

/** Percent of the disc that is lit, 0 to 100: (1 - cos(angle)) / 2. */
static int lightPercent(long age) {
  float angle = 6.2831853f * (float)age / (float)MONTH_MIN;
  return (int)((1.0f - cosf(angle)) * 50.0f + 0.5f);
}

/** Whole days until the next full moon, which comes when the age is half a month. */
static int daysToFull(long age) {
  long ahead = MONTH_MIN / 2 - age;
  if (ahead < 0) ahead += MONTH_MIN;
  return (int)((ahead + 720) / 1440); // round to the nearest day
}

/** Presses of the button not yet acted on: the drawing below can take seconds. */
uint8_t pending = 0;
bool wasDown = false;

static void scanButton() {
  bool down = digitalRead(BUTTON_PIN) == LOW;
  if (down && !wasDown && pending < 30) pending++;
  wasDown = down;
}

float cosine = 1.0f;
bool waxing = true;

/** The lit stretch of the disc on the row `dy` below the centre: columns a to b. */
static void litSpan(int16_t dy, int16_t &a, int16_t &b, int16_t &half) {
  half = (int16_t)(sqrtf((float)(R * R - dy * dy)) + 0.5f);
  int16_t edge = (int16_t)(half * cosine + (cosine < 0 ? -0.5f : 0.5f));
  if (waxing) {
    a = (int16_t)(CX + edge);
    b = (int16_t)(CX + half);
  } else {
    a = (int16_t)(CX - half);
    b = (int16_t)(CX - edge);
  }
}

static void drawMoon(long age) {
  float angle = 6.2831853f * (float)age / (float)MONTH_MIN;
  cosine = cosf(angle);
  waxing = age < MONTH_MIN / 2;
  for (int16_t dy = -R; dy <= R; dy++) {
    int16_t a, b, half;
    litSpan(dy, a, b, half);
    int16_t y = (int16_t)(CY + dy);
    int16_t left = (int16_t)(CX - half);
    int16_t right = (int16_t)(CX + half);
    // Every pixel of the row is painted once: the dark stretch on the side the
    // Sun is missing, and the lit stretch beside it.
    if (waxing) {
      if (a > left) tft.drawFastHLine(left, y, (int16_t)(a - left), SHADOW);
      if (b >= a) tft.drawFastHLine(a, y, (int16_t)(b - a + 1), LIT);
    } else {
      if (b >= a) tft.drawFastHLine(a, y, (int16_t)(b - a + 1), LIT);
      if (right > b) tft.drawFastHLine((int16_t)(b + 1), y, (int16_t)(right - b), SHADOW);
    }
    // Drawing takes a few seconds on this board, so look at the button as we go.
    if ((dy & 3) == 0) scanButton();
  }
  // A few grey seas, drawn only where they fall on the lit face.
  static const int8_t SEAS[5][3] = {{-14, -22, 9}, {10, -14, 7}, {-18, 6, 6}, {8, 12, 10}, {22, -2, 4}};
  for (uint8_t i = 0; i < 5; i++) {
    int16_t a, b, half;
    litSpan(SEAS[i][1], a, b, half);
    int16_t x = (int16_t)(CX + SEAS[i][0]);
    int16_t r = SEAS[i][2];
    if (x - r > a && x + r < b) {
      tft.fillCircle(x, (int16_t)(CY + SEAS[i][1]), r, MARE);
    }
  }
}

static void centred(int16_t y, uint8_t size, const char *s, uint16_t colour) {
  int16_t w = 0;
  for (const char *p = s; *p != '\0'; p++) w += 6 * size;
  tft.setTextSize(size);
  tft.setTextColor(colour);
  tft.setCursor((int16_t)((240 - w) / 2), y);
  tft.print(s);
}

static void two(char *out, uint8_t n) {
  out[0] = (char)('0' + n / 10);
  out[1] = (char)('0' + n % 10);
}

long shownStep = -1;
uint8_t shownMinute = 255;
uint8_t shownDate = 255;

static void drawText(long age) {
  char line[20];
  // The time, big, near the top where the round glass is still wide enough.
  tft.fillRect(60, 10, 120, 18, SKY);
  two(line, t.hour);
  line[2] = ':';
  two(line + 3, t.min);
  line[5] = '\0';
  centred(11, 2, line, GC9A01A_WHITE);
  // The date.
  tft.fillRect(50, 30, 140, 10, SKY);
  line[0] = '2';
  line[1] = '0';
  two(line + 2, t.year);
  line[4] = '-';
  two(line + 5, t.month);
  line[7] = '-';
  two(line + 8, t.date);
  line[10] = '\0';
  centred(31, 1, line, 0x8410);
  // The phase and the wait for the next full moon.
  tft.fillRect(20, 176, 200, 18, SKY);
  centred(177, 2, phaseName(age), GC9A01A_WHITE);
  tft.fillRect(30, 198, 180, 30, SKY);
  int days = daysToFull(age);
  if (days == 0) {
    centred(200, 1, "FULL MOON", LIT);
    centred(212, 2, "TONIGHT", LIT);
  } else {
    centred(198, 1, "NEXT FULL MOON IN", 0x8410);
    char big[12];
    uint8_t n = 0;
    if (days >= 10) big[n++] = (char)('0' + days / 10);
    big[n++] = (char)('0' + days % 10);
    big[n++] = ' ';
    big[n++] = 'D';
    big[n++] = 'A';
    big[n++] = 'Y';
    if (days != 1) big[n++] = 'S';
    big[n] = '\0';
    centred(210, 2, big, LIT);
  }
}

static void report(long age) {
  Serial.print("moon 20");
  Serial.print(t.year);
  Serial.print('-');
  Serial.print(t.month);
  Serial.print('-');
  Serial.print(t.date);
  Serial.print(' ');
  Serial.print(t.hour);
  Serial.print(':');
  Serial.print(t.min);
  Serial.print(" age ");
  Serial.print(age / 144L); // tenths of a day: 1440 minutes a day over ten
  Serial.print(" lit ");
  Serial.print(lightPercent(age));
  Serial.print(" full-in ");
  Serial.print(daysToFull(age));
  Serial.print(' ');
  Serial.println(phaseName(age));
}

void setup() {
  Serial.begin(115200);
  Serial.println("Mokxi Uno: moon phase clock");
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  Wire.begin();
  tft.begin();
  tft.fillScreen(SKY);
}

void loop() {
  scanButton();
  if (!readClock()) {
    delay(50);
    return;
  }
  // One day on for every press, then read the clock again.
  if (pending > 0) {
    while (pending > 0) {
      nextDay();
      pending--;
    }
    shownMinute = 255;
    if (!readClock()) return;
  }
  long age = ageMinutes();
  // Redraw the disc when the picture would change: the phase moves one
  // sixtieth of a month about every twelve hours.
  long step = age / (MONTH_MIN / 60);
  bool newDay = t.date != shownDate;
  if (step != shownStep) {
    drawMoon(age);
    shownStep = step;
  }
  if (t.min != shownMinute || newDay) {
    drawText(age);
    report(age);
    shownMinute = t.min;
    shownDate = t.date;
  }
  delay(40);
}

Code walkthrough

The key functions of the sketch, and what each one does.

setup sketch.ino

Runs once when the board starts. It gets the pins and libraries ready.

void setup() {
  Serial.begin(115200);
  Serial.println("Mokxi Uno: moon phase clock");
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  Wire.begin();
  tft.begin();
  tft.fillScreen(SKY);
}

loop sketch.ino

Runs over and over for as long as the board has power. This is where the project does its work.

void loop() {
  scanButton();
  if (!readClock()) {
    delay(50);
    return;
  }
  // One day on for every press, then read the clock again.
  if (pending > 0) {
    while (pending > 0) {
      nextDay();
      pending--;
    }
    shownMinute = 255;
    if (!readClock()) return;
  }
  long age = ageMinutes();
  // Redraw the disc when the picture would change: the phase moves one
  // sixtieth of a month about every twelve hours.
  long step = age / (MONTH_MIN / 60);
  bool newDay = t.date != shownDate;
  if (step != shownStep) {
    drawMoon(age);
    shownStep = step;
  }
  if (t.min != shownMinute || newDay) {
    drawText(age);
    report(age);
    shownMinute = t.min;
  // ... 4 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The display stays blank or shows garbage. Round display
    Check SCK, MOSI, CS, DC and RST wiring, and use 3.3 V logic. Pick a library that supports the GC9A01 and set its pins to match. Reset the display on start.
  • Colors look swapped or the image is shifted. Round display
    Try the library's RGB or BGR color order option and inversion option. The visible area is a circle, so keep important content near the center.
  • The time shows odd numbers, such as 0x23 printed as 35, when you read it. Real-time clock
    All time registers are BCD, not plain binary. Convert each byte with (b >> 4) * 10 + (b & 0x0F) to read, and the reverse to write. A library such as RTClib does this for you.
  • The clock does not respond, or the I2C bus hangs. Real-time clock
    The DS3231 address is 0x68. Wire SDA and SCL to the right pins with pull-ups, about 4.7k, to the supply. Many breakout boards already have them. Join the grounds.
  • The input reads random HIGH and LOW when the button is not pressed. Pushbutton
    A floating pin picks up noise. Add a 10 k pull-down or pull-up resistor, or use INPUT_PULLUP and wire the button between the pin and GND. Then pressed reads LOW.
  • The button seems always pressed, or never changes. Pushbutton
    A 4-leg tactile switch has legs joined in pairs. Place it across the breadboard center gap so the pairs sit on different rows. Wired along one side, the legs are already connected.
  • Upload fails or nothing runs after wiring pins 0 and 1 to a module. Arduino Uno R3
    Pins 0 and 1 are the USB serial port. Move the module to other pins, or unplug it while uploading. Also check the right board is selected.
  • Pins misbehave or the board resets when you power motors or servos from it. Arduino Uno R3
    Each I/O pin gives about 20 mA, 40 mA max. Power motors and servos from their own supply and join its ground to the Uno's GND.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.