Final Project Development

Every product has to answer some questions, provide solutions or an emotion.

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

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.

What It Does

Project Description : A kinetic clock that replaces traditional dual clocks with a moving artistic display. It synchronizes to the time zone of a person of your choice, creating a subtle reminder of their time, their daily rhythm, and their presence in your life. As the sun travels across the arc throughout the day, it quietly reflects the passage of time in their world, turning a simple clock into a meaningful connection.

Who It's For

Problem It SolvesThe dual clock system can be old fashioned. This is a more visually appealing model and a more direct way of displaying time. It allows users to understand the passage of time through movement and form rather than relying only on numbers.

Why It MattersIn the current digital world, it combines the artistic appeal of art and digital technology to create a human connection. It transforms a routine function into an engaging experience that encourages people to interact with and appreciate the concept of time.

What's working? As of now, I have figured out the gear system and the aspects mentioned above.

What's not? I still need to determine how the PCB can be removed easily in case there are any wiring issues or future maintenance requirements.

What questions need to be resolved? The frame thickness had to be finalized by testing the NeoPixel lighting and the image frames. By the end of the week, I was able to answer this question and am now ready to build the frame.

What will happen when? A lot of tasks were expected to be completed by now, but they have been pushed back. By the middle of next week, I should be finished with production and ready to focus on firmware development.

What have you learned? I have learned that many small details, events, and interactions can affect motivation. From a professional perspective, I now better understand the importance of PCB design and component placement. The routing and length of wires, how they are guided through the product, and the design of 3D printed mechanical parts all play a significant role. It is important to consider mechanical factors such as vibration, tolerances, and which aspects can or cannot be neglected. In this project, the gear mounted on the motor requires a tight press fit, and the bearings also need a proper interference fit. Equally important are decisions regarding PCB mounting, power delivery, and the overall aesthetic appearance of the product.

Spiral Approach

This is how I used the spiral approach to incorporate the learning from each week throughout the academy into my design.

Week 02

Computer Aided Design

Here we learned about software that we could use. I learned to use Fusion, and a lot was learned during the later period. Affinity has been of lot of help in designing the images.

Week 03

Computer-Controlled Cutting

The laser cutter machine has been of help in making scaled models and working with different materials.

Week 09

Input Devices

Touch pad, phototransistor, and RTC. I learned about their working methods, how to connect them, and program them.

Week 10

Output Devices

Servo motor (MG90S) and stepper motor (28BYJ48 12V stepper motor). I learned about their working methods, how to connect them, and program them. The types of connections they need, power requirements, how to provide additional power to a board, how the motors are different, and which one to use for the project.

Week 11

Embedded Networking and Communications

I communicated between the input and output boards. This gave me an understanding of how the boards worked. When I was doing this, I did not think I would need breakout boards to connect my motors. The wire connections were not thought of because of my lack of practical knowledge of making devices or machines like these. Machine Week gave me more insight into this.

Week 12

Machine Week

This gave much more insight into the placement of the board, how the power supply could happen, what type of connectors would be needed to supply power, the 3D model of the PCB, why it is important, the PCB components, and how they affect the board design.

Week 14

Moulding and Casting

I did not try the assignment related to my project. But I had a clear idea of the components I would be using and the materials to be used. I had purchased the synthetic paper. The idea of how to assemble the mould was developing. The gear material as 6 mm acrylic sheet was fixed.

Week 15

Interface and Application Programming

This week I tried to complete the software part of it. AI tools were in use to help. I was able to change the time based on the 24 time zones. This was when I was finding it difficult to add all the places. I was wondering how many locations to add since there were so many locations, cities, and countries. This week I had started to choose the microcontroller I would need. Sibin had asked me to work that part out.

Week 16

System Integration

The initial choice of microcontroller was the ESP32. Then, on rechecking, I understood that the Xiao ESP32 C6 itself would be enough to handle the required pins. I also made sure to have an extra pin for the backlight. These were discussed around this time, and it was good that it was done. I did use the backlight, that is, the NeoPixel strip, to illuminate the image.

Week 17

Wildcard Week

This week I focused on cutting the synthetic paper. I tested whether the Trotec laser cutter could do the job, what power settings would be needed, and how it affected the paper. It took eight iterations to find the correct power. I cut the paper on the Trotec Speedy 100. After finding the settings, I cut a different colour and the paper warped, so I had to adjust the power.

Week 18

Applications and Implications, Project Development

This week I thought more about how the project was going to be, and the size was fixed.

Week 19

Invention, Intellectual Property and Income

This week gave insight into the production of the project. I had cut the gear and was iterating the 3D printed models to fit the mechanical components. I routed the wooden frame. This was a crucial time. What I learned from Machine Week was to design the module so that I could reach the PCB module and the wires without having to take the project apart every time something went wrong.

Final Project

By this time, the project was assembled, though it was not screwed or shut completely. While still in pieces, the project could work.

