Clock & Event Visualizer (ESP32 and Google Calendar)

by tootarU in Circuits > Clocks

33 Views, 0 Favorites, 0 Comments

Clock & Event Visualizer (ESP32 and Google Calendar)

PXL_20251130_173147783.jpg

I wanted a way to visualize my day and upcoming month at a glance without looking at a screen. I built this "Linear Clock" and Calendar bar using an ESP32, some LED strips, and 3D printed parts.

It features three rows:

  1. Hours (0-23): Shows the current time in 24-hour format.
  2. Minutes: Shows the progress of the current hour.
  3. Calendar: Shows the next 24 days.

It connects to Google Calendar. If I have an event, the corresponding LED on the clock lights up in the color of that event. It attaches to my magnetic whiteboard, making it a perfect functional addition to my workspace.

Supplies

PXL_20251109_104821321.jpg
PXL_20251109_110446650.jpg
PXL_20251109_121312377.jpg

Electronics:

  1. ESP32-C3 SuperMini (or any standard ESP32).
  2. 12V LED Strip (WS2811): Important: I used the type where 3 LEDs act as 1 pixel (controlled by one address). This gives it a larger light footprint per "dot."
  3. 12V Power Supply
  4. DC-DC Buck Converter: To step down 12V to 5V for the ESP32.
  5. Wires, Solder, Heat shrink.

Housing:

  1. 3D Printed Housing and Electronics Box.
  2. Baking Paper: For diffusion.
  3. Magnets: Glued to the back for mounting.
  4. Superglue.

Tools:

  1. Soldering Iron.
  2. 3D Printer.
  3. Wire strippers.
  4. Computer with Arduino IDE.

The Logic and Design

PXL_20251109_121549184.jpg
PXL_20251109_125526709.jpg
PXL_20251109_144539520.jpg
PXL_20251109_144437206.jpg
PXL_20251123_113137562.jpg

The display consists of three strips, each with 24 addressable "pixels" (groups of 3 LEDs).

  1. Top Row: The 24-hour clock. Blue indicates the time passed. If there is a calendar event at 14:00, the 14th LED will light up in the event's color.
  2. Middle Row: The minutes. Shows how far into the hour we are.
  3. Bottom Row: The next 24 days. Any events in the coming weeks show up here.

3D Printing and Diffusion

PXL_20251123_151317442.jpg
PXL_20251130_131153389.jpg
PXL_20251130_162211001.jpg

I designed a 3 channel holder for the LEDs.

The Diffuser Evolution:

Originally, I printed a thin white PLA cover to diffuse the light. However, I didn't like how it looked.

The Solution: I added a sheet of ordinary baking paper held in place by a 3D printed frame/bracket. This scatters the light better.

Wiring the Electronics

PXL_20251123_113241007.jpg
PXL_20251130_123345181.jpg
PXL_20251130_123418022.jpg

Since the LED strip is 12V but the ESP32 requires 5V, we need a split power setup.

  1. Power Supply: The main 12V PSU connects directly to the LED strips.
  2. Buck Converter: The 12V also goes into the input of the buck converter. Tune the output to 5V.
  3. ESP32: Connect the 5V output of the buck converter to the 5V pin on the ESP32.
  4. Data Lines:
  5. Hour Strip Data -> ESP32 GPIO 4
  6. Minute Strip Data -> ESP32 GPIO 6
  7. Calendar Strip Data -> ESP32 GPIO 7
  8. Ground: Crucial: Ensure the Ground (GND) from the 12V PSU and the ESP32 are connected together.

I designed a separate box to house the Power Supply and the Buck Converter/ESP32 combo to keep the main display slim.

Google Apps Script (The Backend)

Screenshot_20251130_174123.png
Screenshot_20251130_173932.png

Step 5: Google Apps Script

To get data from Google Calendar to the ESP32 without a screen, we use a Google Apps Script. This script checks your calendar and creates a "JSON" file that the ESP32 can read.

  1. Go to script.google.com and create a New Project.
  2. Paste the following code into the editor (delete any existing code).


// This is the official mapping of Google Calendar color IDs to their hex codes.
const COLOR_ID_MAP = {
"1": "#a4bdfc", // Blue
"2": "#7ae7bf", // Green
"3": "#dbadff", // Purple
"4": "#ff887c", // Red
"5": "#fbd75b", // Yellow
"6": "#ffb878", // Orange
"7": "#46d6db", // Cyan
"8": "#e1e1e1", // Gray
"9": "#5484ed", // Bold Blue
"10": "#51b749", // Bold Green
"11": "#dc2127" // Bold Red
};

