Community project

M5Stickc plus2 DeskBuddy

ESP32
Photo of M5Stickc plus2 DeskBuddy

Mindustri guy

Last updated August 26, 2026

DeskBuddy transforms the M5StickC Plus2 into an expressive desktop companion that displays weather, time, and air quality data with animated character moods. The project combines the ESP32's built-in WiFi connectivity with the device's 240x135 LCD screen and IMU sensors to create an interactive desk accessory that responds to your environment.

This guide provides a wiring diagram, complete parts list, and step-by-step assembly instructions to get DeskBuddy running. Simply connect the M5StickC Plus2 via USB-C, configure your WiFi credentials through the web portal, and watch your new desk buddy greet you with animated expressions while keeping you updated on weather, forecasts, and air quality throughout the day.

Wiring diagram

Gather all the parts

QtyComponent
1

USB-C data cable

USB-C data cable

A USB-C cable that supplies power and carries the firmware from your computer to the M5StickC.

Assemble it in 3 steps

1. Connect the Stick to USB

Use a USB-C cable that carries both data and power. Plug one end into the M5StickC PLUS2 and the other end into your computer; this powers DeskBuddy and lets Schematik send the firmware.

  • If the Stick turns on but deployment cannot see it, try another cable; some USB-C cables only provide charging power.
  • Do not force the USB-C plug. If it does not slide in easily, turn it over; forcing it can damage the Stick’s connector.

2. Keep the screen facing you

Place the M5StickC PLUS2 with its screen facing you in landscape orientation. The animated face and dashboard pages are designed for this wide view.

  • Set it on a stable desk while you first test it, so ordinary bumps do not look like deliberate shaking.

3. Turn it on and watch the greeting

Turn on the M5StickC PLUS2. DeskBuddy first appears asleep, opens its eyes and looks left, right, and up, then looks happily forward and says “Hello, friend.” before showing its normal face.

  • The greeting plays once each time the Stick starts. After it finishes, Button A changes brightness and Button B moves through the information pages.
  • Keep the Stick still during startup so the first greeting is easy to see.

Review all connections

1. Connections between "usb_c_data_cable" and "ESP32"

Functionusb_c_data_cableESP32
powerVBUSM5StickC built-in USB-C socketEXT
groundGNDM5StickC built-in USB-C socketEXT
dataUSB dataM5StickC built-in USB-C socketEXT

Deploy the firmware

#include <Arduino.h>
#include <M5Unified.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <time.h>
#include <math.h>
#include "secrets.h"

// DeskBuddy for M5StickC PLUS2: ST7789V2 240x135 LCD in landscape, built-in buttons, IMU, Wi-Fi and battery sensing.

// Button B pages: Face → Clock & Weather → Forecast → Daylight → World Clock → Air Quality → System Status.
enum Page : uint8_t { FACE_PAGE, CLOCK_PAGE, FORECAST_PAGE, DAYLIGHT_PAGE, WORLD_PAGE, AIR_PAGE, STATUS_PAGE, PAGE_COUNT };
enum Mood : uint8_t { NORMAL, HAPPY, SURPRISED, SLEEPY, ANGRY, SAD, EXCITED, LOVE, SUSPICIOUS, MOOD_COUNT };
enum PetState : uint8_t { BOOT_LOOK, AWAKE, FALLING_ASLEEP, ASLEEP, BRIEF_WAKE, ASKING_FOR_PET, PETTED_HAPPY };


// Forward declarations
void drawClockDigit(int x, int y, int digit, float scale = 1.0f);

void setBrightness();
void loadWiFi();
void saveWiFi(const String& ssid, const String& pass);
void portalHome();
void portalSave();
void startConfigPortal();
bool connectWiFi();
String weatherName(int code);
void fetchWeather();
void fetchAirQuality();
void drawWeatherIcon(int x, int y, int code, float scale = 1.0f);
void drawEyes(int dx, int dy, bool closed);
void drawPixelSmile(int centerX, int y, uint8_t style);
void drawAirQualityFace(int centerX, int centerY, uint8_t band);
void drawFace(bool force);
void pageTitle(const char* label);
void drawClock();
void drawWeather();
void drawForecast();
void drawDaylight();
void drawWorldClock();
void drawAirQuality();
void drawSystemStatus();
void drawCurrentPage();
void setPage(Page newPage);
void scheduleSleepEvent(uint32_t now);
void enterAwake(uint32_t now, bool happy);
void updatePetState(uint32_t now);
void playBootAnimation();
bool restoreTimeFromRtc();
void saveTimeToRtc();
void waitForNtpAndSaveRtc();
void queueThought(const char* message, uint8_t priority);
uint8_t usAqiBand(float aqi);
void loadCachedData();
void saveCachedData();
String savedAgeLabel();

constexpr uint16_t BG = TFT_BLACK;
constexpr uint16_t FG = TFT_WHITE;
constexpr uint16_t DIM_FG = TFT_LIGHTGREY;
constexpr uint32_t FACE_FRAME_MS = 50;
constexpr uint32_t WEATHER_REFRESH_MS = 4UL * 60UL * 1000UL;
constexpr uint32_t LONG_PRESS_MS = 900;
constexpr uint32_t INACTIVITY_MS = 60UL * 1000UL;
constexpr uint32_t SHAKE_COOLDOWN_MS = 3000;
// These deliberately exclude ordinary table vibration and gentle handling.
constexpr float ACTIVITY_SPIN_DPS = 55.0f;
constexpr float ACTIVITY_ACCEL_DELTA_G = 0.24f;
constexpr float WAKE_SPIN_DPS = 150.0f;
constexpr float WAKE_ACCEL_DELTA_G = 0.55f;
constexpr float SHAKE_SPIN_DPS = 620.0f;
constexpr float SHAKE_ACCEL_G = 3.2f;

Preferences prefs;
WebServer portal(80);
String wifiSsid, wifiPass;
bool configMode = false;
bool bright = true;
Page page = FACE_PAGE;
Mood mood = NORMAL;
PetState petState = BOOT_LOOK;

uint32_t stateSince = 0;
uint32_t lastFrame = 0;
uint32_t lastWeatherFetch = 0;
uint32_t lastTapA = 0;
uint32_t buttonBDownAt = 0;
uint32_t powerButtonDownAt = 0;
uint32_t lastShakeAt = 0;
uint32_t lastMeaningfulActivity = 0;
uint32_t motionStartedAt = 0;
uint32_t nextSleepEventAt = 0;
uint32_t attentionUntil = 0;
uint32_t petHoldStartedAt = 0;
uint32_t angryUntil = 0;
uint32_t nextBlinkAt = 0;
uint32_t blinkUntil = 0;
uint32_t nextIdleGlanceAt = 0;
uint32_t nextIdleGestureAt = 0;
uint32_t idleGestureUntil = 0;
uint32_t wakeLookStartedAt = 0;
uint32_t wakeLookUntil = 0;
// Chosen once for each real wake from sleep: 0 calm stretch, 1 curious check-in, 2 happy settle.
uint8_t wakeAnimation = 0;
uint32_t nextIdleThoughtAt = 0;
uint32_t idleThoughtUntil = 0;
String idleThought;
String pendingThought;
uint8_t pendingThoughtPriority = 0;
bool offlineWakeThoughtPending = false;
bool usbStateKnown = false;
bool usbConnected = false;
bool usbWasCharging = false;
bool lowBatteryReminderArmed = true;
bool sleepInteractionUsed = false;
uint32_t nextUsbCheckAt = 0;
time_t lastSavedDataAt = 0;
int lastClockSecond = -1;
bool longBHandled = false;
bool powerTimeSaved = false;
bool petHoldHandled = false;

float gazeX = 0.0f, gazeY = 0.0f;
float idleGlanceX = 0.0f, idleGlanceY = 0.0f;
int shownX = 999, shownY = 999;
bool lastClosed = false;

float tempC = NAN;
float windKph = NAN;
int humidity = -1;
int weatherCode = -1;
float forecastHigh[3] = { NAN, NAN, NAN };
float forecastLow[3] = { NAN, NAN, NAN };
int forecastCode[3] = { -1, -1, -1 };
String forecastDay[3] = { "Day 1", "Day 2", "Day 3" };
String weatherStatus = "Waiting for Wi-Fi";
String sunriseText = "--:--";
String sunsetText = "--:--";
int sunriseMinutes = -1;
int sunsetMinutes = -1;
float usAqi = NAN;
float pm25 = NAN;
float pm10 = NAN;
float nitrogenDioxide = NAN;
String airStatus = "Waiting for Wi-Fi";

String savedAgeLabel() {
  const time_t now = time(nullptr);
  if (lastSavedDataAt < 1704067200 || now < 1704067200) return "SAVED DATA";
  const long age = max(0L, (long)(now - lastSavedDataAt));
  if (age < 60) return "SAVED JUST NOW";
  if (age < 3600) return String("SAVED ") + String((age + 30) / 60) + " MIN AGO";
  if (age < 24L * 3600L) return String("SAVED ") + String((age + 1800) / 3600) + "H AGO";
  if (age < 48L * 3600L) return "SAVED YESTERDAY";
  return String("SAVED ") + String(age / 86400L) + "D AGO";
}

