Introduction: DIY Timelapse Camera Using Unihiker K10

About: Maker | Developer | Content Creator

Welcome to this Instructables tutorial. In this guide, you’ll learn how to build time lapse camera using Unihiker k10 By Dfrobot.

Timelapse photography is a great way to capture slow changes over time, such as plant growth, construction progress, or daily light changes.

In this project, I’ll show you how to build a DIY timelapse camera using UNIHIKER K10. This compact setup can automatically capture photos at fixed intervals and store them for later processing into a timelapse video.

I received the UNIHIKER K10 through the DFRobot Explore AI with K10 contest a few months ago, but only recently got time to work with it. I decided to start with a simple timelapse camera project.

This project is a good introduction to the UNIHIKER K10 and shows why it’s a useful board for many future projects.

Supplies

Hardware:

  1. UNIHIKER K10 Board (DFROBOT)
  2. MicroSD Card (FAT32 formatted)
  3. USB-C Cable

Software:

  1. Arduino IDE (Version 2.0 or higher recommended)
  2. Libraries: TFT_eSPI (for the display) and the unihiker_k10 hardware support library.

(Optional)

  1. Tripod or enclosure
  2. 3D-printed case

Download the Case.stl file for 3D printing

Step 1: Introduction to UNIHIKER K10

For this timelapse camera project, the UNIHIKER K10 is a solid choice because all the required hardware is already built into one board. A timelapse camera needs a camera module, a capable processor, basic controls, a display, and storage—and the K10 has everything.

The K10 is built around the ESP32-S3 Xtensa LX7chip and comes with:

  1. 16 MB flash memory
  2. 512 KB SRAM
  3. 2 MP camera
  4. 2.8-inch, 240×320 color display
  5. 2.4G Wi-Fi
  6. Bluetooth 5.0
  7. MicroSD card slot
  8. Support for multiple environments: Arduino IDE, Thonny IDE, PlatformIO, and Mind+

In short, it’s an all-in-one solution. Since no extra wiring or modules are needed, the setup stays simple and reliable, making the UNIHIKER K10 beginner-friendly and well suited for a DIY timelapse camera build.

Step 2: : Install the Unihiker K10 Board in Arduino IDE

Before you begin, ensure you are using Arduino IDE version 2.3.7 or newer.

Open the Arduino IDE and go to File → Preferences. In the Additional Boards Manager URLs field, paste the following link:

https://downloadcd.dfrobot.com.cn/UNIHIKER/package_unihiker_index.json

Click OK to save the settings.

Next, navigate to Tools → Board → Boards Manager. In the search bar, type Unihiker and install the Unihiker board package.

After the installation is complete, go to Tools → Board and select Unihiker K10 from the list.

Once the board is installed and selected, the Arduino IDE is ready to upload code to the UNIHIKER K10.

Step 3: Programming

In this step, we upload the firmware that controls the timelapse camera. The code provides a simple on-screen menu to select camera mode, resolution, and capture interval. It also handles image capture, file naming, and saving photos to the microSD card automatically.

The complete code is provided below for reference.

Note: The code is long because it includes a full UI, menu system, and recording logic.

/******************************************************
* UNIHIKER K10 Time-Lapse Camera
* --------------------------------
* Features:
* - Stream camera to display
* - Capture time-lapse images to SD card
* - Selectable resolution
*
* Hardware: UNIHIKER K10 (ESP32-S3)
* By Rau7han
*******************************************************/


#include "unihiker_k10.h"

// ============================================================
// CAMERA PINS (K10 - DO NOT CHANGE)
// ============================================================
#define CAM_PWDN -1
#define CAM_RESET -1
#define CAM_XCLK 7
#define CAM_SIOD 47
#define CAM_SIOC 48
#define CAM_Y9 6
#define CAM_Y8 15
#define CAM_Y7 16
#define CAM_Y6 18
#define CAM_Y5 9
#define CAM_Y4 11
#define CAM_Y3 10
#define CAM_Y2 8
#define CAM_VSYNC 4
#define CAM_HREF 5
#define CAM_PCLK 17

// ============================================================
// CONFIG
// ============================================================
#define APP_VERSION "v3.0"
#define APP_AUTHOR "Rau7han"
#define JPG_QUALITY 50
#define EXIT_HOLD_MS 800
#define UI_REFRESH_MS 800

