How to Build a Tiny ESP DeskBuddy with ESP32-C6

Swipeable black-and-white face dashboard with clock, weather, moon, stocks, and GitHub pages

ESP32Smart HomeIntermediate45 minutes3 components

Updated

How to Build a Tiny ESP DeskBuddy with ESP32-C6
For illustrative purposes only
On this page

What you'll build

Build a palm-sized DeskBuddy on the Waveshare ESP32-C6-Touch-LCD-1.47: a black-and-white face dashboard with touch navigation, a clock, date page, weather, moon phase, stock quote, and GitHub stats. It is small enough to sit next to a laptop, but the firmware has enough moving parts to feel like an actual little device.

The face is the first screen. It uses two tall rounded white eyes on a black background, a small mouth, page dots, and a subtle header. Tap the face to cycle through expressions. Tilt the gadget and the eyes drift slightly with the onboard IMU. Keep the screen clean and monochrome; the charm is in the simple shapes, not extra decoration.

Swipe horizontally to move through the other pages. Time and date work from compile time, so the clock starts immediately even without Wi-Fi. Weather uses Open-Meteo, the stock page pulls AAPL from Stooq, and the GitHub page shows follower and public repo counts for the username in secrets.h. If Wi-Fi is not configured, the network pages show a clear setup message instead of failing silently.

The enclosure is part of the build, not an afterthought. Use the tinyespdeskbuddy.stl-style shell with a front display opening, USB-C cutout, battery space, and mounting posts. Flash and test the board before closing the case, then connect the LiPo and make sure the USB-C port is still reachable for charging and firmware updates.

Wiring diagram

Wiring diagram

Interactive wiring diagram

Components needed

ComponentTypeQtyBuy
Waveshare ESP32-C6-Touch-LCD-1.47board1€17.80
3.7V LiPo batteryother1
Tiny ESP DeskBuddy enclosureother1

Prices and availability are indicative and may have been updated by the supplier. Schematik may earn a commission from purchases made through affiliate links.

Assembly

1

Print or prepare the DeskBuddy enclosure

Use the tinyespdeskbuddy.stl-style shell with a clean front window for the 1.47 inch display, a USB-C cutout, room behind the board for the LiPo, and posts or tape points to stop the board shifting inside the case.

2

Create your local secrets file

Copy secrets.example.h to ESP32_C6_Tilt_Orb/secrets.h and add WIFI_SSID, WIFI_PASSWORD, and GITHUB_USER. Keep secrets.h local; the starter code compiles without it, but the weather, stock, and GitHub pages will stay in their no-Wi-Fi state.

3

Flash the ESP32-C6 touch LCD board

Install the ESP32 Arduino core plus Arduino_GFX, ArduinoJson, FastIMU, and the Waveshare touch support library. Compile for an ESP32-C6 target with 8MB flash and CDC on boot, then upload over USB-C.

4

Check the face and touch controls before closing the case

The face should be black-and-white: two tall rounded white eyes, a small mouth, page dots, and a subtle header. Tap the face to cycle expressions, then swipe horizontally to move through time, date, weather, moon, stock, and GitHub pages.

5

Fit the battery and close the enclosure

Connect the LiPo to the board battery socket, tuck the cell behind the display, and secure the board so the USB-C port remains reachable for charging and firmware updates.

Pin assignments

PinConnectionType
GPIO 1esp32-c6-touch-lcd-1 LCD_SCKSPI
GPIO 2esp32-c6-touch-lcd-1 LCD_MOSISPI
GPIO 14esp32-c6-touch-lcd-1 LCD_CSDIGITAL
GPIO 15esp32-c6-touch-lcd-1 LCD_DCDIGITAL
GPIO 22esp32-c6-touch-lcd-1 LCD_RSTDIGITAL
GPIO 23esp32-c6-touch-lcd-1 LCD_BLPWM
GPIO 18esp32-c6-touch-lcd-1 TOUCH_SDAI2C
GPIO 19esp32-c6-touch-lcd-1 TOUCH_SCLI2C
GPIO 20esp32-c6-touch-lcd-1 TOUCH_RSTDIGITAL
GPIO 21esp32-c6-touch-lcd-1 TOUCH_INTDIGITAL

Code

Arduino C++
#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <ArduinoJson.h>
#include <FastIMU.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <Wire.h>
#include <math.h>
#include <string.h>
#include "esp_lcd_touch_axs5106l.h"

#if __has_include("secrets.h")
#include "secrets.h"
#endif

#ifndef WIFI_SSID
#define WIFI_SSID ""
#endif

#ifndef WIFI_PASSWORD
#define WIFI_PASSWORD ""
#endif

#ifndef GITHUB_USER
#define GITHUB_USER "sparklabhqx"
#endif

#define LCD_BL 23
#define LCD_DC 15
#define LCD_CS 14
#define LCD_SCK 1
#define LCD_MOSI 2
#define LCD_RST 22

