Community project

Tiny Touchscreen DeskBuddy

Savad PM

Published July 20, 2026

ESP321 component5 assembly steps
Remix this project
Photo of Tiny Touchscreen DeskBuddy

DeskBuddy is a tiny animated companion that sits on your desk, displaying weather, time, date, stock tickers, GitHub stats, and moon phases on a 1.47-inch touchscreen. Built around the Waveshare ESP32-C6 with a built-in IMU, the device animates its expressive face based on device tilt, responds to touch swipes for page navigation, and fetches live data over Wi-Fi.

This guide provides everything needed to build and deploy DeskBuddy: a complete wiring diagram, full parts list with the 400mAh LiPo battery, step-by-step assembly instructions, and the complete Arduino firmware with network data fetching, touch input handling, and IMU-driven animations. Assembly takes about 30 minutes and requires only basic soldering skills.

Wiring diagram

Interactive · read-only
Wiring diagram for Tiny Touchscreen DeskBuddy

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
Lithium Ion Polymer Battery - 3.7V 400mAh3.7V 400mAh1Lithium-ion polymer (also known as 'lipo' or 'lipoly') batteries are thin, light, and powerful. The output ranges from 4.2V when completely charged to 3.7V. This battery has a capacity of 400mAh for a total of about 1.9 Wh. If you need a larger (or smaller!) battery, we have a full range of LiPoly batteries.The batteries come pre-attached with a genuine 2-pin 25mm long JST-PH connector as shown and include the necessary protection circuitry. Because they have a genuine JST connector, not a knock-off, the cable won't snag or get stuck in a matching JST jack, they click in and out smoothly.

Assembly

5 steps
  1. Prepare the enclosure

    Use a printed shell with a 1.47-inch front window, internal board standoffs or thin foam tape, a clear USB-C cutout, and enough rear depth for the LiPo. Dry-fit the Waveshare board before installing the battery.

    • Tip: Use a non-conductive spacer or foam tape behind the display board.
    • Tip: Keep the touch surface and display window clear of adhesive.
    • Do not use a metal enclosure or conductive tape where it can contact the board or battery terminals.
  2. Install the Waveshare board

    The display, capacitive touch controller, and IMU are built into the selected Waveshare ESP32-C6-Touch-LCD-1.47 board. No external signal jumpers are needed. Secure the board with its USB-C connector aligned to the enclosure opening.

    • Tip: The screen is landscape in this firmware.
    • Tip: Do not press directly on the LCD flex area while fastening the board.
  3. Set the local Wi-Fi credentials

    Open include/secrets.h in the project and replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with the local Wi-Fi credentials. Keep this file private. The network pages remain in setup mode until the placeholder Wi-Fi name is changed.

    • Tip: This firmware uses the public IP to estimate city-level location; a VPN, cellular gateway, or corporate network can make it inaccurate.
    • Tip: Wi-Fi is used for Open-Meteo, exchange rates, and the Goodreturns pages.
    • Do not post or commit your filled-in secrets.h file.
  4. Flash and verify the dashboard

    Connect USB-C and use Schematik’s Deploy button. On first network use, swipe through the pages: local time, approximate current location, weather, USD/INR and AED/INR, then 24K India/UAE gold. Swipe left or right to change page; a tap changes the face mood on the face page.

    • Tip: Allow a few seconds on a network page for its first refresh.
    • Tip: Gold values are Goodreturns indicative 24K per-gram rates and may exclude GST, TCS, dealer premiums, and other charges.
  5. Fit the LiPo and close safely

    With USB-C unplugged, connect only the correct 3.7 V single-cell LiPo plug to the board’s matching battery connector. Place the flat cell in the rear pocket with slack in the lead, then close the enclosure without compressing it. Keep USB-C accessible for charging and future deployment.

    • Tip: A protected 300–500 mAh LiPo is a practical fit for a small desktop enclosure.
    • Tip: Charge on a non-flammable surface and inspect the cell periodically.
    • Never use a damaged, swollen, punctured, or hot LiPo.
    • Never pinch the cell or wire in the printed enclosure, short its connector, or charge the device unattended.

Pin assignments

Board wiring reference
PinConnectionType
VINlipo-battery-1 BAT+power
GNDlipo-battery-1 BAT-ground

Firmware

ESP32
schematik_esp32.inoDeploy to device
// ============================================================
// DeskBuddy — Waveshare ESP32-C6-Touch-LCD-1.47
// Features: animated face, clock, date, weather, moon,
//           stock ticker, GitHub stats.  Touch swipe to
//           change pages; tilt via QMI8658 IMU animates eyes.
// ============================================================
#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <Wire.h>
#include <math.h>
#include <string.h>
#include "secrets.h"  // local file: Wi-Fi name and password only

// Network data is fetched over HTTPS. Location is inferred from the public IP,
// so it is approximate; VPNs and mobile/corporate networks can report elsewhere.

// ── Pin definitions ────────────────────────────────────────
#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


struct AccelData {
  float accelX;
  float accelY;
  float accelZ;
  uint32_t timestamp;
};

struct GyroData {
  float gyroX;
  float gyroY;
  float gyroZ;
  uint32_t timestamp;
};

struct calData {
  bool valid;
  float accelBias[3];
  float gyroBias[3];
};

struct TouchPoint {
  uint16_t x;
  uint16_t y;
};

struct touch_data_t {
  uint8_t    count;
  TouchPoint coords[1];  // first active touch point used by this UI
};


// Forward declarations

// Forward declarations

// Forward declarations
void drawRates();
void drawGold();
void drawLocation();

bool httpsGet(const String &url, String &body);
bool fetchLocation();
bool fetchRates();
String htmlCellText(const String &html);
float firstPriceInCell(const String &cell);
float extractGoldTableRate(const String &html, const String &headingMarker);
bool fetchGoldRates();

