Community project

Tiny Touch DeskBuddy

Эльдар Алиев

Published July 19, 2026

ESP321 component4 assembly steps
Remix this project
Photo of Tiny Touch DeskBuddy

Tiny Touch DeskBuddy is a palm-sized animated companion that sits on your desk, displaying an expressive face, local weather, time, and moon phase on a 1.47-inch touchscreen. Built around the Waveshare ESP32-C6-Touch-LCD module and powered by a compact 1000mAh LiPo battery, it responds to touch interactions and tilts its eyes based on the device's orientation thanks to the built-in IMU sensor.

This guide provides a complete parts list, wiring diagram, and step-by-step assembly instructions for the 3D-printed enclosure. You'll also get the full Arduino firmware, which handles Wi-Fi connectivity for live weather data, touch gesture recognition, and animated rendering—everything needed to bring your DeskBuddy to life.

Wiring diagram

Interactive · read-only
Wiring diagram for Tiny Touch DeskBuddy

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

Parts list

Bill of materials
ComponentQtyNotes
LiPo 3.7V 1000mAh Battery3.7 V, 1000 mAh protected 1S LiPo1Single-cell LiPo pack, nominal 3.7 V, 1000 mAh. Default rechargeable choice for portable ESP32 / Pico projects. Pair with a TP4056 charger for safe USB recharging.

Assembly

4 steps
  1. Prepare the printed enclosure

    Use a rigid printed shell with a front opening for the 1.47-inch screen and a USB-C opening. Dry-fit the board before adding the battery. Provide at least 2 mm clearance behind the display, rounded edges wherever the pouch cell sits, and a small exit path for the battery lead.

    • Tip: PETG is preferred if the enclosure may sit in a warm sunny room; PLA is suitable for a normal indoor desk.
    • Tip: Use plastic standoffs, foam tape, or nonconductive pads so the board underside cannot short against screws or foil-backed tape.
    • Do not make the battery compartment airtight or place the cell directly against sharp printed posts, screw tips, or hot components.
    • Keep the USB-C port accessible; it is the board's charging input.
  2. Mount the Waveshare board

    Fit the Waveshare ESP32-C6-Touch-LCD-1.47 board with the display facing the front opening. Secure it without over-tightening or flexing the LCD, and confirm the touch surface and USB-C connector are unobstructed.

    • Tip: All display, touch, and IMU connections are already built into the board; do not add jumper wires to its display pins.
    • Tip: Before final closure, check that the screen is not under pressure from the bezel.
    • Disconnect battery and USB power before changing any wiring.
  3. Connect the protected single-cell LiPo

    Solder or fit a polarized, keyed two-wire pigtail with strain relief: battery +V to the Waveshare VBAT header (H1 pin 2) and battery GND to a board GND header (for example H1 pin 4). Verify polarity with a multimeter before connecting the battery. Secure the protected 1S LiPo flat in its compartment with suitable foam tape.

    • Tip: Use only a protected 1S LiPo/Li-ion pack: 3.6/3.7 V nominal and 4.2 V maximum.
    • Tip: Leave a gentle service loop in the battery lead so opening the enclosure does not pull on the header.
    • Never connect the raw LiPo to 3V3, VBUS, or the USB-C connector.
    • The board's onboard charger is set near 2 A; use a pack explicitly rated to accept at least 2 A charge current, or do not use the onboard charging path.
    • Never use a swollen, damaged, unprotected, 2S, 5 V, or LiFePO4 battery.
  4. Close, configure Wi-Fi, and inspect

    Arrange the battery lead away from the display edges and USB-C opening, then close the enclosure without pinching the pouch. Before deploying, add the local Wi-Fi name and password to include/secrets.h. Once connected, DeskBuddy automatically estimates its city from the network public IP and obtains weather for that area.

    • Tip: IP-based location is typically city-level, not GPS-precise. VPNs, relays, and some ISPs may report a different city.
    • Tip: After the first charge, inspect the enclosure for excess heat and confirm the cell remains cool and flat.
    • Charge only through the board USB-C port from a normal 5 V USB supply.
    • Stop using the device immediately if the battery swells, smells unusual, becomes hot, or the enclosure deforms.

Pin assignments

Board wiring reference
PinConnectionType
EXTlipo-battery-1 +VWaveshare board VBAT header (H1 pin 2)power
GNDlipo-battery-1 GNDground

Firmware

ESP32
schematik_esp32.inoDeploy to device
// ============================================================
// DeskBuddy — Waveshare ESP32-C6-Touch-LCD-1.47
// Features: animated face, clock, date, local weather, moon.
// Touch swipes change pages; face taps provide care; the built-in
// QMI8658 IMU makes the eyes drift with the enclosure's tilt.
// ============================================================
#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <Wire.h>
#include <math.h>
#include <string.h>
#include "secrets.h"