function doGet() {
// --- USER CONFIGURATION ---
// ENTER YOUR GMAIL ADDRESS BELOW
const CALENDAR_ID = "YOUR_EMAIL_HERE@gmail.com";
const DAYS_IN_FUTURE = 24;
const FALLBACK_COLOR = "#888888"; // grey

try {
const calendar = CalendarApp.getCalendarById(CALENDAR_ID);
if (!calendar) {
Logger.log('Error: Calendar not found for ID: ' + CALENDAR_ID);
return ContentService.createTextOutput(JSON.stringify({ error: "Calendar not found" })).setMimeType(ContentService.MimeType.JSON);
}

const now = new Date();
const futureDate = new Date();
futureDate.setDate(now.getDate() + DAYS_IN_FUTURE);
const events = calendar.getEvents(now, futureDate);

const eventList = events.map(event => {
let eventColorId = event.getColor();
let finalHexColor;

if (eventColorId) {
// The event has a specific palette color. Look up its hex code.
finalHexColor = COLOR_ID_MAP[eventColorId] || calendar.getColor() || FALLBACK_COLOR;
} else {
// The event uses the calendar's default color.
finalHexColor = calendar.getColor() || FALLBACK_COLOR;
}

return {
title: event.getTitle(),
startTime: event.getStartTime().toISOString(),
endTime: event.getEndTime().toISOString(),
color: finalHexColor // This is now guaranteed to be a hex code
};
});

const jsonOutput = JSON.stringify(eventList);
return ContentService.createTextOutput(jsonOutput).setMimeType(ContentService.MimeType.JSON);

} catch (e) {
Logger.log('An error occurred: ' + e.message);
return ContentService.createTextOutput(JSON.stringify({ error: e.message })).setMimeType(ContentService.MimeType.JSON);
}
}


  1. Important: Change YOUR_EMAIL_HERE@gmail.com to your actual Google Calendar email address.
  2. Click Deploy -> New Deployment.
  3. Select Type: Web App.
  4. Description: "ESP32 Calendar".
  5. Who has access: Set this to Anyone (This is required so the ESP32 can access it without a login prompt).
  6. Click Deploy.
  7. Copy the Web App URL. You will need this for the next step.

The ESP32 Code

  1. Open Arduino IDE.
  2. Install the following libraries via the Library Manager:
  3. Adafruit_NeoPixel
  4. ArduinoJson
  5. Copy the code below.

Configuration:

You must change the following lines in the code below to match your setup:

  1. ssid: Your WiFi Name.
  2. password: Your WiFi Password.
  3. scriptUrl: Paste the Google Web App URL you copied in the previous step.
  4. tz_string: This sets your Timezone. The default is set for Central Europe (CET). If you are elsewhere, look up your "POSIX Timezone String" online.

Changing Colors:

If you want to change the default background colors of the clock, look for these lines:

  1. #define TIME_COLOR (Currently Blue)
  2. #define SECONDARY_COLOR (The markers for every 6th pixel)


Code:

#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Adafruit_NeoPixel.h>
#include "time.h"

// =========================================================================
// ========================= USER CONFIGURATION ============================
// =========================================================================

// --- Wi-Fi Credentials ---
const char* ssid = "YOUR_WIFI_SSID_HERE"; // <--- CHANGE THIS
const char* password = "YOUR_WIFI_PASSWORD_HERE"; // <--- CHANGE THIS

// --- Google Script URL ---
// Paste the Web App URL you got from Google Scripts here (keep the quotes)
String scriptUrl = "https://script.google.com/macros/s/..../exec";

// --- Time Zone ---
// Look up "POSIX TZ String" for your city if not in Central Europe
const char* tz_string = "CEST-1CET,M3.2.0/2:00:00,M11.1.0/2:00:00";

// --- LED Strip Configuration ---
#define NUM_LEDS 24
#define HOUR_PIN 4 // Pin connected to Top Row
#define MINUTE_PIN 6 // Pin connected to Middle Row
#define CALENDAR_PIN 7 // Pin connected to Bottom Row
#define ONBOARD_LED 8 // ESP32-C3 SuperMini onboard LED

// Default color for the time display (R, G, B)
#define TIME_COLOR 0, 0, 255 // Blue
#define SECONDARY_COLOR 0, 255, 255 // Cyan (markers)

// Second color interval (Every how many LEDs do we show a marker?)
#define SECOND_COLOR_INTERVAL 6

// How often to fetch calendar data (in milliseconds)
#define CALENDAR_UPDATE_INTERVAL 3600000 // 1 hour

