Community project

Cloud LLM Usage Monitor

Austin Terry

Published August 7, 2026 · Updated August 11, 2026

ESP321 component5 assembly steps
Remix this project
Photo of Cloud LLM Usage MonitorGenerated with AI

This project builds a real-time dashboard that monitors API usage across multiple cloud LLM providers including DeepSeek, OpenAI, Anthropic, and OpenCode. The ESP32 microcontroller fetches usage data from each provider's API and displays it on a 2-inch ST7789 TFT screen, updating at configurable intervals to show current consumption metrics.

The guide includes a complete wiring diagram for connecting the display to the ESP32 via SPI, a full parts list, Arduino firmware with WiFi connectivity and secure API communication, and step-by-step assembly instructions. Builders will learn how to configure API credentials, set up the display interface, and deploy a networked monitoring system for tracking cloud service usage.

Wiring diagram

Interactive · read-only
Wiring diagram for Cloud LLM Usage Monitor

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

Parts list

Bill of materials
ComponentQtyNotes
ST7789 TFT Display 2.0 inch240×280, ST7789V312.0-inch IPS TFT color display breakout driven by the ST7789 controller over 4-wire SPI. Native resolution is 320x240. Adafruit's breakout includes a 3.3V regulator, auto-reset circuit, 3V/5V level shifting, and a microSD holder sharing the SPI bus. Display drawing uses SCK, MOSI, CS, DC, and optional RST; MISO and SDCS are only needed for the onboard microSD card.

Assembly

5 steps
  1. Disconnect all power

    Unplug the ESP32 board from USB-C and unplug any LiPo battery from the board before placing jumper wires. Keep the battery disconnected during all display wiring.

    • Tip: Work on a non-conductive surface.
    • Tip: Use short jumper wires for the SPI clock and data lines.
    • Never connect or disconnect the TFT while the ESP32 or LiPo battery is powered.
    • Do not short the 3V3 rail to GND.
  2. Connect display power at 3.3 V

    Connect the TFT header labeled VCC to the ESP32 3V3 pin. Connect the TFT header labeled GND to any ESP32 GND pin. The display and ESP32 must share this ground.

    • Tip: The TFT module header is labeled GND, VCC, SCL, SDA, RES, DC, CS, BLK.
    • Tip: Confirm the VCC wire goes specifically to the ESP32 3V3 pin, not the VIN/5V pin.
    • This project assumes the pictured TFT uses 3.3 V logic and power. Do not power its VCC from 5 V/VIN unless the exact module documentation explicitly confirms a 5 V-tolerant regulator and inputs.
  3. Connect the SPI clock and data

    Connect TFT SCL (the SPI clock input) to ESP32 GPIO18. Connect TFT SDA (the SPI MOSI/DIN input, not I2C SDA) to ESP32 GPIO23.

    • Tip: SCL and SDA are SPI labels on this display: SCL means SCK and SDA means MOSI.
    • Tip: There is no TFT MISO connection in this project.
    • Do not swap SCL and SDA; the screen will not initialize if clock and data are reversed.
  4. Connect the control lines

    Connect TFT CS to ESP32 GPIO25, TFT DC to GPIO26, TFT RES to GPIO27, and TFT BLK to GPIO33. These are all 3.3 V ESP32 outputs.

    • Tip: CS selects the display; DC selects command versus pixel data; RES resets the controller; BLK controls backlight.
    • Tip: If the screen is blank but initialization appears correct, re-check BLK to GPIO33 and the VCC/GND connections first.
    • Do not use ESP32 GPIO1 or GPIO3 for the display: they are connected to the CH340G USB serial interface.
    • Avoid moving these wires to GPIO0, GPIO2, GPIO5, GPIO12, or GPIO15 without changing the design because they are boot/strapping-related pins.
  5. Inspect then apply power

    Inspect every connection against the table: VCC→3V3, GND→GND, SCL→GPIO18, SDA→GPIO23, CS→GPIO25, DC→GPIO26, RES→GPIO27, BLK→GPIO33. Ensure no bare conductors touch. Then connect USB-C for initial power; leave the LiPo disconnected until the display powers normally.

    • Tip: The ESP32 is normally powered from its USB-C port for this desk dashboard.
    • Tip: Once the USB-powered display is operating normally, its board charger may be used with a suitable protected single-cell 3.7 V LiPo connected with verified connector polarity.
    • Check the LiPo plug polarity against the board silkscreen before connecting a battery. A reversed LiPo can permanently damage the charger or battery.
    • The TFT backlight adds current draw; use a good USB power source rather than an unpowered hub.