void resetSharedI2CBus();
void bsp_touch_init(TwoWire *wire, uint8_t rstPin, uint8_t intPin, uint8_t rotation, uint16_t dispW, uint16_t dispH);
void bsp_touch_read();
uint16_t clampTouchCoord(int32_t value, uint16_t maxValue);
uint16_t scaleTouchAxis(uint16_t raw, uint16_t rawMin, uint16_t rawMax, uint16_t outMax);
bool bsp_touch_get_coordinates(uint16_t *outX, uint16_t *outY);
uint16_t rgb(uint8_t r, uint8_t g, uint8_t b);
float clampFloat(float v, float lo, float hi);
void lcdRegInit();
uint32_t compileTimeSeconds();
uint8_t compileMonthNumber();
int32_t daysFromCivil(int32_t y, uint8_t mo, uint8_t d);
void civilFromDays(int32_t z, int32_t *year, uint8_t *month, uint8_t *day);
int32_t compileDateDays();
String csvField(const String &row, uint8_t index);
void centeredText(const char *text, int y, uint8_t size);
void drawPageDots();
void drawWifiName();
void drawDateTimeStatus();
void drawFaceWidget(float tx, float ty);
void drawHeader(const char *title);
void switchApp(int8_t delta);
bool wifiConfigured();
bool ensureWifi();
void drawWeatherIcon(int cx, int cy, int code, bool isDay);
bool fetchWeather();
void drawEye(int cx, int cy, int w, int h, bool closed, int px, int py);
void drawMouth(int cx, int cy);
void drawFace(float tx, float ty);
void drawDigitSegment(int x, int y, int w, int h, int t, uint8_t seg);
void drawDigit(int x, int y, uint8_t digit);
void drawClock();
void drawDatePage();
void drawWeather();
void drawForecast();
void drawMoonDisc(int cx, int cy, int radius, float phase);
void drawMoon();
void triggerFaceTap();
void readSensors();
void readTouch();
void updateFaceTimers();
void updateAutoPage();
void updateNetworkPages();
void calibrateNeutral();

uint32_t lastI2CResetMs = 0;

void resetSharedI2CBus() {
  uint32_t now = millis();
  if (now - lastI2CResetMs < 250) return;
  lastI2CResetMs = now;
  Wire.end();
  delay(5);
  Wire.begin(TOUCH_SDA, TOUCH_SCL);
  // The touch controller is polled at Fast-mode I2C speed. Do not depend on
  // its INT edge: some board revisions leave that line high during a touch.
  Wire.setClock(400000);
}

#define IMU_ADDRESS 0x6B

class QMI8658Mini {
 public:
  int init(calData cal, uint8_t address = IMU_ADDRESS) {
    imuAddress = address;
    calibration = cal;
    if (read8(0x00) != 0x05) return -1;  // WHO_AM_I
    write8(0x60, 0xFF);                  // soft reset
    delay(100);
    write8(0x02, 0x40);                  // CTRL1: auto-increment
    setAccelRange(4);
    setGyroRange(512);
    write8(0x06, 0x03);                  // CTRL5: accel/gyro low-pass defaults
    write8(0x08, 0x03);                  // CTRL7: enable accel + gyro
    delay(100);
    return 0;
  }

  int setAccelRange(int range) {
    uint8_t config = 0x10;
    if (range == 2) { accelScale = 2.0f / 32768.0f; config = 0x00; }
    else if (range == 4) { accelScale = 4.0f / 32768.0f; config = 0x10; }
    else if (range == 8) { accelScale = 8.0f / 32768.0f; config = 0x20; }
    else if (range == 16) { accelScale = 16.0f / 32768.0f; config = 0x30; }
    else return -1;
    write8(0x08, 0x00);
    rmw8(0x03, 0x70, config);            // CTRL2 accel range bits
    write8(0x08, 0x03);
    return 0;
  }

  int setGyroRange(int range) {
    uint8_t config = 0x50;
    if (range == 128 || range == 125) { gyroScale = 128.0f / 32768.0f; config = 0x30; }
    else if (range == 256 || range == 250) { gyroScale = 256.0f / 32768.0f; config = 0x40; }
    else if (range == 512 || range == 500) { gyroScale = 512.0f / 32768.0f; config = 0x50; }
    else if (range == 1024 || range == 1000) { gyroScale = 1024.0f / 32768.0f; config = 0x60; }
    else if (range == 2048 || range == 2000) { gyroScale = 2048.0f / 32768.0f; config = 0x70; }
    else return -1;
    write8(0x08, 0x00);
    rmw8(0x04, 0x70, config);            // CTRL3 gyro range bits
    write8(0x08, 0x03);
    return 0;
  }

  void update() {
    uint8_t status = read8(0x2E);        // STATUS0: accel/gyro ready bits
    if ((status & 0x03) == 0) return;
    uint8_t raw[12] = {0};
    if (!readBytes(0x35, raw, sizeof(raw))) return;

    int16_t ax = (int16_t)((raw[1] << 8) | raw[0]);
    int16_t ay = (int16_t)((raw[3] << 8) | raw[2]);
    int16_t az = (int16_t)((raw[5] << 8) | raw[4]);
    int16_t gx = (int16_t)((raw[7] << 8) | raw[6]);
    int16_t gy = (int16_t)((raw[9] << 8) | raw[8]);
    int16_t gz = (int16_t)((raw[11] << 8) | raw[10]);
    uint32_t now = micros();

    accel.accelX = ax * accelScale - calibration.accelBias[0];
    accel.accelY = ay * accelScale - calibration.accelBias[1];
    accel.accelZ = az * accelScale - calibration.accelBias[2];
    accel.timestamp = now;
    gyro.gyroX = gx * gyroScale - calibration.gyroBias[0];
    gyro.gyroY = gy * gyroScale - calibration.gyroBias[1];
    gyro.gyroZ = gz * gyroScale - calibration.gyroBias[2];
    gyro.timestamp = now;
  }

  void getAccel(AccelData *out) { *out = accel; }
  void getGyro(GyroData *out) { *out = gyro; }

 private:
  uint8_t imuAddress = IMU_ADDRESS;
  float accelScale = 4.0f / 32768.0f;
  float gyroScale = 512.0f / 32768.0f;
  calData calibration = {0};
  AccelData accel = {0};
  GyroData gyro = {0};

  uint8_t read8(uint8_t reg) {
    uint8_t value = 0;
    readBytes(reg, &value, 1);
    return value;
  }

  bool readBytes(uint8_t reg, uint8_t *buffer, uint8_t len) {
    Wire.beginTransmission(imuAddress);
    Wire.write(reg);
    if (Wire.endTransmission(true) != 0) { resetSharedI2CBus(); return false; }
    delayMicroseconds(300);
    if (Wire.requestFrom((uint8_t)imuAddress, len, (uint8_t)true) != len) {
      resetSharedI2CBus();
      return false;
    }
    for (uint8_t i = 0; i < len; i++) buffer[i] = Wire.read();
    return true;
  }

