Time-Lapse Camera Rig
ESP32 DevKit (WROOM-32)·Arduino C++·Updated Jul 27, 2026·by FluxBench TeamIncludes Lab + Schematic
An interrupt-driven time-lapse controller that uses the DS3231’s SQW/INT pin the way battery-powered rigs do: alarm 1 is armed in DS3231_A1_PerSecond mode, the RTC pulls the INT line (wired to GPIO 27) LOW on every match, and attachInterrupt(..., FALLING) delivers the tick — no polling loop asking the RTC if it fired. Press the bench button (GPIO 14) to start rolling: every 5 seconds the rig “takes a frame”, flashing the white LED on GPIO 4 for 150 ms and logging “SHOT n @ HH:MM:SS” with the RTC’s own timestamp. The OLED shows ROLLING/PAUSED, the live RTC clock, the frame tally and a countdown to the next shot; press the button again to pause.
sketch.ino
// Time-Lapse Camera Rig - the DS3231's SQW/INT pin fires the shutter
// ESP32 DevKit v1. DS3231 @0x68 + SSD1306 @0x3C on I2C (SDA=21, SCL=22),
// DS3231 SQW/INT pin wired to GPIO 27, start/pause button on GPIO 14,
// white "shutter flash" LED on GPIO 4.
//
// Unlike a polled alarm, this sketch never asks the RTC "did you fire?" in a
// tight loop. The DS3231 pulls its SQW/INT line LOW whenever alarm 1 matches,
// and attachInterrupt(..., FALLING) wakes the code - the pattern you use when
// the MCU sleeps between frames. Alarm 1 is armed in DS3231_A1_PerSecond mode,
// so the ISR ticks once per second; every 5th tick takes a "shot".
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <RTClib.h>
#define INT_PIN 27 // DS3231 SQW/INT line (open drain, active LOW)
#define BUTTON_PIN 14 // start / pause the rig
#define FLASH_PIN 4 // white LED = camera flash
#define SHOT_EVERY 5 // seconds between frames
RTC_DS3231 rtc;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
volatile bool tick = false; // set by the ISR, consumed in loop()
void IRAM_ATTR onRtcAlarm() {
tick = true;
}
bool rolling = false;
int shots = 0;
int countdown = SHOT_EVERY;
int lastBtn = 1;
unsigned long lastPress = 0;
unsigned long flashOffAt = 0;
void takeShot() {
shots = shots + 1;
digitalWrite(FLASH_PIN, HIGH); // fire the flash for 150 ms
flashOffAt = millis() + 150;
DateTime now = rtc.now();
char stamp[20];
sprintf(stamp, "%02d:%02d:%02d", now.hour(), now.minute(), now.second());
Serial.print("SHOT ");
Serial.print(shots);
Serial.print(" @ ");
Serial.println(stamp);
}
void drawScreen() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(2);
display.setCursor(0, 0);
if (rolling) display.println("ROLLING");
else display.println("PAUSED");
display.setTextSize(1);
DateTime now = rtc.now();
char stamp[20];
sprintf(stamp, "%02d:%02d:%02d", now.hour(), now.minute(), now.second());
display.setCursor(0, 20);
display.print("RTC ");
display.println(stamp);
display.setCursor(0, 34);
display.print("Frames: ");
display.println(shots);
display.setCursor(0, 48);
if (rolling) {
display.print("Next shot in ");
display.print(countdown);
display.print("s");
} else {
display.print("Button = start");
}
display.display();
}
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(FLASH_PIN, OUTPUT);
pinMode(INT_PIN, INPUT_PULLUP); // open-drain INT line needs a pull-up
Wire.begin();
rtc.begin();
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
// Route alarm 1 to the INT/SQW pin and let it match once per second
rtc.writeSqwPinMode(DS3231_OFF); // INT mode, not square wave
rtc.disableAlarm(2);
rtc.clearAlarm(1);
rtc.setAlarm1(rtc.now(), DS3231_A1_PerSecond);
attachInterrupt(digitalPinToInterrupt(INT_PIN), onRtcAlarm, FALLING);
Serial.println("TIMELAPSE READY - press the button to roll");
drawScreen();
}
void loop() {
// button toggles rolling / paused
int btn = digitalRead(BUTTON_PIN);
if (btn == LOW && lastBtn == HIGH && millis() - lastPress > 200) {
lastPress = millis();
rolling = !rolling;
countdown = SHOT_EVERY;
if (rolling) Serial.println("ROLLING");
else Serial.println("PAUSED");
drawScreen();
}
lastBtn = btn;
// one tick per second, delivered by the DS3231 over the INT line
if (tick) {
tick = false;
rtc.clearAlarm(1); // release the INT line so it can fall again
if (rolling) {
countdown = countdown - 1;
if (countdown <= 0) {
takeShot();
countdown = SHOT_EVERY;
}
drawScreen();
}
}
if (flashOffAt > 0 && millis() >= flashOffAt) {
digitalWrite(FLASH_PIN, LOW);
flashOffAt = 0;
}
delay(20);
}Built with FluxBench.AI — the AI workbench for embedded projects.