How to Build a Tiny OLED Desk Command Centre

A small ESP32 status screen you can message from your browser

ESP32Smart HomeBeginner30 minutes3 components

Updated

How to Build a Tiny OLED Desk Command Centre
For illustrative purposes only
On this page

What you'll build

This guide builds a tiny desk command centre: an ESP32 with an SSD1306 OLED that shows the current time, today's weather, and a custom message sent from any browser on the same network. It is a practical desk object, not a screen test sketch.

The scope is deliberately small: one OLED over I²C, Wi-Fi for NTP and weather, a minimal built-in web page for sending messages, and repeated serial printing of the device IP so you can find the board after flashing. The firmware cycles between a live dashboard and any custom message for eight seconds, then returns to the dashboard automatically.

Use it as a meeting sign, a focus mode reminder, or a friendly "do not disturb" notice. The hardware fits on a small breadboard and needs no soldering.

What you are building

This is a single-screen Wi-Fi display. The firmware has four jobs:

  1. connect to Wi-Fi, sync time over NTP, and keep a live clock on the OLED,
  2. fetch current weather from Open-Meteo every ten minutes (no API key required),
  3. serve a minimal web page on port 80 so any device on the same network can post a short message,
  4. print the ESP32 IP address to Serial Monitor on connect and every 30 seconds so you can always find the device.

Out of scope: persistent storage, authentication, cloud push notifications, and multi-display coordination. Those are natural extensions once the single device is reliable.

Upload and calibrate

Open the project in Schematik and edit the marked constants before flashing:

  • WIFI_SSID and WIFI_PASSWORD — your network credentials.
  • TZ_OFFSET_SEC — your UTC offset in seconds (UTC+1 = 3600, UTC−5 = −18000).
  • DST_OFFSET_SEC — daylight-saving offset in seconds (3600 if your region observes it, 0 otherwise).
  • WEATHER_LAT and WEATHER_LON — decimal-degree coordinates for the weather fetch. The defaults are London (51.5074, −0.1278).

After flashing, open Serial Monitor at 115200 baud. You should see Connected. IP: 192.168.x.x within a few seconds, followed by weather output such as Weather: 14°C Partly cloudy. The IP address repeats every 30 seconds — bookmark it on your phone for quick access. Navigate to http://<ip> in any browser on the same network, type a message, and press Send to OLED. The message takes over the screen for 8 seconds, then the normal dashboard returns.

If the display stays blank, Serial Monitor will print SSD1306 not found — check wiring on GPIO21/22. Recheck the four wires and confirm VCC is on 3V3.

Troubleshooting

  • OLED stays blank, no Serial error: confirm GND is shared and VCC is on 3V3. Reseat the SDA/SCL wires on GPIO 21 and GPIO 22.
  • SSD1306 not found in Serial Monitor: the I²C address may differ from 0x3C. Run an I²C scanner sketch to confirm, then update OLED_I2C_ADDR in the code.
  • Wi-Fi never connects: check WIFI_SSID and WIFI_PASSWORD for typos. The ESP32 only supports 2.4 GHz networks.
  • Clock shows wrong time: verify TZ_OFFSET_SEC and DST_OFFSET_SEC. The NTP sync runs at startup; a reboot after correcting the offsets is the quickest fix.
  • Weather shows "weather...": the first fetch runs shortly after NTP sync. If it stays stuck, check that the board can reach the internet; some networks block outbound HTTP on port 80.
  • Cannot reach the web page: the IP printed in Serial Monitor may change if your router reassigns it. Set a DHCP reservation for the ESP32's MAC address for a stable local URL.

Going further

Once the basic display is working, the natural next step is to drive the content from something more specific to your workflow. The web endpoint already accepts POST requests, so any script on your machine — a calendar poller, a build-status hook, a message from your phone's shortcuts — can push text to the screen without changing the firmware. On the hardware side, adding a second OLED over the same I²C bus (with a different address) gives you a two-zone display: one for the clock and weather, one for messages.

Wiring diagram

Loading diagram…
Interactive wiring diagram

Components needed

Supplier links, prices, and availability are shown as a guide and may change. Schematik may earn a commission from purchases made through affiliate links.

Assembly

1

Wire the OLED display