Pin assignments

Board wiring reference
PinConnectionType
3V3tft_169 VCCpower
GNDtft_169 GNDground
GPIO 18tft_169 SCKspi
GPIO 23tft_169 MOSIspi
GPIO 25tft_169 CSspi
GPIO 26tft_169 DCdigital
GPIO 27tft_169 RSTdigital
GPIO 33tft_169 BLdigital

Firmware

ESP32
main.cppDeploy to device
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <SPI.h>
#include <time.h>
#include <Preferences.h>
#include <WebServer.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <ArduinoJson.h>

struct ProviderRow {
  const char *name;
  String value;
  bool enabled;
};

constexpr char WIFI_SSID[] = "";
constexpr char WIFI_PASSWORD[] = "";

constexpr char DEEPSEEK_API_KEY[] = "";
constexpr char OPENAI_ADMIN_KEY[] = "";
constexpr char ANTHROPIC_ADMIN_KEY[] = "";
constexpr char OPENCODE_API_KEY[] = "";

constexpr char DEFAULT_TIME_ZONE[] = "MST7MDT,M3.2.0,M11.1.0";

constexpr int TFT_SCK = 18;
constexpr int TFT_MOSI = 23;
constexpr int TFT_CS = 25;
constexpr int TFT_DC = 26;
constexpr int TFT_RST = 27;
constexpr int TFT_BL = 32;

constexpr uint32_t REFRESH_INTERVAL_MS = 1000UL;
constexpr uint32_t CLOCK_INTERVAL_MS = 1000UL;
constexpr uint32_t RATE_LIMIT_INTERVAL_MS = 600000UL;
constexpr uint32_t ANTHROPIC_REFRESH_INTERVAL_MS = 120000UL;

constexpr int SCREEN_WIDTH = 280;
constexpr int SCREEN_HEIGHT = 240;

constexpr int DISPLAY_X_OFFSET = -8;

constexpr size_t PROVIDER_DEEPSEEK = 0;
constexpr size_t PROVIDER_OPENAI = 1;
constexpr size_t PROVIDER_ANTHROPIC = 2;
constexpr size_t PROVIDER_OPENCODE = 3;

Adafruit_ST7789 tft(TFT_CS, TFT_DC, TFT_RST);
Preferences preferences;
WebServer webServer(80);

String deepSeekApiKey;
String openAiAdminKey;
String anthropicAdminKey;
String openCodeApiKey;
String timeZone;

ProviderRow rows[] = {
  {"DeepSeek", "Waiting...", false},
  {"OpenAI", "Waiting...", false},
  {"Anthropic", "Waiting...", false},
  {"OpenCode", "Waiting...", false}
};

constexpr size_t ROW_COUNT = sizeof(rows) / sizeof(rows[0]);

uint32_t nextRefreshAt = 0;
uint32_t nextAnthropicRefreshAt = 0;
uint32_t nextClockAt = 0;
uint32_t rateLimitUntil[ROW_COUNT] = {0, 0, 0, 0};

String lastStatus;
String lastDrawnRows[ROW_COUNT];

// ---------- Utility ----------

bool configured(const String &key) {
  return key.length() > 0 && key != "YOUR_API_KEY";
}

bool allProvidersDisabled() {
  for (size_t i = 0; i < ROW_COUNT; ++i) {
    if (rows[i].enabled) {
      return false;
    }
  }

  return true;
}

bool providerIsRateLimited(size_t providerIndex) {
  return rateLimitUntil[providerIndex] != 0 &&
         (int32_t)(rateLimitUntil[providerIndex] - millis()) > 0;
}

bool providerCooldownJustExpired(size_t providerIndex) {
  return rateLimitUntil[providerIndex] != 0 &&
         !providerIsRateLimited(providerIndex);
}

void clearRateLimit(size_t providerIndex) {
  rateLimitUntil[providerIndex] = 0;
}

void startRateLimit(size_t providerIndex) {
  rateLimitUntil[providerIndex] = millis() + RATE_LIMIT_INTERVAL_MS;

  Serial.print("[");
  Serial.print(rows[providerIndex].name);
  Serial.println("] HTTP 429: pausing this provider for 60 seconds.");
}