void loadCachedData() {
  prefs.begin("deskcache", true);
  lastSavedDataAt = prefs.getLong64("updated", 0);
  tempC = prefs.getFloat("temp", NAN); windKph = prefs.getFloat("wind", NAN);
  humidity = prefs.getInt("humid", -1); weatherCode = prefs.getInt("wcode", -1);
  for (int i = 0; i < 3; ++i) {
    const String index = String(i);
    forecastHigh[i] = prefs.getFloat(("high" + index).c_str(), NAN);
    forecastLow[i] = prefs.getFloat(("low" + index).c_str(), NAN);
    forecastCode[i] = prefs.getInt(("fcode" + index).c_str(), -1);
    forecastDay[i] = prefs.getString(("fday" + index).c_str(), String("Day ") + String(i + 1));
  }
  sunriseMinutes = prefs.getInt("sunrise", -1); sunsetMinutes = prefs.getInt("sunset", -1);
  sunriseText = prefs.getString("sunriseTx", "--:--"); sunsetText = prefs.getString("sunsetTx", "--:--");
  usAqi = prefs.getFloat("aqi", NAN); pm25 = prefs.getFloat("pm25", NAN);
  pm10 = prefs.getFloat("pm10", NAN); nitrogenDioxide = prefs.getFloat("no2", NAN);
  prefs.end();
  if (!isnan(tempC)) weatherStatus = savedAgeLabel();
  if (!isnan(usAqi)) airStatus = savedAgeLabel();
}

void saveCachedData() {
  const time_t now = time(nullptr);
  if (now >= 1704067200) lastSavedDataAt = now;
  prefs.begin("deskcache", false);
  prefs.putLong64("updated", lastSavedDataAt);
  prefs.putFloat("temp", tempC); prefs.putFloat("wind", windKph);
  prefs.putInt("humid", humidity); prefs.putInt("wcode", weatherCode);
  for (int i = 0; i < 3; ++i) {
    const String index = String(i);
    prefs.putFloat(("high" + index).c_str(), forecastHigh[i]); prefs.putFloat(("low" + index).c_str(), forecastLow[i]);
    prefs.putInt(("fcode" + index).c_str(), forecastCode[i]); prefs.putString(("fday" + index).c_str(), forecastDay[i]);
  }
  prefs.putInt("sunrise", sunriseMinutes); prefs.putInt("sunset", sunsetMinutes);
  prefs.putString("sunriseTx", sunriseText); prefs.putString("sunsetTx", sunsetText);
  prefs.putFloat("aqi", usAqi); prefs.putFloat("pm25", pm25); prefs.putFloat("pm10", pm10); prefs.putFloat("no2", nitrogenDioxide);
  prefs.end();
}

void queueThought(const char* message, uint8_t priority) {
  // Keep only the most useful observation; lower-priority notices never create a backlog.
  if (priority >= pendingThoughtPriority) {
    pendingThought = message;
    pendingThoughtPriority = priority;
  }
}

uint8_t usAqiBand(float aqi) {
  if (isnan(aqi)) return 255;
  if (aqi <= 50) return 0;       // Good
  if (aqi <= 100) return 1;      // Moderate
  if (aqi <= 150) return 2;      // Unhealthy for Sensitive People
  if (aqi <= 200) return 3;      // Unhealthy
  if (aqi <= 300) return 4;      // Very Unhealthy
  return 5;                      // Hazardous
}

void setBrightness() {
  const bool sleeping = petState == ASLEEP || petState == FALLING_ASLEEP;
  M5.Display.setBrightness(sleeping ? 18 : (bright ? 180 : 35));
}

void loadWiFi() {
  prefs.begin("deskbuddy", true);
  wifiSsid = prefs.getString("ssid", DESKBUDDY_WIFI_SSID);
  wifiPass = prefs.getString("pass", DESKBUDDY_WIFI_PASSWORD);
  prefs.end();
}

void saveWiFi(const String& ssid, const String& pass) {
  prefs.begin("deskbuddy", false);
  prefs.putString("ssid", ssid);
  prefs.putString("pass", pass);
  prefs.end();
}

void portalHome() {
  String html = "<!doctype html><html><meta name='viewport' content='width=device-width,initial-scale=1'><body style='font-family:Arial;max-width:420px;margin:30px auto;padding:12px'><h2>DeskBuddy Wi-Fi</h2><p>Enter the Wi-Fi network the M5StickC should join.</p><form method='post' action='/save'><label>Wi-Fi name</label><br><input name='ssid' value='" + wifiSsid + "' style='width:100%;padding:10px;box-sizing:border-box'><br><br><label>Password</label><br><input type='password' name='pass' style='width:100%;padding:10px;box-sizing:border-box'><br><br><button style='padding:10px 16px'>Save and restart</button></form></body></html>";
  portal.send(200, "text/html", html);
}

void portalSave() {
  if (!portal.hasArg("ssid") || portal.arg("ssid").isEmpty()) {
    portal.send(400, "text/plain", "Wi-Fi name is required.");
    return;
  }
  saveWiFi(portal.arg("ssid"), portal.arg("pass"));
  portal.send(200, "text/html", "Saved. DeskBuddy is restarting.");
  delay(700);
  ESP.restart();
}

void startConfigPortal() {
  configMode = true;
  WiFi.disconnect(true, true);
  WiFi.mode(WIFI_AP);
  WiFi.softAP("DeskBuddy-Setup", "deskbuddy");
  portal.on("/", HTTP_GET, portalHome);
  portal.on("/save", HTTP_POST, portalSave);
  portal.begin();
  M5.Display.fillScreen(BG);
  M5.Display.setTextColor(FG, BG);
  M5.Display.setTextSize(1);
  M5.Display.setCursor(7, 8);
  M5.Display.println("Wi-Fi setup");
  M5.Display.println();
  M5.Display.println("Phone network:");
  M5.Display.println("DeskBuddy-Setup");
  M5.Display.println("Password: deskbuddy");
  M5.Display.println();
  M5.Display.println("Open 192.168.4.1");
}

bool connectWiFi() {
  WiFi.mode(WIFI_STA);

  auto tryNetwork = [](const String& ssid, const String& pass) {
    if (ssid.isEmpty()) return false;
    WiFi.disconnect(false, false);
    delay(150);
    WiFi.begin(ssid.c_str(), pass.c_str());
    const uint32_t started = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - started < 12000) {
      M5.update();
      delay(50);
    }
    return WiFi.status() == WL_CONNECTED;
  };

  // Keep the configured/main network first. Use the backup only if it is not reachable.
  if (tryNetwork(wifiSsid, wifiPass)) return true;
  return tryNetwork(DESKBUDDY_WIFI_BACKUP_SSID, DESKBUDDY_WIFI_BACKUP_PASSWORD);
}

String weatherName(int code) {
  // Open-Meteo uses WMO weather interpretation codes.
  switch (code) {
    case 0: return "Clear";
    case 1: return "Mostly clear";
    case 2: return "Partly cloudy";
    case 3: return "Overcast";
    case 45: return "Fog";
    case 48: return "Rime fog";
    case 51: return "Light drizzle";
    case 53: return "Drizzle";
    case 55: return "Heavy drizzle";
    case 56: return "Freezing drizzle";
    case 57: return "Heavy freezing drizzle";
    case 61: return "Light rain";
    case 63: return "Rain";
    case 65: return "Heavy rain";
    case 66: return "Freezing rain";
    case 67: return "Heavy freezing rain";
    case 71: return "Light snow";
    case 73: return "Snow";
    case 75: return "Heavy snow";
    case 77: return "Snow grains";
    case 80: return "Light showers";
    case 81: return "Showers";
    case 82: return "Heavy showers";
    case 85: return "Snow showers";
    case 86: return "Heavy snow showers";
    case 95: return "Thunderstorm";
    case 96: return "Storm with hail";
    case 99: return "Heavy hailstorm";
    default: return "Weather unknown";
  }
}

void fetchWeather() {
  lastWeatherFetch = millis();
  if (WiFi.status() != WL_CONNECTED) { weatherStatus = "No Wi-Fi"; return; }
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(12000);
  http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
  const String url = String("https://api.open-meteo.com/v1/forecast?latitude=") + DESKBUDDY_LATITUDE +
    "&longitude=" + DESKBUDDY_LONGITUDE +
    "&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m"
    "&daily=temperature_2m_max,temperature_2m_min,weather_code,sunrise,sunset"
    "&timezone=Europe%2FMoscow&forecast_days=3";
  if (!http.begin(client, url)) { weatherStatus = "Weather connection failed"; return; }
  const int status = http.GET();
  if (status != HTTP_CODE_OK) {
    weatherStatus = String("Weather error ") + status;
    http.end();
    return;
  }
  JsonDocument doc;
  const DeserializationError err = deserializeJson(doc, http.getString());
  http.end();
  if (err) { weatherStatus = "Weather data error"; return; }
  tempC = doc["current"]["temperature_2m"] | NAN;
  humidity = doc["current"]["relative_humidity_2m"] | -1;
  weatherCode = doc["current"]["weather_code"] | -1;
  windKph = doc["current"]["wind_speed_10m"] | NAN;
  JsonArray highs = doc["daily"]["temperature_2m_max"].as<JsonArray>();
  JsonArray lows = doc["daily"]["temperature_2m_min"].as<JsonArray>();
  JsonArray codes = doc["daily"]["weather_code"].as<JsonArray>();
  JsonArray dates = doc["daily"]["time"].as<JsonArray>();
  JsonArray sunrises = doc["daily"]["sunrise"].as<JsonArray>();
  JsonArray sunsets = doc["daily"]["sunset"].as<JsonArray>();
  if (highs.size() < 3 || lows.size() < 3 || codes.size() < 3 || dates.size() < 3 || sunrises.size() < 1 || sunsets.size() < 1) {
    weatherStatus = "Forecast data missing";
    return;
  }
  for (int i = 0; i < 3; ++i) {
    forecastHigh[i] = highs[i] | NAN;
    forecastLow[i] = lows[i] | NAN;
    forecastCode[i] = codes[i] | -1;
    const char* date = dates[i] | "";
    forecastDay[i] = String(date).substring(5);
  }
  const String sunriseIso = String((const char*)(sunrises[0] | ""));
  const String sunsetIso = String((const char*)(sunsets[0] | ""));
  if (sunriseIso.length() >= 16 && sunsetIso.length() >= 16) {
    sunriseText = sunriseIso.substring(11, 16);
    sunsetText = sunsetIso.substring(11, 16);
    sunriseMinutes = sunriseText.substring(0, 2).toInt() * 60 + sunriseText.substring(3, 5).toInt();
    sunsetMinutes = sunsetText.substring(0, 2).toInt() * 60 + sunsetText.substring(3, 5).toInt();
  }
  weatherStatus = "OK";
  saveCachedData();
}