// How often to cycle through event colors (in milliseconds)
#define COLOR_CYCLE_INTERVAL 2000 // 2 seconds per color

// =========================================================================
// ======================== END OF CONFIGURATION ===========================
// =========================================================================

struct LedColors {
uint32_t colors[10];
int count;
LedColors() : count(0) {}
void addColor(uint32_t color) {
if (count < 10) colors[count++] = color;
}
uint32_t getCurrentColor(int cycleIndex) {
if (count == 0) return 0;
return colors[cycleIndex % count];
}
};

int getCycleIndex() {
return (millis() / COLOR_CYCLE_INTERVAL);
}

Adafruit_NeoPixel hourStrip(NUM_LEDS, HOUR_PIN, NEO_BRG + NEO_KHZ800);
Adafruit_NeoPixel minuteStrip(NUM_LEDS, MINUTE_PIN, NEO_BRG + NEO_KHZ800);
Adafruit_NeoPixel calendarStrip(NUM_LEDS, CALENDAR_PIN, NEO_BRG + NEO_KHZ800);

StaticJsonDocument<4096> calendarJson;
unsigned long lastCalendarUpdateTime = 0;
bool calendarDataLoaded = false;

// Robust UTC Time Conversion
time_t convert_utc_tm_to_time_t(struct tm *tm) {
char original_tz_buffer[64] = {0};
char *original_tz = getenv("TZ");
bool had_tz = false;
if (original_tz) {
had_tz = true;
strncpy(original_tz_buffer, original_tz, sizeof(original_tz_buffer) - 1);
}
setenv("TZ", "UTC", 1);
tzset();
time_t utc_time = mktime(tm);
if (had_tz) setenv("TZ", original_tz_buffer, 1);
else unsetenv("TZ");
tzset();
return utc_time;
}

void setup() {
Serial.begin(115200);
pinMode(ONBOARD_LED, OUTPUT);
digitalWrite(ONBOARD_LED, HIGH);

hourStrip.begin(); minuteStrip.begin(); calendarStrip.begin();
hourStrip.setBrightness(50);
minuteStrip.setBrightness(50);
calendarStrip.setBrightness(50);
hourStrip.show(); minuteStrip.show(); calendarStrip.show();

Serial.print("Connecting to WiFi...");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
digitalWrite(ONBOARD_LED, !digitalRead(ONBOARD_LED));
delay(500);
Serial.print(".");
}
Serial.println("\nConnected!");

setenv("TZ", tz_string, 1);
tzset();
configTime(0, 0, "pool.ntp.org");
fetchCalendarData();
}

void loop() {
struct tm timeinfo;
if (getLocalTime(&timeinfo)) {
updateDisplays(&timeinfo);
}

if (millis() - lastCalendarUpdateTime > CALENDAR_UPDATE_INTERVAL) {
fetchCalendarData();
}

if (WiFi.status() != WL_CONNECTED || !calendarDataLoaded) {
digitalWrite(ONBOARD_LED, LOW); // Error indication
} else {
digitalWrite(ONBOARD_LED, HIGH);
}
delay(1000);
}

void updateDisplays(struct tm *timeinfo) {
JsonArray events = calendarJson.as<JsonArray>();
updateHourStrip(timeinfo, events);
updateMinuteStrip(timeinfo, events);
updateCalendarStrip(timeinfo, events);
hourStrip.show(); minuteStrip.show(); calendarStrip.show();
}

void updateHourStrip(struct tm *timeinfo, JsonArray &events) {
hourStrip.clear();
int currentHour = timeinfo->tm_hour;
LedColors ledColors[NUM_LEDS];

for (int i = 0; i <= currentHour; i++) {
if ((i + 1) % SECOND_COLOR_INTERVAL == 0) hourStrip.setPixelColor(i, hourStrip.Color(SECONDARY_COLOR));
else hourStrip.setPixelColor(i, hourStrip.Color(TIME_COLOR));
}

for (JsonObject event : events) {
const char* startTimeStr = event["startTime"];
struct tm event_tm = {0};
strptime(startTimeStr, "%Y-%m-%dT%H:%M:%S.000Z", &event_tm);
time_t event_time_utc = convert_utc_tm_to_time_t(&event_tm);
struct tm *event_time_local = localtime(&event_time_utc);

if (event_time_local && event_time_local->tm_yday == timeinfo->tm_yday && event_time_local->tm_year == timeinfo->tm_year) {
String colorStr = event["color"];
long colorHex = strtol(colorStr.substring(1).c_str(), NULL, 16);
int eventHour = event_time_local->tm_hour;
if (eventHour < NUM_LEDS) ledColors[eventHour].addColor(colorHex);
}
}
int cycleIndex = getCycleIndex();
for (int i = currentHour + 1; i < NUM_LEDS; i++) {
if (ledColors[i].count > 0) hourStrip.setPixelColor(i, ledColors[i].getCurrentColor(cycleIndex));
}
}