Schedule

To give an idea about the project, I used this to estimate the time required. However, it did not work as expected since this was the first time I was making a product.

This was scheduled during the Wild Card Week, with four weeks remaining before the final project presentation.

DATE WEEKS DAY TASK Design Category CHANGED Output Achieved To Complete
17/05/2026 Sunday TASK
18/05/2026 Wildcard Week Monday wild crd. zund + documentation wild card product cut documentation
19/05/2026 Tuesday PCB Ki CAD design complete + modify 3D model electronics model
20/05/2026 Wednesday Milling + Solder + testing with Arduino + 3D model frame electronics
21/05/2026 Thursday Teaser video SHOPBOT video + docu week18 + 3D model of KiCad
22/05/2026 Friday Shopbot (base cam + milling + sanding) 3D printer + design 3D model gear design + EVE MILLING
23/05/2026 Saturday 3D print ring cylinder + PCB base + choose image for frame 2D design 3D model gear design + EVE PCB
24/05/2026 Sunday documentation Documentation
25/05/2026 Applications and Implications,
Project Development
Monday arrangement of image layers + laser cut images + test on frame fixing 3D model design choose image + laser cut + MILLING
26/05/2026 Tuesday 3D model gear design gear design + 3D print and testing shopbot/zund frame cutting
27/05/2026 Wednesday shopbot remaining cut SHOPBOT shopbot/zund frame cutting
28/05/2026 Thursday documentation, assembly + testing assembly assemble recheck 3D print
29/05/2026 Intellectual Property and Income Friday firmware testing, individual + combination programming firmware + TESTING
30/05/2026 Saturday firmware testing, individual + combination programming firmware
31/05/2026 Sunday buffer
01/06/2026 Final Project Requirements Monday Testing, complete working + complete assembly programming + assembly Testing, complete working + complete assembly
02/06/2026 Tuesday video recording 24hrs + video + image sorting + script editing
03/06/2026 Wednesday Video editing editing
04/06/2026 Thursday Video editing editing
05/06/2026 Friday Documentation documentation
06/06/2026 Saturday Documentation documentation

I had to reschedule it because I could not keep up with the schedule I had created, despite being in the lab almost all the time.

DATE WEEKS DAY TASK Output Achieved To Complete
25/05/2026 Development Monday choose image + milling
26/05/2026 Tuesday 3D model gear design + milling
27/05/2026 Wednesday wood work acrylic layers cutting + PCB milling
28/05/2026 Thursday documentation 3D model
29/05/2026 Intellectual
Property
and
Income
Friday production (wood + acrylic) + testing wood, acrylic cutting, gear
30/05/2026 Saturday firmware testing, individual + wood combination
31/05/2026 Sunday buffer
01/06/2026 Monday Testing, working + assembly Testing, working + assembly
02/06/2026 Tuesday Testing, complete working + complete assembly Testing, complete working + complete assembly
03/06/2026 Final Project
Requirements
Wednesday video recording 24hrs + video + image sorting + script
04/06/2026 Thursday Video editing
05/06/2026 Friday Documentation
06/06/2026 Saturday buffer
07/06/2026 Sunday buffer
08/06/2026 Monday buffer
09/06/2026 Tuesday preparation
10/06/2026 Wednesday presentation

Electronics

PCB boards

Mechanics

3D Printing
Laser cutting
Image in layers
Wood working
SHOPBOT TROUBLESHOOTING AND PROGRAM RECOVERY

ASSEMBLY

Curved Gantry and Pinion
Layered image

Double sided tape was used to bond the layers together and attach the assembly to the frame.

The image was able to pass natural light through

Details that matter

The front frame could not be mounted properly because of warping. To correct the issue, Nadec routed the edges of the frame, allowing it to fit correctly with the synthetic paper layer.

overview

Components on board

Test run with backlight

The wires are organised using 3D printed clips. A plastic tube was cut into a spiral and wrapped around the wires for cable management. A braided wire sleeve is used to bundle and protect the motor wires.

Firmware

The firmware was developed with the assistance of Claude and ChatGPT.


#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;
  }
}

To turn on WiFi on the Celestial Arc, touch the circular touch sensor on the side of the frame. Within about 2 seconds, the ring and the entire frame will glow red, indicating that WiFi mode has been activated.
On your phone, connect to the WiFi network created by the Celestial Arc. A configuration page should open automatically. If it does not, open your browser and navigate to the configuration page manually.
Select your country and save the settings. The Celestial Arc will then calculate the local time and position of the Sun. The illuminated Sun icon will automatically move to the correct position based on the current time at your selected location.

The entire process of learning, facing challenges, and solving problems was a valuable experience. Over the past few months, I have learned more than ever before. Working through each stage of the project helped me develop new skills, improve my confidence, and gain a deeper understanding of the design and fabrication process. The journey was both challenging and rewarding, making it a truly memorable learning experience.