void fetchAirQuality() {
  // WAQI's geo feed selects a real reporting station near Moscow, rather than a forecast-model cell.
  if (WiFi.status() != WL_CONNECTED) { airStatus = isnan(usAqi) ? "No Wi-Fi" : savedAgeLabel(); return; }
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(12000);
  http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
  const String url = String("https://api.waqi.info/feed/geo:") + DESKBUDDY_LATITUDE + ";" +
    DESKBUDDY_LONGITUDE + "/?token=" + DESKBUDDY_WAQI_TOKEN;
  if (!http.begin(client, url)) { airStatus = isnan(usAqi) ? "Air connection failed" : savedAgeLabel(); return; }
  const int status = http.GET();
  if (status != HTTP_CODE_OK) {
    airStatus = isnan(usAqi) ? String("Air error ") + status : savedAgeLabel();
    http.end();
    return;
  }
  JsonDocument doc;
  const DeserializationError err = deserializeJson(doc, http.getString());
  http.end();
  if (err || String((const char*)(doc["status"] | "")) != "ok") {
    airStatus = isnan(usAqi) ? "Station data unavailable" : savedAgeLabel();
    return;
  }
  const float stationAqi = doc["data"]["aqi"] | NAN;
  if (isnan(stationAqi)) { airStatus = isnan(usAqi) ? "Station data missing" : savedAgeLabel(); return; }
  const float previousAqi = usAqi;
  const uint8_t previousBand = usAqiBand(previousAqi);
  usAqi = stationAqi;
  // WAQI may omit a pollutant when that station does not report it; keep a prior valid value in that case.
  if (!doc["data"]["iaqi"]["pm25"]["v"].isNull()) pm25 = doc["data"]["iaqi"]["pm25"]["v"].as<float>();
  if (!doc["data"]["iaqi"]["pm10"]["v"].isNull()) pm10 = doc["data"]["iaqi"]["pm10"]["v"].as<float>();
  if (!doc["data"]["iaqi"]["no2"]["v"].isNull()) nitrogenDioxide = doc["data"]["iaqi"]["no2"]["v"].as<float>();
  airStatus = "WAQI STATION LIVE";
  saveCachedData();
  // Speak only after a meaningful live change, never for the first baseline reading.
  const uint8_t newBand = usAqiBand(usAqi);
  if (!isnan(previousAqi) && (newBand != previousBand || fabsf(usAqi - previousAqi) >= 25.0f)) {
    if (newBand >= 2 && newBand > previousBand) queueThought("Maybe keep the window closed.", 3);
    else if (newBand < previousBand || usAqi + 25.0f <= previousAqi) queueThought("Air is better now.", 2);
    else if (newBand > previousBand) queueThought("Air got worse.", 2);
  }
}

void drawWeatherIcon(int x, int y, int code, float scale) {
  // x/y are the icon's visual centre. Scaling keeps the same WMO symbols
  // usable on the large current-weather page and the three forecast columns.
  auto sx = [&](float value) { return x + (int)lroundf(value * scale); };
  auto sy = [&](float value) { return y + (int)lroundf(value * scale); };
  auto ss = [&](float value) { return max(1, (int)lroundf(value * scale)); };
  auto sun = [&]() {
    M5.Display.drawCircle(sx(-5), sy(-3), ss(8), FG);
    for (int a = 0; a < 8; ++a) {
      const float angle = a * PI / 4.0f;
      M5.Display.drawLine(sx(-5 + cosf(angle) * 12), sy(-3 + sinf(angle) * 12),
                          sx(-5 + cosf(angle) * 17), sy(-3 + sinf(angle) * 17), FG);
    }
  };
  auto cloud = [&]() {
    M5.Display.fillCircle(sx(-8), sy(3), ss(7), FG);
    M5.Display.fillCircle(sx(1), sy(-2), ss(10), FG);
    M5.Display.fillCircle(sx(12), sy(4), ss(6), FG);
    M5.Display.fillRect(sx(-14), sy(3), ss(32), ss(8), FG);
  };
  auto snowflake = [&](float ox, float oy) {
    const int px = sx(ox), py = sy(oy), arm = ss(3);
    M5.Display.drawFastHLine(px - arm, py, arm * 2 + 1, FG);
    M5.Display.drawLine(px - arm, py - arm, px + arm, py + arm, FG);
    M5.Display.drawLine(px - arm, py + arm, px + arm, py - arm, FG);
  };
  auto rain = [&](int drops, bool longDrops) {
    for (int i = 0; i < drops; ++i) {
      const float ox = -10 + i * 8;
      M5.Display.drawLine(sx(ox), sy(14), sx(ox - (longDrops ? 3 : 2)), sy(longDrops ? 23 : 21), FG);
    }
  };

  if (code == 0) { sun(); }
  else if (code == 1 || code == 2) { sun(); cloud(); }
  else if (code == 3) { cloud(); M5.Display.drawFastHLine(sx(-14), sy(13), ss(32), FG); }
  else if (code == 45 || code == 48) {
    cloud(); M5.Display.drawFastHLine(sx(-15), sy(15), ss(37), FG); M5.Display.drawFastHLine(sx(-10), sy(20), ss(28), FG);
    if (code == 48) M5.Display.drawPixel(sx(20), sy(18), FG);
  } else if (code >= 51 && code <= 55) {
    cloud(); M5.Display.drawPixel(sx(-8), sy(17), FG); M5.Display.drawPixel(sx(1), sy(20), FG); M5.Display.drawPixel(sx(10), sy(17), FG);
  } else if (code == 56 || code == 57 || code == 66 || code == 67) {
    cloud(); M5.Display.drawLine(sx(-8), sy(14), sx(-10), sy(20), FG); snowflake(1, 18); M5.Display.drawLine(sx(10), sy(14), sx(8), sy(20), FG);
  } else if (code >= 61 && code <= 65) { cloud(); rain(code == 61 ? 2 : (code == 63 ? 3 : 4), false); }
  else if (code >= 71 && code <= 77) {
    cloud(); snowflake(-8, 18); snowflake(2, 20); if (code != 71) snowflake(11, 17); if (code == 77) M5.Display.drawPixel(sx(15), sy(22), FG);
  } else if (code >= 80 && code <= 82) { cloud(); rain(code == 80 ? 2 : (code == 81 ? 3 : 4), true); }
  else if (code == 85 || code == 86) { cloud(); snowflake(-7, 18); snowflake(5, 20); if (code == 86) snowflake(14, 17); }
  else if (code == 95 || code == 96 || code == 99) {
    cloud(); M5.Display.drawLine(sx(1), sy(13), sx(-4), sy(21), FG); M5.Display.drawLine(sx(-4), sy(21), sx(2), sy(21), FG); M5.Display.drawLine(sx(2), sy(21), sx(-1), sy(27), FG);
    if (code == 96 || code == 99) { M5.Display.drawCircle(sx(11), sy(19), ss(2), FG); if (code == 99) M5.Display.drawCircle(sx(18), sy(22), ss(2), FG); }
  } else {
    M5.Display.drawCircle(x, sy(3), ss(14), FG); M5.Display.setTextColor(FG, BG); M5.Display.setTextSize(max(1, (int)lroundf(2 * scale))); M5.Display.setCursor(sx(-5), sy(-5)); M5.Display.print("?");
  }
}