  void write8(uint8_t reg, uint8_t value) {
    Wire.beginTransmission(imuAddress);
    Wire.write(reg);
    Wire.write(value);
    Wire.endTransmission();
  }

  void rmw8(uint8_t reg, uint8_t mask, uint8_t value) {
    uint8_t current = read8(reg);
    write8(reg, (current & ~mask) | (value & mask));
  }
};

// ── AXS5106L inline touch reader ──────────────────────────
// The AXS5106L is the capacitive touch controller on the
// Waveshare ESP32-C6-Touch-LCD-1.47. ESP-IDF components exist,
// but this Arduino starter inlines a small polling reader so it
// does not need the ESP-IDF/LVGL touch stack.
// Protocol: I2C @ 400 kHz, 7-bit device address 0x63.
// Touch packets are read from register 0x01. The packet starts
// with gesture_id, touch_count, then point data. This sketch uses
// the first active point for tap/swipe navigation.

#define AXS5106L_ADDR 0x63
#define AXS5106L_TOUCH_DATA_REG 0x01





static TwoWire *_touchWire = nullptr;
static uint8_t  _touchRst  = 255;
static uint8_t  _touchInt  = 255;
static uint16_t _touchW    = 320;
static uint16_t _touchH    = 172;
static uint8_t  _touchRot  = 0;

void bsp_touch_init(TwoWire *wire, uint8_t rstPin, uint8_t intPin,
                    uint8_t rotation, uint16_t dispW, uint16_t dispH) {
  _touchWire = wire;
  _touchRst  = rstPin;
  _touchInt  = intPin;
  _touchRot  = rotation;
  _touchW    = dispW;
  _touchH    = dispH;

  if (_touchRst != 255) {
    pinMode(_touchRst, OUTPUT);
    digitalWrite(_touchRst, LOW);
    delay(20);
    digitalWrite(_touchRst, HIGH);
    delay(50);
  }
  if (_touchInt != 255) {
    pinMode(_touchInt, INPUT_PULLUP);
  }
}

// bsp_touch_read — no-op for polling mode; INT pin can be
// checked externally if needed.
void bsp_touch_read() {}

uint16_t clampTouchCoord(int32_t value, uint16_t maxValue) {
  if (value < 0) return 0;
  if (value >= maxValue) return maxValue - 1;
  return (uint16_t)value;
}

uint16_t scaleTouchAxis(uint16_t raw, uint16_t rawMin, uint16_t rawMax, uint16_t outMax) {
  if (rawMax <= rawMin || outMax == 0) return 0;
  if (raw <= rawMin) return 0;
  if (raw >= rawMax) return outMax - 1;
  return (uint32_t)(raw - rawMin) * (outMax - 1) / (rawMax - rawMin);
}

// Returns true if at least one touch point is active.
bool bsp_touch_get_coordinates(uint16_t *outX, uint16_t *outY) {
  if (!_touchWire || !outX || !outY) return false;

  // Read only the first 6-byte touch frame. The UI only uses one point, and
  // shorter reads are less flaky than asking this controller for the optional
  // second-point bytes on every frame.
  _touchWire->beginTransmission(AXS5106L_ADDR);
  _touchWire->write(AXS5106L_TOUCH_DATA_REG);
  if (_touchWire->endTransmission(true) != 0) { resetSharedI2CBus(); return false; }
  delayMicroseconds(300);

  uint8_t len = _touchWire->requestFrom((uint8_t)AXS5106L_ADDR, (uint8_t)6, (uint8_t)true);
  if (len < 6) { resetSharedI2CBus(); return false; }

  uint8_t buf[6];
  for (uint8_t i = 0; i < 6; i++) buf[i] = _touchWire->read();

  uint8_t nPoints = buf[1] & 0x0F;
  if (nPoints == 0 || nPoints > 2) return false;

  // First point begins at byte 2: x_hi/event, x_lo, y_hi/id, y_lo.
  uint16_t rawX = ((uint16_t)(buf[2] & 0x0F) << 8) | buf[3];
  uint16_t rawY = ((uint16_t)(buf[4] & 0x0F) << 8) | buf[5];
  if ((rawX == 0x0FFF && rawY == 0x0FFF) || rawX > 4090 || rawY > 4090) return false;

  // Small edge dead-zone compensation. The controller reports raw axes with a
  // few pixels of slack at the extremes; scaling them to the active screen area
  // makes edge swipes less sticky while preserving the current orientation.
  const uint16_t edge = 3;
  uint16_t mappedX = rawX;
  uint16_t mappedY = rawY;
  switch (_touchRot) {
    case 1:  // landscape, default for this board
      mappedX = scaleTouchAxis(rawY, edge, _touchW > edge ? _touchW - 1 - edge : _touchW - 1, _touchW);
      mappedY = scaleTouchAxis(rawX, edge, _touchH > edge ? _touchH - 1 - edge : _touchH - 1, _touchH);
      break;
    case 2:
      mappedX = _touchW - 1 - scaleTouchAxis(rawX, edge, _touchW > edge ? _touchW - 1 - edge : _touchW - 1, _touchW);
      mappedY = _touchH - 1 - scaleTouchAxis(rawY, edge, _touchH > edge ? _touchH - 1 - edge : _touchH - 1, _touchH);
      break;
    case 3:
      mappedX = _touchW - 1 - scaleTouchAxis(rawY, edge, _touchW > edge ? _touchW - 1 - edge : _touchW - 1, _touchW);
      mappedY = scaleTouchAxis(rawX, edge, _touchH > edge ? _touchH - 1 - edge : _touchH - 1, _touchH);
      break;
    default:  // 0 — portrait
      mappedX = scaleTouchAxis(rawX, edge, _touchW > edge ? _touchW - 1 - edge : _touchW - 1, _touchW);
      mappedY = scaleTouchAxis(rawY, edge, _touchH > edge ? _touchH - 1 - edge : _touchH - 1, _touchH);
      break;
  }
  *outX = clampTouchCoord(mappedX, _touchW);
  *outY = clampTouchCoord(mappedY, _touchH);
  return true;
}
// ── End AXS5106L driver ────────────────────────────────────

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;
static const uint8_t ROTATION = 1;

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);

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