String rateLimitText(size_t providerIndex) {
  if (!providerIsRateLimited(providerIndex)) {
    return "";
  }

  return "Rate Limit";
}

void printCentered(
  const String &text,
  int y,
  uint16_t color,
  uint8_t textSize
) {
  int16_t x1;
  int16_t y1;
  uint16_t textWidth;
  uint16_t textHeight;

  tft.setTextSize(textSize);
  tft.getTextBounds(text, 0, 0, &x1, &y1, &textWidth, &textHeight);

  int x = (SCREEN_WIDTH - textWidth) / 2 + DISPLAY_X_OFFSET;

  if (x < 0) {
    x = 0;
  }

  tft.setTextColor(color, ST77XX_BLACK);
  tft.setCursor(x, y);
  tft.print(text);
}

String currentLocalTime() {
  time_t now = time(nullptr);

  if (now < 1700000000) {
    return "Time syncing...";
  }

  struct tm localTime;
  localtime_r(&now, &localTime);

  char timeText[16];
  strftime(timeText, sizeof(timeText), "%I:%M:%S %p", &localTime);

  if (timeText[0] == '0') {
    return String(timeText + 1);
  }

  return String(timeText);
}

String bottomStatus() {
  if (WiFi.status() != WL_CONNECTED) {
    return "Wi-Fi offline";
  }

  return WiFi.localIP().toString() + " - " + currentLocalTime();
}

uint16_t providerTextColor(const String &value) {
  if (value.startsWith("ERR") ||
      value.startsWith("Rate Limit") ||
      value == "Wi-Fi offline") {
    return ST77XX_RED;
  }

  if (value.startsWith("Waiting") || value == "Time Sync") {
    return ST77XX_YELLOW;
  }

  return ST77XX_GREEN;
}

String visibleProviderValue(const ProviderRow &row) {
  String value = row.value;

  constexpr size_t MAX_VALUE_CHARACTERS = 10;

  if (value.length() > MAX_VALUE_CHARACTERS) {
    value = value.substring(0, MAX_VALUE_CHARACTERS);
  }

  return value;
}

String providerLine(const ProviderRow &row) {
  return String(row.name) + ": " + visibleProviderValue(row);
}

String providerRenderKey(const ProviderRow &row) {
  return providerLine(row) + "|" + String(providerTextColor(row.value));
}

String moneyText(double cents, const char *suffix = "/Mo") {
  char output[24];
  snprintf(output, sizeof(output), "$%.2f%s", cents / 100.0, suffix);
  return String(output);
}

String microCentsMoneyText(int64_t microCents, const char *suffix = "/Mo") {
  double dollars = (double)microCents / 100000000.0;

  char output[24];
  snprintf(output, sizeof(output), "$%.2f%s", dollars, suffix);

  return String(output);
}

// ---------- HTTPS ----------

String httpsRequest(
  const String &url,
  const char *method,
  const String &body,
  const String &apiKey,
  int &httpStatus,
  const char *authHeaderName = "Authorization",
  const char *authHeaderPrefix = "Bearer ",
  const char *extraHeaderName = nullptr,
  const char *extraHeaderValue = nullptr
) {
  httpStatus = -1;

  WiFiClientSecure client;
  client.setInsecure();

  HTTPClient http;

  if (!http.begin(client, url)) {
    return "ERR: HTTP init";
  }

  http.setTimeout(15000);

  http.addHeader(
    authHeaderName,
    String(authHeaderPrefix) + apiKey
  );

  http.addHeader("Content-Type", "application/json");

  if (extraHeaderName != nullptr && extraHeaderValue != nullptr) {
    http.addHeader(extraHeaderName, extraHeaderValue);
  }

  if (strcmp(method, "POST") == 0) {
    httpStatus = http.POST(body);
  } else {
    httpStatus = http.GET();
  }

  String response =
    httpStatus > 0 ? http.getString() : String("ERR: network");

  http.end();

  if (httpStatus < 200 || httpStatus >= 300) {
    Serial.print("[HTTP] ");
    Serial.print(httpStatus);
    Serial.print(": ");
    Serial.println(response);

    return String("ERR ") + String(httpStatus);
  }

  return response;
}