void updateMinuteStrip(struct tm *timeinfo, JsonArray &events) {
minuteStrip.clear();
int currentMinute = timeinfo->tm_min;
int ledsToShow = map(currentMinute, 0, 59, 0, NUM_LEDS);
LedColors ledColors[NUM_LEDS];

for (int i = 0; i < ledsToShow; i++) {
if ((i + 1) % SECOND_COLOR_INTERVAL == 0) minuteStrip.setPixelColor(i, minuteStrip.Color(SECONDARY_COLOR));
else minuteStrip.setPixelColor(i, minuteStrip.Color(TIME_COLOR));
}

for (JsonObject event : events) {
const char* startTimeStr = event["startTime"];
struct tm event_tm = {0};
strptime(startTimeStr, "%Y-%m-%dT%H:%M:%S.000Z", &event_tm);
time_t event_time_utc = convert_utc_tm_to_time_t(&event_tm);
struct tm *event_time_local = localtime(&event_time_utc);

if (event_time_local && event_time_local->tm_yday == timeinfo->tm_yday && event_time_local->tm_year == timeinfo->tm_year && event_time_local->tm_hour == timeinfo->tm_hour) {
String colorStr = event["color"];
long colorHex = strtol(colorStr.substring(1).c_str(), NULL, 16);
int eventMinuteLed = map(event_time_local->tm_min, 0, 59, 0, NUM_LEDS - 1);
ledColors[eventMinuteLed].addColor(colorHex);
}
}
int cycleIndex = getCycleIndex();
for (int i = ledsToShow; i < NUM_LEDS; i++) {
if (ledColors[i].count > 0) minuteStrip.setPixelColor(i, ledColors[i].getCurrentColor(cycleIndex));
}
}

void updateCalendarStrip(struct tm *timeinfo, JsonArray &events) {
calendarStrip.clear();
LedColors ledColors[NUM_LEDS];
time_t now_sec = time(NULL);
struct tm now_tm = *localtime(&now_sec);
now_tm.tm_hour = 0; now_tm.tm_min = 0; now_tm.tm_sec = 0;
time_t beginning_of_today = mktime(&now_tm);

for (JsonObject event : events) {
const char* startTimeStr = event["startTime"];
struct tm event_tm = {0};
strptime(startTimeStr, "%Y-%m-%dT%H:%M:%S.000Z", &event_tm);
time_t event_time_utc = convert_utc_tm_to_time_t(&event_tm);
struct tm* event_local_tm = localtime(&event_time_utc);
if (!event_local_tm) continue;

event_local_tm->tm_hour = 0; event_local_tm->tm_min = 0; event_local_tm->tm_sec = 0;
time_t beginning_of_event_day = mktime(event_local_tm);
double seconds_diff = difftime(beginning_of_event_day, beginning_of_today);
int days_ahead = lround(seconds_diff / 86400.0);

if (days_ahead >= 0 && days_ahead < NUM_LEDS) {
String colorStr = event["color"];
long colorHex = strtol(colorStr.substring(1).c_str(), NULL, 16);
ledColors[days_ahead].addColor(colorHex);
}
}
int cycleIndex = getCycleIndex();
for (int i = 0; i < NUM_LEDS; i++) {
if (ledColors[i].count > 0) calendarStrip.setPixelColor(i, ledColors[i].getCurrentColor(cycleIndex));
}
}

void fetchCalendarData() {
Serial.println("Fetching calendar data...");
lastCalendarUpdateTime = millis();
HTTPClient http;
http.begin(scriptUrl);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
int httpCode = http.GET();
if (httpCode > 0) {
if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY) {
String payload = http.getString();
DeserializationError error = deserializeJson(calendarJson, payload);
if (error) calendarDataLoaded = false;
else calendarDataLoaded = true;
}
} else {
calendarDataLoaded = false;
}
http.end();
}

Finishing Touches

PXL_20251130_162408581.jpg
PXL_20251130_162748631.jpg
PXL_20251130_162933515.jpg

I glued strong magnets to the back of the case so I could stick it directly to my magnetic board.

I also programmed in some alternative colors. If you prefer green or purple, you can simply change the RGB values in the code (TIME_COLOR) to match your aesthetic!

How Do I Read This?

PXL_20251130_162408581.jpg