bool     imuReady        = false;
bool     touchReady      = false;
bool     touchWasDown    = false;
bool     wifiAttempted   = false;
bool     weatherValid    = false;
bool     ratesValid      = false;
bool     goldIndiaValid  = false;
bool     goldUaeValid    = false;
bool     locationValid   = false;
uint8_t  currentApp      = 0;
uint8_t  faceMood        = 0;
uint16_t touchStartX     = 0;
uint16_t touchStartY     = 0;
uint16_t touchLastX      = 0;
uint16_t touchLastY      = 0;
uint32_t touchStartMs    = 0;
uint8_t  touchMissFrames  = 0;
bool     touchMoved       = false;
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 ratesUpdatedAt     = 0;
uint32_t goldUpdatedAt      = 0;
uint32_t locationUpdatedAt  = 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   weatherTempC    = 0;
int   weatherHumidity = 0;
int   weatherWindKmh  = 0;
int   forecastHighC[7] = {0};
int   forecastLowC[7] = {0};
int   forecastCode[7] = {-1,-1,-1,-1,-1,-1,-1};
int   forecastRainChance[7] = {0};
String forecastDate[7];
bool  forecastValid = false;
int   weatherCode     = -1;
bool  weatherIsDay    = true;
String weatherLabel   = "WAITING";
float usdInr = 0.0f;
float aedInr = 0.0f;
float goldIndia24k = 0.0f; // INR per gram
float goldUae24k = 0.0f;   // AED per gram
float latitude = 0.0f;
float longitude = 0.0f;
String locationCity = "LOCATING";
String locationCountry = "";
String localDateText = "";
String localTimeText = "";
uint8_t localHourSeed = 0;
uint8_t localMinuteSeed = 0;
uint32_t localTimeSeedMs = 0;

// ── Helpers ────────────────────────────────────────────────

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

float clampFloat(float v, float lo, float hi) {
  return v < lo ? lo : v > hi ? hi : v;
}

// ── LCD init sequence for the AXS15231B panel ─────────────

void lcdRegInit() {
  static const uint8_t ops[] = {
      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(ops, sizeof(ops));
}

// ── Compile-time clock seed ────────────────────────────────

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 *m = __DATE__;
  static const char names[] = "JanFebMarAprMayJunJulAugSepOctNovDec";
  for (uint8_t i = 0; i < 12; i++)
    if (strncmp(m, names+i*3, 3) == 0) return i+1;
  return 1;
}

int32_t daysFromCivil(int32_t y, uint8_t mo, uint8_t d) {
  y -= mo <= 2;
  const int32_t era = (y >= 0 ? y : y-399)/400;
  const uint32_t yoe = (uint32_t)(y - era*400);
  const uint32_t doy = (153*(mo+(mo>2?-3:9))+2)/5 + d - 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 mo  = mp + (mp < 10 ? 3 : -9);
  y += mo <= 2;
  *year  = y;
  *month = (uint8_t)mo;
  *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 y = (int32_t)(d[7]-'0')*1000 + (int32_t)(d[8]-'0')*100 +
              (int32_t)(d[9]-'0')*10   + (d[10]-'0');
  return daysFromCivil(y, compileMonthNumber(), day);
}

// ── CSV helper ────────────────────────────────────────────

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 v = row.substring(start, end);
  v.trim();
  return v;
}

// ── Drawing primitives ────────────────────────────────────

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() {
  static const uint16_t colors[] = {rgb(255,105,180), rgb(255,190,40), rgb(70,220,255), rgb(120,240,150), rgb(185,135,255), rgb(255,150,70), rgb(80,170,255)};
  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-6, 3, colors[i]);
    else gfx->drawCircle(startX+i*16, SCREEN_H-6, 2, rgb(70,70,90));
  }
}

void drawWifiName() {
  gfx->setTextSize(1);
  if (WiFi.status() == WL_CONNECTED) {
    gfx->setTextColor(rgb(120, 240, 150));
    String name = WiFi.SSID();
    if (name.length() == 0) name = WIFI_SSID;
    if (name.length() > 18) name = name.substring(0, 17) + ".";
    int width = name.length() * 6;
    gfx->setCursor(SCREEN_W - width - 8, 7);
    gfx->print(name);
  } else {
    gfx->setTextColor(rgb(255, 150, 70));
    const char *status = wifiConfigured() ? "WIFI..." : "NO WIFI";
    gfx->setCursor(SCREEN_W - strlen(status) * 6 - 8, 7);
    gfx->print(status);
  }
}

void drawDateTimeStatus() {
  gfx->setTextSize(1);
  gfx->setTextColor(rgb(190, 190, 210));
  if (localDateText.length() >= 10 && localTimeSeedMs > 0) {
    uint32_t totalMinutes = localHourSeed * 60UL + localMinuteSeed +
                            (millis() - localTimeSeedMs) / 60000UL;
    uint8_t hour = (totalMinutes / 60UL) % 24UL;
    uint8_t minute = totalMinutes % 60UL;
    char status[18];
    snprintf(status, sizeof(status), "%s/%s %02u:%02u",
             localDateText.substring(8, 10).c_str(),
             localDateText.substring(5, 7).c_str(), hour, minute);
    gfx->setCursor(8, SCREEN_H - 22);
    gfx->print(status);
  } else {
    gfx->setCursor(8, SCREEN_H - 22);
    gfx->print("TIME SYNCING");
  }
}

