Community project

Tiny ESP DeskBuddy Dashboard

akhrazsaadia8

Published August 23, 2026

ESP32
Photo of Tiny ESP DeskBuddy DashboardGenerated with AI

DeskBuddy is a rolling desktop companion that combines a camera, distance sensor, and voice recognition to create an interactive robotic buddy. Built around the XIAO ESP32S3 Sense, it features motorized movement, offline voice commands, and proximity detection for responsive interactions.

This guide provides a complete parts list, wiring diagram, assembly steps, and firmware to bring your DeskBuddy to life. Learn how to integrate the motor driver, battery charging system, and sensors while building a compact rolling platform that responds to voice and gestures.

Wiring diagram

Interactive · read-only
Wiring diagram for Tiny ESP DeskBuddy Dashboard

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 1200mAh3.7 V, 1200 mAh13.7V 1200mAh LiPoly battery (~4.5 Wh) with JST-PH connector and built-in protection against overcharge, over-discharge, and output shorts. Cables rated for 2A; charge at 500mA or less.
Seeed Studio XIAO ESP32S3 Sense with OV2640 and microSD expansion boardOV2640 2 MP + microSD1A compact camera controller that takes 2 MP photos to its microSD card and controls the two wheel motors.
TB6612FNG dual motor driverTB6612FNG1TB6612FNG dual H-bridge motor driver breakout with raw AIN1/AIN2/PWMA and BIN1/BIN2/PWMB control inputs plus STBY. The motor terminals connect to the driver outputs, not directly to MCU GPIO.
TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio3–6 V, 1:901TT-format bi-metal DC gearbox motor with a 1:90 reduction ratio - roughly double the torque and half the speed of the more common 1:48 TT motor. Runs on 3-6VDC (60-120 RPM no-load depending on voltage), has steel output gears for durability, and is one of several TT-motor gearbox ratios (1:48, 1:90, 1:120) used across 2WD/4WD robot car and crawler chassis kits, this one favoring torque/climbing over speed. Ships with no wires attached and cannot be driven directly from a microcontroller pin; requires a separate high-current motor driver such as a DRV8833 or TB6612FNG.
TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio3–6 V, 1:901TT-format bi-metal DC gearbox motor with a 1:90 reduction ratio - roughly double the torque and half the speed of the more common 1:48 TT motor. Runs on 3-6VDC (60-120 RPM no-load depending on voltage), has steel output gears for durability, and is one of several TT-motor gearbox ratios (1:48, 1:90, 1:120) used across 2WD/4WD robot car and crawler chassis kits, this one favoring torque/climbing over speed. Ships with no wires attached and cannot be driven directly from a microcontroller pin; requires a separate high-current motor driver such as a DRV8833 or TB6612FNG.
PowerBoost 1000 Charger - Rechargeable 5V Lipo USB Boost @ 1A [1000C]5.2 V, 1 A charge/boost1DC/DC boost converter with integrated LiPoly charger and load-sharing circuit, outputting 5.2V at up to 1A from a single-cell LiPoly battery. Can run a 5V project while simultaneously charging the battery from USB.
VL53L0X Time-of-Flight Distance Sensorfront obstacle sensor1ST VL53L0X single-zone time-of-flight ranging sensor. Communicates over I2C; XSHUT and GPIO1/data-ready pins are optional on common breakouts.
DFRobot SEN0539 DF2301Q Offline Voice Recognition Moduleoffline spoken-command module1A small microphone-and-speaker module that recognizes a fixed set of spoken commands and a few taught phrases without using the internet.

Assembly