#define TOUCH_SDA 18
#define TOUCH_SCL 19
#define TOUCH_RST 20
#define TOUCH_INT 21

#define IMU_ADDRESS 0x6B
#define ROTATION 1

static const int SCREEN_W = 320;
static const int SCREEN_H = 172;
static const uint8_t APP_COUNT = 7;
static const uint8_t FACE_MOOD_COUNT = 5;
static const uint32_t PAGE_AUTO_INTERVAL_MS = 8000;
static const uint16_t FG = RGB565_WHITE;
static const uint16_t BG = RGB565_BLACK;

Arduino_DataBus *bus = new Arduino_HWSPI(LCD_DC, LCD_CS, LCD_SCK, LCD_MOSI);
Arduino_GFX *display = new Arduino_ST7789(bus, LCD_RST, 0, false, 172, 320, 34, 0, 34, 0);
Arduino_Canvas *gfx = new Arduino_Canvas(SCREEN_W, SCREEN_H, display);

QMI8658 imu;
calData calib = {0};
AccelData accel;
GyroData gyro;

bool imuReady = false;
bool touchReady = false;
bool touchWasDown = false;
bool wifiAttempted = false;
bool weatherValid = false;
bool stockValid = false;
bool githubValid = false;
uint8_t currentApp = 0;
uint8_t faceMood = 0;
uint16_t touchStartX = 0;
uint16_t touchStartY = 0;
uint32_t touchStartMs = 0;
uint32_t nextBlink = 1400;
uint32_t blinkUntil = 0;
uint32_t nextGlance = 900;
uint32_t nextAutoPage = PAGE_AUTO_INTERVAL_MS;
uint32_t lastSerialMs = 0;
uint32_t clockStartMillis = 0;
uint32_t clockStartSeconds = 0;
uint32_t weatherUpdatedAt = 0;
uint32_t stockUpdatedAt = 0;
uint32_t githubUpdatedAt = 0;
float restAx = 0.0f;
float restAy = 0.0f;
float filteredAx = 0.0f;
float filteredAy = 0.0f;
float filteredGz = 0.0f;
float faceGlanceX = 0.0f;
float faceGlanceY = 0.0f;
float faceTargetX = 0.0f;
float faceTargetY = 0.0f;
float pressPulse = 0.0f;
int weatherTempF = 0;
int weatherHumidity = 0;
int weatherWindMph = 0;
int weatherCode = -1;
bool weatherIsDay = true;
String weatherLabel = "WAITING";
float stockPrice = 0.0f;
float stockOpen = 0.0f;
float stockHigh = 0.0f;
float stockLow = 0.0f;
String stockTime = "";
int githubFollowers = 0;
int githubRepos = 0;

uint16_t rgb(uint8_t r, uint8_t g, uint8_t b) {
  return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}

float clampFloat(float value, float minValue, float maxValue) {
  if (value < minValue) {
    return minValue;
  }
  if (value > maxValue) {
    return maxValue;
  }
  return value;
}

void lcdRegInit() {
  static const uint8_t init_operations[] = {
      BEGIN_WRITE,
      WRITE_COMMAND_8, 0x11,
      END_WRITE,
      DELAY, 120,
      BEGIN_WRITE,
      WRITE_C8_D16, 0xDF, 0x98, 0x53,
      WRITE_C8_D8, 0xB2, 0x23,
      WRITE_COMMAND_8, 0xB7,
      WRITE_BYTES, 4,
      0x00, 0x47, 0x00, 0x6F,
      WRITE_COMMAND_8, 0xBB,
      WRITE_BYTES, 6,
      0x1C, 0x1A, 0x55, 0x73, 0x63, 0xF0,
      WRITE_C8_D16, 0xC0, 0x44, 0xA4,
      WRITE_C8_D8, 0xC1, 0x16,
      WRITE_COMMAND_8, 0xC3,
      WRITE_BYTES, 8,
      0x7D, 0x07, 0x14, 0x06, 0xCF, 0x71, 0x72, 0x77,
      WRITE_COMMAND_8, 0xC4,
      WRITE_BYTES, 12,
      0x00, 0x00, 0xA0, 0x79, 0x0B, 0x0A, 0x16, 0x79, 0x0B, 0x0A, 0x16, 0x82,
      WRITE_COMMAND_8, 0xC8,
      WRITE_BYTES, 32,
      0x3F, 0x32, 0x29, 0x29, 0x27, 0x2B, 0x27, 0x28, 0x28, 0x26, 0x25, 0x17, 0x12, 0x0D, 0x04, 0x00,
      0x3F, 0x32, 0x29, 0x29, 0x27, 0x2B, 0x27, 0x28, 0x28, 0x26, 0x25, 0x17, 0x12, 0x0D, 0x04, 0x00,
      WRITE_COMMAND_8, 0xD0,
      WRITE_BYTES, 5,
      0x04, 0x06, 0x6B, 0x0F, 0x00,
      WRITE_C8_D16, 0xD7, 0x00, 0x30,
      WRITE_C8_D8, 0xE6, 0x14,
      WRITE_C8_D8, 0xDE, 0x01,
      WRITE_COMMAND_8, 0xB7,
      WRITE_BYTES, 5,
      0x03, 0x13, 0xEF, 0x35, 0x35,
      WRITE_COMMAND_8, 0xC1,
      WRITE_BYTES, 3,
      0x14, 0x15, 0xC0,
      WRITE_C8_D16, 0xC2, 0x06, 0x3A,
      WRITE_C8_D16, 0xC4, 0x72, 0x12,
      WRITE_C8_D8, 0xBE, 0x00,
      WRITE_C8_D8, 0xDE, 0x02,
      WRITE_COMMAND_8, 0xE5,
      WRITE_BYTES, 3,
      0x00, 0x02, 0x00,
      WRITE_COMMAND_8, 0xE5,
      WRITE_BYTES, 3,
      0x01, 0x02, 0x00,
      WRITE_C8_D8, 0xDE, 0x00,
      WRITE_C8_D8, 0x35, 0x00,
      WRITE_C8_D8, 0x3A, 0x05,
      WRITE_COMMAND_8, 0x2A,
      WRITE_BYTES, 4,
      0x00, 0x22, 0x00, 0xCD,
      WRITE_COMMAND_8, 0x2B,
      WRITE_BYTES, 4,
      0x00, 0x00, 0x01, 0x3F,
      WRITE_C8_D8, 0xDE, 0x02,
      WRITE_COMMAND_8, 0xE5,
      WRITE_BYTES, 3,
      0x00, 0x02, 0x00,
      WRITE_C8_D8, 0xDE, 0x00,
      WRITE_C8_D8, 0x36, 0x00,
      WRITE_COMMAND_8, 0x21,
      END_WRITE,
      DELAY, 10,
      BEGIN_WRITE,
      WRITE_COMMAND_8, 0x29,
      END_WRITE};
  bus->batchOperation(init_operations, sizeof(init_operations));
}

uint32_t compileTimeSeconds() {
  const char *t = __TIME__;
  uint8_t hh = (t[0] - '0') * 10 + (t[1] - '0');
  uint8_t mm = (t[3] - '0') * 10 + (t[4] - '0');
  uint8_t ss = (t[6] - '0') * 10 + (t[7] - '0');
  return (uint32_t)hh * 3600UL + (uint32_t)mm * 60UL + ss;
}

uint8_t compileMonthNumber() {
  const char *month = __DATE__;
  static const char names[] = "JanFebMarAprMayJunJulAugSepOctNovDec";
  for (uint8_t i = 0; i < 12; i++) {
    if (strncmp(month, names + i * 3, 3) == 0) {
      return i + 1;
    }
  }
  return 1;
}

int32_t daysFromCivil(int32_t year, uint8_t month, uint8_t day) {
  year -= month <= 2;
  const int32_t era = (year >= 0 ? year : year - 399) / 400;
  const uint32_t yoe = (uint32_t)(year - era * 400);
  const uint32_t doy = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1;
  const uint32_t doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
  return era * 146097 + (int32_t)doe - 719468;
}

void civilFromDays(int32_t z, int32_t *year, uint8_t *month, uint8_t *day) {
  z += 719468;
  const int32_t era = (z >= 0 ? z : z - 146096) / 146097;
  const uint32_t doe = (uint32_t)(z - era * 146097);
  const uint32_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
  int32_t y = (int32_t)yoe + era * 400;
  const uint32_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
  const uint32_t mp = (5 * doy + 2) / 153;
  const uint32_t d = doy - (153 * mp + 2) / 5 + 1;
  const uint32_t m = mp + (mp < 10 ? 3 : -9);
  y += m <= 2;
  *year = y;
  *month = (uint8_t)m;
  *day = (uint8_t)d;
}

int32_t compileDateDays() {
  const char *d = __DATE__;
  uint8_t day = (d[4] == ' ' ? 0 : d[4] - '0') * 10 + (d[5] - '0');
  int32_t year = (int32_t)(d[7] - '0') * 1000 + (int32_t)(d[8] - '0') * 100 +
                 (int32_t)(d[9] - '0') * 10 + (d[10] - '0');
  return daysFromCivil(year, compileMonthNumber(), day);
}

String csvField(const String &row, uint8_t index) {
  int start = 0;
  for (uint8_t i = 0; i < index; i++) {
    start = row.indexOf(',', start);
    if (start < 0) {
      return "";
    }
    start++;
  }
  int end = row.indexOf(',', start);
  if (end < 0) {
    end = row.length();
  }
  String value = row.substring(start, end);
  value.trim();
  return value;
}

void centeredText(const char *text, int y, uint8_t size) {
  gfx->setTextSize(size);
  gfx->setTextColor(FG);
  int width = (int)strlen(text) * 6 * size;
  gfx->setCursor((SCREEN_W - width) / 2, y);
  gfx->print(text);
}