// ============================================================
// COLORS
// ============================================================
#define C_BG 0x0000
#define C_CARD 0x10A2
#define C_CARD_LIGHT 0x2124
#define C_BORDER 0x4228
#define C_ACCENT 0x07FF
#define C_CYAN 0x07FF
#define C_WHITE 0xFFFF
#define C_GRAY 0x8410
#define C_DARK_GRAY 0x4208
#define C_YELLOW 0xFFE0
#define C_GREEN 0x07E0
#define C_RED 0xF800
#define C_MAGENTA 0xF81F

// ============================================================
// SCREEN
// ============================================================
#define SCR_W 240
#define SCR_H 320

// ============================================================
// STATES
// ============================================================
enum State {
S_SPLASH,
S_MENU,
S_RES,
S_HRS,
S_MIN,
S_SEC,
S_CONFIRM,
S_STREAM,
S_REC,
S_DONE,
S_ERR
};

// ============================================================
// RESOLUTIONS
// ============================================================
struct ResInfo {
const char* name;
const char* detail;
framesize_t size;
};

const ResInfo RES[] = {
{"QVGA", "240x320 - Fast", FRAMESIZE_QVGA},
{"VGA", "640x480 - Good", FRAMESIZE_SVGA},
{"SVGA", "800x600 - Better", FRAMESIZE_XGA},
{"HD", "1280x720 - HD", FRAMESIZE_SXGA},
{"SXGA", "1280x1024 - Best", FRAMESIZE_UXGA}
};
#define NUM_RES 5

// ============================================================
// GLOBALS
// ============================================================
UNIHIKER_K10 k10;
TFT_eSPI tft = TFT_eSPI();

// Buttons
volatile bool flagA = false;
volatile bool flagB = false;
uint32_t bothStart = 0;

// State
State st = S_SPLASH;
bool needDraw = true;
uint32_t stTime = 0;
uint32_t lastDraw = 0;

// Settings
int selMode = 0;
int selRes = 1;
int valH = 0, valM = 0, valS = 10;
uint32_t interval = 10000;

// Capture
uint32_t capStart = 0;
uint32_t nextCap = 0;
uint32_t imgNum = 0;
uint32_t imgBase = 0;
uint32_t totalKB = 0;
int errCnt = 0;

// Camera
bool camOn = false;
uint8_t* jpgBuf = NULL;
size_t jpgSize = 0;

// SD
bool sdOn = false;

// Buffers
char msgErr[40] = {0};
char msgFile[20] = {0};

// Stream
bool streaming = false;

// ============================================================
// ISR
// ============================================================
void IRAM_ATTR onA() { flagA = true; }
void IRAM_ATTR onB() { flagB = true; }

// ============================================================
// BUTTONS
// ============================================================
bool pressA() {
if (flagA) { flagA = false; return true; }
return false;
}

bool pressB() {
if (flagB) { flagB = false; return true; }
return false;
}

void clearBtn() {
flagA = false;
flagB = false;
bothStart = 0;
}

bool checkExit() {
if (flagA && flagB) {
if (bothStart == 0) bothStart = millis();
if (millis() - bothStart >= EXIT_HOLD_MS) {
flagA = false;
flagB = false;
bothStart = 0;
return true;
}
} else {
bothStart = 0;
}
return false;
}

// ============================================================
// CLEANUP
// ============================================================
void stopCam() {
if (jpgBuf) { free(jpgBuf); jpgBuf = NULL; jpgSize = 0; }
if (camOn) { esp_camera_deinit(); camOn = false; }
}

void stopStream() {
streaming = false;
k10.initScreen(2);
tft.init();
tft.setRotation(2);
tft.setTextSize(1);
delay(50);
}

void cleanup() {
if (streaming) stopStream();
stopCam();
imgNum = 0;
totalKB = 0;
errCnt = 0;
msgFile[0] = 0;
msgErr[0] = 0;
}

void goHome() {
cleanup();
st = S_MENU;
needDraw = true;
stTime = millis();
clearBtn();
}

void goTo(State s) {
st = s;
needDraw = true;
stTime = millis();
clearBtn();
}

