Community project
Ik Wil Een Deskbuddy Maken Met Een Gezichtje
Build a charming desk companion with an animated face displayed on a 3.2-inch TFT screen. This project uses an ESP32 to drive the ILI9341 display, showing expressive animations and system monitoring information via Bluetooth connectivity from a PC.
The guide includes a complete wiring diagram for connecting the TFT display to the ESP32, a full parts list, and firmware that animates facial expressions with yawning, displays CPU and GPU temperatures received over Bluetooth, and cycles through different information screens. Assembly takes just a few minutes of connecting power and data lines, then uploading the code to bring your deskbuddy to life.
Wiring diagram

Gather all the parts
| Qty | Component |
|---|---|
| 1 | 3.2-inch ILI9341 SPI TFT zonder aanraking 3.2 inch, ILI9341, 320×240, zonder touch, circa 8.1 cm diagonaal Een 3,2-inch kleurenscherm waarop het DeskBuddy-gezicht en de pc-temperaturen zichtbaar zijn. |
Assemble it in 4 steps
1. Leg het scherm en de XIAO klaar
Leg de Seeed Studio XIAO ESP32S3, een 3,2-inch ILI9341 SPI-kleurenscherm zonder aanraking, zeven dunne jumperdraden en een USB-C-kabel neer. Kies een scherm waarop `VCC`, `GND`, `CS`, `RST`, `DC` of `RS`, `MOSI` of `SDI` en `SCK` staat.
- Dit scherm is ongeveer 8,1 cm van hoek tot hoek.
- Een variant zonder touch is goedkoper en heeft geen onnodige aansluitingen.
- Sluit de USB-C-kabel nog niet aan terwijl je de draden plaatst; een draad op de verkeerde plek kan het scherm beschadigen.
2. Sluit de voeding van het scherm aan
Verbind `VCC` van het scherm met `3V3` op de XIAO met een rode draad (voeding). Verbind `GND` van het scherm met `GND` op de XIAO met een zwarte draad (grond). Heeft jouw scherm een losse `LED`- of `BL`-pin voor de schermlamp, verbind die dan ook met `3V3` (schermlamp).
- `BL` betekent hetzelfde als `LED` op veel schermprintjes.
- Gebruik 3V3 voor dit scherm, niet 5V.
- Maak zeker dat VCC en GND niet zijn verwisseld — verkeerd aangesloten voeding kan het scherm beschadigen.
3. Sluit de beeld-draden aan
Verbind `SCK` van het scherm met GPIO12 op de XIAO (klok), `MOSI` of `SDI` met GPIO11 (beeldgegevens), `CS` met GPIO10 (scherm kiezen), `DC` of `RS` met GPIO4 (beeld of opdracht) en `RST` met GPIO3 (scherm opnieuw starten).
- Volg de woorden op jouw schermprint: `SDI` is hetzelfde als MOSI en `RS` is hetzelfde als DC.
- Dit scherm heeft geen touch-pinnen en ook geen MISO-draad nodig.
- Verwissel SCK en MOSI niet; dan kan het scherm leeg blijven of rommel tonen.
4. Controleer en geef stroom
Vergelijk elke draad met de labels hierboven. Steek daarna de USB-C-kabel in de XIAO en in een USB-poort van je pc of een degelijke USB-wandadapter (voeding). Het scherm moet het DeskBuddy-gezicht tonen en de XIAO verschijnt via Bluetooth als `DeskBuddy-PC-Temp`.
- Zet het scherm later in een behuizing of achter een venster, zodat de dunne draden niet losgetrokken worden.
- De DeskBuddy werkt nu alleen via USB-C; je hebt geen batterij of laadmodule nodig.
- Controleer de voeding eerst goed voordat je USB-C aansluit; omgewisselde voeding kan het scherm beschadigen.
Review all connections
1. Connections between "oled_1" and "ESP32"
| Function | oled_1 | ESP32 |
|---|---|---|
| power | VCC | 3V3 |
| ground | GND | GND |
| spi | MOSI | GPIO 11 |
| spi | SCK | GPIO 12 |
| digital | CS | GPIO 10 |
| digital | DC | GPIO 4 |
| digital | RST | GPIO 3 |
Deploy the firmware
#include <Arduino.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
enum DisplayPhase { FACE_PHASE, CPU_PHASE, GPU_PHASE };
// Forward declarations
bool isYawning(unsigned long now);
void drawSweatDrop(int x, int y);
void drawFaceRegion();
void drawFaceHeaderAndTemperature();
void drawTemperatureScreen(const char *label, float temperatureC);
void redrawScreen();
void requestPcTemperature(const char *request);
void setupBluetooth();
void updateFaceAnimation(unsigned long now);
void updateSchedule(unsigned long now);
constexpr int TFT_SCK_PIN = 12;
constexpr int TFT_MOSI_PIN = 11;
constexpr int TFT_CS_PIN = 10;
constexpr int TFT_DC_PIN = 4;
constexpr int TFT_RST_PIN = 3;
constexpr int SCREEN_WIDTH = 320;
constexpr int SCREEN_HEIGHT = 240;
constexpr uint16_t BACKGROUND = ILI9341_BLACK;
constexpr uint16_t FEATURE_COLOR = ILI9341_BLUE;
constexpr uint16_t SWEAT_COLOR = ILI9341_CYAN;
constexpr uint16_t TEXT_COLOR = ILI9341_WHITE;
constexpr char SERVICE_UUID[] = "a9c1c100-7d3e-4c2e-9bf1-6e6846e10001";
constexpr char REQUEST_UUID[] = "a9c1c100-7d3e-4c2e-9bf1-6e6846e10002";
constexpr char TEMPERATURE_UUID[] = "a9c1c100-7d3e-4c2e-9bf1-6e6846e10003";
constexpr unsigned long FACE_PERIOD_MS = 5UL * 60UL * 1000UL;
constexpr unsigned long CPU_SCREEN_MS = 20UL * 1000UL;
constexpr unsigned long GPU_SCREEN_MS = 20UL * 1000UL;
constexpr unsigned long COOL_YAWN_DELAY_MS = 10UL * 60UL * 1000UL;
constexpr unsigned long MOUTH_MOVE_MS = 420UL;
constexpr unsigned long YAWN_FRAME_MS = 700UL;
Adafruit_ILI9341 display(TFT_CS_PIN, TFT_DC_PIN, TFT_RST_PIN);
BLECharacteristic *requestCharacteristic = nullptr;
bool bluetoothConnected = false, eyesClosed = false, mouthOpen = false;
DisplayPhase phase = FACE_PHASE;
unsigned long phaseStartedMs = 0, blinkEndsMs = 0, nextBlinkMs = 0, mouthEndsMs = 0, nextMouthMoveMs = 0, coolSinceMs = 0;
float cpuTemperatureC = NAN, gpuTemperatureC = NAN;
bool isYawning(unsigned long now) { return coolSinceMs != 0 && now - coolSinceMs >= COOL_YAWN_DELAY_MS; }
void drawSweatDrop(int x, int y) { display.fillTriangle(x, y - 13, x - 9, y + 5, x + 9, y + 5, SWEAT_COLOR); display.fillCircle(x, y + 5, 9, SWEAT_COLOR); }
void drawFaceRegion() {
display.fillRect(0, 28, SCREEN_WIDTH, 185, BACKGROUND);
// A soft circular outline keeps the face cute while the background stays black.
display.drawCircle(160, 122, 84, FEATURE_COLOR);
if (eyesClosed) { display.drawLine(116, 111, 140, 111, FEATURE_COLOR); display.drawLine(180, 111, 204, 111, FEATURE_COLOR); }
else { display.fillCircle(128, 111, 10, FEATURE_COLOR); display.fillCircle(192, 111, 10, FEATURE_COLOR); }
if (isYawning(millis())) { if (mouthOpen) display.fillRoundRect(137, 149, 46, 38, 18, FEATURE_COLOR); else display.drawRoundRect(143, 160, 34, 18, 8, FEATURE_COLOR); }
else if (mouthOpen) display.fillRoundRect(143, 156, 34, 18, 10, FEATURE_COLOR);
else { display.drawLine(128, 158, 141, 170, FEATURE_COLOR); display.drawLine(141, 170, 179, 170, FEATURE_COLOR); display.drawLine(179, 170, 192, 158, FEATURE_COLOR); }
if (!isnan(gpuTemperatureC) && gpuTemperatureC >= 55.0f) drawSweatDrop(259, 93);
if (!isnan(gpuTemperatureC) && gpuTemperatureC >= 70.0f) drawSweatDrop(272, 130);
if (!isnan(gpuTemperatureC) && gpuTemperatureC >= 80.0f) drawSweatDrop(52, 90);
}
void drawFaceHeaderAndTemperature() {
display.fillRect(0, 0, SCREEN_WIDTH, 28, BACKGROUND); display.setTextColor(TEXT_COLOR, BACKGROUND); display.setTextSize(2); display.setCursor(8, 6); display.print(bluetoothConnected ? "GPU gezicht" : "Wachten op pc");
display.fillRect(0, 213, SCREEN_WIDTH, 27, BACKGROUND); display.setCursor(8, 218);
if (isnan(gpuTemperatureC)) display.print("GPU: wachten op waarde"); else { display.print("GPU "); display.print(gpuTemperatureC, 1); display.print(" C"); }
}
void drawTemperatureScreen(const char *label, float temperatureC) {
display.fillScreen(BACKGROUND); display.setTextColor(TEXT_COLOR); display.setTextSize(4); display.setCursor((SCREEN_WIDTH - strlen(label) * 24) / 2, 45); display.print(label); display.setTextSize(5); display.setCursor(38, 130);
if (isnan(temperatureC)) display.print("--.- C"); else { display.print(temperatureC, 1); display.print(" C"); }
}
void redrawScreen() { if (phase == FACE_PHASE) { drawFaceHeaderAndTemperature(); drawFaceRegion(); } else if (phase == CPU_PHASE) drawTemperatureScreen("CPU", cpuTemperatureC); else drawTemperatureScreen("GPU", gpuTemperatureC); }
void requestPcTemperature(const char *request) { if (bluetoothConnected && requestCharacteristic != nullptr) { requestCharacteristic->setValue(request); requestCharacteristic->notify(); } }
class DeskBuddyServerCallbacks : public BLEServerCallbacks { void onConnect(BLEServer *) override { bluetoothConnected = true; redrawScreen(); } void onDisconnect(BLEServer *server) override { bluetoothConnected = false; redrawScreen(); delay(150); server->startAdvertising(); } };
class TemperatureCallbacks : public BLECharacteristicCallbacks { void onWrite(BLECharacteristic *characteristic) override {
String received = characteristic->getValue().c_str(); received.trim(); int separator = received.indexOf(':'); if (separator < 1) return;
String source = received.substring(0, separator), value = received.substring(separator + 1); value.trim(); float temperatureC = value.toFloat(); if (value.length() == 0 || temperatureC < 0.0f || temperatureC > 150.0f) return; source.toUpperCase();
if (source == "CPU") cpuTemperatureC = temperatureC; else if (source == "GPU") { gpuTemperatureC = temperatureC; if (temperatureC >= 55.0f) coolSinceMs = 0; else if (coolSinceMs == 0) coolSinceMs = millis(); } else return; redrawScreen();
} };
void setupBluetooth() {
BLEDevice::init("DeskBuddy-PC-Temp"); BLEServer *server = BLEDevice::createServer(); server->setCallbacks(new DeskBuddyServerCallbacks()); BLEService *service = server->createService(SERVICE_UUID);
requestCharacteristic = service->createCharacteristic(REQUEST_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); requestCharacteristic->addDescriptor(new BLE2902()); requestCharacteristic->setValue("READY");
BLECharacteristic *temperatureCharacteristic = service->createCharacteristic(TEMPERATURE_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE); temperatureCharacteristic->setCallbacks(new TemperatureCallbacks()); temperatureCharacteristic->setValue(""); service->start();
BLEAdvertising *advertising = BLEDevice::getAdvertising(); advertising->addServiceUUID(SERVICE_UUID); advertising->setScanResponse(true); advertising->start();
}
void updateFaceAnimation(unsigned long now) {
if (phase != FACE_PHASE) return; bool yawning = isYawning(now);
if (!eyesClosed && now >= nextBlinkMs) { eyesClosed = true; blinkEndsMs = now + 140; redrawScreen(); } else if (eyesClosed && now >= blinkEndsMs) { eyesClosed = false; nextBlinkMs = now + random(3500, 8000); redrawScreen(); }
if (yawning) { if (now >= nextMouthMoveMs) { mouthOpen = !mouthOpen; nextMouthMoveMs = now + YAWN_FRAME_MS; redrawScreen(); } }
else if (!mouthOpen && now >= nextMouthMoveMs) { mouthOpen = true; mouthEndsMs = now + MOUTH_MOVE_MS; nextMouthMoveMs = now + random(5000, 11000); redrawScreen(); } else if (mouthOpen && now >= mouthEndsMs) { mouthOpen = false; redrawScreen(); }
}
void updateSchedule(unsigned long now) {
unsigned long elapsed = now - phaseStartedMs;
if (phase == FACE_PHASE && elapsed >= FACE_PERIOD_MS) { phase = CPU_PHASE; phaseStartedMs = now; eyesClosed = false; mouthOpen = false; requestPcTemperature("GET_CPU_TEMP"); redrawScreen(); }
else if (phase == CPU_PHASE && elapsed >= CPU_SCREEN_MS) { phase = GPU_PHASE; phaseStartedMs = now; requestPcTemperature("GET_GPU_TEMP"); redrawScreen(); }
else if (phase == GPU_PHASE && elapsed >= GPU_SCREEN_MS) { phase = FACE_PHASE; phaseStartedMs = now; nextBlinkMs = now + random(3500, 8000); nextMouthMoveMs = now + random(5000, 11000); redrawScreen(); }
}
void setup() { SPI.begin(TFT_SCK_PIN, -1, TFT_MOSI_PIN, TFT_CS_PIN); display.begin(); display.setRotation(1); display.fillScreen(BACKGROUND); randomSeed(esp_random()); phaseStartedMs = millis(); nextBlinkMs = phaseStartedMs + random(3500, 8000); nextMouthMoveMs = phaseStartedMs + random(5000, 11000); redrawScreen(); setupBluetooth(); requestPcTemperature("GET_GPU_TEMP"); }
void loop() { unsigned long now = millis(); updateFaceAnimation(now); updateSchedule(now); }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.