void drawEyes(int dx, int dy, bool closed) {
  const int width = M5.Display.width();
  const int height = M5.Display.height();
  // Larger soft-cornered eyes match the supplied reference face.
  const int eyeW = 48, normalH = 58;
  int eyeH = normalH;
  int leftY = (height - normalH) / 2 + 4 + dy;
  int rightY = leftY;
  int leftX = width / 2 - 63 + dx;
  int rightX = width / 2 + 15 + dx;
  if (closed || mood == SLEEPY) { eyeH = 9; leftY += 25; rightY += 25; }
  else if (mood == SURPRISED) { eyeH = 64; leftY -= 3; rightY -= 3; }
  else if (mood == SUSPICIOUS) { eyeH = 43; leftY += 11; rightY += 6; }
  else if (mood == SAD) { eyeH = 51; leftY += 5; rightY += 5; }
  const int radius = closed || mood == SLEEPY ? 5 : min(17, eyeH / 3);
  M5.Display.fillRoundRect(leftX, leftY + (normalH - eyeH) / 2, eyeW, eyeH, radius, FG);
  M5.Display.fillRoundRect(rightX, rightY + (normalH - eyeH) / 2, eyeW, eyeH, radius, FG);
  if (!closed && mood == SUSPICIOUS) {
    // The curious double-take gets raised brows and a small question mark.
    M5.Display.fillRoundRect(leftX + 2, leftY - 13, eyeW - 5, 6, 3, FG);
    M5.Display.fillRoundRect(rightX + 4, rightY - 20, eyeW - 7, 6, 3, FG);
    M5.Display.setTextColor(FG, BG);
    M5.Display.setTextSize(2);
    M5.Display.setCursor(width - 28, 9);
    M5.Display.print("?");
  } else if (!closed && mood == HAPPY) {
    M5.Display.fillCircle(leftX + eyeW / 2, leftY + eyeH + 8, eyeW / 2 + 3, BG);
    M5.Display.fillCircle(rightX + eyeW / 2, rightY + eyeH + 8, eyeW / 2 + 3, BG);
  } else if (!closed && mood == ANGRY) {
    M5.Display.fillTriangle(leftX, leftY, leftX + eyeW, leftY, leftX + eyeW, leftY + 16, BG);
    M5.Display.fillTriangle(rightX, rightY, rightX + eyeW, rightY, rightX, rightY + 16, BG);
  } else if (!closed && mood == SAD) {
    M5.Display.fillTriangle(leftX, leftY, leftX + eyeW, leftY, leftX, leftY + 15, BG);
    M5.Display.fillTriangle(rightX, rightY, rightX + eyeW, rightY, rightX + eyeW, rightY + 15, BG);
  } else if (!closed && mood == LOVE) {
    M5.Display.fillCircle(width / 2 - 4, 8, 4, FG);
    M5.Display.fillCircle(width / 2 + 4, 8, 4, FG);
    M5.Display.fillTriangle(width / 2 - 8, 9, width / 2 + 8, 9, width / 2, 18, FG);
  } else if (!closed && mood == EXCITED) {
    M5.Display.drawString("!", 4, 4, 2);
    M5.Display.drawString("!", width - 16, 4, 2);
  }
}

// Small stepped mouths in the same crisp monochrome style as the supplied faces.
// They appear only for friendly moods, leaving DeskBuddy's normal eye-only face intact.
void drawPixelSmile(int centerX, int y, uint8_t style) {
  const int halfWidth = style == 2 ? 17 : 14;
  const int cornerDrop = style == 2 ? 5 : 3;
  const int midDrop = style == 2 ? 8 : 6;
  // A three-step U-shaped line reads clearly on the small display without a full face redraw design change.
  M5.Display.drawFastHLine(centerX - halfWidth, y, 7, FG);
  M5.Display.drawFastHLine(centerX + halfWidth - 6, y, 7, FG);
  M5.Display.drawFastVLine(centerX - halfWidth + 6, y, cornerDrop, FG);
  M5.Display.drawFastVLine(centerX + halfWidth - 7, y, cornerDrop, FG);
  M5.Display.drawFastHLine(centerX - halfWidth + 6, y + cornerDrop, 6, FG);
  M5.Display.drawFastHLine(centerX + halfWidth - 12, y + cornerDrop, 6, FG);
  M5.Display.drawFastVLine(centerX - 6, y + cornerDrop, midDrop - cornerDrop, FG);
  M5.Display.drawFastVLine(centerX + 5, y + cornerDrop, midDrop - cornerDrop, FG);
  M5.Display.drawFastHLine(centerX - 5, y + midDrop, 11, FG);
}

// A self-contained data-page icon. It deliberately does not use DeskBuddy's
// main-face eyes, gaze, or mood state.
void drawAirQualityFace(int centerX, int centerY, uint8_t band) {
  M5.Display.drawCircle(centerX, centerY, 25, FG);
  // Clean through moderate air: open eyes. Poorer air: tired/worried eyes.
  if (band < 3) {
    M5.Display.fillRect(centerX - 12, centerY - 9, 5, 8, FG);
    M5.Display.fillRect(centerX + 7, centerY - 9, 5, 8, FG);
  } else if (band < 5) {
    M5.Display.drawFastHLine(centerX - 13, centerY - 6, 10, FG);
    M5.Display.drawFastHLine(centerX + 3, centerY - 6, 10, FG);
  } else {
    M5.Display.drawFastHLine(centerX - 13, centerY - 8, 10, FG);
    M5.Display.drawFastHLine(centerX + 3, centerY - 8, 10, FG);
    M5.Display.drawFastHLine(centerX - 8, centerY - 4, 6, FG);
    M5.Display.drawFastHLine(centerX + 2, centerY - 4, 6, FG);
  }
  // Six US-AQI bands: broad smile, smile, straight mouth, concern, frown, alarmed frown.
  if (band == 0) {
    M5.Display.drawLine(centerX - 14, centerY + 7, centerX - 8, centerY + 13, FG);
    M5.Display.drawFastHLine(centerX - 8, centerY + 13, 17, FG);
    M5.Display.drawLine(centerX + 8, centerY + 13, centerX + 14, centerY + 7, FG);
  } else if (band == 1) {
    M5.Display.drawLine(centerX - 11, centerY + 9, centerX - 6, centerY + 13, FG);
    M5.Display.drawFastHLine(centerX - 6, centerY + 13, 13, FG);
    M5.Display.drawLine(centerX + 6, centerY + 13, centerX + 11, centerY + 9, FG);
  } else if (band == 2) {
    M5.Display.drawFastHLine(centerX - 11, centerY + 12, 23, FG);
  } else if (band == 3) {
    M5.Display.drawFastHLine(centerX - 11, centerY + 14, 23, FG);
    M5.Display.drawFastHLine(centerX - 8, centerY + 10, 17, FG);
  } else {
    M5.Display.drawLine(centerX - 13, centerY + 17, centerX - 8, centerY + 11, FG);
    M5.Display.drawFastHLine(centerX - 8, centerY + 11, 17, FG);
    M5.Display.drawLine(centerX + 8, centerY + 11, centerX + 13, centerY + 17, FG);
  }
}

void drawFace(bool force) {
  const uint32_t now = millis();
  if (petState == ASLEEP) {
    mood = SLEEPY;
    const int dx = 0, dy = 7;
    if (!force && dx == shownX && dy == shownY && lastClosed) return;
    M5.Display.fillScreen(BG);
    drawEyes(dx, dy, true);
    shownX = dx; shownY = dy; lastClosed = true;
    return;
  }
  if (now >= nextBlinkAt && petState != FALLING_ASLEEP) {
    blinkUntil = now + 110;
    nextBlinkAt = now + random(2200, 6000);
    if (random(0, 5) == 0) nextBlinkAt = now + random(260, 420);
  }
  bool closed = now < blinkUntil;
  if (petState == FALLING_ASLEEP) closed = (now - stateSince > 400);
  // A real wake starts with one unhurried eye-open moment, not an abrupt jump
  // from sleeping eyes to normal tracking.
  if (wakeLookStartedAt && now - wakeLookStartedAt < 260) closed = true;
  float wiggle = mood == EXCITED ? sinf(now / 65.0f) * 3.0f : 0.0f;
  float bob = (petState == PETTED_HAPPY || mood == LOVE || mood == EXCITED) ? sinf(now / 95.0f) * 2.0f : 0.0f;
  // A slightly larger gaze range makes looking around visible without changing eye shape or placement.
  int dx = constrain((int)lroundf(gazeX * 13.0f + wiggle), -15, 15);
  int dy = constrain((int)lroundf(gazeY * 10.0f + bob), -12, 12);
  if (petState == FALLING_ASLEEP) dy = 8;
  const bool showIdleThought = petState == AWAKE && mood == NORMAL && now < idleThoughtUntil;
  static bool lastThoughtVisible = false;
  if (!force && dx == shownX && dy == shownY && closed == lastClosed && petState != ASKING_FOR_PET && showIdleThought == lastThoughtVisible) return;
  M5.Display.fillScreen(BG);
  drawEyes(dx, dy, closed);
  // The main face is mouth-free in every state. Friendly moments use soft lids and motion.
  if (showIdleThought) {
    M5.Display.setTextColor(FG, BG);
    M5.Display.setTextSize(1);
    M5.Display.setCursor((M5.Display.width() - M5.Display.textWidth(idleThought)) / 2, 7);
    M5.Display.print(idleThought);
  }
  lastThoughtVisible = showIdleThought;
  if (petState == ASKING_FOR_PET) {
    M5.Display.setTextColor(FG, BG);
    M5.Display.setTextSize(1);
    M5.Display.setCursor(68, 2);
    M5.Display.print("...");
    M5.Display.setCursor(48, 69);
    M5.Display.print("hold A");
  }
  shownX = dx; shownY = dy; lastClosed = closed;
}

void pageTitle(const char* label) {
  const int width = M5.Display.width();
  M5.Display.fillScreen(BG);
  M5.Display.setTextColor(FG, BG);
  M5.Display.setTextSize(1);
  M5.Display.setCursor((width - M5.Display.textWidth(label)) / 2, 5);
  M5.Display.print(label);
  M5.Display.drawFastHLine(4, 20, width - 8, FG);
}