// ============================================================
// DRAWING HELPERS
// ============================================================
void card(int x, int y, int w, int h, uint16_t fill, uint16_t edge, bool shadow = false) {
if (shadow) {
tft.fillRoundRect(x + 2, y + 2, w, h, 8, C_DARK_GRAY);
}
tft.fillRoundRect(x, y, w, h, 8, fill);
tft.drawRoundRect(x, y, w, h, 8, edge);
}

void topBar(const char* txt, uint16_t accent = C_CYAN) {
tft.fillRect(0, 0, SCR_W, 38, C_CARD);
tft.drawFastHLine(0, 37, SCR_W, accent);
tft.drawFastHLine(0, 38, SCR_W, accent);
tft.setTextColor(C_DARK_GRAY, C_CARD);
tft.drawString(txt, 10, 11, 4);
tft.setTextColor(C_WHITE, C_CARD);
tft.drawString(txt, 8, 9, 4);
}

void botBar(const char* a, const char* b, bool showExit = false) {
int y = SCR_H - 38;
tft.fillRect(0, y, SCR_W, 38, C_CARD);
tft.drawFastHLine(0, y, SCR_W, C_BORDER);
if (a && a[0]) {
tft.fillRoundRect(8, y + 8, 22, 22, 4, C_YELLOW);
tft.setTextColor(C_BG, C_YELLOW);
tft.drawString("A", 14, y + 12, 2);
tft.setTextColor(C_GRAY, C_CARD);
tft.drawString(a, 35, y + 12, 2);
}
if (b && b[0]) {
tft.fillRoundRect(125, y + 8, 22, 22, 4, C_GREEN);
tft.setTextColor(C_BG, C_GREEN);
tft.drawString("B", 131, y + 12, 2);
tft.setTextColor(C_GRAY, C_CARD);
tft.drawString(b, 152, y + 12, 2);
}
if (showExit) {
tft.setTextColor(C_DARK_GRAY, C_CARD);
tft.drawString("A+B Exit", 175, y + 28, 1);
}
}

void menuOpt(int y, const char* txt, const char* sub, bool sel) {
uint16_t bg = sel ? C_CARD_LIGHT : C_BG;
uint16_t edge = sel ? C_CYAN : C_BORDER;
card(8, y, SCR_W - 16, sub ? 42 : 32, bg, edge, sel);
int dotX = 22;
int dotY = y + (sub ? 21 : 16);
if (sel) {
tft.fillCircle(dotX, dotY, 6, C_CYAN);
tft.fillCircle(dotX, dotY, 3, C_WHITE);
} else {
tft.drawCircle(dotX, dotY, 5, C_BORDER);
}
tft. setTextColor(sel ? C_YELLOW : C_WHITE, bg);
tft.drawString(txt, 38, y + 8, 2);
if (sub) {
tft.setTextColor(C_GRAY, bg);
tft.drawString(sub, 38, y + 26, 1);
}
}

void infoCard(int y, const char* lbl, const char* val, uint16_t col, bool highlight = false) {
uint16_t bg = highlight ? C_CARD_LIGHT : C_CARD;
card(8, y, SCR_W - 16, 38, bg, highlight ? col : C_BORDER);
tft.setTextColor(C_GRAY, bg);
tft.drawString(lbl, 16, y + 5, 1);
tft.setTextColor(col, bg);
tft.drawString(val, 16, y + 19, 2);
if (highlight) {
tft.fillRect(8, y, 3, 38, col);
}
}

void progressBar(int y, float pct, uint16_t col = C_CYAN) {
pct = constrain(pct, 0, 1);
int w = SCR_W - 32;
int filled = (int)(w * pct);
tft.fillRoundRect(16, y, w, 10, 5, C_DARK_GRAY);
if (filled > 0) {
tft.fillRoundRect(16, y, filled, 10, 5, col);
tft.drawFastHLine(16, y + 2, max(1, filled - 4), C_WHITE);
}
}