// ---------- Provider APIs ----------

String deepSeekBalance(int &httpStatus) {
  String response = httpsRequest(
    "https://api.deepseek.com/user/balance",
    "GET",
    "",
    deepSeekApiKey,
    httpStatus
  );

  if (response.startsWith("ERR")) {
    return response;
  }

  JsonDocument doc;

  if (deserializeJson(doc, response)) {
    return "ERR: JSON";
  }

  JsonArray balances = doc["balance_infos"].as<JsonArray>();

  if (balances.isNull() || balances.size() == 0) {
    return "No balance";
  }

  JsonVariant info = balances[0];
  String amount;

  if (!info["total_balance"].isNull()) {
    amount = info["total_balance"].as<String>();
  } else if (!info["granted_balance"].isNull()) {
    amount = info["granted_balance"].as<String>();
  } else if (!info["topped_up_balance"].isNull()) {
    amount = info["topped_up_balance"].as<String>();
  } else {
    return "No balance";
  }

  return "Bal $" + amount;
}

String openAiSpend(int &httpStatus) {
  time_t now = time(nullptr);

  if (now < 1700000000) {
    httpStatus = 0;
    return "Time Sync";
  }

  struct tm localTime;
  localtime_r(&now, &localTime);

  localTime.tm_mday = 1;
  localTime.tm_hour = 0;
  localTime.tm_min = 0;
  localTime.tm_sec = 0;

  time_t monthStart = mktime(&localTime);

  String url =
    String("https://api.openai.com/v1/organization/costs?start_time=") +
    String((long)monthStart) +
    "&end_time=" +
    String((long)now) +
    "&bucket_width=1d";

  String response = httpsRequest(
    url,
    "GET",
    "",
    openAiAdminKey,
    httpStatus
  );

  if (response.startsWith("ERR")) {
    return response;
  }

  JsonDocument doc;

  if (deserializeJson(doc, response)) {
    return "ERR: JSON";
  }

  double cents = 0.0;

  for (JsonVariant bucket : doc["data"].as<JsonArray>()) {
    for (JsonVariant result : bucket["results"].as<JsonArray>()) {
      cents += result["amount"]["value"] | 0.0;
    }
  }

  return moneyText(cents);
}

String anthropicSpend(int &httpStatus) {
  time_t now = time(nullptr);

  if (now < 1700000000) {
    httpStatus = 0;
    return "Time Sync";
  }

  struct tm localTime;
  localtime_r(&now, &localTime);

  char startDate[11];
  char endDate[11];

  strftime(startDate, sizeof(startDate), "%Y-%m-01", &localTime);
  strftime(endDate, sizeof(endDate), "%Y-%m-%d", &localTime);

  String url =
    String("https://api.anthropic.com/v1/organizations/cost_report?starting_at=") +
    startDate +
    "&ending_at=" +
    endDate;

  String response = httpsRequest(
    url,
    "GET",
    "",
    anthropicAdminKey,
    httpStatus,
    "x-api-key",
    "",
    "anthropic-version",
    "2023-06-01"
  );

  if (response.startsWith("ERR")) {
    return response;
  }

  JsonDocument doc;

  if (deserializeJson(doc, response)) {
    return "ERR: JSON";
  }

  double cents = 0.0;

  for (JsonVariant item : doc["data"].as<JsonArray>()) {
    cents += item["amount"]["value"] | 0.0;
  }

  return moneyText(cents);
}

String csvField(const String &line, int targetIndex) {
  int fieldIndex = 0;
  bool inQuotes = false;
  String field;

  for (size_t i = 0; i <= line.length(); ++i) {
    char c = i < line.length() ? line[i] : ',';

    if (c == '"') {
      if (inQuotes && i + 1 < line.length() && line[i + 1] == '"') {
        field += '"';
        ++i;
      } else {
        inQuotes = !inQuotes;
      }
      continue;
    }

    if (c == ',' && !inQuotes) {
      if (fieldIndex == targetIndex) {
        return field;
      }

      field = "";
      ++fieldIndex;
      continue;
    }

    field += c;
  }

  return "";
}

int csvHeaderIndex(const String &header, const char *wantedName) {
  int index = 0;
  int commaCount = 0;

  for (size_t i = 0; i < header.length(); ++i) {
    if (header[i] == ',') {
      ++commaCount;
    }
  }

  for (int i = 0; i <= commaCount; ++i) {
    if (csvField(header, i) == wantedName) {
      return i;
    }
  }

  return -1;
}