6 steps
  1. Build the rolling base

    Fit the two TT gear motors into the printed enclosure so their shafts point out through the side wheel holes. Press the wheels on and add a small rear skid or ball caster so the body has three points touching the floor.

    • Tip: Turn each wheel by hand; it should spin freely without rubbing the enclosure.
    • Keep fingers, hair, and loose clothing away from the wheels while power is connected.
  2. Mount the motor driver

    Secure the TB6612FNG board above the motors on plastic standoffs. Connect left motor M1 and M2 to A01 and A02, and right motor M1 and M2 to B01 and B02; these wires carry the wheel power.

    • Tip: If a wheel turns the wrong direction during the first test, unplug power and swap that motor’s two wires.
    • Do not connect motor wires directly to either ESP32 board — motor current can damage the boards.
  3. Add the camera controller and face board

    Mount the XIAO ESP32-S3 Sense camera so its lens looks through the front opening, then mount the Waveshare screen as the face. Connect Waveshare GPIO7 to XIAO D7 (command), and connect the boards’ GND pins together (shared ground).

    • Tip: Keep the camera lens opening clear and use a thin foam ring so the lens does not scratch against the printed enclosure.
    • Do not force the camera ribbon cable or cover the camera lens with glue.
  4. Wire the motor signals and battery

    Connect XIAO D0 → TB6612 AIN1, D1 → AIN2, D2 → PWMA, D3 → BIN1, D4 → BIN2, D5 → PWMB, D6 → STBY (motor-control signals). Connect XIAO 3V3 → TB6612 VCC (logic power), and XIAO GND → TB6612 GND (ground). Connect the LiPo BAT+ and BAT- to the protected charger/boost board battery connector, then connect the driver’s VM to the battery-positive motor supply and its GND to battery-negative ground.

    • Tip: Use red wire for battery positive and black wire for ground so the power wiring is easy to check later.
    • Never short the LiPo leads together, puncture or crush the battery, or charge it unattended. Stop using the battery if it becomes swollen, hot, or damaged.
  5. Fit the distance and voice modules

    Put the VL53L0X sensor behind a front window with a clear view. Connect VIN → 5V (power), GND → GND (ground), SDA → GPIO18 (data), and SCL → GPIO19 (clock). Connect the voice module VCC → 3V3 (power), GND → GND (ground), SDA → GPIO18 (data), and SCL → GPIO19 (clock).

    • Tip: Both small modules share the two data wires; that is normal. Keep their sensor openings clear of the enclosure wall.
    • Make sure VCC and GND are not swapped — swapped power can damage a sensor or voice module.
  6. Close the enclosure and test safely

    Place the LiPo in a snug separate battery pocket lined with thin foam, with a removable cover and strain relief on its wires. Leave ventilation gaps around the boost board and keep the charging connector reachable. Test with the rover lifted so its wheels cannot touch the floor, then set it down in an open area with no stairs, pets, or people in front of it.

    • Tip: A long touch on the face takes a photo; an upward swipe requests a short forward move only when the front sensor has clear space.
    • Do not charge the rover inside a sealed enclosure or while it is moving. This build is supervised only: do not let it drive near stairs, water, roads, children, pets, or medical equipment.

Pin assignments

Board wiring reference
PinConnectionType
EXTxiao_esp32s3_sense D0TB6612FNG dual motor driver AIN1digital
EXTxiao_esp32s3_sense D1TB6612FNG dual motor driver AIN2digital
EXTxiao_esp32s3_sense D2TB6612FNG dual motor driver PWMApwm
EXTxiao_esp32s3_sense D3TB6612FNG dual motor driver BIN1digital
EXTxiao_esp32s3_sense D4TB6612FNG dual motor driver BIN2digital
EXTxiao_esp32s3_sense D5TB6612FNG dual motor driver PWMBpwm
EXTxiao_esp32s3_sense D6TB6612FNG dual motor driver STBYdigital
GNDtb6612_motor_driver GNDground
EXTtb6612_motor_driver A01TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M1digital
EXTtb6612_motor_driver A02TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M2digital
EXTtb6612_motor_driver B01TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M1digital
EXTtb6612_motor_driver B02TT Motor Bi-Metal Gearbox - 1:90 Gear Ratio M2digital
EXTlipo_1200mah BAT+TB6612FNG dual motor driver VMpower
GNDlipo_1200mah BAT-ground
EXTxiao_esp32s3_sense BAT+Lithium Ion Polymer Battery - 3.7v 1200mAh BAT+power
GNDxiao_esp32s3_sense BAT-ground
EXTxiao_esp32s3_sense 3V3TB6612FNG dual motor driver VCCpower
GPIO 7xiao_esp32s3_sense D7digital
EXTpowerboost_1000 BATLithium Ion Polymer Battery - 3.7v 1200mAh BAT+power
GNDpowerboost_1000 GNDground
VINpowerboost_1000 5Vpower
3V3front_tof VINpower
GNDfront_tof GNDground
GPIO 18front_tof SDAi2c
GPIO 19front_tof SCLi2c
3V3voice_commands VCCpower
GNDvoice_commands GNDground
GPIO 18voice_commands SDAi2c
GPIO 19voice_commands SCLi2c

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 <Adafruit_VL53L0X.h>
#include <DFRobot_DF2301Q.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"

