How to Build a Tiny ESP DeskBuddy with ESP32-C6
Swipeable black-and-white face dashboard with clock, weather, moon, stocks, and GitHub pages
Updated

What you'll build
Build a palm-sized DeskBuddy on the Waveshare ESP32-C6-Touch-LCD-1.47: a black-and-white face dashboard with touch navigation, a clock, date page, weather, moon phase, stock quote, and GitHub stats. It is small enough to sit next to a laptop, but the firmware has enough moving parts to feel like an actual little device.
The face is the first screen. It uses two tall rounded white eyes on a black background, a small mouth, page dots, and a subtle header. Tap the face to cycle through five expressions. Tilt the gadget and the eyes drift slightly with the onboard QMI8658 IMU. The aesthetic is deliberately clean and monochrome; the charm is in the simple shapes, not extra decoration.
Swipe horizontally to move through the other pages. The clock starts immediately even without Wi-Fi because it uses the compile-time timestamp as a baseline. Weather comes from Open-Meteo, the stock page pulls AAPL from Stooq, and the GitHub page shows follower and public repo counts for the username you put in secrets.h. If Wi-Fi is not configured, the network pages show a clear setup message rather than failing silently.
What you are building
This guide covers one complete board. The firmware has five jobs:
- draw and animate a monochrome face with tappable expressions using the 172×320 LCD,
- respond to horizontal swipe gestures via the AXS5106L capacitive touch controller to page between views,
- drift the eyes with the QMI8658 IMU reading (falling back to a sinusoidal animation if the IMU is not detected),
- fetch Wi-Fi data — weather, moon phase, stock price, and GitHub stats — when credentials are present,
- report IMU calibration and app state over USB Serial at 115200 baud.
The firmware does not cover: multi-board networking, custom wake word, OTA updates, or driving any external display or sensor. Everything runs on the self-contained Waveshare board.
Upload and calibrate
Flash the firmware through Schematik in the usual way. Before compiling, create a secrets.h file alongside the sketch with the following fields:
#define WIFI_SSID "your-network-name"
#define WIFI_PASSWORD "your-password"
#define GITHUB_USER "your-github-username"
The firmware compiles and runs without secrets.h present — it will show a NO WIFI CONFIG message on the network pages instead of fetching data. Add the file when you are ready to use those pages.
In Arduino IDE or the Schematik uploader, make sure CDC on Boot is enabled. This setting allows the board's native USB-C port to appear as a Serial device. Without it, Serial Monitor will show nothing even though the firmware is running. The option is usually in the Tools menu under your board selection.
After flashing, open Serial Monitor at 115200 baud. On first boot you should see:
- an IMU calibration message as the QMI8658 initialises,
- app and mood state lines appearing every 1.2 seconds.
The face screen should be visible on the LCD immediately. Tap anywhere on the face to cycle through the five expressions. Swipe left or right to move between pages. If the IMU is detected, tilting the board will make the eyes drift; if it is not detected (rare, but possible if I²C is stuck), the eyes will animate with a sinusoidal fallback instead.
No step calibration or threshold tuning is required. The AXS5106L driver is inlined in the firmware and does not need a separate library package. The libraries you do need to have installed are:
- GFX Library for Arduino by moononournation
- ArduinoJson
- FastIMU
Troubleshooting
- Serial Monitor shows nothing. CDC on Boot is not enabled. Re-flash with that option turned on in your board settings. The USB-C port will not appear as a serial device otherwise.
- IMU drift does not work; eyes move in a smooth sine wave instead. The QMI8658 was not detected at
0x6Bon startup. The firmware falls back to sinusoidal animation automatically. Check that the board is not a batch with a different IMU address, and that no other I²C device is conflicting on GPIO 18/19. - Network pages say "NO WIFI CONFIG" or "setup required".
secrets.his missing or the credentials are wrong. Add the file withWIFI_SSID,WIFI_PASSWORD, andGITHUB_USERdefined, then recompile and reflash. - Upload stalls or the port is not found. Hold the BOOT button on the board, then press Reset (or reconnect USB-C), and try uploading again. This forces the board into download mode. Release BOOT once the upload starts.
- LiPo battery is warm during charging. This is normal for small LiPo cells during fast charge, but if the cell becomes hot to the touch, disconnect it and check the cell capacity against what the onboard charger circuit supports. Do not charge a damaged or swollen cell.
- Touch is unresponsive on part of the screen. The AXS5106L reports touch at
0x3B. If touch stops working entirely after a code change, check thatTOUCH_RST(GPIO 20) andTOUCH_INT(GPIO 21) are not being reassigned elsewhere in your sketch.
Going further
The face page has five built-in expressions, but the drawing code is straightforward to extend. Each expression is a set of eye shape parameters and a mouth curve; adding a new mood is a matter of adding a case to the cycle and defining the geometry. The IMU tilt effect uses a simple linear mapping from accelerometer reading to eye offset — tightening or loosening that mapping changes how reactive the eyes feel.
On the data side, the Open-Meteo integration uses a free, no-key API, which makes it easy to swap in a different endpoint or add a second data page. The stock and GitHub pages follow the same fetch-and-render pattern, so adding a new card — a CO₂ sensor reading, a home server status ping, or a countdown to a date — is a case of writing one fetch function and one render function and wiring them into the page loop.
Wiring diagram
Components needed
Supplier links, prices, and availability are shown as a guide and may change. Schematik may earn a commission from purchases made through affiliate links.
Assembly
Print or prepare the DeskBuddy enclosure
Use a tinyespdeskbuddy-style shell with a clean front window for the 1.47-inch display, a USB-C cutout at the bottom, and posts or tape points to hold the board in place. Leave clearance behind the board for the LiPo battery.
- PLA or PETG both work well. Print the front bezel in black for the best contrast with the white-on-black display.
Edit the starter configuration
At the top of the Schematik starter sketch, fill in WIFI_SSID, WIFI_PASSWORD, and GITHUB_USER. Schematik serves this as one sketch, so there is no separate credentials header to create. If WIFI_SSID is left blank, the firmware still runs and the network pages show a setup message.
- For this local desk gadget, putting your own Wi-Fi details directly in the starter sketch is acceptable. Do not publish the edited sketch with real credentials.
Flash the ESP32-C6 board
All hardware (display, touch controller, IMU) is soldered to the Waveshare module — no external wiring is required. Connect the board to your PC with a USB-C cable. Open the Schematik Deploy panel and click Deploy, or use PlatformIO with board esp32-c6-devkitc-1 and the libraries listed. If the upload stalls, hold BOOT while clicking Deploy and release after flashing begins.
- The display SPI is on GPIO1/2/14/15/22/23 and touch I2C is on GPIO18/19/20/21 — all internal to the module, no jumper wires needed.
- Enable CDC on Boot in the board settings (or add -DARDUINO_USB_CDC_ON_BOOT=1 to build flags) so the Serial monitor works over the native USB-C port.
Verify the face and touch controls
After boot you should see two white rounded eyes on a black background with page-navigation dots at the bottom. Tap the display to cycle through five face expressions. Swipe left to advance through Time, Date, Weather, Moon, Stock, and GitHub pages. Swipe right to go back.
- Open Serial Monitor at 115200 baud to confirm the IMU calibration message and see app/mood state printed every 1.2 seconds.
- If the IMU is not detected the firmware falls back to sinusoidal eye animation — the face still works.
Fit the LiPo battery and close the enclosure
Connect the 3.7 V LiPo to the board's JST battery socket, tuck the cell behind the display module, and secure the board so the USB-C port remains reachable through the enclosure cutout for charging and future firmware updates.
- A 300–500 mAh LiPo fits comfortably in most DeskBuddy shells and gives several hours of runtime.
- Do not pinch or puncture the LiPo when closing the printed shell. If the battery is swollen or warm, stop and replace it.
Pin assignments
| Pin | Connection | Type |
|---|---|---|
| GPIO 1 | esp32-c6-touch-lcd-1 LCD_SCK | SPI |
| GPIO 2 | esp32-c6-touch-lcd-1 LCD_MOSI | SPI |
| GPIO 14 | esp32-c6-touch-lcd-1 LCD_CS | DIGITAL |
| GPIO 15 | esp32-c6-touch-lcd-1 LCD_DC | DIGITAL |
| GPIO 22 | esp32-c6-touch-lcd-1 LCD_RST | DIGITAL |
| GPIO 23 | esp32-c6-touch-lcd-1 LCD_BL | PWM |
| GPIO 18 | esp32-c6-touch-lcd-1 TOUCH_SDA | I2C |
| GPIO 19 | esp32-c6-touch-lcd-1 TOUCH_SCL | I2C |
| GPIO 20 | esp32-c6-touch-lcd-1 TOUCH_RST | DIGITAL |
| GPIO 21 | esp32-c6-touch-lcd-1 TOUCH_INT | DIGITAL |
Code
// ============================================================
// DeskBuddy — Waveshare ESP32-C6-Touch-LCD-1.47
// Features: animated face, clock, date, weather, moon,
// stock ticker, GitHub stats. Touch swipe to
// change pages; tilt via QMI8658 IMU animates eyes.
// ============================================================
#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <Wire.h>
#include <math.h>
#include <string.h>
// Schematik serves this starter as a single sketch. Edit these values before flashing.
// Leaving WIFI_SSID blank keeps the network pages in setup mode.
#define WIFI_SSID ""
#define WIFI_PASSWORD ""
#define GITHUB_USER "" // your GitHub username, for the stats page
// ── 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
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
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];
};
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
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
};
static TwoWire *_touchWire = nullptr;
static uint8_t _touchRst = 255;
static uint8_t _touchInt = 255;
static uint16_t _touchW = 320;
static uint16_t _touchH = 172;
static uint8_t _touchRot = 0;
void bsp_touch_init(TwoWire *wire, uint8_t rstPin, uint8_t intPin,
uint8_t rotation, uint16_t dispW, uint16_t dispH) {
_touchWire = wire;
_touchRst = rstPin;
_touchInt = intPin;
_touchRot = rotation;
_touchW = dispW;
_touchH = dispH;
if (_touchRst != 255) {
pinMode(_touchRst, OUTPUT);
digitalWrite(_touchRst, LOW);
delay(20);
digitalWrite(_touchRst, HIGH);
delay(50);
}
if (_touchInt != 255) {
pinMode(_touchInt, INPUT_PULLUP);
}
}
// bsp_touch_read — no-op for polling mode; INT pin can be
// checked externally if needed.
void bsp_touch_read() {}
uint16_t clampTouchCoord(int32_t value, uint16_t maxValue) {
if (value < 0) return 0;
if (value >= maxValue) return maxValue - 1;
return (uint16_t)value;
}
uint16_t scaleTouchAxis(uint16_t raw, uint16_t rawMin, uint16_t rawMax, uint16_t outMax) {
if (rawMax <= rawMin || outMax == 0) return 0;
if (raw <= rawMin) return 0;
if (raw >= rawMax) return outMax - 1;
return (uint32_t)(raw - rawMin) * (outMax - 1) / (rawMax - rawMin);
}
// Returns true if at least one touch point is active.
bool bsp_touch_get_coordinates(uint16_t *outX, uint16_t *outY) {
if (!_touchWire || !outX || !outY) return false;
// Read only the first 6-byte touch frame. The UI only uses one point, and
// shorter reads are less flaky than asking this controller for the optional
// second-point bytes on every frame.
_touchWire->beginTransmission(AXS5106L_ADDR);
_touchWire->write(AXS5106L_TOUCH_DATA_REG);
if (_touchWire->endTransmission(true) != 0) { resetSharedI2CBus(); return false; }
delayMicroseconds(300);
uint8_t len = _touchWire->requestFrom((uint8_t)AXS5106L_ADDR, (uint8_t)6, (uint8_t)true);
if (len < 6) { resetSharedI2CBus(); return false; }
uint8_t buf[6];
for (uint8_t i = 0; i < 6; i++) buf[i] = _touchWire->read();
uint8_t nPoints = buf[1] & 0x0F;
if (nPoints == 0 || nPoints > 2) return false;
// First point begins at byte 2: x_hi/event, x_lo, y_hi/id, y_lo.
uint16_t rawX = ((uint16_t)(buf[2] & 0x0F) << 8) | buf[3];
uint16_t rawY = ((uint16_t)(buf[4] & 0x0F) << 8) | buf[5];
if ((rawX == 0x0FFF && rawY == 0x0FFF) || rawX > 4090 || rawY > 4090) return false;
// Small edge dead-zone compensation. The controller reports raw axes with a
// few pixels of slack at the extremes; scaling them to the active screen area
// makes edge swipes less sticky while preserving the current orientation.
const uint16_t edge = 3;
uint16_t mappedX = rawX;
uint16_t mappedY = rawY;
switch (_touchRot) {
case 1: // landscape, default for this board
mappedX = scaleTouchAxis(rawY, edge, _touchW > edge ? _touchW - 1 - edge : _touchW - 1, _touchW);
mappedY = scaleTouchAxis(rawX, edge, _touchH > edge ? _touchH - 1 - edge : _touchH - 1, _touchH);
break;
case 2:
mappedX = _touchW - 1 - scaleTouchAxis(rawX, edge, _touchW > edge ? _touchW - 1 - edge : _touchW - 1, _touchW);
mappedY = _touchH - 1 - scaleTouchAxis(rawY, edge, _touchH > edge ? _touchH - 1 - edge : _touchH - 1, _touchH);
break;
case 3:
mappedX = _touchW - 1 - scaleTouchAxis(rawY, edge, _touchW > edge ? _touchW - 1 - edge : _touchW - 1, _touchW);
mappedY = scaleTouchAxis(rawX, edge, _touchH > edge ? _touchH - 1 - edge : _touchH - 1, _touchH);
break;
default: // 0 — portrait
mappedX = scaleTouchAxis(rawX, edge, _touchW > edge ? _touchW - 1 - edge : _touchW - 1, _touchW);
mappedY = scaleTouchAxis(rawY, edge, _touchH > edge ? _touchH - 1 - edge : _touchH - 1, _touchH);
break;
}
*outX = clampTouchCoord(mappedX, _touchW);
*outY = clampTouchCoord(mappedY, _touchH);
return true;
}
// ── End AXS5106L driver ────────────────────────────────────
static const int SCREEN_W = 320;
static const int SCREEN_H = 172;
static const uint8_t APP_COUNT = 7;
static const uint8_t FACE_MOOD_COUNT = 5;
static const uint32_t PAGE_AUTO_INTERVAL_MS = 8000;
static const uint16_t FG = RGB565_WHITE;
static const uint16_t BG = RGB565_BLACK;
static const uint8_t ROTATION = 1;
Arduino_DataBus *bus = new Arduino_HWSPI(LCD_DC, LCD_CS, LCD_SCK, LCD_MOSI);
Arduino_GFX *display = new Arduino_ST7789(bus, LCD_RST, 0, false, 172, 320, 34, 0, 34, 0);
Arduino_Canvas *gfx = new Arduino_Canvas(SCREEN_W, SCREEN_H, display);
QMI8658Mini imu;
calData calib = {0};
AccelData accel;
GyroData gyro;
bool imuReady = false;
bool touchReady = false;
bool touchWasDown = false;
bool wifiAttempted = false;
bool weatherValid = false;
bool 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;
// ── 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"
"¤t=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 ───────────────────────────────────────────
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 triggerFaceTap() {
if (currentApp == 0) {
faceMood = (faceMood+1) % FACE_MOOD_COUNT;
pressPulse = 1.0f;
} else {
switchApp(1);
}
nextAutoPage = millis() + PAGE_AUTO_INTERVAL_MS;
}
void readSensors() {
if (!imuReady) {
// Animate eyes sinusoidally when IMU is absent
filteredAx = sin(millis()*0.0012f)*0.12f;
filteredAy = cos(millis()*0.0010f)*0.12f;
return;
}
imu.update();
imu.getAccel(&accel);
imu.getGyro(&gyro);
filteredAx = filteredAx*0.88f + accel.accelX*0.12f;
filteredAy = filteredAy*0.88f + accel.accelY*0.12f;
filteredGz = filteredGz*0.82f + gyro.gyroZ*0.18f;
// Fast spin → surprised face
if (fabs(filteredGz) > 130.0f) { faceMood = 2; pressPulse = 1.0f; }
}
void readTouch() {
if (!touchReady) return;
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 updateFaceTimers() {
uint32_t now = millis();
if (now > nextBlink) {
blinkUntil = now + (random(0,6)==0 ? 220 : 105);
nextBlink = now + 1000 + random(0, 2600);
}
if (now > nextGlance) {
faceTargetX = (float)random(-8, 9);
faceTargetY = (float)random(-4, 5);
nextGlance = now + 650 + random(0, 1500);
}
faceGlanceX = faceGlanceX*0.84f + faceTargetX*0.16f;
faceGlanceY = faceGlanceY*0.84f + faceTargetY*0.16f;
pressPulse *= 0.86f;
}
void updateAutoPage() {
if (millis() > nextAutoPage) switchApp(1);
}
void updateNetworkPages() {
if (currentApp==3 && (!weatherValid || millis()-weatherUpdatedAt > 15UL*60UL*1000UL)) fetchWeather();
else if (currentApp==5 && (!stockValid || millis()-stockUpdatedAt > 10UL*60UL*1000UL)) fetchStock();
else if (currentApp==6 && (!githubValid || millis()-githubUpdatedAt > 30UL*60UL*1000UL)) fetchGithub();
}
void calibrateNeutral() {
gfx->fillScreen(BG);
centeredText("HOLD STILL", 76, 2);
gfx->flush();
delay(900);
for (uint8_t i = 0; i < 100; i++) { imu.update(); delay(5); }
float sumX=0.0f, 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;
}
void loop() {
readSensors();
readTouch();
updateAutoPage();
updateFaceTimers();
updateNetworkPages();
float tx=0.0f, ty=0.0f;
if (imuReady) {
tx = clampFloat(-(filteredAy-restAy)*2.2f, -1.0f, 1.0f);
ty = clampFloat( (filteredAx-restAx)*2.2f, -1.0f, 1.0f);
} else {
tx = sin(millis()*0.0014f)*0.25f;
ty = cos(millis()*0.0011f)*0.16f;
}
switch (currentApp) {
case 0: drawFace(tx, ty); break;
case 1: drawClock(); break;
case 2: drawDatePage(); break;
case 3: drawWeather(); break;
case 4: drawMoon(); break;
case 5: drawStock(); break;
default: drawGithub(); break;
}
gfx->flush();
if (millis()-lastSerialMs > 1200) {
lastSerialMs = millis();
Serial.print("app="); Serial.print(currentApp);
Serial.print(" mood="); Serial.println(faceMood);
}
delay(24);
}
// Run this and build other cool things at schematik.ioReady to build this?
Open this project in Schematik to get the full wiring diagram, pin assignments, and deployable code for the ESP-C6 Touch LCD Gadget.
Open in Schematik →