void valueBox(int y, const char* lbl, int val, const char* unit, int maxVal) {
card(8, y, SCR_W - 16, 80, C_CARD, C_CYAN, true);
tft.setTextColor(C_GRAY, C_CARD);
tft.drawString(lbl, 16, y + 8, 2);
char buf[16];
sprintf(buf, "%d", val);
tft.setTextColor(C_YELLOW, C_CARD);
tft.setTextDatum(MC_DATUM);
tft.drawString(buf, SCR_W / 2, y + 45, 6);
tft.setTextDatum(TL_DATUM);
tft.setTextColor(C_GRAY, C_CARD);
tft.drawString(unit, SCR_W / 2 + 30, y + 40, 2);
sprintf(buf, "0 - %d", maxVal);
tft.setTextColor(C_DARK_GRAY, C_CARD);
tft.drawString(buf, 16, y + 65, 1);
}

// ============================================================
// BEAUTIFUL SPLASH SCREEN WITH "By Rau7han"
// ============================================================
void drawSplash() {
tft.fillScreen(C_BG);
int cx = SCR_W / 2;
int iconY = 55;
// Camera body with shadow
tft.fillRoundRect(cx - 42, iconY + 2, 84, 58, 10, C_DARK_GRAY);
tft.fillRoundRect(cx - 44, iconY, 88, 60, 12, C_CARD_LIGHT);
tft.drawRoundRect(cx - 44, iconY, 88, 60, 12, C_CYAN);
// Lens rings
tft.fillCircle(cx, iconY + 30, 22, C_CYAN);
tft.fillCircle(cx, iconY + 30, 18, C_CARD);
tft.fillCircle(cx, iconY + 30, 14, C_CYAN);
tft.fillCircle(cx, iconY + 30, 10, C_CARD_LIGHT);
tft.fillCircle(cx, iconY + 30, 5, C_WHITE);
// Flash
tft.fillRoundRect(cx - 32, iconY + 8, 18, 12, 3, C_YELLOW);
// Shutter button
tft.fillCircle(cx + 30, iconY + 10, 6, C_RED);
// App name with glow effect
tft.setTextColor(C_CYAN, C_BG);
tft.setTextDatum(MC_DATUM);
tft.drawString("Time-Lapse", cx + 1, 146, 4);
tft.setTextColor(C_WHITE, C_BG);
tft.drawString("Time-Lapse", cx, 145, 4);
tft.setTextColor(C_CYAN, C_BG);
tft.drawString("Camera", cx + 1, 176, 4);
tft.setTextColor(C_WHITE, C_BG);
tft.drawString("Camera", cx, 175, 4);
// Version badge
card(cx - 30, 200, 60, 24, C_CARD, C_CYAN);
tft.setTextColor(C_CYAN, C_CARD);
tft.drawString(APP_VERSION, cx, 212, 2);
// ========== AUTHOR SECTION ==========
int authY = 245;
// Decorative line with center dot
tft.drawFastHLine(40, authY - 10, SCR_W - 80, C_BORDER);
tft.fillCircle(cx, authY - 10, 3, C_CYAN);
// "Created by" text
tft.setTextColor(C_GRAY, C_BG);
tft.drawString("Created by", cx, authY + 5, 2);
// Author name - PROMINENT with shadow effect
tft.setTextColor(C_MAGENTA, C_BG);
tft.drawString(APP_AUTHOR, cx + 1, authY + 31, 4);
tft.setTextColor(C_YELLOW, C_BG);
tft.drawString(APP_AUTHOR, cx, authY + 30, 4);
// Bottom decorative line
tft.drawFastHLine(40, authY + 55, SCR_W - 80, C_BORDER);
// ========== LOADING ANIMATION ==========
tft.setTextColor(C_DARK_GRAY, C_BG);
tft.drawString("Loading", cx, SCR_H - 25, 1);
tft.setTextDatum(TL_DATUM);
// Animated loading dots
for (int i = 0; i < 3; i++) {
delay(300);
int dotX = cx + 25 + (i * 8);
tft.fillCircle(dotX, SCR_H - 22, 3, C_CYAN);
}
delay(400);
}