Connect the SSD1306 OLED display to the ESP32 development board using four jumper wires: VCC to the 3V3 rail, GND to a GND rail, SDA to GPIO21, and SCL to GPIO22.

2

Check power and common ground

Confirm that GND on the OLED and GND on the ESP32 are joined on the same rail. Both the OLED VCC and the ESP32 3V3 pin share the same 3.3 V regulator output, so no separate supply is needed.

3

Edit Wi-Fi credentials and location

Open code.cpp and update WIFI_SSID and WIFI_PASSWORD to match your network. Optionally adjust WEATHER_LAT and WEATHER_LON to your coordinates (decimal degrees) and set TZ_OFFSET_SEC to your UTC offset in seconds.

4

Flash the firmware

Connect the ESP32 to your computer with a USB cable, select the correct port in your IDE (or let PlatformIO detect it), and upload the firmware. Open Serial Monitor at 115200 baud.

5

Confirm startup in Serial Monitor

Watch the Serial Monitor for the device IP address — it prints on connect and repeats every 30 seconds so you can always find the device. You should see lines like 'Connected. IP: 192.168.x.x' and 'Weather: 14°C Partly cloudy'.

6

Send a message from your browser

On any device on the same Wi-Fi network, open a browser and navigate to the IP address printed in Serial Monitor (e.g. http://192.168.1.42). Type a message and press 'Send to OLED'. The message takes over the screen for about 8 seconds before the normal dashboard returns.

Pin assignments

PinConnectionType
3V3ssd1306-oled VCCPOWER
GNDssd1306-oled GNDGROUND
GPIO 21ssd1306-oled SDAI2C
GPIO 22ssd1306-oled SCLI2C

Code

Arduino C++
// Tiny OLED Desk Command Centre
// SSD1306 0.96" I2C OLED — shows time (NTP), weather (Open-Meteo), and
// accepts short messages sent from a browser on the LAN.
// Edit WIFI_SSID / WIFI_PASSWORD and the location / timezone constants below.

#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <time.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <ArduinoJson.h>

// ── User-editable constants ─────────────────────────────────────────────────

const char* WIFI_SSID     = "YOUR_WIFI_NAME";      // ← edit
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";  // ← edit

// Timezone offset from UTC in seconds (e.g. UTC+1 = 3600, UTC-5 = -18000)
const long  TZ_OFFSET_SEC  = 0;
// Daylight-saving offset in seconds (1 hour = 3600; set 0 if your region has none)
const int   DST_OFFSET_SEC = 3600;

// Open-Meteo location (decimal degrees)
const float WEATHER_LAT = 51.5074f;   // London default — edit to your location
const float WEATHER_LON = -0.1278f;

// ── Pin definitions ─────────────────────────────────────────────────────────
#define SDA_PIN 21
#define SCL_PIN 22

// ── Display config ──────────────────────────────────────────────────────────
#define SCREEN_WIDTH  128
#define SCREEN_HEIGHT  64
#define OLED_RESET     -1   // no reset pin wired
#define OLED_I2C_ADDR 0x3C

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// ── Web server ──────────────────────────────────────────────────────────────
WebServer server(80);

// ── State ───────────────────────────────────────────────────────────────────
String  weatherLine    = "weather...";   // e.g. "12°C Partly cloudy"
bool    weatherOk      = false;

String  customMessage  = "";
unsigned long msgShowUntil = 0;          // millis() deadline for custom message

unsigned long lastWeatherFetch = 0;
unsigned long lastClockUpdate  = 0;
unsigned long lastIpPrint      = 0;

const unsigned long WEATHER_INTERVAL = 10UL * 60UL * 1000UL; // 10 minutes
const unsigned long CLOCK_INTERVAL   = 1000UL;                // 1 second
const unsigned long IP_PRINT_INTERVAL = 30UL * 1000UL;        // 30 seconds
const unsigned long MSG_DISPLAY_MS   = 8000UL;                 // 8 seconds

// ── Forward declarations ────────────────────────────────────────────────────
void connectWiFi();
void fetchWeather();
void updateDisplay();
void handleRoot();
void handleSend();
void showMessage(const String& msg);
String buildTimeLine();

// ── setup() ─────────────────────────────────────────────────────────────────
void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println("OLED Desk Command Centre starting");

  Wire.begin(SDA_PIN, SCL_PIN);

  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_I2C_ADDR)) {
    Serial.println("SSD1306 not found — check wiring on GPIO21/22");
    // Keep running so Serial diagnostics stay alive; display calls become no-ops
  }
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println("Connecting to Wi-Fi");
  display.display();

  connectWiFi();

  // Sync time via NTP
  configTime(TZ_OFFSET_SEC, DST_OFFSET_SEC, "pool.ntp.org", "time.nist.gov");
  Serial.println("NTP sync requested");

  // Web server routes
  server.on("/",     HTTP_GET,  handleRoot);
  server.on("/send", HTTP_POST, handleSend);
  server.begin();
  Serial.println("HTTP server started on port 80");

  // First weather fetch
  fetchWeather();

  updateDisplay();
}