String openCodeUsage(int &httpStatus) {
  String response = httpsRequest(
    "https://console.opencode.ai/api/v1/usage/export"
    "?scope=organization&range=30d",
    "GET",
    "",
    openCodeApiKey,
    httpStatus,
    "Authorization",
    "Bearer ",
    "Accept",
    "text/csv"
  );

  if (response.startsWith("ERR")) {
    return response;
  }

  int firstNewline = response.indexOf('\n');

  if (firstNewline < 0) {
    Serial.println("[OpenCode] Invalid CSV response.");
    return "ERR: CSV";
  }

  String header = response.substring(0, firstNewline);
  header.trim();

  int costColumn = csvHeaderIndex(header, "cost_micro_cents");

  if (costColumn < 0) {
    Serial.println("[OpenCode] CSV lacks cost_micro_cents column.");
    return "ERR: CSV";
  }

  int64_t totalMicroCents = 0;
  int lineStart = firstNewline + 1;

  while (lineStart < (int)response.length()) {
    int lineEnd = response.indexOf('\n', lineStart);

    if (lineEnd < 0) {
      lineEnd = response.length();
    }

    String line = response.substring(lineStart, lineEnd);
    line.trim();

    if (line.length() > 0) {
      String costText = csvField(line, costColumn);
      costText.trim();

      if (costText.length() > 0) {
        totalMicroCents += strtoll(costText.c_str(), nullptr, 10);
      }
    }

    lineStart = lineEnd + 1;
  }

  return microCentsMoneyText(totalMicroCents);
}

// ---------- Display ----------

void drawHeader() {
  tft.fillScreen(ST77XX_BLACK);
  tft.setTextWrap(false);

  printCentered("LLM Usage", 12, ST77XX_CYAN, 2);
  tft.drawFastHLine(
    16 + DISPLAY_X_OFFSET,
    40,
    SCREEN_WIDTH - 32,
    ST77XX_BLUE
  );
}

void drawProviderRow(const ProviderRow &row, size_t displayIndex) {
  const int y = 55 + displayIndex * 38;
  const String title = String(row.name) + ": ";
  const String value = visibleProviderValue(row);

  tft.fillRect(0, y - 2, SCREEN_WIDTH, 28, ST77XX_BLACK);

  tft.setTextSize(2);

  const int rowX = 14 + DISPLAY_X_OFFSET;

  tft.setTextColor(ST77XX_WHITE, ST77XX_BLACK);
  tft.setCursor(rowX, y);
  tft.print(title);

  int16_t x1;
  int16_t y1;
  uint16_t titleWidth;
  uint16_t titleHeight;

  tft.getTextBounds(title, rowX, y, &x1, &y1, &titleWidth, &titleHeight);

  tft.setTextColor(providerTextColor(row.value), ST77XX_BLACK);
  tft.setCursor(rowX + titleWidth, y);
  tft.print(value);
}

void drawDashboardRows() {
  size_t displayIndex = 0;

  for (size_t i = 0; i < ROW_COUNT; ++i) {
    if (!rows[i].enabled) {
      continue;
    }

    String renderKey = providerRenderKey(rows[i]);

    if (renderKey != lastDrawnRows[i]) {
      drawProviderRow(rows[i], displayIndex);
      lastDrawnRows[i] = renderKey;
    }

    ++displayIndex;
  }

  if (displayIndex == 0) {
    tft.fillRect(8, 52, SCREEN_WIDTH - 16, 150, ST77XX_BLACK);
    printCentered("No provider keys", 72, ST77XX_YELLOW, 1);
  }
}

void drawStatus(const String &status, bool force) {
  if (!force && status == lastStatus) {
    return;
  }

  tft.fillRect(4, 216, SCREEN_WIDTH - 8, 16, ST77XX_BLACK);
  printCentered(status, 218, ST77XX_YELLOW, 1);

  lastStatus = status;
}

void logProviderResult(const ProviderRow &row) {
  if (!row.enabled) {
    return;
  }

  Serial.print("[");
  Serial.print(row.name);
  Serial.print("] ");
  Serial.println(row.value);
}

// ---------- Configuration page ----------