// Large seven-segment digits for the 240x135 landscape dashboard.
void drawClockDigit(int x, int y, int digit, float scale) {
  static const uint8_t segments[10] = { 0b0111111, 0b0000110, 0b1011011, 0b1001111, 0b1100110, 0b1101101, 0b1111101, 0b0000111, 0b1111111, 0b1101111 };
  // The clock uses the same seven-segment style for all digits. Scale lets the
  // minute pair be trimmed very slightly without changing the hours or face page.
  const int w = max(1, (int)lroundf(36.0f * scale));
  const int h = max(1, (int)lroundf(60.0f * scale));
  const int t = max(1, (int)lroundf(7.0f * scale));
  const uint8_t on = segments[constrain(digit, 0, 9)];
  if (on & 0b0000001) M5.Display.fillRect(x + t, y, w - 2 * t, t, FG);
  if (on & 0b0000010) M5.Display.fillRect(x + w - t, y + t, t, h / 2 - t, FG);
  if (on & 0b0000100) M5.Display.fillRect(x + w - t, y + h / 2, t, h / 2 - t, FG);
  if (on & 0b0001000) M5.Display.fillRect(x + t, y + h - t, w - 2 * t, t, FG);
  if (on & 0b0010000) M5.Display.fillRect(x, y + h / 2, t, h / 2 - t, FG);
  if (on & 0b0100000) M5.Display.fillRect(x, y + t, t, h / 2 - t, FG);
  if (on & 0b1000000) M5.Display.fillRect(x + t, y + h / 2 - t / 2, w - 2 * t, t, FG);
}

void drawClock() {
  // Combined Moscow dashboard: time stays prominent on the left, with the live
  // weather reading permanently visible on the right instead of a separate page.
  const int width = M5.Display.width();
  constexpr int splitX = 137;
  M5.Display.fillScreen(BG);
  M5.Display.drawRoundRect(2, 2, width - 4, 131, 4, FG);
  M5.Display.drawFastVLine(splitX, 3, 129, FG);

  struct tm localTime;
  if (!getLocalTime(&localTime, 100)) {
    M5.Display.setTextColor(FG, BG); M5.Display.setTextSize(2);
    M5.Display.setCursor(14, 57); M5.Display.print("Syncing time");
  } else {
    char dateText[20];
    strftime(dateText, sizeof(dateText), "%a %d %b", &localTime);
    M5.Display.setTextColor(FG, BG); M5.Display.setTextSize(1);
    M5.Display.setCursor((splitX - M5.Display.textWidth(dateText)) / 2, 10);
    M5.Display.print(dateText);

    constexpr float digitScale = 0.70f;
    constexpr int digitY = 34;
    drawClockDigit(7, digitY, localTime.tm_hour / 10, digitScale);
    drawClockDigit(35, digitY, localTime.tm_hour % 10, digitScale);
    if ((localTime.tm_sec & 1) == 0) {
      M5.Display.fillRect(64, digitY + 12, 4, 4, FG);
      M5.Display.fillRect(64, digitY + 29, 4, 4, FG);
    }
    drawClockDigit(75, digitY, localTime.tm_min / 10, digitScale);
    drawClockDigit(103, digitY, localTime.tm_min % 10, digitScale);
    M5.Display.setTextSize(1);
    M5.Display.setCursor(47, 104); M5.Display.print("MOSCOW");
  }

  M5.Display.setTextSize(1);
  M5.Display.setCursor(splitX + 8, 10); M5.Display.print("MOSCOW WEATHER");
  M5.Display.drawFastHLine(splitX + 5, 22, width - splitX - 10, DIM_FG);
  if (isnan(tempC)) {
    M5.Display.setCursor(splitX + 10, 55); M5.Display.print(weatherStatus);
  } else {
    M5.Display.setTextSize(3);
    M5.Display.setCursor(splitX + 8, 31); M5.Display.printf("%.0fC", tempC);
    drawWeatherIcon(207, 57, weatherCode, 1.15f);
    String condition = weatherName(weatherCode);
    M5.Display.setTextSize(1);
    M5.Display.setCursor(splitX + (width - splitX - M5.Display.textWidth(condition)) / 2, 84);
    M5.Display.print(condition);
    M5.Display.drawFastHLine(splitX + 5, 96, width - splitX - 10, DIM_FG);
    M5.Display.setCursor(splitX + 9, 105);
    if (humidity >= 0) M5.Display.printf("RH %d%%", humidity); else M5.Display.print("RH --");
    M5.Display.setCursor(splitX + 9, 118);
    if (!isnan(windKph)) M5.Display.printf("WIND %.0f", windKph); else M5.Display.print("WIND --");
  }
}

void drawWeather() {
  const int width = M5.Display.width(), height = M5.Display.height();
  M5.Display.fillScreen(BG); M5.Display.setTextColor(FG, BG); M5.Display.setTextSize(1);
  M5.Display.setCursor(6, 5); M5.Display.print(DESKBUDDY_CITY);
  struct tm localTime;
  if (getLocalTime(&localTime, 0)) { char timeText[6]; strftime(timeText, sizeof(timeText), "%H:%M", &localTime); M5.Display.setCursor(width - 30, 5); M5.Display.print(timeText); }
  M5.Display.drawFastHLine(4, 20, width - 8, FG);
  if (isnan(tempC)) { M5.Display.setTextSize(2); M5.Display.setCursor((width - M5.Display.textWidth(weatherStatus)) / 2, 60); M5.Display.print(weatherStatus); return; }
  M5.Display.setTextSize(7); M5.Display.setCursor(14, 30); M5.Display.printf("%.0f", tempC);
  M5.Display.setTextSize(3); M5.Display.setCursor(104, 38); M5.Display.print("C");
  drawWeatherIcon(184, 48, weatherCode, 2.15f);
  String condition = weatherName(weatherCode); M5.Display.setTextSize(2);
  M5.Display.setCursor((width - M5.Display.textWidth(condition)) / 2, 84); M5.Display.print(condition);
  M5.Display.drawFastHLine(8, 105, width - 16, DIM_FG);
  M5.Display.setTextSize(1);
  // Small wind symbol: three moving-air strokes, placed before the wind reading.
  M5.Display.drawFastHLine(12, 115, 10, FG);
  M5.Display.drawFastHLine(15, 119, 11, FG);
  M5.Display.drawFastHLine(12, 123, 8, FG);
  M5.Display.drawPixel(22, 114, FG);
  M5.Display.drawPixel(26, 118, FG);
  M5.Display.setCursor(30, 116);
  if (!isnan(windKph)) M5.Display.printf("WIND %.0f km/h", windKph); else M5.Display.print("WIND --");
  if (weatherStatus != "OK") { M5.Display.setCursor(8, 128); M5.Display.print(weatherStatus); }
  char humidityText[16]; snprintf(humidityText, sizeof(humidityText), "RH %d%%", humidity);
  const int humidityTextX = width - 12 - M5.Display.textWidth(humidityText);
  // Small droplet symbol: outline and pointed top, placed before the humidity reading.
  const int dropX = humidityTextX - 12, dropY = 120;
  M5.Display.drawCircle(dropX, dropY + 2, 5, FG);
  M5.Display.fillTriangle(dropX, dropY - 7, dropX - 4, dropY, dropX + 4, dropY, FG);
  M5.Display.fillCircle(dropX, dropY + 2, 3, BG);
  M5.Display.setCursor(humidityTextX, 116); M5.Display.print(humidityText);
}

void drawForecast() {
  const int width = M5.Display.width(), height = M5.Display.height(), columnW = width / 3;
  pageTitle("3-DAY FORECAST");
  if (isnan(forecastHigh[0])) { M5.Display.setTextSize(2); M5.Display.setCursor((width - M5.Display.textWidth(weatherStatus)) / 2, 62); M5.Display.print(weatherStatus); return; }
  for (int i = 0; i < 3; ++i) {
    const int center = i * columnW + columnW / 2;
    M5.Display.setTextSize(1); M5.Display.setCursor(center - M5.Display.textWidth(forecastDay[i]) / 2, 31); M5.Display.print(forecastDay[i]);
    drawWeatherIcon(center, 59, forecastCode[i], 1.65f);
    char temperatures[14]; snprintf(temperatures, sizeof(temperatures), "%.0f/%.0f", forecastHigh[i], forecastLow[i]);
    M5.Display.setTextSize(2); M5.Display.setCursor(center - M5.Display.textWidth(temperatures) / 2, 108); M5.Display.print(temperatures);
    if (i < 2) M5.Display.drawFastVLine((i + 1) * columnW, 26, height - 30, DIM_FG);
  }
}

void drawDaylight() {
  const int width = M5.Display.width(), height = M5.Display.height();
  pageTitle("MOSCOW DAYLIGHT");
  if (sunriseMinutes < 0 || sunsetMinutes <= sunriseMinutes) { const String message = weatherStatus == "OK" ? "Waiting for sun data" : weatherStatus; M5.Display.setTextSize(2); M5.Display.setCursor((width - M5.Display.textWidth(message)) / 2, 62); M5.Display.print(message); return; }
  struct tm localTime; int nowMinutes = -1; if (getLocalTime(&localTime, 0)) nowMinutes = localTime.tm_hour * 60 + localTime.tm_min;
  const int noonMinutes = (sunriseMinutes + sunsetMinutes) / 2, dayMinutes = sunsetMinutes - sunriseMinutes;
  const float fraction = nowMinutes < 0 ? 0.0f : constrain((nowMinutes - sunriseMinutes) / (float)dayMinutes, 0.0f, 1.0f);
  const int centers[3] = { width / 6, width / 2, width * 5 / 6 };
  const char* labels[3] = { "SUNRISE", "NOON", "SUNSET" };
  char noonText[6], dayLength[12], remaining[12]; snprintf(noonText, sizeof(noonText), "%02d:%02d", noonMinutes / 60, noonMinutes % 60); snprintf(dayLength, sizeof(dayLength), "%dh%02dm", dayMinutes / 60, dayMinutes % 60); snprintf(remaining, sizeof(remaining), "%d%%", (int)lroundf(fraction * 100.0f));
  const char* times[3] = { sunriseText.c_str(), noonText, sunsetText.c_str() };
  for (int i = 0; i < 3; ++i) { M5.Display.setTextSize(1); M5.Display.setCursor(centers[i] - M5.Display.textWidth(labels[i]) / 2, 29); M5.Display.print(labels[i]); }
  // Sunrise / noon / sunset symbols have independent 80-pixel columns.
  for (int i = 0; i < 3; ++i) { const int cx = centers[i]; M5.Display.drawFastHLine(cx - 22, 62, 45, FG); M5.Display.drawCircle(cx, 58, 11, FG); M5.Display.fillRect(cx - 12, 58, 25, 12, BG); M5.Display.drawFastVLine(cx, 39, 8, FG); M5.Display.drawFastHLine(cx - 27, 53, 7, FG); M5.Display.drawFastHLine(cx + 21, 53, 7, FG); if (i == 0) M5.Display.fillTriangle(cx, 38, cx - 5, 45, cx + 5, 45, FG); else if (i == 2) M5.Display.fillTriangle(cx, 46, cx - 5, 39, cx + 5, 39, FG); else { for (int a = 0; a < 8; ++a) { const float angle = a * PI / 4.0f; M5.Display.drawLine(cx + cosf(angle) * 15, 54 + sinf(angle) * 15, cx + cosf(angle) * 20, 54 + sinf(angle) * 20, FG); } } }
  for (int i = 0; i < 3; ++i) { M5.Display.setTextSize(2); M5.Display.setCursor(centers[i] - M5.Display.textWidth(times[i]) / 2, 76); M5.Display.print(times[i]); }
  M5.Display.setTextSize(1); M5.Display.setCursor(centers[0] - M5.Display.textWidth(dayLength) / 2, 102); M5.Display.print(dayLength); M5.Display.setCursor(centers[1] - M5.Display.textWidth(remaining) / 2, 102); M5.Display.print(remaining);
  const int untilSunset = max(0, sunsetMinutes - max(nowMinutes, sunriseMinutes)); snprintf(remaining, sizeof(remaining), "%dh%02dm", untilSunset / 60, untilSunset % 60); M5.Display.setCursor(centers[2] - M5.Display.textWidth(remaining) / 2, 102); M5.Display.print(remaining);
  const int barX = 9, barY = height - 18, barW = width - 18, barH = 9; M5.Display.drawRect(barX, barY, barW, barH, FG); M5.Display.fillRect(barX + 2, barY + 2, (int)lroundf((barW - 4) * fraction), barH - 4, FG);
}

void drawWorldClock() {
  const int width = M5.Display.width(); pageTitle("WORLD CLOCK");
  time_t now; time(&now);
  if (now < 100000) { M5.Display.setTextSize(2); M5.Display.setCursor((width - M5.Display.textWidth("Syncing time")) / 2, 62); M5.Display.print("Syncing time"); return; }
  struct tm utcTime, moscowTime; gmtime_r(&now, &utcTime); const time_t moscowEpoch = now + 3 * 3600; gmtime_r(&moscowEpoch, &moscowTime);
  char utcText[6], moscowText[6]; strftime(utcText, sizeof(utcText), "%H:%M", &utcTime); strftime(moscowText, sizeof(moscowText), "%H:%M", &moscowTime);
  const int leftCenter = width / 4, rightCenter = width * 3 / 4;
  M5.Display.setTextSize(2); M5.Display.setCursor(leftCenter - M5.Display.textWidth("UTC") / 2, 42); M5.Display.print("UTC"); M5.Display.setCursor(rightCenter - M5.Display.textWidth("MOSCOW") / 2, 42); M5.Display.print("MOSCOW");
  M5.Display.drawFastVLine(width / 2, 36, 68, DIM_FG); M5.Display.setTextSize(4); M5.Display.setCursor(leftCenter - M5.Display.textWidth(utcText) / 2, 73); M5.Display.print(utcText); M5.Display.setCursor(rightCenter - M5.Display.textWidth(moscowText) / 2, 73); M5.Display.print(moscowText);
}

void drawAirQuality() {
  const int width = M5.Display.width(), height = M5.Display.height();
  pageTitle("MOSCOW US AQI");
  if (isnan(usAqi)) {
    M5.Display.setTextColor(FG, BG); M5.Display.setTextSize(2);
    M5.Display.setCursor((width - M5.Display.textWidth(airStatus)) / 2, 62);
    M5.Display.print(airStatus);
    return;
  }
  const char* rating = "GOOD";
  if (usAqi > 300) rating = "HAZARDOUS";
  else if (usAqi > 200) rating = "VERY UNHEALTHY";
  else if (usAqi > 150) rating = "UNHEALTHY";
  else if (usAqi > 100) rating = "SENSITIVE";
  else if (usAqi > 50) rating = "MODERATE";

  // Large AQI reading, then a simple friendly face to make this data page fit DeskBuddy.
  M5.Display.setTextSize(6); M5.Display.setCursor(12, 34); M5.Display.printf("%.0f", usAqi);
  M5.Display.setTextSize(2); M5.Display.setCursor(104, 48); M5.Display.print("US");
  const int faceX = 188, faceY = 58;
  drawAirQualityFace(faceX, faceY, usAqiBand(usAqi));
  M5.Display.setTextSize(2); M5.Display.setCursor(faceX - M5.Display.textWidth(rating) / 2, 88); M5.Display.print(rating);
  M5.Display.drawFastHLine(8, 108, width - 16, DIM_FG);
  M5.Display.setTextSize(1);
  char pm25Text[18], pm10Text[18], no2Text[18];
  snprintf(pm25Text, sizeof(pm25Text), "PM2.5 %.0f", pm25);
  snprintf(pm10Text, sizeof(pm10Text), "PM10 %.0f", pm10);
  snprintf(no2Text, sizeof(no2Text), "NO2 %.0f", nitrogenDioxide);
  M5.Display.setCursor(8, 118); M5.Display.print(pm25Text);
  M5.Display.setCursor((width - M5.Display.textWidth(pm10Text)) / 2, 118); M5.Display.print(pm10Text);
  M5.Display.setCursor(width - 8 - M5.Display.textWidth(no2Text), 118); M5.Display.print(no2Text);
}

void drawSystemStatus() {
  const int width = M5.Display.width();
  pageTitle("SYSTEM STATUS");
  const int battery = M5.Power.getBatteryLevel();
  const int batteryMv = M5.Power.getBatteryVoltage();
  M5.Display.setTextSize(2);
  M5.Display.setCursor(12, 30); M5.Display.printf("BATTERY  %d%%", battery);
  M5.Display.setTextSize(1);
  M5.Display.setCursor(14, 52);
  M5.Display.printf("BATTERY  %d mV", batteryMv);
  M5.Display.drawFastHLine(8, 67, width - 16, DIM_FG);
  M5.Display.setCursor(14, 77); M5.Display.print("WI-FI: ");
  M5.Display.print(WiFi.status() == WL_CONNECTED ? "CONNECTED" : "OFFLINE");
  M5.Display.setCursor(14, 92); M5.Display.print("WEATHER: "); M5.Display.print(weatherStatus);
  M5.Display.setCursor(14, 107); M5.Display.print("AIR: "); M5.Display.print(airStatus);
  // The Plus2's discrete power circuit has no reliable USB-present or charge-state signal.
  // Do not infer charging from battery voltage: it can rise or fall for unrelated reasons.
  M5.Display.setCursor(14, 122); M5.Display.print("USB/CHG: NOT AVAILABLE");
}

void drawCurrentPage() {
  shownX = 999;
  if (page == FACE_PAGE) drawFace(true);
  else if (page == CLOCK_PAGE) drawClock();
  else if (page == FORECAST_PAGE) drawForecast();
  else if (page == DAYLIGHT_PAGE) drawDaylight();
  else if (page == WORLD_PAGE) drawWorldClock();
  else if (page == AIR_PAGE) drawAirQuality();
  else drawSystemStatus();
}

void setPage(Page newPage) { page = newPage; drawCurrentPage(); }

void scheduleSleepEvent(uint32_t now) { nextSleepEventAt = now + random(20000, 45001); }

void enterAwake(uint32_t now, bool happy) {
  const bool wasSleeping = petState == ASLEEP || petState == BRIEF_WAKE || petState == ASKING_FOR_PET || petState == FALLING_ASLEEP;
  petState = happy ? PETTED_HAPPY : AWAKE;
  stateSince = now;
  // Every real wake-up starts with a short deliberate scan before normal tilt gaze resumes.
  if (wasSleeping && !happy) {
    wakeLookStartedAt = now;
    // Pick one friendly variation for this wake. All variants still look left,
    // right, slightly upward, and back to centre before tilt gaze resumes.
    wakeAnimation = random(0, 3);
    wakeLookUntil = now + 3300;
    gazeX = 0.0f;
    gazeY = 0.0f;
  }
  mood = happy ? HAPPY : NORMAL;
  // If this wake has no network but weather readings are already in memory,
  // let DeskBuddy mention that once after its look-around finishes.
  offlineWakeThoughtPending = wasSleeping && !happy && WiFi.status() != WL_CONNECTED &&
    (!isnan(tempC) || !isnan(usAqi));
  lastMeaningfulActivity = now;
  setBrightness();
  if (page == FACE_PAGE) drawFace(true);
}

void updatePetState(uint32_t now) {
  // HAPPY is used only for the final beat of the wake routine, then normal
  // behaviour resumes without leaving DeskBuddy in a special mood.
  if (petState == AWAKE && wakeLookUntil && now >= wakeLookUntil && mood == HAPPY) mood = NORMAL;
  if (petState == BOOT_LOOK && now - stateSince >= 2800) enterAwake(now, false);
  else if (petState == FALLING_ASLEEP && now - stateSince >= 1000) {
    petState = ASLEEP;
    stateSince = now;
    mood = SLEEPY;
    scheduleSleepEvent(now);
    setBrightness();
    if (page == FACE_PAGE) drawFace(true);
  } else if (petState == BRIEF_WAKE && now - stateSince >= 3500) {
    petState = ASLEEP;
    stateSince = now;
    mood = SLEEPY;
    scheduleSleepEvent(now);
    setBrightness();
  } else if (petState == ASKING_FOR_PET && now >= attentionUntil) {
    petState = ASLEEP;
    stateSince = now;
    mood = SLEEPY;
    scheduleSleepEvent(now);
    setBrightness();
  } else if (petState == PETTED_HAPPY && now - stateSince >= 2200) {
    enterAwake(now, false);
  } else if (petState == AWAKE && now - lastMeaningfulActivity >= INACTIVITY_MS) {
    petState = FALLING_ASLEEP;
    stateSince = now;
    mood = SLEEPY;
    if (page == FACE_PAGE) drawFace(true);
  } else if (petState == ASLEEP && !sleepInteractionUsed && now >= nextSleepEventAt) {
    sleepInteractionUsed = true;
    stateSince = now;
    mood = NORMAL;
    if (random(0, 2) == 0) {
      petState = BRIEF_WAKE;
    } else {
      petState = ASKING_FOR_PET;
      attentionUntil = now + 8000;
      petHoldStartedAt = 0;
      petHoldHandled = false;
    }
    setBrightness();
    if (page == FACE_PAGE) drawFace(true);
  }
}

// The RTC is kept alive by the Stick's power system while the ESP32 is off.
// It stores UTC; local Moscow time is still supplied by DESKBUDDY_TZ.
bool restoreTimeFromRtc() {
  const auto rtc = M5.Rtc.getDateTime();
  if (rtc.date.year < 2024 || rtc.date.year > 2099 || rtc.date.month < 1 || rtc.date.month > 12 ||
      rtc.date.date < 1 || rtc.date.date > 31 || rtc.time.hours > 23 || rtc.time.minutes > 59 || rtc.time.seconds > 59) {
    return false;
  }
  M5.Rtc.setSystemTimeFromRtc();
  return time(nullptr) >= 1704067200;
}

void saveTimeToRtc() {
  const time_t now = time(nullptr);
  if (now < 1704067200) return;
  struct tm utcTime;
  gmtime_r(&now, &utcTime);
  M5.Rtc.setDateTime(&utcTime);
}

void waitForNtpAndSaveRtc() {
  const uint32_t started = millis();
  while (millis() - started < 5000) {
    if (time(nullptr) >= 1704067200) {
      saveTimeToRtc();
      return;
    }
    M5.update();
    delay(50);
  }
}

void playBootAnimation() {
  // A quiet, one-time startup scene: DeskBuddy is asleep, wakes up, looks around,
  // then gives a brief friendly hello. It uses the normal face drawing unchanged.
  auto showBootFace = [](float lookX, float lookY, Mood bootMood, bool closed, const char* message) {
    mood = bootMood;
    gazeX = lookX;
    gazeY = lookY;
    M5.Display.fillScreen(BG);
    drawEyes((int)lroundf(gazeX * 13.0f), (int)lroundf(gazeY * 10.0f), closed);
    if (message != nullptr) {
      M5.Display.setTextColor(FG, BG);
      M5.Display.setTextSize(1);
      M5.Display.setCursor((M5.Display.width() - M5.Display.textWidth(message)) / 2, 7);
      M5.Display.print(message);
    }
  };

  showBootFace(0.0f, 0.7f, SLEEPY, true, nullptr);
  delay(650);
  showBootFace(-0.82f, 0.0f, NORMAL, false, nullptr);
  delay(420);
  showBootFace(0.82f, 0.0f, NORMAL, false, nullptr);
  delay(420);
  showBootFace(0.0f, -0.68f, NORMAL, false, nullptr);
  delay(360);
  showBootFace(0.0f, 0.0f, HAPPY, false, "Hello, friend.");
  delay(1050);
  M5.Display.fillScreen(BG);
}

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  // Plus2 power latching: keep GPIO4 high after button or RTC wake.
  pinMode(4, OUTPUT);
  digitalWrite(4, HIGH);
  M5.Display.setRotation(1);
  M5.Display.setTextColor(FG, BG);
  randomSeed((uint32_t)esp_random());
  setenv("TZ", DESKBUDDY_TZ, 1);
  tzset();
  // A previous clean power-button hold supplies time here when Wi-Fi is absent.
  restoreTimeFromRtc();
  stateSince = millis();
  lastMeaningfulActivity = stateSince;
  nextBlinkAt = stateSince + 1800;
  nextIdleThoughtAt = stateSince + random(20UL * 60UL * 1000UL, 45UL * 60UL * 1000UL);
  // Small curiosity moments are deliberately less frequent than blinks and do not interrupt sleep or messages.
  nextIdleGestureAt = stateSince + random(45UL * 1000UL, 90UL * 1000UL);
  loadWiFi();
  loadCachedData();
  setBrightness();
  const uint32_t checkStarted = millis();
  while (millis() - checkStarted < 1500) {
    M5.update();
    if (!M5.BtnB.isPressed()) break;
    delay(20);
  }
  if (M5.BtnB.isPressed()) { startConfigPortal(); return; }
  playBootAnimation();
  // The startup scene has completed; begin ordinary awake behaviour without
  // replaying the separate BOOT_LOOK scan after Wi-Fi setup finishes.
  petState = AWAKE;
  stateSince = millis();
  lastMeaningfulActivity = stateSince;
  mood = NORMAL;
  if (connectWiFi()) {
    configTzTime(DESKBUDDY_TZ, "pool.ntp.org", "time.nist.gov");
    waitForNtpAndSaveRtc();
    fetchWeather();
    fetchAirQuality();
  }
  drawCurrentPage();
}

void loop() {
  M5.update();
  if (configMode) { portal.handleClient(); return; }
  const uint32_t now = millis();

  // BtnPWR is the mechanical side/power button on M5StickC Plus. Hardware
  // power-off follows at about two seconds, so save at one second instead.
  if (M5.BtnPWR.wasPressed()) {
    powerButtonDownAt = now;
    powerTimeSaved = false;
  }
  if (M5.BtnPWR.isPressed() && !powerTimeSaved && powerButtonDownAt && now - powerButtonDownAt >= 1000) {
    saveTimeToRtc();
    powerTimeSaved = true;
  }
  if (M5.BtnPWR.wasReleased()) {
    powerButtonDownAt = 0;
    powerTimeSaved = false;
  }

  // Button A no longer changes DeskBuddy's mood. During an attention request,
  // hold it to pet DeskBuddy; otherwise a tap only changes screen brightness.
  if (petState == ASKING_FOR_PET) {
    if (M5.BtnA.isPressed()) {
      if (!petHoldStartedAt) petHoldStartedAt = now;
      if (!petHoldHandled && now - petHoldStartedAt >= LONG_PRESS_MS) {
        petHoldHandled = true;
        enterAwake(now, true);
      }
    } else {
      petHoldStartedAt = 0;
    }
  } else if (petState == AWAKE || petState == PETTED_HAPPY) {
    if (M5.BtnA.wasClicked()) {
      bright = !bright;
      setBrightness();
      lastMeaningfulActivity = now;
    }
  }

  // B wakes a sleeping pet first. Only an awake pet advances the dashboard page.
  if (M5.BtnB.wasPressed()) { buttonBDownAt = now; longBHandled = false; }
  if (M5.BtnB.isPressed() && !longBHandled && now - buttonBDownAt >= LONG_PRESS_MS) {
    longBHandled = true;
    startConfigPortal();
    return;
  }
  if (M5.BtnB.wasReleased() && !longBHandled) {
    if (petState == ASLEEP || petState == BRIEF_WAKE || petState == ASKING_FOR_PET || petState == FALLING_ASLEEP) {
      enterAwake(now, false);
      setPage(FACE_PAGE);
    } else {
      setPage((Page)((page + 1) % PAGE_COUNT));
      lastMeaningfulActivity = now;
    }
  }

  // StickC Plus2 uses discrete battery sensing, not the legacy AXP192 PMIC.
  // Its reviewed profile provides battery level/voltage but not reliable USB or active-charge state.
  if (now >= nextUsbCheckAt) {
    nextUsbCheckAt = now + 1000;
    // One gentle reminder per recharge cycle. A recovered level above 25% arms it again.
    const int batteryLevel = M5.Power.getBatteryLevel();
    if (batteryLevel > 25) lowBatteryReminderArmed = true;
    if (lowBatteryReminderArmed && batteryLevel >= 0 && batteryLevel <= 20) {
      queueThought("I may need a charge soon.", 2);
      lowBatteryReminderArmed = false;
    }
    if (page == STATUS_PAGE) drawSystemStatus();
  }

  if (WiFi.status() == WL_CONNECTED && now - lastWeatherFetch >= WEATHER_REFRESH_MS) {
    fetchWeather();
    fetchAirQuality();
    if (page == CLOCK_PAGE || page == FORECAST_PAGE || page == DAYLIGHT_PAGE || page == AIR_PAGE || page == STATUS_PAGE) drawCurrentPage();
  }

  if (M5.Imu.update()) {
    const auto data = M5.Imu.getImuData();
    const float spin = sqrtf(data.gyro.x * data.gyro.x + data.gyro.y * data.gyro.y + data.gyro.z * data.gyro.z);
    const float acceleration = sqrtf(data.accel.x * data.accel.x + data.accel.y * data.accel.y + data.accel.z * data.accel.z);
    const float accelDelta = fabsf(acceleration - 1.0f);
    const bool meaningfulSample = spin > ACTIVITY_SPIN_DPS || accelDelta > ACTIVITY_ACCEL_DELTA_G;
    if (meaningfulSample) {
      if (!motionStartedAt) motionStartedAt = now;
      if (now - motionStartedAt >= 160 && (petState == AWAKE || petState == PETTED_HAPPY)) lastMeaningfulActivity = now;
    } else motionStartedAt = 0;

    const bool forcefulWake = spin > WAKE_SPIN_DPS || accelDelta > WAKE_ACCEL_DELTA_G;
    if ((petState == ASLEEP || petState == BRIEF_WAKE || petState == ASKING_FOR_PET) && forcefulWake) {
      enterAwake(now, false);
      setPage(FACE_PAGE);
    }

    const bool deliberateShake = (spin > SHAKE_SPIN_DPS && acceleration > 1.65f) || acceleration > SHAKE_ACCEL_G;
    if ((petState == AWAKE || petState == PETTED_HAPPY) && deliberateShake && now - lastShakeAt >= SHAKE_COOLDOWN_MS) {
      lastShakeAt = now;
      mood = ANGRY;
      angryUntil = now + 1800;
      lastMeaningfulActivity = now;
      setPage(FACE_PAGE);
    }

    if (page == FACE_PAGE && petState != ASLEEP) {
      if (now >= nextIdleGlanceAt) {
        idleGlanceX = random(-35, 36) / 100.0f;
        idleGlanceY = random(-22, 23) / 100.0f;
        nextIdleGlanceAt = now + random(1800, 4200);
      }
      // Startup keeps its established scan. A wake from sleep gets a longer,
      // friendlier sequence: slow open, left/right/up, a small downward stretch,
      // centre, then a short happy look at the user.
      if (petState == BOOT_LOOK || now < wakeLookUntil) {
        if (petState == BOOT_LOOK) {
          const uint32_t phase = (now - stateSince) / 520;
          static const float lookX[] = { -0.82f, 0.82f, 0.0f, 0.0f, 0.0f };
          static const float lookY[] = { 0.0f, 0.0f, -0.68f, 0.0f, 0.0f };
          gazeX = lookX[min<uint32_t>(phase, 4)];
          gazeY = lookY[min<uint32_t>(phase, 4)];
        } else {
          const uint32_t phase = (now - wakeLookStartedAt) / 430;
          // Every version is a recognisable wake-up, not random face movement:
          // a sleepy pause, left/right/up scan, then its own small finishing gesture.
          static const float calmX[] =    { 0.0f, -0.88f, 0.88f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f };
          static const float calmY[] =    { 0.0f,  0.00f, 0.00f,-0.72f, 0.48f, 0.0f, 0.0f, 0.0f };
          static const float curiousX[] = { 0.0f, -0.78f, 0.88f, 0.0f,-0.35f, 0.20f, 0.0f, 0.0f };
          static const float curiousY[] = { 0.0f,  0.00f, 0.00f,-0.68f,-0.20f,-0.10f,0.0f, 0.0f };
          static const float happyX[] =   { 0.0f, -0.88f, 0.88f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f };
          static const float happyY[] =   { 0.0f,  0.00f, 0.00f,-0.74f, 0.00f,-0.12f,0.0f, 0.0f };
          const uint32_t index = min<uint32_t>(phase, 7);
          if (wakeAnimation == 1) {
            gazeX = curiousX[index];
            gazeY = curiousY[index];
          } else if (wakeAnimation == 2) {
            gazeX = happyX[index];
            gazeY = happyY[index];
          } else {
            gazeX = calmX[index];
            gazeY = calmY[index];
          }
          // The calm routine finishes with a small stretch; the other two end
          // with a friendly happy look. Normal face tracking takes over next.
          mood = (phase >= 6 || (wakeAnimation == 2 && phase >= 5)) ? HAPPY : NORMAL;
        }
      } else if (now < idleGestureUntil) {
        // A short double-take: glance aside, look the other way, then settle at centre.
        const uint32_t phase = (now - (idleGestureUntil - 1350)) / 340;
        static const float lookX[] = { -0.58f, 0.62f, 0.0f, 0.0f };
        static const float lookY[] = { -0.10f, 0.04f, -0.18f, 0.0f };
        gazeX = lookX[min<uint32_t>(phase, 3)];
        gazeY = lookY[min<uint32_t>(phase, 3)];
      } else {
        const float targetX = constrain(data.accel.y * 0.85f + data.gyro.z * 0.012f + idleGlanceX, -1.0f, 1.0f);
        const float targetY = constrain(-data.accel.x * 0.85f - data.gyro.x * 0.012f + idleGlanceY, -1.0f, 1.0f);
        gazeX = gazeX * 0.84f + targetX * 0.16f;
        gazeY = gazeY * 0.84f + targetY * 0.16f;
      }
    }
  }

  if (angryUntil && now >= angryUntil) { angryUntil = 0; mood = NORMAL; }

  // Occasionally DeskBuddy does a small curious double-take while calmly idle.
  // It never runs while a thought, pet interaction, wake scan, or sleep transition is active.
  if (idleGestureUntil && now >= idleGestureUntil) {
    idleGestureUntil = 0;
    if (mood == SUSPICIOUS) mood = NORMAL;
  }
  if (!idleGestureUntil && petState == AWAKE && mood == NORMAL && page == FACE_PAGE &&
      now >= wakeLookUntil && now >= idleThoughtUntil && pendingThought.isEmpty() && now >= nextIdleGestureAt) {
    mood = SUSPICIOUS;
    idleGestureUntil = now + 1350;
    nextIdleGestureAt = now + random(45UL * 1000UL, 90UL * 1000UL);
  }

  // An offline wake gets one confidence message, only after the wake scan.
  // It takes priority over the ordinary rare-thought timer and is then cleared.
  if (offlineWakeThoughtPending && petState == AWAKE && mood == NORMAL && page == FACE_PAGE &&
      now >= wakeLookUntil && now >= idleThoughtUntil) {
    idleThought = "I remember the weather.";
    idleThoughtUntil = now + 4500;
    offlineWakeThoughtPending = false;
  }

  // A stored conclusion waits for calm face-page idle time and is shown once.
  if (!offlineWakeThoughtPending && !pendingThought.isEmpty() && petState == AWAKE && mood == NORMAL &&
      page == FACE_PAGE && now >= wakeLookUntil && now >= idleThoughtUntil) {
    idleThought = pendingThought;
    idleThoughtUntil = now + 4500;
    pendingThought = "";
    pendingThoughtPriority = 0;
  }

  // Thoughts are rare, brief, and only belong to a calm normal idle face.
  if (!offlineWakeThoughtPending && pendingThought.isEmpty() && petState == AWAKE && mood == NORMAL && page == FACE_PAGE &&
      now >= nextIdleThoughtAt && now >= idleThoughtUntil) {
    static const char* thoughts[] = {
      "Thinking...", "Watching clouds...", "Nice to see you.",
      "What are you building?", "Quiet day.", "How's it going?"
    };
    idleThought = thoughts[random(0, sizeof(thoughts) / sizeof(thoughts[0]))];
    idleThoughtUntil = now + random(3500, 6001);
    nextIdleThoughtAt = now + random(20UL * 60UL * 1000UL, 45UL * 60UL * 1000UL);
  }
  updatePetState(now);
  if (page == FACE_PAGE && now - lastFrame >= FACE_FRAME_MS) { drawFace(false); lastFrame = now; }
  if (page == CLOCK_PAGE || page == DAYLIGHT_PAGE) {
    struct tm localTime;
    if (getLocalTime(&localTime, 0) && localTime.tm_sec != lastClockSecond) {
      lastClockSecond = localTime.tm_sec;
      if (page == CLOCK_PAGE) drawClock();
      else drawDaylight();
    }
  } else lastClockSecond = -1;
}

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