// ── loop() ──────────────────────────────────────────────────────────────────
void loop() {
  server.handleClient();

  unsigned long now = millis();

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

  // Periodic weather refresh
  if (now - lastWeatherFetch >= WEATHER_INTERVAL) {
    fetchWeather();
    lastWeatherFetch = now;
  }

  // Clock tick
  if (now - lastClockUpdate >= CLOCK_INTERVAL) {
    lastClockUpdate = now;
    // Only refresh display when no custom message is showing
    if (now >= msgShowUntil) {
      updateDisplay();
    }
  }

  // Clear custom message when timer expires and return to dashboard
  if (customMessage.length() > 0 && now >= msgShowUntil) {
    customMessage = "";
    updateDisplay();
  }

  // Repeat IP to Serial so the user can find the device
  if (now - lastIpPrint >= IP_PRINT_INTERVAL) {
    lastIpPrint = now;
    if (WiFi.status() == WL_CONNECTED) {
      Serial.print("Device IP: http://");
      Serial.println(WiFi.localIP());
    }
  }
}

// ── Wi-Fi ────────────────────────────────────────────────────────────────────
void connectWiFi() {
  Serial.printf("Connecting to %s", WIFI_SSID);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  display.clearDisplay();
  display.setCursor(0, 0);
  display.println("Connecting...");
  display.display();

  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < 20000UL) {
    delay(500);
    Serial.print(".");
  }

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println();
    Serial.print("Connected. IP: ");
    Serial.println(WiFi.localIP());
    display.clearDisplay();
    display.setCursor(0, 0);
    display.println("Connected!");
    display.print("IP: ");
    display.println(WiFi.localIP().toString());
    display.display();
    delay(2000);
  } else {
    Serial.println("\nWi-Fi connection failed — retrying in main loop");
    display.clearDisplay();
    display.setCursor(0, 0);
    display.println("Wi-Fi failed");
    display.println("Retrying...");
    display.display();
  }
}

// ── Weather fetch (Open-Meteo, no key required) ──────────────────────────────
void fetchWeather() {
  if (WiFi.status() != WL_CONNECTED) {
    weatherLine = "weather unavailable";
    weatherOk   = false;
    return;
  }

  char url[200];
  snprintf(url, sizeof(url),
    "http://api.open-meteo.com/v1/forecast"
    "?latitude=%.4f&longitude=%.4f"
    "&current_weather=true"
    "&temperature_unit=celsius",
    WEATHER_LAT, WEATHER_LON);

  HTTPClient http;
  http.begin(url);
  http.setTimeout(8000);
  int code = http.GET();

  if (code == 200) {
    String body = http.getString();
    // Parse with ArduinoJson — stream-style to keep heap use low
    StaticJsonDocument<512> doc;
    DeserializationError err = deserializeJson(doc, body);
    if (!err) {
      float temp      = doc["current_weather"]["temperature"].as<float>();
      int   wmoCode   = doc["current_weather"]["weathercode"].as<int>();

      // Map a handful of WMO codes to short labels
      const char* cond = "Unknown";
      if      (wmoCode == 0)                    cond = "Clear";
      else if (wmoCode <= 3)                    cond = "Partly cloudy";
      else if (wmoCode <= 48)                   cond = "Fog/mist";
      else if (wmoCode <= 57)                   cond = "Drizzle";
      else if (wmoCode <= 67)                   cond = "Rain";
      else if (wmoCode <= 77)                   cond = "Snow";
      else if (wmoCode <= 82)                   cond = "Showers";
      else if (wmoCode <= 99)                   cond = "Thunderstorm";

      char buf[40];
      snprintf(buf, sizeof(buf), "%.0f%cC %s", temp, (char)247, cond);
      weatherLine = String(buf);
      weatherOk   = true;
      Serial.print("Weather: ");
      Serial.println(weatherLine);
    } else {
      Serial.println("Weather JSON parse error");
      weatherLine = "weather unavailable";
      weatherOk   = false;
    }
  } else {
    Serial.printf("Weather HTTP error: %d\n", code);
    weatherLine = "weather unavailable";
    weatherOk   = false;
  }
  http.end();
  lastWeatherFetch = millis();
}