// ============================================================
// SCREENS (Only 2 menu options: Stream & Time-Lapse)
// ============================================================
void scrMenu() {
tft.fillScreen(C_BG);
topBar("Mode Select");
botBar("Change", "Select", false);
// Only 2 options
menuOpt(60, "Live Stream", "Preview camera on screen", selMode == 0);
menuOpt(115, "Time-Lapse", "Capture to SD card", selMode == 1);
// Info card
card(8, 175, SCR_W - 16, 80, C_CARD, C_BORDER);
tft.setTextColor(C_CYAN, C_CARD);
tft.drawString("Info", 16, 182, 2);
tft.setTextColor(C_GRAY, C_CARD);
if (selMode == 0) {
tft.drawString("Live preview on screen", 16, 210, 1);
tft.drawString("No images saved", 16, 225, 1);
tft.drawString("Press A+B to exit stream", 16, 240, 1);
} else {
tft.drawString("Capture at intervals", 16, 210, 1);
tft.drawString("Save JPEG to SD card", 16, 225, 1);
tft.drawString("Press A+B to stop recording", 16, 240, 1);
}
}

void scrRes() {
tft.fillScreen(C_BG);
topBar("Resolution");
botBar("Change", "Select", true);
for (int i = 0; i < NUM_RES; i++) {
menuOpt(45 + i * 45, RES[i].name, RES[i].detail, i == selRes);
}
}

void scrTime(const char* title, const char* lbl, int val, int mx) {
tft.fillScreen(C_BG);
topBar(title);
botBar("+1", "OK", true);
valueBox(70, lbl, val, "", mx);
card(8, 170, SCR_W - 16, 55, C_CARD, C_BORDER);
tft.setTextColor(C_GRAY, C_CARD);
tft.drawString("Interval Preview", 16, 178, 1);
char buf[20];
sprintf(buf, "%02d:%02d:%02d", valH, valM, valS);
tft.setTextColor(C_CYAN, C_CARD);
tft.drawString(buf, 16, 195, 4);
}

void scrConfirm() {
tft.fillScreen(C_BG);
topBar("Confirm", C_GREEN);
botBar("Back", "Start!", true);
char buf[32];
infoCard(48, "Resolution", RES[selRes].name, C_CYAN, true);
sprintf(buf, "%02d:%02d:%02d", valH, valM, valS);
infoCard(92, "Interval", buf, C_CYAN, true);
infoCard(136, "SD Card", sdOn ? "Ready" : "NOT FOUND!", sdOn ? C_GREEN : C_RED, ! sdOn);
sprintf(buf, "img%05lu.jpg", imgBase + 1);
infoCard(180, "First Image", buf, C_GRAY, false);
if (! sdOn) {
card(8, 230, SCR_W - 16, 35, C_RED, C_RED);
tft.setTextColor(C_WHITE, C_RED);
tft.setTextDatum(MC_DATUM);
tft.drawString("Insert SD card!", SCR_W/2, 247, 2);
tft.setTextDatum(TL_DATUM);
}
}

void scrRec() {
tft.fillScreen(C_BG);
topBar("Recording", C_RED);
tft.fillCircle(SCR_W - 25, 19, 8, C_RED);
tft.fillCircle(SCR_W - 25, 19, 4, C_WHITE);
tft.fillRect(0, SCR_H - 30, SCR_W, 30, C_CARD);
tft.setTextColor(C_YELLOW, C_CARD);
tft.setTextDatum(MC_DATUM);
tft.drawString("Hold A+B to Stop", SCR_W/2, SCR_H - 15, 2);
tft.setTextDatum(TL_DATUM);
}

void updateRec() {
uint32_t el = (millis() - capStart) / 1000;
int h = el / 3600;
int m = (el % 3600) / 60;
int s = el % 60;
int32_t rem = (nextCap - millis()) / 1000;
if (rem < 0) rem = 0;
float pct = 1.0f - (float)(nextCap - millis()) / interval;
char buf[24];
sprintf(buf, "%02d:%02d:%02d", h, m, s);
infoCard(48, "Elapsed", buf, C_CYAN, true);
sprintf(buf, "%lu", imgNum);
infoCard(92, "Images Captured", buf, C_GREEN, true);
sprintf(buf, "%ld sec", rem);
infoCard(136, "Next Capture", buf, C_YELLOW, false);
progressBar(180, pct, C_CYAN);
if (msgFile[0]) {
infoCard(195, "Last File", msgFile, C_GRAY, false);
}
if (totalKB > 1024) {
sprintf(buf, "%.1f MB", (float)totalKB / 1024);
} else {
sprintf(buf, "%lu KB", totalKB);
}
infoCard(239, "Total Size", buf, C_GRAY, false);
// Blink record indicator
static uint32_t lastBlink = 0;
static bool vis = true;
if (millis() - lastBlink > 500) {
vis = !vis;
lastBlink = millis();
}
tft.fillCircle(SCR_W - 25, 19, 8, vis ? C_RED : C_CARD);
if (vis) tft.fillCircle(SCR_W - 25, 19, 4, C_WHITE);
}