String enabledText(const String &key) {
  return configured(key) ? "Configured" : "Not configured";
}

String selectedTimeZone(const char *value) {
  return timeZone == value ? " selected" : "";
}

void addTimeZoneOption(String &page, const char *value, const char *label) {
  page += "<option value='";
  page += value;
  page += "'";
  page += selectedTimeZone(value);
  page += ">";
  page += label;
  page += "</option>";
}

bool validTimeZone(const String &zone) {
  return
    zone == "MST7MDT,M3.2.0,M11.1.0" ||
    zone == "MST7" ||
    zone == "PST8PDT,M3.2.0,M11.1.0" ||
    zone == "CST6CDT,M3.2.0,M11.1.0" ||
    zone == "EST5EDT,M3.2.0,M11.1.0" ||
    zone == "AKST9AKDT,M3.2.0,M11.1.0" ||
    zone == "HST10" ||
    zone == "UTC0";
}

void handleConfigPage() {
  String page;
  page.reserve(5200);

  page += F(
    "<!doctype html><html><head>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'>"
    "<title>LLM Usage Configuration</title>"
    "<style>"
    "body{font-family:Arial,sans-serif;background:#101216;color:#f4f4f4;"
    "max-width:600px;margin:30px auto;padding:0 18px}"
    "h1{color:#21d4fd}h2{margin-top:32px;color:#c4b5fd;font-size:20px}"
    "label{display:block;margin-top:18px;font-weight:bold}"
    "input,select{box-sizing:border-box;width:100%;padding:12px;margin-top:6px;"
    "border:1px solid #4b5563;border-radius:6px;background:#1f2937;color:white}"
    "button{margin-top:24px;background:#0891b2;color:white;border:0;"
    "padding:12px 18px;border-radius:6px;font-size:16px}"
    ".state{font-size:13px;color:#a7f3d0}.note{color:#fcd34d;font-size:13px}"
    ".clear{font-size:13px;font-weight:normal;color:#fca5a5;margin-top:7px}"
    ".clear input{width:auto;margin:0 6px 0 0;vertical-align:middle}"
    "</style></head><body><h1>LLM Usage</h1>"
    "<p>Settings are saved in ESP32 nonvolatile memory.</p>"
    "<p class='note'>Use this HTTP page only on a trusted local network.</p>"
    "<form method='POST' action='/save'>"
    "<h2>Timezone</h2>"
    "<label for='timezone'>Display timezone</label>"
    "<select id='timezone' name='timezone'>"
  );

  addTimeZoneOption(page, "MST7MDT,M3.2.0,M11.1.0", "US Mountain — Utah (MDT/MST)");
  addTimeZoneOption(page, "MST7", "Arizona (MST, no DST)");
  addTimeZoneOption(page, "PST8PDT,M3.2.0,M11.1.0", "US Pacific (PDT/PST)");
  addTimeZoneOption(page, "CST6CDT,M3.2.0,M11.1.0", "US Central (CDT/CST)");
  addTimeZoneOption(page, "EST5EDT,M3.2.0,M11.1.0", "US Eastern (EDT/EST)");
  addTimeZoneOption(page, "AKST9AKDT,M3.2.0,M11.1.0", "Alaska (AKDT/AKST)");
  addTimeZoneOption(page, "HST10", "Hawaii (HST)");
  addTimeZoneOption(page, "UTC0", "UTC");

  page += F(
    "</select><h2>Provider API Keys</h2>"
    "<p class='note'>Blank fields preserve saved keys. Use Clear to delete one.</p>"
  );

  page += "<label>DeepSeek <span class='state'>" + enabledText(deepSeekApiKey) + "</span></label>";
  page += "<input type='password' name='deepseek' placeholder='New DeepSeek API key'>";
  page += "<label class='clear'><input type='checkbox' name='clearDeepseek'>Clear saved DeepSeek key</label>";

  page += "<label>OpenAI Admin <span class='state'>" + enabledText(openAiAdminKey) + "</span></label>";
  page += "<input type='password' name='openai' placeholder='New OpenAI Admin API key'>";
  page += "<label class='clear'><input type='checkbox' name='clearOpenai'>Clear saved OpenAI key</label>";

  page += "<label>Anthropic Admin <span class='state'>" + enabledText(anthropicAdminKey) + "</span></label>";
  page += "<input type='password' name='anthropic' placeholder='New Anthropic Admin API key'>";
  page += "<label class='clear'><input type='checkbox' name='clearAnthropic'>Clear saved Anthropic key</label>";

  page += "<label>OpenCode Service Account <span class='state'>" + enabledText(openCodeApiKey) + "</span></label>";
  page += "<input type='password' name='opencode' placeholder='oc_sk_... service account key'>";
  page += "<label class='clear'><input type='checkbox' name='clearOpencode'>Clear saved OpenCode key</label>";

  page += F(
    "<button type='submit'>Save settings</button></form>"
    "<p class='note'>OpenCode usage export requires an oc_sk_ service-account key.</p>"
    "</body></html>"
  );

  webServer.send(200, "text/html", page);
}