// Wi-Fi and installed location live in include/secrets.h.
// Leaving WIFI_SSID blank keeps the weather page 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


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

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();
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 ensureWifi();
bool fetchLocation();
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 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);
  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    = 5;
static const uint32_t HUNGER_INTERVAL_MS = 45UL * 60UL * 1000UL;
static const uint32_t ATTENTION_DECAY_MS = 20UL * 60UL * 1000UL;
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;
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 locationCheckedAt  = 0;
uint32_t lastNeedsUpdateMs  = 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;
bool  locationValid   = false;
float locationLat     = 0.0f;
float locationLon     = 0.0f;
String locationLabel  = "LOCATING";
String weatherLabel   = "WAITING";
float attentionLevel = 100.0f;  // taps refill attention; it fades over time
float hungerLevel    = 0.0f;    // rises gradually after power-on

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

// ── 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 && strlen(WIFI_PASSWORD) > 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;
}

// ── Approximate IP location + Open-Meteo weather ───────────
// The location service uses the Wi-Fi network's public IP address. This is
// city-level only, needs Internet access, and can be wrong behind a VPN.
bool fetchLocation() {
  if (!ensureWifi()) return false;
  locationCheckedAt = millis();
  HTTPClient http;
  http.setTimeout(6000);
  if (!http.begin("http://ip-api.com/json/?fields=status,city,lat,lon")) return false;
  if (http.GET() != HTTP_CODE_OK) { http.end(); return false; }
  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, http.getString());
  http.end();
  if (err || String(doc["status"].as<const char *>() ?: "") != "success") return false;
  locationLat = doc["lat"].as<float>();
  locationLon = doc["lon"].as<float>();
  const char *city = doc["city"] | "LOCAL AREA";
  locationLabel = String(city);
  locationValid = true;
  return true;
}

// ── Weather fetch (Open-Meteo, detected location) ──────────

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;
  if (!locationValid && !fetchLocation()) return false;
  HTTPClient http;
  http.setTimeout(6000);
  if (!http.begin(
        String("http://api.open-meteo.com/v1/forecast?latitude=") + String(locationLat, 4) +
        "&longitude=" + String(locationLon, 4) +
        "&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,is_day"
        "&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto"))
    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;
}

// ── 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(locationValid ? locationLabel.c_str() : "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 (!locationValid) { centeredText("FINDING LOCATION", 76, 2); 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();
}

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

void triggerFaceTap() {
  if (currentApp == 0) {
    // A tap is care: DeskBuddy feels noticed and gets a small snack.
    attentionLevel = 100.0f;
    hungerLevel = clampFloat(hungerLevel - 28.0f, 0.0f, 100.0f);
    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;
  // A quick movement is acknowledged, but care and weather determine the mood.
  if (fabs(filteredGz) > 130.0f) 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 (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 updateBuddyNeeds() {
  uint32_t now = millis();
  if (lastNeedsUpdateMs == 0) { lastNeedsUpdateMs = now; return; }
  uint32_t elapsed = now - lastNeedsUpdateMs;
  lastNeedsUpdateMs = now;
  hungerLevel = clampFloat(hungerLevel + 100.0f * elapsed / HUNGER_INTERVAL_MS, 0.0f, 100.0f);
  attentionLevel = clampFloat(attentionLevel - 100.0f * elapsed / ATTENTION_DECAY_MS, 0.0f, 100.0f);

  // Priority keeps the face understandable: urgent care needs override weather.
  if (hungerLevel >= 75.0f) faceMood = 4;               // hungry / unhappy
  else if (attentionLevel <= 25.0f) faceMood = 3;       // ignored / sleepy
  else if (weatherValid && (weatherCode >= 51 || weatherCode == 45 || weatherCode == 48)) faceMood = 4;
  else if (weatherValid && weatherCode <= 1) faceMood = 1; // sunny = cheerful
  else faceMood = 0;
}

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() {
  // Re-check the IP location occasionally for a moved DeskBuddy, then refresh
  // weather. Weather still updates off-screen because it informs Buddy's mood.
  if (!locationValid || millis() - locationCheckedAt > 6UL * 60UL * 60UL * 1000UL) {
    if (fetchLocation()) weatherValid = false;
  }
  if (locationValid && (!weatherValid || millis() - weatherUpdatedAt > 15UL * 60UL * 1000UL)) fetchWeather();
}

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(100000);
  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;
  lastNeedsUpdateMs = millis();
}

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

  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;
    default: drawMoon();      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