void scrDone() {
tft.fillScreen(C_BG);
topBar("Complete", C_GREEN);
botBar("", "Home", false);
int cx = SCR_W / 2;
tft.fillCircle(cx, 85, 30, C_GREEN);
tft.setTextColor(C_BG, C_GREEN);
tft.setTextDatum(MC_DATUM);
tft.drawString("OK", cx, 85, 4);
tft.setTextDatum(TL_DATUM);
char buf[24];
sprintf(buf, "%lu images", imgNum);
infoCard(130, "Captured", buf, C_CYAN, true);
if (totalKB > 1024) {
sprintf(buf, "%. 1f MB", (float)totalKB / 1024);
} else {
sprintf(buf, "%lu KB", totalKB);
}
infoCard(174, "Total Size", buf, C_GRAY, false);
}

void scrErr() {
tft.fillScreen(C_BG);
topBar("Error", C_RED);
botBar("", "Retry", false);
int cx = SCR_W / 2;
tft.fillCircle(cx, 85, 30, C_RED);
tft.setTextColor(C_WHITE, C_RED);
tft.setTextDatum(MC_DATUM);
tft.drawString("!", cx, 85, 4);
tft.setTextDatum(TL_DATUM);
card(8, 130, SCR_W - 16, 60, C_CARD, C_RED);
tft.setTextColor(C_WHITE, C_CARD);
tft.drawString(msgErr, 16, 155, 2);
}

// ============================================================
// SD & CAMERA FUNCTIONS
// ============================================================
bool sdInit() {
if (!SD.begin()) { strcpy(msgErr, "SD mount failed"); return false; }
if (SD.cardType() == CARD_NONE) { strcpy(msgErr, "No SD card"); return false; }
return true;
}

uint32_t sdScan() {
uint32_t mx = 0;
File root = SD.open("/");
if (! root) return 0;
while (true) {
File f = root.openNextFile();
if (!f) break;
const char* n = f.name();
int len = strlen(n);
if (len >= 12 && n[0] == 'i' && n[1] == 'm' && n[2] == 'g') {
if (n[len-4] == '.' && n[len-3] == 'j' && n[len-2] == 'p' && n[len-1] == 'g') {
char num[6] = {0};
for (int i = 0; i < 5; i++) num[i] = n[3 + i];
uint32_t v = atoi(num);
if (v > mx) mx = v;
}
}
f.close();
}
root.close();
return mx;
}

bool sdSave(uint8_t* buf, size_t len, uint32_t num) {
char path[24];
sprintf(path, "/img%05lu.jpg", num);
File f = SD.open(path, FILE_WRITE);
if (!f) { strcpy(msgErr, "File create failed"); return false; }
size_t w = f.write(buf, len);
f.close();
if (w != len) { strcpy(msgErr, "Write failed"); return false; }
for (int i = 0; i < 18; i++) msgFile[i] = path[i + 1];
msgFile[18] = 0;
totalKB += len / 1024;
return true;
}

bool camInit(framesize_t sz) {
camera_config_t c;
c. ledc_channel = LEDC_CHANNEL_0;
c.ledc_timer = LEDC_TIMER_0;
c.pin_d0 = CAM_Y2; c.pin_d1 = CAM_Y3; c.pin_d2 = CAM_Y4; c.pin_d3 = CAM_Y5;
c.pin_d4 = CAM_Y6; c. pin_d5 = CAM_Y7; c.pin_d6 = CAM_Y8; c.pin_d7 = CAM_Y9;
c.pin_xclk = CAM_XCLK; c.pin_pclk = CAM_PCLK;
c.pin_vsync = CAM_VSYNC; c.pin_href = CAM_HREF;
c. pin_sscb_sda = CAM_SIOD; c.pin_sscb_scl = CAM_SIOC;
c. pin_pwdn = CAM_PWDN; c.pin_reset = CAM_RESET;
c.xclk_freq_hz = 10000000;
c.pixel_format = PIXFORMAT_RGB565;
c.frame_size = sz;
c. grab_mode = CAMERA_GRAB_LATEST;
c.fb_count = 2;
if (esp_camera_init(&c) != ESP_OK) {
strcpy(msgErr, "Camera init failed");
return false;
}
sensor_t* s = esp_camera_sensor_get();
if (s) s->set_hmirror(s, 1);
camOn = true;
return true;
}