void updateStoredKey(
  const char *formField,
  const char *clearField,
  const char *preferenceKey,
  String &key
) {
  if (webServer.hasArg(clearField)) {
    key = "";
    preferences.putString(preferenceKey, key);
    return;
  }

  String newKey = webServer.arg(formField);

  if (newKey.length() > 0) {
    key = newKey;
    preferences.putString(preferenceKey, key);
  }
}

void handleSaveConfig() {
  updateStoredKey("deepseek", "clearDeepseek", "deepseek", deepSeekApiKey);
  updateStoredKey("openai", "clearOpenai", "openai", openAiAdminKey);
  updateStoredKey("anthropic", "clearAnthropic", "anthropic", anthropicAdminKey);
  updateStoredKey("opencode", "clearOpencode", "opencode", openCodeApiKey);

  String selectedZone = webServer.arg("timezone");

  if (validTimeZone(selectedZone)) {
    timeZone = selectedZone;
    preferences.putString("timezone", timeZone);
    configTzTime(timeZone.c_str(), "pool.ntp.org", "time.nist.gov");

    lastStatus = "";
    drawStatus(bottomStatus(), true);
  }

  for (size_t i = 0; i < ROW_COUNT; ++i) {
    rateLimitUntil[i] = 0;
    lastDrawnRows[i] = "";
  }

  nextAnthropicRefreshAt = 0;
  nextRefreshAt = millis();

  webServer.sendHeader("Location", "/");
  webServer.send(303, "text/plain", "Saved");
}

void startWebServer() {
  webServer.on("/", HTTP_GET, handleConfigPage);
  webServer.on("/save", HTTP_POST, handleSaveConfig);

  webServer.onNotFound([]() {
    webServer.send(404, "text/plain", "Not found");
  });

  webServer.begin();
  Serial.println("Configuration web server started.");
}

// ---------- Provider refresh ----------

void updateProviderEnabledStates() {
  rows[PROVIDER_DEEPSEEK].enabled = configured(deepSeekApiKey);
  rows[PROVIDER_OPENAI].enabled = configured(openAiAdminKey);
  rows[PROVIDER_ANTHROPIC].enabled = configured(anthropicAdminKey);
  rows[PROVIDER_OPENCODE].enabled = configured(openCodeApiKey);
}

void fetchProvider(size_t providerIndex) {
  if (!rows[providerIndex].enabled) {
    return;
  }

  if (providerIsRateLimited(providerIndex)) {
    rows[providerIndex].value = rateLimitText(providerIndex);
    return;
  }

  if (providerCooldownJustExpired(providerIndex)) {
    clearRateLimit(providerIndex);

    Serial.print("[");
    Serial.print(rows[providerIndex].name);
    Serial.println("] Rate-limit pause ended; retrying.");
  }

  int httpStatus = -1;
  String result;

  if (providerIndex == PROVIDER_DEEPSEEK) {
    result = deepSeekBalance(httpStatus);
  } else if (providerIndex == PROVIDER_OPENAI) {
    result = openAiSpend(httpStatus);
  } else if (providerIndex == PROVIDER_ANTHROPIC) {
    result = anthropicSpend(httpStatus);
  } else if (providerIndex == PROVIDER_OPENCODE) {
    result = openCodeUsage(httpStatus);
  } else {
    return;
  }

  if (httpStatus == 429) {
    startRateLimit(providerIndex);
    rows[providerIndex].value = "Rate Limit";
  } else {
    clearRateLimit(providerIndex);
    rows[providerIndex].value = result;
  }

  logProviderResult(rows[providerIndex]);
}