// Wi-Fi and the GitHub name are kept in include/secrets.h so they stay out of
// the main firmware. Leaving WIFI_SSID blank keeps network pages in setup mode.

// ── 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
#define ROVER_TX 7  // One-way command wire to the XIAO ESP32-S3 Sense RX pin.


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
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 drawHeader(const char *title);
void switchApp(int8_t delta);
bool wifiConfigured();
bool githubConfigured();
bool ensureWifi();
void drawWeatherIcon(int cx, int cy, int code, bool isDay);
bool fetchWeather();
bool fetchStock();
bool fetchGithub();
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 drawMoonDisc(int cx, int cy, int radius, float phase);
void drawMoon();
void drawStock();
void drawGithub();
void drawHelp();
bool fetchBuddyHelp();
void sendRoverCommand(char command);
void triggerFaceTap();
void readSensors();
void readTouch();
void updateFaceTimers();
void updateAutoPage();
void updateNetworkPages();
void updateFrontObstacle();
void updateVoiceCommands();
void calibrateNeutral();

uint32_t roverStopAt = 0;
Adafruit_VL53L0X frontTof;
bool frontTofReady = false;
bool frontBlocked = false;
uint32_t lastObstacleReadMs = 0;
DFRobot_DF2301Q_I2C voice(&Wire, 0x64);
bool voiceReady = false;
uint32_t lastVoiceReadMs = 0;

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);
  Wire.setClock(100000);
}

#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    = 8;
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     stockValid      = false;
bool     githubValid     = 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 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;

// The Help page offers short online learning cards. It is intentionally not a
// diagnostic or medicine-prescribing feature.
static const char *helpTopics[] = {"STUDY", "RECIPE", "LANGUAGE", "EVERYDAY", "HEALTH"};
static const uint8_t HELP_TOPIC_COUNT = sizeof(helpTopics) / sizeof(helpTopics[0]);
uint8_t helpTopic = 0;
bool helpValid = false;
String helpAnswer = "TAP FOR A SHORT TIP";

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

// ── 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; }
bool githubConfigured() { return strlen(GITHUB_USER) > 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 < 3500UL) delay(120);
  return WiFi.status() == WL_CONNECTED;
}

// ── 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) {
  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+(int)(cos(a)*30), cy+(int)(sin(a)*30),
                    cx+(int)(cos(a)*40), cy+(int)(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;
  http.setTimeout(6000);
  if (!http.begin(
        "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"))
    return false;
  if (http.GET() != 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;
}

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

bool fetchStock() {
  if (!ensureWifi()) return false;
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(7000);
  if (!http.begin(client, "https://stooq.com/q/l/?s=aapl.us&f=sd2t2ohlcv&h&e=csv"))
    return false;
  if (http.GET() != 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;
}

// ── GitHub fetch ──────────────────────────────────────────

bool fetchGithub() {
  if (!githubConfigured()) return false;
  if (!ensureWifi()) return false;
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(7000);
  String url = String("https://api.github.com/users/") + GITHUB_USER;
  if (!http.begin(client, url)) return false;
  http.addHeader("User-Agent", "ESP32-C6-Touch-LCD");
  if (http.GET() != 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;
}

// ── 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() {
  uint32_t elapsed = (millis()-clockStartMillis)/1000UL;
  uint32_t sod     = (clockStartSeconds+elapsed)%86400UL;
  uint8_t hh = sod/3600UL, mm = (sod/60UL)%60UL, ss = sod%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();
}

// ── 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("NEW YORK");
  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(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();
}

// ── 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 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 drawStock() {
  drawHeader("AAPL");
  if (!wifiConfigured()) {
    centeredText("NO WIFI CONFIG", 70, 2);
    centeredText("EDIT CONFIG", 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();
}

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

bool fetchBuddyHelp() {
  if (strlen(GEMINI_API_KEY) == 0 || !ensureWifi()) return false;

  String request;
  if (helpTopic == 0) request = "Give one friendly study tip for a student in at most 18 words.";
  else if (helpTopic == 1) request = "Give one simple kitchen safety or recipe tip in at most 18 words.";
  else if (helpTopic == 2) request = "Teach one useful language-learning tip with a tiny example, in at most 18 words.";
  else if (helpTopic == 3) request = "Give one helpful, safe everyday organization or learning tip in at most 18 words.";
  else request = "Give general health education only in at most 16 words. Do not diagnose, prescribe, or give dosage. Include: ask a clinician for personal advice.";

  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(10000);
  const char *url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";
  if (!http.begin(client, url)) return false;
  http.addHeader("Content-Type", "application/json");
  http.addHeader("x-goog-api-key", GEMINI_API_KEY);
  JsonDocument body;
  body["contents"][0]["parts"][0]["text"] = request;
  String payload;
  serializeJson(body, payload);
  int status = http.POST(payload);
  if (status != HTTP_CODE_OK) { http.end(); return false; }
  JsonDocument reply;
  DeserializationError err = deserializeJson(reply, http.getString());
  http.end();
  if (err) return false;
  String answer = reply["candidates"][0]["content"]["parts"][0]["text"].as<String>();
  answer.replace("\n", " ");
  answer.trim();
  if (answer.length() == 0) return false;
  if (answer.length() > 120) answer = answer.substring(0, 120);
  helpAnswer = answer;
  helpValid = true;
  return true;
}

void drawHelp() {
  drawHeader("ASK DESKBUDDY");
  gfx->setTextColor(FG);
  gfx->setTextSize(2);
  gfx->setCursor(12, 33); gfx->print(helpTopics[helpTopic]);
  gfx->setTextSize(1);
  if (strlen(GEMINI_API_KEY) == 0) {
    gfx->setCursor(12, 62); gfx->print("ADD GEMINI KEY IN secrets.h");
    gfx->setCursor(12, 82); gfx->print("TAP: CHANGE TOPIC / GET TIP");
  } else if (!wifiConfigured() || WiFi.status() != WL_CONNECTED) {
    gfx->setCursor(12, 62); gfx->print("CONNECTING TO WIFI...");
  } else {
    int start = 0;
    for (uint8_t line = 0; line < 5 && start < helpAnswer.length(); line++) {
      int end = min(start + 38, (int)helpAnswer.length());
      if (end < helpAnswer.length()) {
        int space = helpAnswer.lastIndexOf(' ', end);
        if (space > start) end = space;
      }
      gfx->setCursor(12, 58 + line * 15);
      gfx->print(helpAnswer.substring(start, end));
      start = end;
      while (start < helpAnswer.length() && helpAnswer[start] == ' ') start++;
    }
    gfx->setCursor(12, 143); gfx->print("TAP: NEXT TOPIC + NEW TIP");
  }
  if (helpTopic == 4) {
    gfx->setCursor(12, 158); gfx->print("NOT FOR EMERGENCIES OR DIAGNOSIS");
  }
  drawPageDots();
}

void drawGithub() {
  drawHeader("GITHUB");
  if (!wifiConfigured() || !githubConfigured()) {
    centeredText("SETUP REQUIRED", 68, 2);
    centeredText("EDIT CONFIG", 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();
}

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

void sendRoverCommand(char command) {
  // Do not deliberately drive forward into a nearby wall, person, pet, or furniture.
  if (command == 'F' && frontTofReady && frontBlocked) {
    command = 'S';
    pressPulse = 1.0f;
  }
  // GPIO7 is a one-way 3.3 V pulse line to XIAO D7, not a UART pin.
  // The XIAO sketch measures the high-pulse length: 90 ms = photo,
  // 180 ms = forward, 270 ms = reverse, and 30 ms = immediate stop.
  uint16_t pulseMs = 30;
  if (command == 'P') pulseMs = 90;
  else if (command == 'F') pulseMs = 180;
  else if (command == 'B') pulseMs = 270;
  digitalWrite(ROVER_TX, HIGH);
  delay(pulseMs);
  digitalWrite(ROVER_TX, LOW);
  // Every drive pulse is time-limited; a communication fault cannot leave it moving.
  if (command == 'F' || command == 'B') roverStopAt = millis() + 450;
}

void triggerFaceTap() {
  if (currentApp == 0) {
    faceMood = (faceMood+1) % FACE_MOOD_COUNT;
    pressPulse = 1.0f;
  } else if (currentApp == 7) {
    helpTopic = (helpTopic + 1) % HELP_TOPIC_COUNT;
    helpValid = false;
    helpAnswer = "GETTING A SHORT TIP...";
    fetchBuddyHelp();
    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;
  if (!touchWasDown && TOUCH_INT != 255 && digitalRead(TOUCH_INT) != LOW) return;
  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) {
      switchApp(dx < 0 ? 1 : -1);
      touchWasDown = false;
      touchMissFrames = 0;
      touchMoved = false;
    } else if (currentApp == 0 && abs(dy) > 55 && abs(dy) > abs(dx)+18) {
      sendRoverCommand(dy < 0 ? 'F' : 'B');
      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 >= 800 && !touchMoved && abs(dx) < 35 && abs(dy) < 35 && currentApp == 0) {
      sendRoverCommand('P');  // Save one 2 MP photo to the camera microSD card.
      pressPulse = 1.0f;
    } else 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() {
  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();
  else if (currentApp==7 && !helpValid && strlen(GEMINI_API_KEY) > 0) fetchBuddyHelp();
}

void updateFrontObstacle() {
  if (!frontTofReady || millis() - lastObstacleReadMs < 75) return;
  lastObstacleReadMs = millis();
  VL53L0X_RangingMeasurementData_t reading;
  frontTof.rangingTest(&reading, false);
  if (reading.RangeStatus == 4) return;  // no usable target this pass
  frontBlocked = reading.RangeMilliMeter > 0 && reading.RangeMilliMeter < 180;
  if (frontBlocked && roverStopAt != 0) {
    sendRoverCommand('S');
    roverStopAt = 0;
  }
}

void updateVoiceCommands() {
  if (!voiceReady || millis() - lastVoiceReadMs < 120) return;
  lastVoiceReadMs = millis();
  uint8_t commandId = voice.getCMDID();
  if (commandId == 0) return;

  // This first voice-enabled build never guesses a route from speech.
  // A phrase always fails safe by stopping the rover until a guided route
  // has been deliberately taught, tested, and assigned to that command.
  sendRoverCommand('S');
  roverStopAt = 0;
  pressPulse = 1.0f;
  Serial.print("Voice command received safely: ");
  Serial.println(commandId);
}

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");
  pinMode(ROVER_TX, OUTPUT);
  digitalWrite(ROVER_TX, LOW);

  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(100000);
  bsp_touch_init(&Wire, TOUCH_RST, TOUCH_INT, ROTATION, gfx->width(), gfx->height());
  touchReady = true;
  frontTofReady = frontTof.begin(0x29, false, &Wire);
  if (!frontTofReady) Serial.println("Front obstacle sensor unavailable — rover remains time-limited only");
  voiceReady = voice.begin();
  if (voiceReady) {
    voice.setVolume(4);
    voice.setWakeTime(20);
  } else {
    Serial.println("Voice module unavailable — touch controls remain active");
  }
  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();
  updateFrontObstacle();
  updateVoiceCommands();
  if (roverStopAt != 0 && millis() >= roverStopAt) {
    sendRoverCommand('S');
    roverStopAt = 0;
  }

  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: drawDatePage();   break;
    case 3: drawWeather();    break;
    case 4: drawMoon();       break;
    case 5: drawStock();      break;
    case 6: drawGithub();     break;
    default: drawHelp();      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