bool camCapture() {
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) { errCnt++; return false; }
bool ok = fmt2jpg(fb->buf, fb->len, fb->width, fb->height, fb->format, JPG_QUALITY, &jpgBuf, &jpgSize);
esp_camera_fb_return(fb);
if (!ok) { errCnt++; return false; }
errCnt = 0;
return true;
}

void camFreeJpg() {
if (jpgBuf) { free(jpgBuf); jpgBuf = NULL; jpgSize = 0; }
}

// ============================================================
// STATE HANDLERS
// ============================================================
void hSplash() {
if (needDraw) { drawSplash(); needDraw = false; }
if (millis() - stTime > 2500 || pressA() || pressB()) {
goTo(S_MENU);
}
}

void hMenu() {
if (needDraw) { scrMenu(); needDraw = false; }
if (pressA()) { selMode = (selMode + 1) % 2; scrMenu(); } // Only 2 options
if (pressB()) {
if (selMode == 0) goTo(S_STREAM);
else goTo(S_RES);
}
}

void hRes() {
if (checkExit()) { goHome(); return; }
if (needDraw) { scrRes(); needDraw = false; }
if (pressA()) { selRes = (selRes + 1) % NUM_RES; scrRes(); }
if (pressB()) { goTo(S_HRS); }
}

void hHrs() {
if (checkExit()) { goHome(); return; }
if (needDraw) { scrTime("Hours", "Hours", valH, 24); needDraw = false; }
if (pressA()) { valH = (valH + 1) % 25; scrTime("Hours", "Hours", valH, 24); }
if (pressB()) { goTo(S_MIN); }
}

void hMin() {
if (checkExit()) { goHome(); return; }
if (needDraw) { scrTime("Minutes", "Minutes", valM, 59); needDraw = false; }
if (pressA()) { valM = (valM + 1) % 60; scrTime("Minutes", "Minutes", valM, 59); }
if (pressB()) { goTo(S_SEC); }
}

void hSec() {
if (checkExit()) { goHome(); return; }
if (needDraw) { scrTime("Seconds", "Seconds", valS, 59); needDraw = false; }
if (pressA()) { valS = (valS + 1) % 60; scrTime("Seconds", "Seconds", valS, 59); }
if (pressB()) {
interval = 1000UL * (3600UL * valH + 60UL * valM + valS);
if (interval < 1000) { interval = 1000; valS = 1; valM = 0; valH = 0; }
sdOn = sdInit();
if (sdOn) imgBase = sdScan();
goTo(S_CONFIRM);
}
}

void hConfirm() {
if (checkExit()) { goHome(); return; }
if (needDraw) { scrConfirm(); needDraw = false; }
if (pressA()) { goTo(S_MENU); }
if (pressB()) {
if (! sdOn) {
sdOn = sdInit();
if (sdOn) imgBase = sdScan();
scrConfirm();
if (! sdOn) { strcpy(msgErr, "SD not ready"); goTo(S_ERR); }
} else {
if (camInit(RES[selRes].size)) {
imgNum = 0; totalKB = 0; errCnt = 0; msgFile[0] = 0;
capStart = millis(); nextCap = capStart;
goTo(S_REC);
} else { goTo(S_ERR); }
}
}
}

void hStream() {
if (checkExit()) { stopStream(); goHome(); return; }
if (needDraw) { streaming = true; k10. initBgCamerImage(); k10.setBgCamerImage(); needDraw = false; }
flagA = false; flagB = false;
}