// Small always-visible DeskBuddy face. A layered yellow circle, warm lower-right
// shadow, and upper-left highlight give it a cheerful 3D smiley appearance.
// Blink, mood, glance, and IMU-driven gaze remain shared with the main face page.
void drawFaceWidget(float tx, float ty) {
  const int cx = 177, cy = 10;
  uint32_t now = millis();
  bool closed = now < blinkUntil || faceMood == 3;
  int gazeX = (int)(tx * 2.0f + faceGlanceX * 0.12f);
  int gazeY = (int)(ty * 1.0f + faceGlanceY * 0.10f);
  uint16_t outline = rgb(176, 90, 0);
  uint16_t shadow = rgb(218, 132, 0);
  uint16_t yellow = rgb(255, 211, 36);
  uint16_t highlight = rgb(255, 246, 170);
  uint16_t feature = rgb(82, 52, 24);

  // The outline and offset shadow make the circular face legible over every header.
  gfx->fillCircle(cx, cy + 1, 11, outline);
  gfx->fillCircle(cx + 1, cy + 2, 10, shadow);
  gfx->fillCircle(cx - 1, cy - 1, 10, yellow);
  gfx->fillCircle(cx - 4, cy - 5, 3, highlight);
  gfx->drawCircle(cx - 1, cy - 1, 10, rgb(255, 183, 0));

  if (closed) {
    gfx->drawLine(cx - 8, cy - 1, cx - 3, cy, feature);
    gfx->drawLine(cx + 3, cy, cx + 8, cy - 1, feature);
  } else {
    gfx->fillCircle(cx - 6 + gazeX, cy - 1 + gazeY, 2, feature);
    gfx->fillCircle(cx + 6 + gazeX, cy - 1 + gazeY, 2, feature);
    gfx->drawPixel(cx - 7 + gazeX, cy - 2 + gazeY, RGB565_WHITE);
    gfx->drawPixel(cx + 5 + gazeX, cy - 2 + gazeY, RGB565_WHITE);
  }

  if (faceMood == 4) {
    // Playful diagonal mouth.
    gfx->drawLine(cx - 6, cy + 6, cx + 6, cy + 3, feature);
    gfx->drawLine(cx - 5, cy + 7, cx + 6, cy + 4, feature);
  } else if (faceMood == 2) {
    // Surprised mouth.
    gfx->fillCircle(cx, cy + 5, 3, feature);
    gfx->fillCircle(cx, cy + 4, 1, rgb(255, 211, 36));
  } else if (faceMood == 3) {
    gfx->drawLine(cx - 5, cy + 6, cx + 5, cy + 6, feature);
  } else {
    // Warm, thick smile curve.
    gfx->drawLine(cx - 6, cy + 4, cx - 3, cy + 7, feature);
    gfx->drawLine(cx - 3, cy + 7, cx, cy + 8, feature);
    gfx->drawLine(cx, cy + 8, cx + 3, cy + 7, feature);
    gfx->drawLine(cx + 3, cy + 7, cx + 6, cy + 4, feature);
  }
}

void drawHeader(const char *title) {
  gfx->fillScreen(BG);
  gfx->drawLine(0, 20, SCREEN_W, 20, rgb(70,220,255));
  gfx->setTextSize(1);
  gfx->setTextColor(rgb(70,220,255));
  gfx->setCursor(8, 7);
  gfx->print(title);
  float tx = imuReady ? clampFloat(-(filteredAy-restAy)*2.2f, -1.0f, 1.0f) : sin(millis()*0.0014f)*0.25f;
  float ty = imuReady ? clampFloat( (filteredAx-restAx)*2.2f, -1.0f, 1.0f) : cos(millis()*0.0011f)*0.16f;
  drawFaceWidget(tx, ty);
  drawWifiName();
  drawDateTimeStatus();
}

// ── App navigation ────────────────────────────────────────

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

// ── Wi-Fi ─────────────────────────────────────────────────

bool wifiConfigured() {
  return strlen(WIFI_SSID) > 0 && strcmp(WIFI_SSID, "YOUR_WIFI_NAME") != 0;
}

bool ensureWifi() {
  if (WiFi.status() == WL_CONNECTED) return true;
  if (!wifiConfigured() || 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 < 9000UL) delay(120);
  return WiFi.status() == WL_CONNECTED;
}

bool httpsGet(const String &url, String &body) {
  WiFiClientSecure client;
  client.setInsecure(); // HTTPS encryption; no root bundle is embedded in this small firmware.
  HTTPClient http;
  http.setTimeout(9000);
  if (!http.begin(client, url)) return false;
  http.addHeader("User-Agent", "DeskBuddy-ESP32C6/1.0");
  int status = http.GET();
  if (status != HTTP_CODE_OK) { http.end(); return false; }
  body = http.getString();
  http.end();
  return body.length() > 0;
}

bool fetchLocation() {
  if (!ensureWifi()) return false;
  String body;
  if (!httpsGet("https://ipwho.is/", body)) return false;
  JsonDocument doc;
  if (deserializeJson(doc, body) || !doc["success"].as<bool>()) return false;
  float lat = doc["latitude"].as<float>();
  float lon = doc["longitude"].as<float>();
  if (isnan(lat) || isnan(lon) || (lat == 0.0f && lon == 0.0f)) return false;
  latitude = lat; longitude = lon;
  locationCity = doc["city"].as<String>();
  locationCountry = doc["country_code"].as<String>();
  locationValid = locationCity.length() > 0;
  locationUpdatedAt = millis();
  return locationValid;
}

// ── Weather fetch (Open-Meteo, NYC default) ───────────────

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) {
  uint16_t iconColor = (code == 0 || code == 1) ? rgb(255, 190, 40) : ((code >= 51 && code <= 82) ? rgb(70, 160, 255) : rgb(220, 210, 255));
  if (code == 0) {
    gfx->drawCircle(cx, cy, 22, iconColor);
    for (uint8_t i = 0; i < 8; i++) {
      float a = i*0.7854f;
      gfx->drawLine(cx+(int)(cos(a)*30), cy+(int)(sin(a)*30),
                    cx+(int)(cos(a)*40), cy+(int)(sin(a)*40), iconColor);
    }
    if (!isDay) gfx->fillCircle(cx+11, cy-8, 18, BG);
    return;
  }
  gfx->fillCircle(cx-19, cy+5, 19, iconColor);
  gfx->fillCircle(cx+2,  cy-6, 25, iconColor);
  gfx->fillCircle(cx+27, cy+8, 17, iconColor);
  gfx->fillRoundRect(cx-42, cy+8, 87, 25, 12, iconColor);
  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 (!locationValid && !fetchLocation()) return false;
  String url = String("https://api.open-meteo.com/v1/forecast?latitude=") + String(latitude, 4) +
    "&longitude=" + String(longitude, 4) +
    "&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,is_day&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&forecast_days=7&timezone=auto";
  String body;
  if (!httpsGet(url, body)) return false;
  JsonDocument doc;
  if (deserializeJson(doc, body)) return false;
  weatherTempC = (int)round(doc["current"]["temperature_2m"].as<float>());
  weatherHumidity = doc["current"]["relative_humidity_2m"].as<int>();
  weatherWindKmh = (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);
  String stamp = doc["current"]["time"].as<String>(); // local due to timezone=auto
  if (stamp.length() >= 16) {
    localDateText = stamp.substring(0, 10); localTimeText = stamp.substring(11, 16);
    localHourSeed = stamp.substring(11, 13).toInt();
    localMinuteSeed = stamp.substring(14, 16).toInt();
    localTimeSeedMs = millis();
  }
  JsonArray days = doc["daily"]["time"].as<JsonArray>();
  JsonArray highs = doc["daily"]["temperature_2m_max"].as<JsonArray>();
  JsonArray lows = doc["daily"]["temperature_2m_min"].as<JsonArray>();
  JsonArray codes = doc["daily"]["weather_code"].as<JsonArray>();
  JsonArray rain = doc["daily"]["precipitation_probability_max"].as<JsonArray>();
  forecastValid = days.size() >= 7 && highs.size() >= 7 && lows.size() >= 7 && codes.size() >= 7;
  if (forecastValid) {
    for (uint8_t i = 0; i < 7; i++) {
      forecastDate[i] = days[i].as<String>();
      forecastHighC[i] = (int)round(highs[i].as<float>());
      forecastLowC[i] = (int)round(lows[i].as<float>());
      forecastCode[i] = codes[i].as<int>();
      forecastRainChance[i] = rain.size() > i ? rain[i].as<int>() : 0;
    }
  }
  weatherUpdatedAt = millis(); weatherValid = true;
  return true;
}