void refreshUsage() {
  updateProviderEnabledStates();

  Serial.println();
  Serial.println("----- LLM Usage refresh -----");

  if (allProvidersDisabled()) {
    Serial.println("[Dashboard] No provider API keys configured.");
    Serial.println("-----------------------------");
    return;
  }

  if (WiFi.status() != WL_CONNECTED) {
    for (size_t i = 0; i < ROW_COUNT; ++i) {
      if (rows[i].enabled) {
        rows[i].value = "Wi-Fi offline";
        logProviderResult(rows[i]);
      }
    }

    Serial.println("-----------------------------");
    return;
  }

  for (size_t i = 0; i < ROW_COUNT; ++i) {
    // Anthropic's cost-report endpoint is queried at a longer interval.
    // All other configured providers use the regular refresh interval.
    if (i == PROVIDER_ANTHROPIC) {
      if ((int32_t)(millis() - nextAnthropicRefreshAt) < 0) {
        continue;
      }

      fetchProvider(i);
      nextAnthropicRefreshAt = millis() + ANTHROPIC_REFRESH_INTERVAL_MS;
      continue;
    }

    fetchProvider(i);
  }


  Serial.println("-----------------------------");
}

bool anyCooldownExpired() {
  for (size_t i = 0; i < ROW_COUNT; ++i) {
    if (rows[i].enabled && providerCooldownJustExpired(i)) {
      return true;
    }
  }

  return false;
}

// ---------- Wi-Fi ----------

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  Serial.print("Connecting to Wi-Fi");

  uint32_t startedAt = millis();

  while (
    WiFi.status() != WL_CONNECTED &&
    millis() - startedAt < 20000UL
  ) {
    delay(250);
    Serial.print(".");
  }

  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.print("Wi-Fi connected. IP: ");
    Serial.println(WiFi.localIP());

    Serial.print("Configuration page: http://");
    Serial.print(WiFi.localIP());
    Serial.println("/");
  } else {
    Serial.println("Wi-Fi connection failed.");
  }
}

// ---------- Arduino ----------

void setup() {
  Serial.begin(115200);
  delay(300);

  Serial.println();
  Serial.println("LLM Usage starting");
  Serial.println("API refresh interval: 5 seconds");

  preferences.begin("llmusage", false);

  deepSeekApiKey = preferences.getString("deepseek", DEEPSEEK_API_KEY);
  openAiAdminKey = preferences.getString("openai", OPENAI_ADMIN_KEY);
  anthropicAdminKey = preferences.getString("anthropic", ANTHROPIC_ADMIN_KEY);
  openCodeApiKey = preferences.getString("opencode", OPENCODE_API_KEY);
  timeZone = preferences.getString("timezone", DEFAULT_TIME_ZONE);

  if (!validTimeZone(timeZone)) {
    timeZone = DEFAULT_TIME_ZONE;
  }

  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, HIGH);

  SPI.begin(TFT_SCK, -1, TFT_MOSI, TFT_CS);

  tft.init(240, 280);
  tft.setRotation(3);

  drawHeader();
  drawDashboardRows();
  drawStatus("Connecting Wi-Fi...", true);

  connectWiFi();

  configTzTime(timeZone.c_str(), "pool.ntp.org", "time.nist.gov");
  startWebServer();

  refreshUsage();
  drawDashboardRows();
  drawStatus(bottomStatus(), true);

  nextRefreshAt = millis() + REFRESH_INTERVAL_MS;
  nextClockAt = millis() + CLOCK_INTERVAL_MS;
}

void loop() {
  webServer.handleClient();

  if (WiFi.status() != WL_CONNECTED) {
    connectWiFi();
  }

  if ((int32_t)(millis() - nextClockAt) >= 0) {
    drawStatus(bottomStatus(), false);
    nextClockAt = millis() + CLOCK_INTERVAL_MS;
  }

  if (anyCooldownExpired()) {
    refreshUsage();
    drawDashboardRows();
    nextRefreshAt = millis() + REFRESH_INTERVAL_MS;
  } else if ((int32_t)(millis() - nextRefreshAt) >= 0) {
    refreshUsage();
    drawDashboardRows();
    nextRefreshAt = millis() + REFRESH_INTERVAL_MS;
  }

  delay(20);
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik