Celestial Arc

An artistic dual clock connecting lives across time

Final Presentation Slide


Final Presentation Video


A brief introduction

Celestial Arc is a kinetic dual time zone clock that transforms timekeeping into an artistic experience. It is both a functional timepiece and a wall décor piece. Celestial Arc allows users to keep track of the time of a friend or loved one living in another part of the world. During the day, the glowing ring acts as the sun, gradually travelling along an arc to represent the passage of time. At night, it transitions into the moon, creating a dynamic and calming visual display.

PROJECT IN A COMIC STRIP

Prompt : My final project is inspired by these mechanical picture frames with an illuminated sun that can be moved along an axis. I thought about automating this, but then I questioned why I would need it as a clock when I already have clocks around me. This led to the idea of using it to show the time of a friend living abroad. If the sun could move on its own based on time, it would become a visually appealing way of displaying it. illustrate this as a comic strip with Indian woman

Ideation Process

Concept Evolution

Image source on left: This was the design that inspired me to develop the Celestial Arc project. The first plan was to make it automated, then came the idea of syncing it as a dual clock. The initial sketch shows how I thought the product would look.

This was sketched on Procreate around Week 15. This gave a basic idea of the outlook. At the same time, it also raised questions about the fixing, wire connections, number of boards required, and how each part was going to be fixed.

3D Model in Fusion

3D model done in fusion

We learnt this software in WEEK 2 - Computer Aided Design


Design Journey

Design Journey -Post Production

Project assembly

The synthetic paper stuck in layers.


Internal and the main aspect of the design.


The assembled product powered by type c charger


The backlight changes when it is night time.


Few details

The interactive feature when tuning on Wifi to change time in the app that pops open


Wire management with 3d printed modules

Firmware


#include <Wire.h>
#include <RTClib.h>
#include <Adafruit_NeoPixel.h>
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>   // captive portal — redirects all DNS queries to our IP
#include <ESPmDNS.h>     // celestialarc.local fallback

// =====================================================
// PINS
// =====================================================

#define IN1 D7
#define IN2 D8
#define IN3 D9
#define IN4 D10

#define LIMIT_SWITCH D6

#define BACKLIGHT_PIN D0
#define BACKLIGHT_COUNT 118

#define SUNMOON_PIN D1
#define SUNMOON_COUNT 9

#define SDA_PIN D4
#define SCL_PIN D5

#define TOUCH_PIN D3

// =====================================================
// 24C32 EEPROM
// Most DS3231 combo boards pull A0-A2 HIGH -> 0x57.
// If yours has A0-A2 LOW, change to 0x50.
// =====================================================

#define EEPROM_I2C_ADDR   0x57
#define EEPROM_MAGIC      0xCA
#define EEPROM_ADDR_MAGIC 0x0000  // 1 byte  -- magic
#define EEPROM_ADDR_TZ    0x0001  // 2 bytes -- int16_t offset in minutes

// =====================================================
// TIMEZONES  (38 real-world zones, UTC baseline)
// =====================================================

struct Timezone {
  const char* label;
  const char* abbr;
  int16_t     offset; // minutes from UTC
};

const Timezone TIMEZONES[] = {
  { "UTC-12:00", "IDLW",  -720 },
  { "UTC-11:00", "NUT",   -660 },
  { "UTC-10:00", "HST",   -600 },
  { "UTC-09:30", "MART",  -570 },
  { "UTC-09:00", "AKST",  -540 },
  { "UTC-08:00", "PST",   -480 },
  { "UTC-07:00", "MST",   -420 },
  { "UTC-06:00", "CST",   -360 },
  { "UTC-05:00", "EST",   -300 },
  { "UTC-04:00", "AST",   -240 },
  { "UTC-03:30", "NST",   -210 },
  { "UTC-03:00", "BRT",   -180 },
  { "UTC-02:00", "GST",   -120 },
  { "UTC-01:00", "AZOT",   -60 },
  { "UTC+00:00", "UTC",      0 },
  { "UTC+01:00", "CET",     60 },
  { "UTC+02:00", "EET",    120 },
  { "UTC+03:00", "MSK",    180 },
  { "UTC+03:30", "IRST",   210 },
  { "UTC+04:00", "GST",    240 },
  { "UTC+04:30", "AFT",    270 },
  { "UTC+05:00", "PKT",    300 },
  { "UTC+05:30", "IST",    330 },
  { "UTC+05:45", "NPT",    345 },
  { "UTC+06:00", "BST",    360 },
  { "UTC+06:30", "MMT",    390 },
  { "UTC+07:00", "ICT",    420 },
  { "UTC+08:00", "CST",    480 },
  { "UTC+08:45", "ACWST",  525 },
  { "UTC+09:00", "JST",    540 },
  { "UTC+09:30", "ACST",   570 },
  { "UTC+10:00", "AEST",   600 },
  { "UTC+10:30", "LHST",   630 },
  { "UTC+11:00", "SBT",    660 },
  { "UTC+12:00", "NZST",   720 },
  { "UTC+12:45", "CHAST",  765 },
  { "UTC+13:00", "TOT",    780 },
  { "UTC+14:00", "LINT",   840 }
};

const uint8_t TZ_COUNT = sizeof(TIMEZONES) / sizeof(TIMEZONES[0]);

int16_t activeTZOffset = 0; // loaded from EEPROM; defaults to UTC

// =====================================================
// RTC
// =====================================================

RTC_DS3231 rtc;

// =====================================================
// LEDS
// =====================================================

Adafruit_NeoPixel backlight(BACKLIGHT_COUNT, BACKLIGHT_PIN, NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel sunMoon(SUNMOON_COUNT, SUNMOON_PIN, NEO_GRB + NEO_KHZ800);

// =====================================================
// MOTOR
// =====================================================

const uint8_t stepSequence[8][4] = {
  {1,0,0,0},
  {1,1,0,0},
  {0,1,0,0},
  {0,1,1,0},
  {0,0,1,0},
  {0,0,1,1},
  {0,0,0,1},
  {1,0,0,1}
};

const unsigned long stepDelayMs      = 1;
const long          MAX_POSITION     = 23500;
const long          HOMING_MAX_STEPS = 30000;

int  currentStepIndex = 0;
long currentPosition  = 0;
long targetPosition   = 0;

// =====================================================
// WIFI / WEBSERVER
// =====================================================

const char* AP_SSID = "CelestialArc";
WebServer   server(80);
DNSServer   dnsServer;              // captive portal DNS
const byte  DNS_PORT = 53;

bool pendingMotorTest = false;      // set by /test route, consumed in loop

// =====================================================
// TIMERS
// =====================================================

unsigned long lastStepTime      = 0;
int           lastPrintedMinute = -1;
int           lastTimeSlot      = -1;

// AP timeout: exit settings if no client connects within this window
const unsigned long AP_TIMEOUT_MS = 60000UL; // 60 seconds
unsigned long       apStartTime   = 0;
bool                apClientEverConnected = false;

// =====================================================
// TOUCH BUTTON
// =====================================================

bool          lastTouchState   = false;
unsigned long touchPressStart  = 0;
bool          longPressHandled = false;
const unsigned long LONG_PRESS_MS = 2000;

// =====================================================
// STATES
// =====================================================

enum SystemState { HOMING, STARTUP_SYNC, RUNNING, SETTINGS, FAULT };
SystemState systemState = HOMING;

// =====================================================
// 24C32 EEPROM HELPERS
// =====================================================

void eeprom_writeByte(uint16_t addr, uint8_t data) {
  Wire.beginTransmission(EEPROM_I2C_ADDR);
  Wire.write((uint8_t)(addr >> 8));
  Wire.write((uint8_t)(addr & 0xFF));
  Wire.write(data);
  Wire.endTransmission();
  delay(5); // 24C32 write cycle max 5ms
}

uint8_t eeprom_readByte(uint16_t addr) {
  Wire.beginTransmission(EEPROM_I2C_ADDR);
  Wire.write((uint8_t)(addr >> 8));
  Wire.write((uint8_t)(addr & 0xFF));
  Wire.endTransmission(false); // repeated start
  Wire.requestFrom((uint8_t)EEPROM_I2C_ADDR, (uint8_t)1);
  return Wire.available() ? Wire.read() : 0xFF;
}

void eeprom_writeInt16(uint16_t addr, int16_t value) {
  eeprom_writeByte(addr,     (uint8_t)((uint16_t)value >> 8));
  eeprom_writeByte(addr + 1, (uint8_t)((uint16_t)value & 0xFF));
}

int16_t eeprom_readInt16(uint16_t addr) {
  uint8_t hi = eeprom_readByte(addr);
  uint8_t lo = eeprom_readByte(addr + 1);
  return (int16_t)(((uint16_t)hi << 8) | lo);
}

void loadTimezoneFromEEPROM() {
  uint8_t magic = eeprom_readByte(EEPROM_ADDR_MAGIC);
  if (magic == EEPROM_MAGIC) {
    int16_t stored = eeprom_readInt16(EEPROM_ADDR_TZ);
    for (uint8_t i = 0; i < TZ_COUNT; i++) {
      if (TIMEZONES[i].offset == stored) {
        activeTZOffset = stored;
        Serial.print("TZ loaded from EEPROM: ");
        Serial.println(stored);
        return;
      }
    }
    Serial.println("EEPROM TZ unrecognised, defaulting UTC");
  } else {
    Serial.println("EEPROM uninitialised, defaulting UTC");
  }
  activeTZOffset = 0;
}

void saveTimezoneToEEPROM(int16_t offset) {
  eeprom_writeByte(EEPROM_ADDR_MAGIC, EEPROM_MAGIC);
  eeprom_writeInt16(EEPROM_ADDR_TZ, offset);
  Serial.print("TZ saved: ");
  Serial.println(offset);
}

// =====================================================
// TIME HELPERS
// =====================================================

DateTime applyOffset(DateTime utc, int16_t offsetMinutes) {
  uint32_t unix = utc.unixtime() + (int32_t)offsetMinutes * 60L;
  return DateTime(unix);
}

DateTime localNow() {
  return applyOffset(rtc.now(), activeTZOffset);
}

// =====================================================
// LED FUNCTIONS
// =====================================================

void setBacklightColor(uint8_t r, uint8_t g, uint8_t b) {
  for (int i = 0; i < BACKLIGHT_COUNT; i++)
    backlight.setPixelColor(i, backlight.Color(r, g, b));
  backlight.show();
}

void setSunMoonColor(uint8_t r, uint8_t g, uint8_t b) {
  for (int i = 0; i < SUNMOON_COUNT; i++)
    sunMoon.setPixelColor(i, sunMoon.Color(r, g, b));
  sunMoon.show();
}

void dayMode() {
  setBacklightColor(255, 180, 0);
  setSunMoonColor(255, 120, 20);
}

void nightMode() {
  setBacklightColor(0, 0, 0);
  setSunMoonColor(180, 180, 255);
}

void settingsMode_LED() {
  backlight.setBrightness(128);
  setBacklightColor(255, 0, 0);
}

void restoreNormalBrightness() {
  backlight.setBrightness(50);
}

// =====================================================
// MOTOR FUNCTIONS
// =====================================================

void writeMotorOutputs() {
  digitalWrite(IN1, stepSequence[currentStepIndex][0]);
  digitalWrite(IN2, stepSequence[currentStepIndex][1]);
  digitalWrite(IN3, stepSequence[currentStepIndex][2]);
  digitalWrite(IN4, stepSequence[currentStepIndex][3]);
}

void stepCW() {
  currentStepIndex = (currentStepIndex + 1) % 8;
  writeMotorOutputs();
  currentPosition++;
}

void stepCCW() {
  currentStepIndex = (currentStepIndex + 7) % 8;
  writeMotorOutputs();
  currentPosition--;
}

void motorOff() {
  digitalWrite(IN1, LOW);
  digitalWrite(IN2, LOW);
  digitalWrite(IN3, LOW);
  digitalWrite(IN4, LOW);
}

void fastTravelTo(long target) {
  const long MAX_TRAVEL = MAX_POSITION + 500;
  long steps = 0;
  while (currentPosition < target && steps < MAX_TRAVEL) { stepCW();  delay(1); steps++; }
  while (currentPosition > target && steps < MAX_TRAVEL) { stepCCW(); delay(1); steps++; }
  motorOff();
  lastStepTime = millis();
}

// =====================================================
// RTC PRINT
// =====================================================

void printTime(DateTime t) {
  Serial.printf("Local: %02d:%02d\n", t.hour(), t.minute());
}

// =====================================================
// TIME SLOT
// =====================================================

int get5MinuteSlot(DateTime t) { return t.minute() / 5; }

// =====================================================
// POSITION CALCULATION
// =====================================================

long calculatePosition(DateTime local) {
  int hour          = local.hour();
  int minute        = local.minute();
  int flooredMinute = (minute / 5) * 5;

  if ((hour > 6 && hour < 18) || (hour == 6) || (hour == 18 && flooredMinute == 0)) {
    long minutesSince6AM = ((hour - 6) * 60L) + flooredMinute;
    if (minutesSince6AM > 720) minutesSince6AM = 720;
    return (minutesSince6AM * MAX_POSITION) / 720L;
  }

  long minutesSince6PM;
  if (hour >= 18) minutesSince6PM = ((hour - 18) * 60L) + flooredMinute;
  else            minutesSince6PM = ((hour + 6)  * 60L) + flooredMinute;

  long position = MAX_POSITION - ((minutesSince6PM * MAX_POSITION) / 720L);
  return (position < 0) ? 0 : position;
}

// =====================================================
// LIGHTING
// =====================================================

void updateLighting(DateTime local) {
  int h = local.hour(), m = local.minute();
  if ((h > 6 && h < 18) || (h == 6) || (h == 18 && m == 0)) dayMode();
  else nightMode();
}

// =====================================================
// HOMING
// =====================================================

long homingSteps = 0;

void runHoming() {
  if (digitalRead(LIMIT_SWITCH) == LOW) {
    currentPosition = 0;
    motorOff();
    Serial.println("HOME FOUND");
    systemState = STARTUP_SYNC;
    return;
  }
  if (homingSteps >= HOMING_MAX_STEPS) {
    motorOff();
    Serial.println("HOMING FAILED");
    systemState = FAULT;
    return;
  }
  if (millis() - lastStepTime >= stepDelayMs) {
    lastStepTime = millis();
    stepCCW();
    homingSteps++;
  }
}

// =====================================================
// STARTUP SYNC
// =====================================================

void startupSync() {
  DateTime local = localNow();
  updateLighting(local);
  targetPosition = calculatePosition(local);
  Serial.print("Sync -> position ");
  Serial.println(targetPosition);
  fastTravelTo(targetPosition);
  lastTimeSlot = get5MinuteSlot(local);
  Serial.println("Sync complete -> RUNNING");
  systemState = RUNNING;
}

// =====================================================
// MOTOR TRACKING
// =====================================================

void updateMotor() {
  if (millis() - lastStepTime < stepDelayMs) return;
  lastStepTime = millis();
  if      (currentPosition < targetPosition) stepCW();
  else if (currentPosition > targetPosition) stepCCW();
  else    motorOff();
}

// =====================================================
// TOUCH BUTTON
// =====================================================

void handleTouchButton() {
  bool touched = (digitalRead(TOUCH_PIN) == HIGH);

  if (touched && !lastTouchState) {
    touchPressStart  = millis();
    longPressHandled = false;
  }

  if (touched && !longPressHandled) {
    if (millis() - touchPressStart >= LONG_PRESS_MS) {
      longPressHandled = true;
      if (systemState == RUNNING) enterSettingsMode();
    }
  }

  lastTouchState = touched;
}

// =====================================================
// MOTOR TEST SEQUENCE
// Called from loop() when pendingMotorTest is true.
// Blocking by design — this is a deliberate full-sweep test.
// Sequence: home → MAX_POSITION (dayMode) → home (nightMode) → RUNNING
// =====================================================

void runMotorTest() {
  pendingMotorTest = false;
  Serial.println("MOTOR TEST: starting");

  // ---- Phase 1: Home ----
  Serial.println("MOTOR TEST: homing");
  long safetySteps = 0;
  while (digitalRead(LIMIT_SWITCH) != LOW && safetySteps < HOMING_MAX_STEPS) {
    stepCCW();
    delay(1);
    safetySteps++;
  }

  if (digitalRead(LIMIT_SWITCH) != LOW) {
    // Homing failed during test — abort and go to FAULT
    motorOff();
    Serial.println("MOTOR TEST: homing failed — entering FAULT");
    systemState = FAULT;
    return;
  }

  currentPosition = 0;
  motorOff();
  Serial.println("MOTOR TEST: home found");
  delay(300);

  // ---- Phase 2: Sweep to MAX_POSITION in dayMode ----
  Serial.println("MOTOR TEST: sweeping to MAX (dayMode)");
  dayMode();
  fastTravelTo(MAX_POSITION);
  delay(500); // brief pause at end of arc so it's visible

  // ---- Phase 3: Return to home in nightMode ----
  Serial.println("MOTOR TEST: returning home (nightMode)");
  nightMode();
  fastTravelTo(0);
  delay(300);

  motorOff();
  Serial.println("MOTOR TEST: complete — resuming RUNNING");

  // ---- Phase 4: Exit settings and resume normally ----
  exitSettingsMode(activeTZOffset); // no TZ change, just clean resume
}

// =====================================================
// SETTINGS MODE — ENTER / EXIT
// =====================================================

void enterSettingsMode() {
  Serial.println("-> SETTINGS MODE");
  systemState = SETTINGS;
  settingsMode_LED();

  WiFi.softAP(AP_SSID);
  IPAddress apIP = WiFi.softAPIP(); // default: 192.168.4.1
  Serial.print("AP IP: ");
  Serial.println(apIP);

  // Captive portal: answer every DNS query with our own IP.
  // This makes Android/iOS/Windows detect a "login required" network
  // and pop the browser open automatically.
  dnsServer.start(DNS_PORT, "*", apIP);

  setupWebServer();
  server.begin();

  // mDNS: reachable at http://celestialarc.local as a named fallback
  if (MDNS.begin("celestialarc")) {
    MDNS.addService("http", "tcp", 80);
    Serial.println("mDNS: http://celestialarc.local");
  }

  // Start the no-connection timeout countdown
  apStartTime           = millis();
  apClientEverConnected = false;

  Serial.println("Web server started (60s timeout if no client connects)");
}

void exitSettingsMode(int16_t newOffset) {
  bool tzChanged = (newOffset != activeTZOffset);

  if (tzChanged) {
    activeTZOffset = newOffset;
    saveTimezoneToEEPROM(activeTZOffset);
    Serial.print("TZ changed to: ");
    Serial.println(activeTZOffset);
  }

  server.stop();
  dnsServer.stop();
  MDNS.end();
  WiFi.softAPdisconnect(true);
  WiFi.mode(WIFI_OFF);

  restoreNormalBrightness();

  DateTime local = localNow();
  updateLighting(local);

  if (tzChanged) {
    long newTarget = calculatePosition(local);
    Serial.print("Fast-travel -> ");
    Serial.println(newTarget);
    fastTravelTo(newTarget);
    targetPosition = newTarget;
  }

  lastTimeSlot = get5MinuteSlot(local);
  lastStepTime = millis();
  systemState  = RUNNING;
  Serial.println("-> RUNNING");
}

// =====================================================
// WEBPAGE HELPERS
// =====================================================

uint8_t tzIndexByOffset(int16_t offset) {
  for (uint8_t i = 0; i < TZ_COUNT; i++)
    if (TIMEZONES[i].offset == offset) return i;
  return 14; // UTC fallback
}

String formatHMS(DateTime t) {
  char buf[9];
  snprintf(buf, sizeof(buf), "%02d:%02d:%02d", t.hour(), t.minute(), t.second());
  return String(buf);
}

String formatDate(DateTime t) {
  const char* days[]   = {"Sunday","Monday","Tuesday","Wednesday",
                           "Thursday","Friday","Saturday"};
  const char* months[] = {"January","February","March","April","May","June",
                           "July","August","September","October","November","December"};
  return String(days[t.dayOfTheWeek()]) + ", " +
         String(t.day()) + " " +
         String(months[t.month() - 1]) + " " +
         String(t.year());
}

// =====================================================
// WEBPAGE BUILD
// =====================================================

String buildHTMLPage() {
  DateTime utcTime  = rtc.now();
  DateTime locTime  = applyOffset(utcTime, activeTZOffset);

  uint8_t  activeIdx = tzIndexByOffset(activeTZOffset);

  // Build timezone <option> list
  String tzOptions = "";
  for (uint8_t i = 0; i < TZ_COUNT; i++) {
    tzOptions += "<option value=\"";
    tzOptions += TIMEZONES[i].offset;
    tzOptions += "\"";
    if (i == activeIdx) tzOptions += " selected";
    tzOptions += ">";
    tzOptions += TIMEZONES[i].label;
    tzOptions += " \xe2\x80\x94 ";  // em dash UTF-8
    tzOptions += TIMEZONES[i].abbr;
    tzOptions += "</option>\n";
  }

  // Build JS timezone array (mirrors C++ array for client-side time math)
  String jsTZArray = "[\n";
  for (uint8_t i = 0; i < TZ_COUNT; i++) {
    jsTZArray += "{offset:";
    jsTZArray += TIMEZONES[i].offset;
    jsTZArray += ",abbr:\"";
    jsTZArray += TIMEZONES[i].abbr;
    jsTZArray += "\",label:\"";
    jsTZArray += TIMEZONES[i].label;
    jsTZArray += "\"}";
    if (i < TZ_COUNT - 1) jsTZArray += ",";
    jsTZArray += "\n";
  }
  jsTZArray += "]";

  String html = "";
  html.reserve(8000);

  html += "<!DOCTYPE html><html lang='en'><head>"
          "<meta charset='UTF-8'>"
          "<meta name='viewport' content='width=device-width,initial-scale=1.0'>"
          "<title>CelestialArc Settings</title>"
          "<style>"
          "*{box-sizing:border-box;margin:0;padding:0}"
          "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
          "background:#0f0f1a;color:#e0d8ff;min-height:100vh;"
          "display:flex;flex-direction:column;align-items:center;"
          "justify-content:center;padding:24px;gap:14px;}"
          "h1{font-size:1.25rem;font-weight:500;letter-spacing:.12em;"
          "color:#a090d0;text-transform:uppercase;}"
          ".card{background:#1a1830;border:1px solid #2e2a50;border-radius:16px;"
          "padding:24px 28px;width:100%;max-width:400px;}"
          ".lbl{font-size:.65rem;text-transform:uppercase;letter-spacing:.15em;"
          "color:#5a5080;margin-bottom:8px;}"
          ".time-row{display:flex;align-items:baseline;gap:10px;}"
          ".t-big{font-size:2.8rem;font-weight:300;letter-spacing:.04em;"
          "color:#fff;font-variant-numeric:tabular-nums;}"
          ".t-abbr{font-size:.85rem;color:#7060a0;}"
          ".date-ln{font-size:.88rem;color:#7060a0;margin-top:4px;margin-bottom:16px;}"
          ".divider{border:none;border-top:1px solid #2e2a50;margin:14px 0;}"
          ".utc-row{display:flex;align-items:center;justify-content:space-between;}"
          ".utc-lbl{font-size:.65rem;text-transform:uppercase;letter-spacing:.12em;color:#5a5080;}"
          ".utc-t{font-size:1.3rem;font-weight:300;letter-spacing:.04em;"
          "color:#5a5080;font-variant-numeric:tabular-nums;}"
          "select{width:100%;background:#0f0f1a;color:#e0d8ff;"
          "border:1px solid #2e2a50;border-radius:10px;padding:11px 36px 11px 14px;"
          "font-size:.88rem;appearance:none;cursor:pointer;"
          "background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'"
          " width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%237060a0'"
          " stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E\");"
          "background-repeat:no-repeat;background-position:right 14px center;}"
          "select:focus{outline:none;border-color:#534AB7;}"
          ".preview-row{display:flex;align-items:baseline;gap:8px;margin-top:12px;min-height:30px;}"
          ".prev-t{font-size:1.6rem;font-weight:300;color:#c0b0ff;font-variant-numeric:tabular-nums;}"
          ".prev-lbl{font-size:.8rem;color:#5a5080;}"
          ".save-btn{background:#534AB7;color:#fff;border:none;border-radius:10px;"
          "padding:14px;font-size:.95rem;font-weight:500;cursor:pointer;"
          "width:100%;max-width:400px;transition:background .2s;}"
          ".save-btn:hover{background:#7F77DD;}"
          ".test-btn{background:#1a3a2a;color:#4dbb7a;border:1px solid #2a5a3a;"
          "border-radius:10px;padding:13px;font-size:.9rem;font-weight:500;"
          "cursor:pointer;width:100%;max-width:400px;transition:background .2s;}"
          ".test-btn:hover{background:#1e4a32;}"
          ".test-note{font-size:.68rem;color:#3a3060;text-align:center;"
          "max-width:400px;line-height:1.5;}"
          "</style></head><body>"
          "<h1>&#9732; CelestialArc</h1>";

  // --- Active timezone card ---
  html += "<div class='card'>"
          "<div class='lbl'>Active timezone &mdash; ";
  html += TIMEZONES[activeIdx].label;
  html += " (";
  html += TIMEZONES[activeIdx].abbr;
  html += ")</div>"
          "<div class='time-row'>"
          "<span class='t-big' id='localClock'>";
  html += formatHMS(locTime);
  html += "</span>"
          "<span class='t-abbr' id='localAbbr'>";
  html += TIMEZONES[activeIdx].abbr;
  html += "</span></div>"
          "<div class='date-ln' id='localDate'>";
  html += formatDate(locTime);
  html += "</div>"
          "<hr class='divider'>"
          "<div class='utc-row'>"
          "<span class='utc-lbl'>UTC</span>"
          "<span class='utc-t' id='utcClock'>";
  html += formatHMS(utcTime);
  html += "</span></div></div>";

  // --- Timezone selector card ---
  html += "<div class='card'>"
          "<div class='lbl'>Select timezone</div>"
          "<select id='tzSelect' onchange='onTZChange(this)'>\n";
  html += tzOptions;
  html += "</select>"
          "<div class='preview-row'>"
          "<span class='prev-t' id='previewClock'></span>"
          "<span class='prev-lbl' id='previewLabel'></span>"
          "</div></div>";

  // --- Save form ---
  html += "<form action='/save' method='POST'>"
          "<input type='hidden' name='tz' id='tzHidden' value='";
  html += activeTZOffset;
  html += "'>"
          "<button class='save-btn' type='submit'>Save &amp; Exit</button>"
          "</form>";

  // --- Motor test button ---
  html += "<form action='/test' method='POST'>"
          "<button class='test-btn' type='submit'>"
          "&#9654; Run Motor Test"
          "</button>"
          "</form>"
          "<div class='test-note'>"
          "Homes &rarr; sweeps to MAX (day) &rarr; returns home (night) &rarr; resumes"
          "</div>";

  // --- Script ---
  html += "<script>"
          "const TZ=";
  html += jsTZArray;
  html += ";\n"
          "const utcParts=document.getElementById('utcClock').textContent.split(':');\n"
          "let uh=+utcParts[0],um=+utcParts[1],us=+utcParts[2];\n"
          "let activeTZMin=";
  html += activeTZOffset;
  html += ";\n"
          "let selectedTZMin=activeTZMin;\n"
          "function pad(n){return String(n).padStart(2,'0');}\n"
          "function localFromUTC(uh,um,us,offMin){\n"
          "  let t=uh*3600+um*60+us+offMin*60;\n"
          "  t=((t%86400)+86400)%86400;\n"
          "  return{h:Math.floor(t/3600),m:Math.floor((t%3600)/60),s:t%60};\n"
          "}\n"
          "function tick(){\n"
          "  us++;if(us>=60){us=0;um++;}if(um>=60){um=0;uh++;}if(uh>=24)uh=0;\n"
          "  document.getElementById('utcClock').textContent=pad(uh)+':'+pad(um)+':'+pad(us);\n"
          "  const loc=localFromUTC(uh,um,us,activeTZMin);\n"
          "  document.getElementById('localClock').textContent=pad(loc.h)+':'+pad(loc.m)+':'+pad(loc.s);\n"
          "  if(selectedTZMin!==activeTZMin){\n"
          "    const p=localFromUTC(uh,um,us,selectedTZMin);\n"
          "    document.getElementById('previewClock').textContent=pad(p.h)+':'+pad(p.m)+':'+pad(p.s);\n"
          "  }\n"
          "}\n"
          "setInterval(tick,1000);\n"
          "function onTZChange(sel){\n"
          "  selectedTZMin=parseInt(sel.value);\n"
          "  document.getElementById('tzHidden').value=selectedTZMin;\n"
          "  const tz=TZ.find(t=>t.offset===selectedTZMin);\n"
          "  if(selectedTZMin===activeTZMin){\n"
          "    document.getElementById('previewClock').textContent='';\n"
          "    document.getElementById('previewLabel').textContent='';\n"
          "  } else {\n"
          "    const p=localFromUTC(uh,um,us,selectedTZMin);\n"
          "    document.getElementById('previewClock').textContent=pad(p.h)+':'+pad(p.m)+':'+pad(p.s);\n"
          "    document.getElementById('previewLabel').textContent=tz?tz.abbr+' '+tz.label:'';\n"
          "  }\n"
          "}\n"
          "</script></body></html>";

  return html;
}

// =====================================================
// WEB SERVER ROUTES
// =====================================================

void setupWebServer() {

  // ---- Captive portal detection endpoints ----
  // Each OS probes a known URL; if it doesn't get the expected response
  // it concludes there's a captive portal and pops the browser.
  // We redirect all of them to our main page.

  // iOS / macOS
  server.on("/hotspot-detect.html",        HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });
  server.on("/library/test/success.html",  HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });

  // Android
  server.on("/generate_204",               HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });
  server.on("/gen_204",                    HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });

  // Windows (NCSI)
  server.on("/ncsi.txt",                   HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });
  server.on("/connecttest.txt",            HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });
  server.on("/redirect",                   HTTP_GET, []() { server.sendHeader("Location", "/", true); server.send(302, "text/plain", ""); });

  // Catch-all: anything not matched above also redirects to main page.
  // This is the safety net that makes the captive portal fully robust —
  // any unknown probe URL from any OS still lands on the settings page.
  server.onNotFound([]() {
    server.sendHeader("Location", "/", true);
    server.send(302, "text/plain", "");
  });

  // ---- Main routes ----
  server.on("/", HTTP_GET, []() {
    server.send(200, "text/html", buildHTMLPage());
  });

  server.on("/save", HTTP_POST, []() {
    int16_t newOffset = activeTZOffset;

    if (server.hasArg("tz")) {
      long val = server.arg("tz").toInt();
      for (uint8_t i = 0; i < TZ_COUNT; i++) {
        if (TIMEZONES[i].offset == (int16_t)val) {
          newOffset = (int16_t)val;
          break;
        }
      }
    }

    uint8_t idx = tzIndexByOffset(newOffset);

    String resp = "<!DOCTYPE html><html lang='en'><head>"
                  "<meta charset='UTF-8'>"
                  "<meta name='viewport' content='width=device-width,initial-scale=1.0'>"
                  "<title>CelestialArc</title>"
                  "<style>"
                  "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
                  "background:#0f0f1a;color:#e0d8ff;min-height:100vh;display:flex;"
                  "flex-direction:column;align-items:center;justify-content:center;"
                  "padding:24px;text-align:center;gap:12px;}"
                  "h2{font-weight:400;color:#a090d0;font-size:1.2rem;}"
                  "p{color:#5a5080;font-size:.85rem;line-height:1.6;}"
                  ".tz{color:#c0b0ff;font-size:1rem;}"
                  "</style></head><body>"
                  "<h2>&#10003; Settings saved</h2>"
                  "<p class='tz'>";
    resp += TIMEZONES[idx].label;
    resp += " &mdash; ";
    resp += TIMEZONES[idx].abbr;
    resp += "</p><p>CelestialArc is syncing to the new timezone<br>"
            "and resuming normal operation.</p>"
            "<p>You can close this page.</p>"
            "</body></html>";

    server.send(200, "text/html", resp);
    exitSettingsMode(newOffset);
  });

  server.on("/test", HTTP_POST, []() {
    // Send the response immediately — the test sequence is blocking
    // so we can't run it inside the handler. Set a flag and let loop() do it.
    String resp = "<!DOCTYPE html><html lang='en'><head>"
                  "<meta charset='UTF-8'>"
                  "<meta name='viewport' content='width=device-width,initial-scale=1.0'>"
                  "<title>CelestialArc</title>"
                  "<style>"
                  "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
                  "background:#0f0f1a;color:#e0d8ff;min-height:100vh;display:flex;"
                  "flex-direction:column;align-items:center;justify-content:center;"
                  "padding:24px;text-align:center;gap:14px;}"
                  "h2{font-weight:400;color:#a090d0;font-size:1.2rem;}"
                  "p{color:#5a5080;font-size:.85rem;line-height:1.6;}"
                  ".steps{background:#1a1830;border:1px solid #2e2a50;border-radius:12px;"
                  "padding:20px 24px;text-align:left;max-width:320px;width:100%;}"
                  ".step{display:flex;align-items:center;gap:10px;padding:6px 0;"
                  "font-size:.85rem;color:#8070b0;border-bottom:1px solid #1e1c30;}"
                  ".step:last-child{border-bottom:none;}"
                  ".dot{width:8px;height:8px;border-radius:50%;background:#534AB7;flex-shrink:0;}"
                  "</style></head><body>"
                  "<h2>&#9654; Motor Test Running</h2>"
                  "<div class='steps'>"
                  "<div class='step'><span class='dot'></span>Homing to position 0</div>"
                  "<div class='step'><span class='dot'></span>Sweeping to MAX in day mode</div>"
                  "<div class='step'><span class='dot'></span>Returning home in night mode</div>"
                  "<div class='step'><span class='dot'></span>Resuming normal operation</div>"
                  "</div>"
                  "<p>CelestialArc will resume automatically.<br>You can close this page.</p>"
                  "</body></html>";
    server.send(200, "text/html", resp);
    pendingMotorTest = true; // handled in loop() after response is sent
  });
}

// =====================================================
// SETUP
// =====================================================

void setup() {
  Serial.begin(115200);

  pinMode(IN1, OUTPUT);
  pinMode(IN2, OUTPUT);
  pinMode(IN3, OUTPUT);
  pinMode(IN4, OUTPUT);
  pinMode(LIMIT_SWITCH, INPUT_PULLUP);
  pinMode(TOUCH_PIN, INPUT);

  Wire.begin(SDA_PIN, SCL_PIN);

  if (!rtc.begin()) {
    Serial.println("RTC NOT FOUND");
    backlight.begin();
    backlight.setBrightness(50);
    setBacklightColor(255, 0, 0);
    motorOff();
    systemState = FAULT;
    return;
  }

  if (rtc.lostPower())
    Serial.println("WARNING: RTC lost power - time may be invalid");

  loadTimezoneFromEEPROM();

  backlight.begin();
  backlight.setBrightness(50);
  backlight.show();

  sunMoon.begin();
  sunMoon.setBrightness(204);
  sunMoon.show();

  dayMode();
  WiFi.mode(WIFI_OFF);

  Serial.println("STARTING HOMING");
}

// =====================================================
// LOOP
// =====================================================

void loop() {
  if (systemState == RUNNING || systemState == SETTINGS)
    handleTouchButton();

  switch (systemState) {

    case HOMING:
      runHoming();
      break;

    case STARTUP_SYNC:
      startupSync();
      break;

    case RUNNING: {
      DateTime local = localNow();
      updateLighting(local);

      if (local.minute() != lastPrintedMinute) {
        lastPrintedMinute = local.minute();
        printTime(local);
      }

      int slot = get5MinuteSlot(local);
      if (slot != lastTimeSlot) {
        lastTimeSlot   = slot;
        targetPosition = calculatePosition(local);
        Serial.print("TARGET=");
        Serial.println(targetPosition);
      }

      updateMotor();
      break;
    }

    case SETTINGS:
      dnsServer.processNextRequest(); // must be called every loop for captive portal
      updateMotor();
      server.handleClient();

      // Motor test requested via webpage — run after HTTP response is flushed
      if (pendingMotorTest) {
        runMotorTest(); // blocking; exits settings mode internally when done
        break;
      }

      // Latch once any device joins the AP — timer stops permanently
      if (!apClientEverConnected && WiFi.softAPgetStationNum() > 0) {
        apClientEverConnected = true;
        Serial.println("Client connected — AP timeout cancelled");
      }

      // If no client ever connected and the window has expired, bail out
      if (!apClientEverConnected &&
          (millis() - apStartTime >= AP_TIMEOUT_MS)) {
        Serial.println("AP timeout — no client connected, returning to RUNNING");
        exitSettingsMode(activeTZOffset); // no TZ change, just clean exit
      }
      break;

    case FAULT:
      motorOff();
      break;
  }
}


Moving to position

Bill of Materials

Sl. No. Category Item Quantity Unit Cost (₹) Total Cost (₹) Supplier Alternate Supplier Search Name
Electronics
1 Microcontroller XIAO ESP32-C6 1 555 555 FAB INVENTORY Robu Seeed Studio XIAO ESP32C6
2 Stepper Motor 28BYJ-48 Stepper Motor 5V 1 92 92 FAB INVENTORY Robu 28BYJ-48 Stepper Motor DC 5V
3 Motor Driver ULN2003A IC 1 75 75 ROBU Robu ULN2003LVDR Texas Instruments Darlington Transistor Array
4 NeoPixel WS2812B RGB SMD 5050 LED 9 5 45 FAB INVENTORY Ktron WS2812B RGB SMD 5050 LED
6 RTC DS3231 RTC Module 1 158 158 ROBU Robokits DS3231 AT24C32 High Precision Clock I2C Module
7 Limit Switch Limit Switch 1 49 49 FAB INVENTORY Robu OMRON 3D Printer Limit Switch ENDSTOP SS-5GL
8 Light Sensor CJMCU TEMT6000 Ambient Light Sensor Module 1 80 80 ROBU Robu CJMCU TEMT6000 Ambient Light Sensor Module
9 Battery for RTC CR2032 Coin Cell Battery 1 43 43 ROBU Robu Panasonic CR2032 3V Lithium Coin Battery
10 NeoPixel Strip WS2812B Addressable LED Strip 1 720 720 Quartz Components Quartz Components WS2812B Neopixel LED Strip 60 LED/Meter
Mechanical
1 Bearings Transparent OpenBuilds 5mm V-Shaped Pulley 3 64 192 Flyrobo Flyrobo Transparent OpenBuilds 5mm V-Shaped Pulley
2 Rubwood 18mm Rubwood Sheet (8x4 ft) 12 135 1620 FAB INVENTORY IndiaMART 18mm Rubwood
3 Acrylic Sheet 6mm Acrylic Sheet 2.5 110 275 FAB INVENTORY IndiaMART Acrylic Sheet
4 Synthetic Paper Synthetic Paper 2 50 100 Local Store Phoenix Kochi Synthetic Paper

Total electronics cost: ₹1,817.

Total mechanical cost: ₹2,187.

Total project cost: ₹4,004.

Licence

Anyone may use and adapt this design for non commercial purposes with proper credit. Any derivative work must be shared under the same Creative Commons license. Since this project was inspired by the work of others, I hope sharing it openly will help others learn, build upon it, and create new ideas.

Design files - download

Download KiCAD Files

Download Arduino Files

Download Fusion Files

Acknowledgement

Instructors : Sibin KS    Jogin Francis    Revishankar S    Saheen Palayi    Mufeed Mohamed    Namita Aravind    Akhil G Babu    Akash Edamana    Sreyas George

Global Instructors : Pradnya P Shindekar    Rico Kanthatham    Ye Eun Miriam Choi    Adrian Torres

Batchmates : Abhishek Shah    Ali Abdul Gafoor    Archita B K    Ashtami P S    Kevin Jijo    Merin Cyriac    Mishael Sharaf    Nadec Biju    Kurian