void drawPageDots() {
  int startX = SCREEN_W / 2 - ((APP_COUNT - 1) * 16) / 2;
  for (uint8_t i = 0; i < APP_COUNT; i++) {
    if (i == currentApp) {
      gfx->fillCircle(startX + i * 16, SCREEN_H - 12, 3, FG);
    } else {
      gfx->drawCircle(startX + i * 16, SCREEN_H - 12, 2, rgb(90, 90, 90));
    }
  }
}

void drawHeader(const char *title) {
  gfx->fillScreen(BG);
  gfx->drawLine(0, 20, SCREEN_W, 20, FG);
  gfx->setTextSize(1);
  gfx->setTextColor(FG);
  gfx->setCursor(8, 7);
  gfx->print(title);
}

void switchApp(int8_t delta) {
  currentApp = (currentApp + APP_COUNT + delta) % APP_COUNT;
  pressPulse = 1.0f;
  nextAutoPage = millis() + PAGE_AUTO_INTERVAL_MS;
}

bool wifiConfigured() {
  return strlen(WIFI_SSID) > 0;
}

bool ensureWifi() {
  if (WiFi.status() == WL_CONNECTED) {
    return true;
  }
  if (!wifiConfigured()) {
    return false;
  }
  if (wifiAttempted) {
    return false;
  }

  wifiAttempted = true;
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  uint32_t start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < 3500UL) {
    delay(120);
  }
  return WiFi.status() == WL_CONNECTED;
}

const char *weatherCodeText(int code) {
  if (code == 0) return "CLEAR";
  if (code == 1 || code == 2) return "PARTLY CLOUDY";
  if (code == 3) return "CLOUDY";
  if (code == 45 || code == 48) return "FOG";
  if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) return "RAIN";
  if (code >= 71 && code <= 77) return "SNOW";
  if (code >= 95) return "STORM";
  return "WEATHER";
}

void drawWeatherIcon(int cx, int cy, int code, bool isDay) {
  if (code == 0) {
    gfx->drawCircle(cx, cy, 22, FG);
    for (uint8_t i = 0; i < 8; i++) {
      float a = i * 0.7854f;
      gfx->drawLine(cx + cos(a) * 30, cy + sin(a) * 30,
                    cx + cos(a) * 40, cy + sin(a) * 40, FG);
    }
    if (!isDay) {
      gfx->fillCircle(cx + 11, cy - 8, 18, BG);
    }
    return;
  }

  gfx->fillCircle(cx - 19, cy + 5, 19, FG);
  gfx->fillCircle(cx + 2, cy - 6, 25, FG);
  gfx->fillCircle(cx + 27, cy + 8, 17, FG);
  gfx->fillRoundRect(cx - 42, cy + 8, 87, 25, 12, FG);
  if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) {
    for (int x = -25; x <= 25; x += 17) {
      gfx->drawLine(cx + x, cy + 45, cx + x - 8, cy + 62, FG);
      gfx->drawLine(cx + x + 1, cy + 45, cx + x - 7, cy + 62, FG);
    }
  } else if (code >= 71 && code <= 77) {
    for (int x = -24; x <= 24; x += 24) {
      gfx->drawLine(cx + x - 6, cy + 53, cx + x + 6, cy + 53, FG);
      gfx->drawLine(cx + x, cy + 47, cx + x, cy + 59, FG);
      gfx->drawLine(cx + x - 5, cy + 48, cx + x + 5, cy + 58, FG);
      gfx->drawLine(cx + x + 5, cy + 48, cx + x - 5, cy + 58, FG);
    }
  }
}

bool fetchWeather() {
  if (!ensureWifi()) {
    return false;
  }
  HTTPClient http;
  const char *url =
      "http://api.open-meteo.com/v1/forecast?"
      "latitude=40.7128&longitude=-74.0060"
      "&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,is_day"
      "&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=America%2FNew_York";
  http.setTimeout(6000);
  if (!http.begin(url)) {
    return false;
  }
  int status = http.GET();
  if (status != HTTP_CODE_OK) {
    http.end();
    return false;
  }
  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, http.getString());
  http.end();
  if (err) {
    return false;
  }

  weatherTempF = (int)round(doc["current"]["temperature_2m"].as<float>());
  weatherHumidity = doc["current"]["relative_humidity_2m"].as<int>();
  weatherWindMph = (int)round(doc["current"]["wind_speed_10m"].as<float>());
  weatherCode = doc["current"]["weather_code"].as<int>();
  weatherIsDay = doc["current"]["is_day"].as<int>() != 0;
  weatherLabel = weatherCodeText(weatherCode);
  weatherUpdatedAt = millis();
  weatherValid = true;
  return true;
}

bool fetchStock() {
  if (!ensureWifi()) {
    return false;
  }
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  const char *url = "https://stooq.com/q/l/?s=aapl.us&f=sd2t2ohlcv&h&e=csv";
  http.setTimeout(7000);
  if (!http.begin(client, url)) {
    return false;
  }
  int status = http.GET();
  if (status != HTTP_CODE_OK) {
    http.end();
    return false;
  }
  String csv = http.getString();
  http.end();
  int rowStart = csv.indexOf('\n');
  if (rowStart < 0) {
    return false;
  }
  String row = csv.substring(rowStart + 1);
  row.trim();
  String closeText = csvField(row, 6);
  if (closeText.length() == 0 || closeText == "N/D") {
    return false;
  }
  stockTime = csvField(row, 2);
  stockOpen = csvField(row, 3).toFloat();
  stockHigh = csvField(row, 4).toFloat();
  stockLow = csvField(row, 5).toFloat();
  stockPrice = closeText.toFloat();
  stockUpdatedAt = millis();
  stockValid = true;
  return true;
}

bool fetchGithub() {
  if (!ensureWifi()) {
    return false;
  }
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  String url = String("https://api.github.com/users/") + GITHUB_USER;
  http.setTimeout(7000);
  if (!http.begin(client, url)) {
    return false;
  }
  http.addHeader("User-Agent", "ESP32-C6-Touch-LCD");
  int status = http.GET();
  if (status != HTTP_CODE_OK) {
    http.end();
    return false;
  }
  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, http.getString());
  http.end();
  if (err) {
    return false;
  }
  githubFollowers = doc["followers"].as<int>();
  githubRepos = doc["public_repos"].as<int>();
  githubUpdatedAt = millis();
  githubValid = true;
  return true;
}

void drawEye(int cx, int cy, int w, int h, bool closed, int px, int py) {
  if (closed) {
    gfx->fillRoundRect(cx - w / 2, cy - 3, w, 6, 3, FG);
    return;
  }
  gfx->fillRoundRect(cx - w / 2, cy - h / 2, w, h, h / 2, FG);
  gfx->fillRoundRect(cx - 7 + px, cy - 9 + py, 14, 18, 7, BG);
}

void drawMouth(int cx, int cy) {
  if (faceMood == 2) {
    gfx->fillEllipse(cx, cy + 2, 15, 21, FG);
    gfx->fillEllipse(cx, cy + 2, 7, 11, BG);
  } else if (faceMood == 3) {
    gfx->fillRoundRect(cx - 38, cy, 76, 6, 3, FG);
  } else if (faceMood == 4) {
    gfx->drawLine(cx - 28, cy + 8, cx + 28, cy - 8, FG);
    gfx->drawLine(cx - 28, cy + 9, cx + 28, cy - 7, FG);
  } else {
    int radius = faceMood == 1 ? 48 : 40;
    gfx->fillArc(cx, cy - 16, radius, radius - 5, 34.0f, 146.0f, FG);
  }
}

void drawFace(float tx, float ty) {
  uint32_t now = millis();
  drawHeader("FACE");
  float breathe = sin(now * 0.0021f) * 0.03f + pressPulse * 0.08f;
  int dx = (int)(tx * 14.0f + faceGlanceX);
  int dy = (int)(ty * 8.0f + faceGlanceY);
  int eyeW = 44 + (int)(breathe * 28.0f);
  int eyeH = faceMood == 2 ? 45 : (faceMood == 3 ? 16 : 62 + (int)(breathe * 18.0f));
  bool blink = now < blinkUntil;

  for (int x = 12; x < SCREEN_W - 12; x += 18) {
    gfx->drawLine(x, 31, x + 8, 31, FG);
    gfx->drawLine(x + 4, 144, x + 12, 144, FG);
  }
  drawEye(114 + dx, 75 + dy, eyeW, eyeH, blink || faceMood == 3, dx / 4, dy / 5);
  drawEye(206 + dx, 75 + dy, eyeW, eyeH, blink || faceMood == 3 || faceMood == 4, dx / 4, dy / 5);
  drawMouth(160 + dx / 4, 116 + dy / 4);
  drawPageDots();
}

void drawDigitSegment(int x, int y, int w, int h, int t, uint8_t segment) {
  int half = h / 2;
  int r = t / 2;
  switch (segment) {
  case 0: gfx->fillRoundRect(x + t, y, w - 2 * t, t, r, FG); break;
  case 1: gfx->fillRoundRect(x + w - t, y + t, t, half - t, r, FG); break;
  case 2: gfx->fillRoundRect(x + w - t, y + half, t, half - t, r, FG); break;
  case 3: gfx->fillRoundRect(x + t, y + h - t, w - 2 * t, t, r, FG); break;
  case 4: gfx->fillRoundRect(x, y + half, t, half - t, r, FG); break;
  case 5: gfx->fillRoundRect(x, y + t, t, half - t, r, FG); break;
  case 6: gfx->fillRoundRect(x + t, y + half - t / 2, w - 2 * t, t, r, FG); break;
  }
}

void drawDigit(int x, int y, uint8_t digit) {
  static const uint8_t masks[10] = {
      0b00111111, 0b00000110, 0b01011011, 0b01001111, 0b01100110,
      0b01101101, 0b01111101, 0b00000111, 0b01111111, 0b01101111};
  for (uint8_t segment = 0; segment < 7; segment++) {
    if (masks[digit % 10] & (1 << segment)) {
      drawDigitSegment(x, y, 42, 76, 8, segment);
    }
  }
}

void drawClock() {
  uint32_t elapsed = (millis() - clockStartMillis) / 1000UL;
  uint32_t secondsOfDay = (clockStartSeconds + elapsed) % 86400UL;
  uint8_t hh = secondsOfDay / 3600UL;
  uint8_t mm = (secondsOfDay / 60UL) % 60UL;
  uint8_t ss = secondsOfDay % 60UL;

  drawHeader("TIME");
  drawDigit(43, 46, hh / 10);
  drawDigit(93, 46, hh % 10);
  if ((ss % 2) == 0) {
    gfx->fillRoundRect(141, 68, 8, 8, 4, FG);
    gfx->fillRoundRect(141, 96, 8, 8, 4, FG);
  }
  drawDigit(159, 46, mm / 10);
  drawDigit(209, 46, mm % 10);
  gfx->setTextSize(2);
  gfx->setTextColor(FG);
  gfx->setCursor(268, 101);
  if (ss < 10) gfx->print("0");
  gfx->print(ss);
  drawPageDots();
}

void drawDatePage() {
  static const char *weekdays[] = {"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY"};
  static const char *months[] = {"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"};
  uint32_t elapsedSeconds = (millis() - clockStartMillis) / 1000UL;
  int32_t days = compileDateDays() + (int32_t)((clockStartSeconds + elapsedSeconds) / 86400UL);
  int32_t year;
  uint8_t month;
  uint8_t day;
  civilFromDays(days, &year, &month, &day);
  uint8_t weekday = (uint8_t)((days + 4) % 7);

  drawHeader("DATE");
  centeredText(weekdays[weekday], 35, 3);
  char line[24];
  snprintf(line, sizeof(line), "%s %02u", months[month - 1], day);
  centeredText(line, 82, 5);
  snprintf(line, sizeof(line), "%ld", (long)year);
  centeredText(line, 130, 2);
  drawPageDots();
}

void drawWeather() {
  drawHeader("NEW YORK");
  if (!wifiConfigured()) {
    centeredText("NO WIFI CONFIG", 70, 2);
    centeredText("CREATE secrets.h", 102, 1);
    drawPageDots();
    return;
  }
  if (WiFi.status() != WL_CONNECTED) {
    centeredText("CONNECTING", 75, 2);
    drawWeatherIcon(250, 82, 3, true);
    drawPageDots();
    return;
  }
  if (!weatherValid) {
    centeredText("UPDATING", 76, 2);
    drawPageDots();
    return;
  }
  drawWeatherIcon(241, 70, weatherCode, weatherIsDay);
  gfx->setTextSize(7);
  gfx->setTextColor(FG);
  gfx->setCursor(20, 60);
  gfx->print(weatherTempF);
  gfx->setTextSize(3);
  gfx->print("F");
  gfx->setTextSize(1);
  gfx->setCursor(24, 136);
  gfx->print(weatherLabel);
  gfx->setCursor(146, 136);
  gfx->print("H ");
  gfx->print(weatherHumidity);
  gfx->print("%");
  gfx->setCursor(214, 136);
  gfx->print("W ");
  gfx->print(weatherWindMph);
  gfx->print("MPH");
  drawPageDots();
}

const char *moonPhaseLabel(float phase) {
  if (phase < 0.03f || phase > 0.97f) return "NEW MOON";
  if (phase < 0.22f) return "WAXING CRESCENT";
  if (phase < 0.28f) return "FIRST QUARTER";
  if (phase < 0.47f) return "WAXING GIBBOUS";
  if (phase < 0.53f) return "FULL MOON";
  if (phase < 0.72f) return "WANING GIBBOUS";
  if (phase < 0.78f) return "LAST QUARTER";
  return "WANING CRESCENT";
}

void drawMoonDisc(int cx, int cy, int radius, float phase) {
  phase = phase - floor(phase);
  gfx->drawCircle(cx, cy, radius + 3, rgb(72, 72, 72));
  gfx->fillCircle(cx, cy, radius, FG);
  if (phase < 0.03f || phase > 0.97f) {
    gfx->fillCircle(cx, cy, radius - 2, BG);
    gfx->drawCircle(cx, cy, radius, FG);
    return;
  }
  if (phase > 0.47f && phase < 0.53f) {
    return;
  }
  int shadowX = phase < 0.5f ? cx - (int)(4.0f * radius * phase)
                             : cx + (int)(2.0f * radius - 4.0f * radius * (phase - 0.5f));
  gfx->fillCircle(shadowX, cy, radius, BG);
  gfx->drawCircle(cx, cy, radius, FG);
}

void drawMoon() {
  const float synodicMonth = 29.53058867f;
  uint32_t elapsedSeconds = (millis() - clockStartMillis) / 1000UL;
  float days = (float)compileDateDays() + ((float)compileTimeSeconds() + (float)elapsedSeconds) / 86400.0f;
  float age = fmod(days - 10962.7597f, synodicMonth);
  if (age < 0.0f) age += synodicMonth;
  float phase = age / synodicMonth;
  int illumination = (int)round((1.0f - cos(phase * 6.2831853f)) * 50.0f);

  drawHeader("MOON");
  drawMoonDisc(232, 82, 45, phase);
  gfx->setTextSize(2);
  gfx->setTextColor(FG);
  gfx->setCursor(24, 58);
  gfx->print(moonPhaseLabel(phase));
  gfx->setTextSize(1);
  gfx->setCursor(26, 98);
  gfx->print("AGE ");
  gfx->print(age, 1);
  gfx->print(" DAYS");
  gfx->setCursor(26, 118);
  gfx->print("LIGHT ");
  gfx->print(illumination);
  gfx->print("%");
  drawPageDots();
}

void drawStock() {
  drawHeader("AAPL");
  if (!wifiConfigured()) {
    centeredText("NO WIFI CONFIG", 70, 2);
    centeredText("CREATE secrets.h", 102, 1);
    drawPageDots();
    return;
  }
  if (WiFi.status() != WL_CONNECTED) {
    centeredText("CONNECTING", 75, 2);
    drawPageDots();
    return;
  }
  if (!stockValid) {
    centeredText("UPDATING", 76, 2);
    drawPageDots();
    return;
  }
  gfx->setTextSize(6);
  gfx->setTextColor(FG);
  gfx->setCursor(18, 58);
  gfx->print("$");
  gfx->print(stockPrice, 2);
  gfx->setTextSize(1);
  gfx->setCursor(24, 132);
  gfx->print("O ");
  gfx->print(stockOpen, 2);
  gfx->setCursor(105, 132);
  gfx->print("H ");
  gfx->print(stockHigh, 2);
  gfx->setCursor(186, 132);
  gfx->print("L ");
  gfx->print(stockLow, 2);
  gfx->setCursor(256, 18);
  gfx->print(stockTime);
  drawPageDots();
}

void drawGithub() {
  drawHeader("GITHUB");
  if (!wifiConfigured()) {
    centeredText("NO WIFI CONFIG", 70, 2);
    centeredText("CREATE secrets.h", 102, 1);
    drawPageDots();
    return;
  }
  if (WiFi.status() != WL_CONNECTED) {
    centeredText("CONNECTING", 75, 2);
    drawPageDots();
    return;
  }
  if (!githubValid) {
    centeredText("UPDATING", 76, 2);
    drawPageDots();
    return;
  }
  gfx->setTextSize(6);
  gfx->setTextColor(FG);
  gfx->setCursor(22, 56);
  gfx->print(githubFollowers);
  gfx->setTextSize(2);
  gfx->setCursor(24, 118);
  gfx->print("FOLLOWERS");
  gfx->setTextSize(1);
  gfx->setCursor(218, 20);
  gfx->print("@");
  gfx->print(GITHUB_USER);
  gfx->setCursor(222, 132);
  gfx->print("REPOS ");
  gfx->print(githubRepos);
  drawPageDots();
}

void triggerFaceTap() {
  if (currentApp == 0) {
    faceMood = (faceMood + 1) % FACE_MOOD_COUNT;
    pressPulse = 1.0f;
  } else {
    switchApp(1);
  }
  nextAutoPage = millis() + PAGE_AUTO_INTERVAL_MS;
}

void readSensors() {
  if (!imuReady) {
    filteredAx = sin(millis() * 0.0012f) * 0.12f;
    filteredAy = cos(millis() * 0.0010f) * 0.12f;
    return;
  }
  imu.update();
  imu.getAccel(&accel);
  imu.getGyro(&gyro);
  filteredAx = filteredAx * 0.88f + accel.accelX * 0.12f;
  filteredAy = filteredAy * 0.88f + accel.accelY * 0.12f;
  filteredGz = filteredGz * 0.82f + gyro.gyroZ * 0.18f;
  if (fabs(filteredGz) > 130.0f) {
    faceMood = 2;
    pressPulse = 1.0f;
  }
}

void readTouch() {
  if (!touchReady) return;
  touch_data_t touchData;
  bsp_touch_read();
  if (bsp_touch_get_coordinates(&touchData)) {
    uint16_t x = touchData.coords[0].x;
    uint16_t y = touchData.coords[0].y;
    uint32_t now = millis();
    if (!touchWasDown || now - touchStartMs > 700) {
      touchStartX = x;
      touchStartY = y;
      touchStartMs = now;
      touchWasDown = true;
      return;
    }
    int16_t dx = (int16_t)x - (int16_t)touchStartX;
    int16_t dy = (int16_t)y - (int16_t)touchStartY;
    if (abs(dx) > 55 && abs(dx) > abs(dy) + 18) {
      switchApp(dx < 0 ? 1 : -1);
      touchWasDown = false;
    }
  } else if (touchWasDown && millis() - touchStartMs > 150) {
    triggerFaceTap();
    touchWasDown = false;
  }
}

void updateFaceTimers() {
  uint32_t now = millis();
  if (now > nextBlink) {
    blinkUntil = now + (random(0, 6) == 0 ? 220 : 105);
    nextBlink = now + 1000 + random(0, 2600);
  }
  if (now > nextGlance) {
    faceTargetX = (float)random(-8, 9);
    faceTargetY = (float)random(-4, 5);
    nextGlance = now + 650 + random(0, 1500);
  }
  faceGlanceX = faceGlanceX * 0.84f + faceTargetX * 0.16f;
  faceGlanceY = faceGlanceY * 0.84f + faceTargetY * 0.16f;
  pressPulse *= 0.86f;
}

void updateAutoPage() {
  if (millis() > nextAutoPage) {
    switchApp(1);
  }
}

void updateNetworkPages() {
  if (currentApp == 3 && (!weatherValid || millis() - weatherUpdatedAt > 15UL * 60UL * 1000UL)) {
    fetchWeather();
  } else if (currentApp == 5 && (!stockValid || millis() - stockUpdatedAt > 10UL * 60UL * 1000UL)) {
    fetchStock();
  } else if (currentApp == 6 && (!githubValid || millis() - githubUpdatedAt > 30UL * 60UL * 1000UL)) {
    fetchGithub();
  }
}

void calibrateNeutral() {
  gfx->fillScreen(BG);
  centeredText("HOLD STILL", 76, 2);
  gfx->flush();
  delay(900);
  for (uint8_t i = 0; i < 100; i++) {
    imu.update();
    delay(5);
  }
  float sumX = 0.0f;
  float sumY = 0.0f;
  for (uint8_t i = 0; i < 140; i++) {
    imu.update();
    imu.getAccel(&accel);
    sumX += accel.accelX;
    sumY += accel.accelY;
    delay(5);
  }
  restAx = sumX / 140.0f;
  restAy = sumY / 140.0f;
  filteredAx = restAx;
  filteredAy = restAy;
}

void setup() {
  Serial.begin(115200);
  delay(150);
  Serial.println("ESP32-C6 ambient face dashboard");

  if (!gfx->begin(40000000)) {
    Serial.println("Display init failed");
  }
  lcdRegInit();
  display->setRotation(ROTATION);
  pinMode(LCD_BL, OUTPUT);
  digitalWrite(LCD_BL, HIGH);
  gfx->fillScreen(BG);
  gfx->flush();

  Wire.begin(TOUCH_SDA, TOUCH_SCL);
  Wire.setClock(400000);
  bsp_touch_init(&Wire, TOUCH_RST, TOUCH_INT, ROTATION, gfx->width(), gfx->height());
  touchReady = true;

  int err = imu.init(calib, IMU_ADDRESS);
  if (err == 0) {
    imuReady = imu.setAccelRange(4) == 0 && imu.setGyroRange(512) == 0;
    if (imuReady) {
      calibrateNeutral();
    }
  }
  if (!imuReady) {
    Serial.println("IMU unavailable, using animated fallback motion");
  }

  randomSeed(micros());
  clockStartMillis = millis();
  clockStartSeconds = compileTimeSeconds();
  nextBlink = millis() + 1200;
  nextGlance = millis() + 600;
  nextAutoPage = millis() + PAGE_AUTO_INTERVAL_MS;
}

void loop() {
  readSensors();
  readTouch();
  updateAutoPage();
  updateFaceTimers();
  updateNetworkPages();

  float tx = 0.0f;
  float ty = 0.0f;
  if (imuReady) {
    tx = clampFloat(-(filteredAy - restAy) * 2.2f, -1.0f, 1.0f);
    ty = clampFloat((filteredAx - restAx) * 2.2f, -1.0f, 1.0f);
  } else {
    tx = sin(millis() * 0.0014f) * 0.25f;
    ty = cos(millis() * 0.0011f) * 0.16f;
  }

  if (currentApp == 0) {
    drawFace(tx, ty);
  } else if (currentApp == 1) {
    drawClock();
  } else if (currentApp == 2) {
    drawDatePage();
  } else if (currentApp == 3) {
    drawWeather();
  } else if (currentApp == 4) {
    drawMoon();
  } else if (currentApp == 5) {
    drawStock();
  } else {
    drawGithub();
  }
  gfx->flush();

  if (millis() - lastSerialMs > 1200) {
    lastSerialMs = millis();
    Serial.print("app=");
    Serial.print(currentApp);
    Serial.print(" mood=");
    Serial.println(faceMood);
  }
  delay(24);
}

// Run this and build other cool things at schematik.io
Libraries: Arduino_GFX, ArduinoJson, FastIMU

Ready to build this?

Open this project in Schematik to get the full wiring diagram, pin assignments, and deployable code for the ESP-C6 Touch LCD Gadget.

Open in Schematik →

Related guides