// ── Stock fetch (stooq CSV, AAPL) ────────────────────────

bool fetchRates() {
  if (!ensureWifi()) return false;
  String body;
  if (!httpsGet("https://open.er-api.com/v6/latest/USD", body)) return false;
  JsonDocument doc;
  if (deserializeJson(doc, body) || doc["result"].as<String>() != "success") return false;
  float inrPerUsd = doc["rates"]["INR"].as<float>();
  float aedPerUsd = doc["rates"]["AED"].as<float>();
  if (inrPerUsd <= 0.0f || aedPerUsd <= 0.0f) return false;
  usdInr = inrPerUsd;
  aedInr = inrPerUsd / aedPerUsd;
  ratesUpdatedAt = millis(); ratesValid = true;
  return true;
}

// Return visible text from one small HTML table cell. Goodreturns can add spans,
// currency entities, and change CSS classes, so the parser deliberately ignores tags.
String htmlCellText(const String &html) {
  String text;
  bool inTag = false;
  for (uint16_t i = 0; i < html.length(); i++) {
    char c = html[i];
    if (c == '<') { inTag = true; continue; }
    if (c == '>') { inTag = false; continue; }
    if (!inTag) text += c;
  }
  text.replace("&nbsp;", " ");
  text.replace("&#x20B9;", " ");
  text.replace("&#8377;", " ");
  text.trim();
  return text;
}

// Take the first price-like number in the cell. This intentionally ignores
// Goodreturns' optional change figure, such as "(+2.75)", after the price.
float firstPriceInCell(const String &cell) {
  String number;
  bool started = false;
  for (uint16_t i = 0; i < cell.length(); i++) {
    char c = cell[i];
    if ((c >= '0' && c <= '9') || c == '.' || c == ',') {
      number += c;
      started = true;
    } else if (started) {
      break;
    }
  }
  number.replace(",", "");
  return number.toFloat();
}

// Find the country-specific "per gram" table, then use its first data row:
// first cell = 1 gram, second cell = 24K. This avoids matching 10g, 22K,
// city tables, and the INR-converted table that appears lower on the UAE page.
float extractGoldTableRate(const String &html, const String &headingMarker) {
  int heading = html.indexOf(headingMarker);
  if (heading < 0) return 0.0f;
  int tableStart = html.indexOf("<table", heading);
  int tableEnd = tableStart < 0 ? -1 : html.indexOf("</table>", tableStart);
  if (tableStart < 0 || tableEnd < 0) return 0.0f;
  String table = html.substring(tableStart, tableEnd);
  if (table.indexOf("24K") < 0 || table.indexOf("22K") < 0 || table.indexOf("Gram") < 0) return 0.0f;

  int rowStart = table.indexOf("<tr");
  while (rowStart >= 0) {
    int rowEnd = table.indexOf("</tr>", rowStart);
    if (rowEnd < 0) break;
    String row = table.substring(rowStart, rowEnd);
    int cellStart = row.indexOf("<td");
    if (cellStart >= 0) {
      int firstOpenEnd = row.indexOf('>', cellStart);
      int firstClose = firstOpenEnd < 0 ? -1 : row.indexOf("</td>", firstOpenEnd);
      if (firstClose >= 0 && htmlCellText(row.substring(firstOpenEnd + 1, firstClose)) == "1") {
        int secondStart = row.indexOf("<td", firstClose + 5);
        int secondOpenEnd = secondStart < 0 ? -1 : row.indexOf('>', secondStart);
        int secondClose = secondOpenEnd < 0 ? -1 : row.indexOf("</td>", secondOpenEnd);
        if (secondClose >= 0) return firstPriceInCell(row.substring(secondOpenEnd + 1, secondClose));
      }
    }
    rowStart = table.indexOf("<tr", rowEnd + 5);
  }
  return 0.0f;
}

bool fetchGoldRates() {
  if (!ensureWifi()) { Serial.println("Gold: Wi-Fi unavailable"); return false; }

  // XAUS supplies 24K/pure-gold indicative spot prices directly in the requested
  // currency and unit. This replaces the Goodreturns HTML scraper, whose layout
  // and bot protection can make an embedded-device request fail.
  String india, uae;
  if (!httpsGet("https://xaus.com/api/v1/spot?currency=INR&unit=gram&compact=1", india)) {
    Serial.println("Gold: INR spot request failed"); return false;
  }
  if (!httpsGet("https://xaus.com/api/v1/spot?currency=AED&unit=gram&compact=1", uae)) {
    Serial.println("Gold: AED spot request failed"); return false;
  }

  JsonDocument inrDoc, aedDoc;
  if (deserializeJson(inrDoc, india) || deserializeJson(aedDoc, uae)) {
    Serial.println("Gold: spot JSON parse failed"); return false;
  }
  String inrCurrency = inrDoc["xau"]["currency"].as<String>();
  String aedCurrency = aedDoc["xau"]["currency"].as<String>();
  String inrUnit = inrDoc["xau"]["unit"].as<String>();
  String aedUnit = aedDoc["xau"]["unit"].as<String>();
  float inr = inrDoc["xau"]["price"].as<float>();
  float aed = aedDoc["xau"]["price"].as<float>();
  if (inr <= 0.0f || aed <= 0.0f || inrCurrency != "INR" || aedCurrency != "AED" ||
      inrUnit != "gram" || aedUnit != "gram") {
    Serial.println("Gold: invalid spot response"); return false;
  }

  goldIndia24k = inr; goldUae24k = aed;
  goldIndiaValid = goldUaeValid = true; goldUpdatedAt = millis();
  Serial.print("Gold spot updated: India INR "); Serial.print(inr, 2);
  Serial.print("/g, UAE AED "); Serial.println(aed, 2);
  return true;
}