// ── Time helper ──────────────────────────────────────────────────────────────
String buildTimeLine() {
  struct tm t;
  if (!getLocalTime(&t)) {
    return "time sync...";
  }
  char buf[20];
  strftime(buf, sizeof(buf), "%H:%M:%S", &t);
  return String(buf);
}

// ── Display refresh ──────────────────────────────────────────────────────────
void updateDisplay() {
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);

  if (customMessage.length() > 0) {
    // Show incoming message — wrap at word boundaries is handled by GFX
    display.setCursor(0, 0);
    display.println("-- Message --");
    display.println();
    display.setTextSize(1);
    display.println(customMessage);
  } else {
    // ── Dashboard layout ──
    // Row 0: "DESK CENTRE" header (small)
    display.setCursor(0, 0);
    display.print("DESK COMMAND CENTRE");

    // Row 1: time (large)
    display.setTextSize(2);
    display.setCursor(0, 12);
    display.print(buildTimeLine());

    // Row 2: weather (small)
    display.setTextSize(1);
    display.setCursor(0, 42);
    display.print(weatherLine);

    // Row 3: IP hint
    if (WiFi.status() == WL_CONNECTED) {
      display.setCursor(0, 54);
      display.print(WiFi.localIP().toString());
    } else {
      display.setCursor(0, 54);
      display.print("No Wi-Fi");
    }
  }

  display.display();
}

// ── Web server handlers ──────────────────────────────────────────────────────
void handleRoot() {
  String html =
    "<!DOCTYPE html><html><head>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'>"
    "<title>Desk Command Centre</title>"
    "<style>body{font-family:sans-serif;max-width:400px;margin:40px auto;padding:0 16px}"
    "input[type=text]{width:100%;box-sizing:border-box;padding:8px;font-size:1rem;margin:8px 0}"
    "button{padding:10px 24px;font-size:1rem;cursor:pointer;background:#1a1a2e;color:#fff;border:none;border-radius:4px}"
    "</style></head><body>"
    "<h2>Desk Command Centre</h2>"
    "<p>Type a message to display on the OLED for a few seconds.</p>"
    "<form method='POST' action='/send'>"
    "<input type='text' name='msg' maxlength='64' placeholder='Your message' required>"
    "<br><button type='submit'>Send to OLED</button>"
    "</form>"
    "</body></html>";
  server.send(200, "text/html", html);
}

void handleSend() {
  if (server.hasArg("msg")) {
    String msg = server.arg("msg");
    msg.trim();
    if (msg.length() > 0) {
      showMessage(msg);
      server.send(200, "text/html",
        "<!DOCTYPE html><html><head>"
        "<meta http-equiv='refresh' content='2;url=/'>"
        "<meta name='viewport' content='width=device-width,initial-scale=1'>"
        "<title>Sent</title>"
        "<style>body{font-family:sans-serif;text-align:center;margin-top:80px}</style>"
        "</head><body><h2>Message sent!</h2><p>Returning…</p></body></html>");
      return;
    }
  }
  server.sendHeader("Location", "/");
  server.send(302);
}

void showMessage(const String& msg) {
  customMessage  = msg;
  msgShowUntil   = millis() + MSG_DISPLAY_MS;
  Serial.print("Displaying message: ");
  Serial.println(msg);
  updateDisplay();
}

// Run this and build other cool things at schematik.io
Libraries: Adafruit SSD1306, Adafruit GFX Library, ArduinoJson