void hRec() {
if (checkExit()) { stopCam(); goTo(S_DONE); return; }
if (needDraw) { scrRec(); updateRec(); needDraw = false; }
uint32_t now = millis();
if (now >= nextCap) {
if (camCapture()) {
uint32_t n = imgBase + imgNum + 1;
if (sdSave(jpgBuf, jpgSize, n)) imgNum++;
else errCnt++;
camFreeJpg();
}
nextCap += interval;
if (nextCap < now) nextCap = now + interval;
if (errCnt >= 5) { strcpy(msgErr, "Too many errors"); stopCam(); goTo(S_ERR); return; }
}
if (now - lastDraw >= UI_REFRESH_MS) { updateRec(); lastDraw = now; }
flagA = false; flagB = false;
}

void hDone() {
if (needDraw) { scrDone(); needDraw = false; }
if (pressB()) { goTo(S_MENU); }
}

void hErr() {
if (needDraw) { scrErr(); needDraw = false; }
if (pressB()) { msgErr[0] = 0; errCnt = 0; goTo(S_MENU); }
}

// ============================================================
// MAIN
// ============================================================
void run() {
switch (st) {
case S_SPLASH: hSplash(); break;
case S_MENU: hMenu(); break;
case S_RES: hRes(); break;
case S_HRS: hHrs(); break;
case S_MIN: hMin(); break;
case S_SEC: hSec(); break;
case S_CONFIRM: hConfirm(); break;
case S_STREAM: hStream(); break;
case S_REC: hRec(); break;
case S_DONE: hDone(); break;
case S_ERR: hErr(); break;
}
}

void setup() {
Serial.begin(115200);
Serial.println("\n=============================");
Serial.println(" K10 Time-Lapse Camera");
Serial.printf(" %s by %s\n", APP_VERSION, APP_AUTHOR);
Serial.println("=============================\n");
k10.begin();
k10.initScreen(2);
tft.init();
tft.setRotation(2);
tft.setTextSize(1);
tft.fillScreen(C_BG);
k10.buttonA->setPressedCallback(onA);
k10.buttonB->setPressedCallback(onB);
clearBtn();
Serial.println("Ready!\n");
}

void loop() {
run();
delay(10);
}


After uploading the code, restart the board. You should see the menu appear on the screen, allowing you to start live preview or begin timelapse recording.

Step 4: Testing and Demonstration

After uploading the code and powering on the UNIHIKER K10, the device starts with a custom splash screen showing a cute timelapse camera icon along with the app name and author. This confirms that the firmware has loaded correctly.

Once the splash screen finishes, the Mode Select menu appears on the display. Using Button A, you can switch between the two modes:

  1. Live Stream – shows a real-time camera preview on the screen
  2. Time-Lapse – captures images at fixed intervals and saves them to the microSD card

Step 5: Time-Lapse Mode Testing

Press Button B to select a mode. In Time-Lapse mode, the screen guides you step by step to choose the image resolution.

Available image resolutions:

  1. QVGA (240×320) – Fast and stable (recommended)
  2. VGA (640×480) – Balanced quality
  3. SVGA (800×600) – Higher detail
  4. HD (1280×720) – High resolution
  5. SXGA (1280×1024) – Maximum quality

Note:

For long timelapse recordings, it is recommended to use lower resolutions like QVGA or VGA. Higher resolutions such as HD or SXGA can slow down capture, increase heat, and use more storage.


  1. Select Time-Lapse mode using Button B
  2. Choose the desired resolution
  3. Set the hours, minutes, and seconds for the capture interval
  4. Confirm the settings on the confirmation screen
  5. Recording starts automatically
  6. Hold Button A + B together to stop recording safely


Step 6: Time Lapse Results: Example Videos

After testing the camera, I used it to record real timelapse videos in two different scenarios to demonstrate how it performs in practical use.

1. 3D Printing Timelapse

The camera was placed facing a 3D printer bed to capture the printing process from start to finish. Using a short capture interval and a lower resolution helped keep the recording smooth and stable.

2. Nature Timelapse

For outdoor testing, the camera was used to capture slow natural changes such as moving light shifts, or plant movement. A longer capture interval was used in this case to create a smooth timelapse.

These examples show that this camera can be used both indoors and outdoors for different types of long-duration recordings.