// ── Face rendering ────────────────────────────────────────

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();
}

// ── 7-segment clock ───────────────────────────────────────

void drawDigitSegment(int x, int y, int w, int h, int t, uint8_t seg) {
  int half = h/2, r = t/2;
  switch (seg) {
    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 seg = 0; seg < 7; seg++)
    if (masks[digit%10] & (1<<seg)) drawDigitSegment(x, y, 42, 76, 8, seg);
}

void drawClock() {
  drawHeader("LOCAL TIME");
  if (!weatherValid || localTimeText.length() < 5) {
    centeredText(wifiConfigured() ? "SYNCING TIME" : "SET WIFI IN secrets.h", 72, 2);
    drawPageDots(); return;
  }
  uint32_t minutes = localHourSeed * 60UL + localMinuteSeed + (millis() - localTimeSeedMs) / 60000UL;
  uint8_t hh = (minutes / 60UL) % 24UL, mm = minutes % 60UL;
  char timeLine[6]; snprintf(timeLine, sizeof(timeLine), "%02u:%02u", hh, mm);
  gfx->setTextSize(7); gfx->setTextColor(FG);
  gfx->setCursor(48, 55); gfx->print(timeLine);
  gfx->setTextSize(2); gfx->setCursor(87, 128); gfx->print(localDateText);
  drawPageDots();
}

// ── Date page ─────────────────────────────────────────────

void drawDatePage() {
  static const char *wd[]  = {"SUNDAY","MONDAY","TUESDAY","WEDNESDAY","THURSDAY","FRIDAY","SATURDAY"};
  static const char *mon[] = {"JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"};
  uint32_t elapsedSec = (millis()-clockStartMillis)/1000UL;
  int32_t days = compileDateDays() + (int32_t)((clockStartSeconds+elapsedSec)/86400UL);
  int32_t year; uint8_t month, day;
  civilFromDays(days, &year, &month, &day);
  uint8_t weekday = (uint8_t)((days+4)%7);
  drawHeader("DATE");
  centeredText(wd[weekday], 35, 3);
  char line[24];
  snprintf(line, sizeof(line), "%s %02u", mon[month-1], day);
  centeredText(line, 82, 5);
  snprintf(line, sizeof(line), "%ld", (long)year);
  centeredText(line, 130, 2);
  drawPageDots();
}

// ── Weather page ──────────────────────────────────────────

void drawWeather() {
  drawHeader(locationValid ? locationCity.c_str() : "LOCAL WEATHER");
  if (!wifiConfigured()) {
    centeredText("NO WIFI CONFIG", 70, 2);
    centeredText("EDIT CONFIG", 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(rgb(255, 190, 40));
  gfx->setCursor(20, 60); gfx->print(weatherTempC);
  gfx->setTextSize(3); gfx->print("C");
  gfx->setTextSize(1); gfx->setTextColor(rgb(120, 220, 255));
  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(weatherWindKmh); gfx->print("KM/H");
  drawPageDots();
}

// ── Moon phase page ───────────────────────────────────────

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 drawForecast() {
  drawHeader(locationValid ? "7 DAY FORECAST" : "WEEKLY FORECAST");
  if (!wifiConfigured()) { centeredText("SET WIFI IN secrets.h", 76, 2); drawPageDots(); return; }
  if (!forecastValid) { centeredText("UPDATING FORECAST", 76, 2); drawPageDots(); return; }
  static const char *weekDays[] = {"SUN","MON","TUE","WED","THU","FRI","SAT"};
  for (uint8_t i = 0; i < 7; i++) {
    int x = 4 + i * 45;
    int dayNum = 0;
    if (forecastDate[i].length() >= 10) {
      int y = forecastDate[i].substring(0,4).toInt();
      int m = forecastDate[i].substring(5,7).toInt();
      int d = forecastDate[i].substring(8,10).toInt();
      dayNum = (daysFromCivil(y, m, d) + 4) % 7;
      if (dayNum < 0) dayNum += 7;
    }
    gfx->setTextSize(1); gfx->setTextColor(rgb(120, 220, 255));
    gfx->setCursor(x + 5, 28); gfx->print(weekDays[dayNum]);
    int iconColor = (forecastCode[i] <= 2) ? rgb(255,190,40) : (forecastCode[i] >= 51 ? rgb(70,160,255) : rgb(210,190,255));
    gfx->fillCircle(x + 20, 56, 10, iconColor);
    if (forecastCode[i] >= 51) gfx->drawLine(x+16, 68, x+12, 76, rgb(70,160,255));
    gfx->setTextColor(rgb(255, 120, 90)); gfx->setCursor(x + 5, 84); gfx->print(forecastHighC[i]); gfx->print("C");
    gfx->setTextColor(rgb(120, 220, 255)); gfx->setCursor(x + 5, 101); gfx->print(forecastLowC[i]); gfx->print("C");
    gfx->setTextColor(rgb(100, 180, 255)); gfx->setCursor(x + 5, 122); gfx->print(forecastRainChance[i]); gfx->print("%");
  }
  gfx->setTextSize(1); gfx->setTextColor(rgb(150,150,150)); gfx->setCursor(63, 143); gfx->print("HIGH / LOW / RAIN CHANCE");
  drawPageDots();
}

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 syn = 29.53058867f;
  uint32_t elapsed = (millis()-clockStartMillis)/1000UL;
  float days = (float)compileDateDays() + ((float)compileTimeSeconds()+(float)elapsed)/86400.0f;
  float age  = fmod(days-10962.7597f, syn);
  if (age < 0.0f) age += syn;
  float phase       = age/syn;
  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();
}

// ── Stock page ────────────────────────────────────────────

void drawRates() {
  drawHeader("EXCHANGE RATES");
  if (!wifiConfigured()) { centeredText("SET WIFI IN secrets.h", 76, 2); drawPageDots(); return; }
  if (!ratesValid) { centeredText("UPDATING", 76, 2); drawPageDots(); return; }
  gfx->setTextSize(3); gfx->setTextColor(FG);
  gfx->setCursor(24, 52); gfx->print("USD / INR");
  gfx->setCursor(24, 112); gfx->print("AED / INR");
  gfx->setTextSize(4);
  gfx->setCursor(186, 52); gfx->print(usdInr, 2);
  gfx->setCursor(186, 112); gfx->print(aedInr, 2);
  gfx->setTextSize(1); gfx->setCursor(24, 142); gfx->print("MID-MARKET; REFRESH 30 MIN");
  drawPageDots();
}

// ── GitHub page ───────────────────────────────────────────

void drawGold() {
  drawHeader("24K GOLD / GRAM");
  if (!wifiConfigured()) { centeredText("SET WIFI IN secrets.h", 76, 2); drawPageDots(); return; }
  if (!goldIndiaValid || !goldUaeValid) { centeredText("UPDATING", 76, 2); drawPageDots(); return; }
  // Tax-inclusive values are estimates for pure 24K gold: India GST 3%, UAE
  // VAT 5%. Jewellery making charges, dealer premiums, customs, and any local
  // exemptions are not included.
  float indiaWithTax = goldIndia24k * 1.03f;
  float uaeWithTax = goldUae24k * 1.05f;
  gfx->setTextSize(1); gfx->setTextColor(rgb(255, 190, 40));
  gfx->setCursor(16, 31); gfx->print("INDIA 24K/G + 3% GST");
  gfx->setCursor(16, 84); gfx->print("UAE 24K/G + 5% VAT");
  gfx->setTextSize(3); gfx->setTextColor(FG);
  gfx->setCursor(16, 45); gfx->print("INR "); gfx->print(indiaWithTax, 0);
  gfx->setCursor(16, 98); gfx->print("AED "); gfx->print(uaeWithTax, 2);
  gfx->setTextSize(1); gfx->setTextColor(rgb(160, 175, 195));
  gfx->setCursor(16, 120); gfx->print("SPOT + EST. TAX / GRAM");
  gfx->setCursor(16, 134); gfx->print("EXCL. MAKING / DEALER FEES");
  drawPageDots();
}

void drawLocation() {
  drawHeader("CURRENT LOCATION");
  if (!wifiConfigured()) { centeredText("SET WIFI IN secrets.h", 76, 2); drawPageDots(); return; }
  if (!locationValid) { centeredText("LOCATING", 76, 2); drawPageDots(); return; }
  centeredText(locationCity.c_str(), 55, 3);
  centeredText(locationCountry.c_str(), 92, 3);
  gfx->setTextSize(1); gfx->setTextColor(FG); gfx->setCursor(28, 132);
  gfx->print("IP LOCATION - APPROXIMATE");
  drawPageDots();
}

// ── Interaction handlers ──────────────────────────────────

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) {
    // Animate eyes sinusoidally when IMU is absent
    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;
  // Fast spin → surprised face
  if (fabs(filteredGz) > 130.0f) { faceMood = 2; pressPulse = 1.0f; }
}

void readTouch() {
  if (!touchReady) return;
  // Poll every frame instead of gating on TOUCH_INT. This keeps swipes working
  // on boards where the AXS controller's interrupt is not asserted reliably.
  uint16_t x = 0, y = 0;
  bsp_touch_read();
  if (bsp_touch_get_coordinates(&x, &y)) {
    uint32_t now = millis();
    touchLastX = x; touchLastY = y;
    touchMissFrames = 0;
    if (!touchWasDown) {
      touchStartX = x; touchStartY = y; touchStartMs = now;
      touchMoved = false;
      touchWasDown = true;
      return;
    }
    int16_t dx = (int16_t)x-(int16_t)touchStartX;
    int16_t dy = (int16_t)y-(int16_t)touchStartY;
    if (abs(dx) > 12 || abs(dy) > 12) touchMoved = true;
    if (abs(dx) > 55 && abs(dx) > abs(dy)+18) {
      // Left swipe returns to the previous page; right swipe advances.
      switchApp(dx < 0 ? -1 : 1);
      touchWasDown = false;
      touchMissFrames = 0;
      touchMoved = false;
    }
  } else if (touchWasDown) {
    // The AXS5106L INT/read path can miss the odd frame. Require a few
    // consecutive misses before treating it as release, otherwise taps/swipes
    // get chopped up and feel flaky.
    if (++touchMissFrames < 3) return;
    uint32_t pressMs = millis() - touchStartMs;
    int16_t dx = (int16_t)touchLastX-(int16_t)touchStartX;
    int16_t dy = (int16_t)touchLastY-(int16_t)touchStartY;
    if (pressMs >= 35 && pressMs <= 650 && !touchMoved && abs(dx) < 35 && abs(dy) < 35) {
      triggerFaceTap();
    }
    touchWasDown = false;
    touchMissFrames = 0;
    touchMoved = 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() {
  // Requests occur only when a related page is visible; failed requests retry
  // after two minutes rather than blocking the touch UI in a tight loop.
  uint32_t now = millis();
  if (currentApp == 1 || currentApp == 2 || currentApp == 3 || currentApp == 6) {
    if ((!locationValid && (locationUpdatedAt == 0 || now - locationUpdatedAt > 120000UL)) || now - locationUpdatedAt > 24UL*60UL*60UL*1000UL) {
      locationUpdatedAt = now; fetchLocation();
    }
  }
  if ((currentApp == 1 || currentApp == 3 || currentApp == 6) && (!weatherValid || now - weatherUpdatedAt > 15UL*60UL*1000UL)) {
    weatherUpdatedAt = now; fetchWeather();
  }
  if (currentApp == 4 && (!ratesValid || now - ratesUpdatedAt > 30UL*60UL*1000UL)) {
    ratesUpdatedAt = now; fetchRates();
  }
  if (currentApp == 5 && (!goldIndiaValid || now - goldUpdatedAt > 6UL*60UL*60UL*1000UL)) {
    goldUpdatedAt = now; fetchGoldRates();
  }
}

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

// ── Arduino entry points ──────────────────────────────────

void setup() {
  Serial.begin(115200);
  delay(150);
  Serial.println("ESP32-C6 DeskBuddy starting");

  if (!gfx->begin(40000000)) Serial.println("Display init failed — check wiring");
  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;
  Serial.println("Touch controller initialised");

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

  switch (currentApp) {
    case 0: drawFace(tx, ty);  break;
    case 1: drawClock();       break;
    case 2: drawLocation();    break;
    case 3: drawWeather();     break;
    case 4: drawRates();       break;
    case 5: drawGold();        break;
    default: drawForecast();   break;
  }
  gfx->flush();

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

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik