Desk command slab

Clock, weather, and a browser message on the C6 slab. Short BOOT pages; hold BOOT rotates. A tiny web UI sets the message.

Desk command slab
DifficultyIntermediate
Build time1h 10m
Est. cost$20
Parts4
Files5
Steps4

Things you'll need

QtyPartType
1
Waveshare ESP32-C6-LCD-1.47 (silhouette slab)
Waveshare ESP32-C6-LCD-1.47 (silhouette slab) 172×320 ST7789, GPIO8 WS2812, TF slot, BOOT/RESET. No touch or IMU. Amazon B0DK5J6LX3.
electronics
1
Command slab housing
Command slab housing 26° wedge console for the 1.47" slab. USB-C faces you.
printed
1
USB-C cable Data-capable cable for flash and power.
electronics
1
PLA or PETG About 12–20 g for the snap shells.
consumable

Tools & software

1
PlatformIO or Arduino IDE Flash over USB-C. Serial 115200.

Files & downloads

Download all

Flash writes a precompiled image over USB-C in Chrome or Edge. The .ino below is the source — you do not need Arduino IDE unless you want to change the sketch.

What you're building

A three-page desk brick: a clock, a weather panel, and a message you can set from a phone browser. A short press of BOOT cycles those pages. Hold BOOT about 0.7 seconds to rotate portrait and landscape.

Leave Wi-Fi empty (the published default) and the slab creates its own network named DeskCommand, with no password. Join it and open the address the serial monitor prints. On this sketch that Soft-AP address is http://192.168.4.1. Or fill in a 2.4 GHz home network if you want live weather and the clock set from the internet.

What you will learn

  • The difference between joining Wi-Fi and creating a hotspot (Soft-AP).
  • What a tiny web server is, and GET vs POST.
  • How a weather API returns JSON you can unpack.
  • How NTP (network time) sets a clock without a user typing 2:30 PM.

Meet the silhouette slab

This project uses the Waveshare ESP32-C6-LCD-1.47 — a slim rectangle with a 1.47 inch screen.

  • A 172×320 color screen. You can turn it portrait or landscape in software.
  • No touch. No motion sensor. Interaction is the BOOT button (a short press vs a ~0.7 second hold).
  • A single RGB LED on the board (GPIO8) that can glow any color.
  • Wi-Fi that is 2.4 GHz only.
  • Keep the backlight at 50%. Full brightness can leave a heat spot on the panel.

The program is a .ino file (Arduino-style C++). You will not memorize it. You will learn the ideas, then see the small piece of code that does each job.

Note

Word to know — GPIO. It means “general purpose input/output” — a pin the chip can turn on or read. BOOT is GPIO9. The RGB LED is GPIO8. You do not rewire anything; the board already connected those pins.

The finished gadget

Desk command slab
Desk command slab on Silhouette slab (ESP32-C6).

Build it

1

Print the stand

0.2 mm layers, 3 walls. The back is a 26° wedge that sits on the desk, USB-C toward you. The screen starts in portrait. Hold BOOT to turn it landscape.

Print the stand
2

Decide: hotspot or home Wi-Fi

Empty SSID (published) → join Soft-AP DeskCommand and set a message. Filled SSID → station mode, NTP clock, Open-Meteo weather. Set WEATHER_LAT / WEATHER_LON if you go online. Never commit a real password.

3

Flash the slab

Flash means copy the program onto the chip. Easiest path: on this page, click Flash to board (Chrome or Edge, data-capable USB-C). That writes the published image for this project. The downloadable desk-command-slab.bin is the merged file if you would rather use esptool. To change the code later: Arduino IDE → ESP32C6 Dev Module, USB CDC On Boot, 115200 — or unzip platformio.zip next to the .ino and run python3 -m platformio run -t upload. Keep the backlight at 50% (C6_BL_PWM is 128). This slab has no touch screen and no motion sensor. You press BOOT on GPIO9.

Flash the slab
4

Page it

Short BOOT (longer than a 40 ms tap, shorter than 0.7 s): clock → weather → message. Hold BOOT about 0.7 seconds: rotate, and the orientation is saved. A browser message stays for 60 seconds, or until the next short BOOT, then the previous page comes back. The onboard RGB LED is blue for the clock, cyan for weather, and amber for the message.

Page it
Warning

Soft-AP DeskCommand has no password. Fine on a classroom bench. Do not leave that up on a public network — anyone nearby could POST a message.

Lesson 1 — Station vs hotspot

Two jobs a Wi-Fi chip can do:

  • Station (STA) — join someone else’s network (your home router), like your phone at home.
  • Access point (AP / Soft-AP)be the network. Other devices join you.

This firmware tries STA if you filled in an SSID. If that fails — or the SSID is empty — it becomes DeskCommand. That fallback is good engineering: always have a way in.

The policy in English, then in code

desk-command-slab.ino
#define WIFI_SSID ""   // YOUR_WIFI_SSID_HERE  (empty = hotspot)
#define WIFI_PASSWORD ""
#define AP_SSID "DeskCommand"

// if WIFI_SSID is not empty: try to join for 8 seconds
// if that fails (or it was empty): WiFi.softAP("DeskCommand")

Lesson 2 — Your first web server

A web server is a program that waits for a browser and answers. You do not need Apache. The slab is enough.

  • GET / — “show me the form.” The board sends a tiny HTML page.
  • POST /msg — “here is a message; please display it.” POST means the browser is sending data, not just asking.

That GET/POST pair is how login forms, comments, and search boxes work on the real web. You just ran both ends on a $16 board.

Lesson 3 — Weather is someone else’s JSON

When station mode has joined the internet, the slab asks Open-Meteo for current conditions. The answer is JSON. This sketch reads four fields inside current: temperature_2m, relative_humidity_2m, weather_code, and wind_speed_10m. It asks for Fahrenheit. It does not set a wind unit, so the wind number is Open-Meteo's default, km/h. It asks again about every 15 minutes.

Until that request succeeds, the weather page shows a built-in sample: 52°F, partly cloudy, wind 12, humidity 55. Those numbers are not live.

The sample coordinates are Chicago (41.8781, −87.6298). Change WEATHER_LAT and WEATHER_LON for your desk.

You did not scrape a random website. You used a documented API — a door the owner meant for programs to use. Open-Meteo's docs describe this forecast URL as JSON over plain HTTP. That is the polite way.

The question the slab asks

desk-command-slab.ino
// GET https://api.open-meteo.com/v1/forecast
//   ?latitude=41.8781
//   &longitude=-87.6298
//   &current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m
//   &temperature_unit=fahrenheit
//   &timezone=auto

Lesson 4 — Clocks from the internet (NTP)

NTP (Network Time Protocol) is how almost every computer learns the time. The slab asks pool.ntp.org, then applies your timezone offset. No user typed 7:45. That is why the clock is wrong until STA works — there is no battery clock on this board.

Try this

  • Join DeskCommand and POST a message from a phone. Watch the amber page.
  • Type status and read the IP. That number is the board’s address on the network.
  • Fill in Wi-Fi + lat/lon at home and compare the weather page to your phone.

Talking to the board (serial)

Your computer can talk to the board over the same USB-C cable. That text chat is called serial. In Arduino IDE or PlatformIO, open the Serial Monitor at 115200 baud (that number is the speed — both sides must match).

Type a word, press Enter, and the board answers. Try help first. Then try status.

Commands for this project: help · status · page N · rotate

Tip

If you see nothing, pick the right USB port, set 115200, and press the board’s RESET once. On a Mac the port often looks like /dev/cu.usbmodem….


The complete program (reference)

Everything above taught the ideas. Below is the full sketch so you can search, copy, and tinker. It is long on purpose — that is a real program, not a toy snippet. Scroll inside the box. The downloadable .ino is the same file.

Wi-Fi and API fields are placeholders (YOUR_WIFI_SSID_HERE, YOUR_WIFI_PASSWORD_HERE). Never paste a real password into a public copy.

desk-command-slab.ino (complete)

desk-command-slab.ino
// Desk Command Slab — ESP32-C6-LCD-1.47 (172x320, LCD-only, no touch/IMU)
// Short BOOT (GPIO9) cycles: clock → weather → message
// Hold BOOT ~0.7s = portrait (172×320) / landscape (320×172)
// RGB GPIO8: blue clock, cyan weather, amber message
// BL GPIO22 ≤ 50%. Web: GET /  POST /msg  (STA or Soft-AP "DeskCommand")
//
// Flash:  cd board-sketches/desk-command-slab/firmware && pio run -t upload
// Serial: 115200 — help | status | page N | rotate
// Sim:    python .cursor/skills/board-firmware-sim/scripts/serve_sim.py board-sketches/desk-command-slab

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <time.h>
#include <Adafruit_NeoPixel.h>
#include <Arduino_GFX_Library.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include "../../../shared/c6_slab_pins.h"

#define BOOT_LONG_MS 700UL

// Fill these in to join your 2.4 GHz network. Leave empty to stay on Soft-AP
// "DeskCommand" (no password) so you can POST /msg from a browser.
#define WIFI_SSID ""   // YOUR_WIFI_SSID_HERE
#define WIFI_PASSWORD ""  // YOUR_WIFI_PASSWORD_HERE
#define AP_SSID "DeskCommand"

#define WEATHER_LAT 41.8781
#define WEATHER_LON -87.6298
#define WEATHER_FAHRENHEIT 1

#define TZ_OFFSET_HOURS -6
#define DST_OFFSET_HOURS 1

#define MSG_HOLD_MS 60000UL
#define MSG_MAX_LEN 140
#define WEATHER_TTL_MS 900000UL

enum Page : uint8_t { PAGE_CLOCK = 0, PAGE_WEATHER = 1, PAGE_MSG = 2, PAGE_COUNT = 3 };

static const uint16_t COL_BG = 0x0884;
static const uint16_t COL_TEXT = 0xEF7F;
static const uint16_t COL_MUTED = 0x7C10;
static const uint16_t COL_BLUE = 0x3BFF;
static const uint16_t COL_CYAN = 0x269D;
static const uint16_t COL_AMBER = 0xF524;

Arduino_DataBus *bus = new Arduino_ESP32SPI(
    C6_LCD_DC_PIN, C6_LCD_CS_PIN, C6_LCD_SCLK_PIN, C6_LCD_MOSI_PIN, GFX_NOT_DEFINED);
Arduino_GFX *gfx = new Arduino_ST7789(
    bus, C6_LCD_RST_PIN, 0 /* rotation */, true /* IPS */,
    C6_LCD_W, C6_LCD_H, 34, 0, 34, 0);

Adafruit_NeoPixel rgb(1, C6_RGB_PIN, NEO_GRB + NEO_KHZ800);
WebServer server(80);
Preferences prefs;

static Page page = PAGE_CLOCK;
static Page pageBeforeMsg = PAGE_CLOCK;
static bool dirty = true;
static bool g_landscape = false;
static bool g_bootDown = false;
static bool g_bootLong = false;
static unsigned long g_bootDownAt = 0;
static unsigned long lastClockTick = 0;
static char lastTimeKey[8] = "";
static char lastDateKey[12] = "";
static unsigned long lastHoldShown = 0;

static int sw() { return gfx->width(); }
static int sh() { return gfx->height(); }

static void applyOrient() { gfx->setRotation(g_landscape ? 1 : 0); }

static void persistOrient() { prefs.putUChar("land", g_landscape ? 1 : 0); }

static String customMsg;
static unsigned long msgHoldUntil = 0;

static bool weatherOk = true;
static float weatherTemp = 52;
static int weatherCode = 2;
static int weatherWind = 12;
static int weatherRh = 55;
static unsigned long weatherFetchedAt = 0;
static bool weatherFetchDue = true;

static time_t fallbackEpoch = 0;
static unsigned long fallbackMillis = 0;
static bool staOk = false;
static String ipLabel = "offline";

static uint16_t accentFor(Page p) {
  if (p == PAGE_WEATHER) return COL_CYAN;
  if (p == PAGE_MSG) return COL_AMBER;
  return COL_BLUE;
}

static void setRgbFor(Page p) {
  uint8_t r = 0, g = 0, b = 0;
  if (p == PAGE_CLOCK) {
    b = 220;
  } else if (p == PAGE_WEATHER) {
    g = 200;
    b = 220;
  } else {
    r = 240;
    g = 150;
  }
  rgb.setPixelColor(0, rgb.Color(r, g, b));
  rgb.show();
}

static const char *pageName(Page p) {
  if (p == PAGE_WEATHER) return "WEATHER";
  if (p == PAGE_MSG) return "MESSAGE";
  return "CLOCK";
}

static const char *wmoText(int code) {
  if (code == 0) return "Clear";
  if (code <= 3) return "Partly cloudy";
  if (code <= 48) return "Fog";
  if (code <= 57) return "Drizzle";
  if (code <= 67) return "Rain";
  if (code <= 77) return "Snow";
  if (code <= 82) return "Showers";
  if (code <= 86) return "Snow showers";
  if (code <= 99) return "Thunder";
  return "Unknown";
}

static void initFallbackClock() {
  static const char kMonths[] = "JanFebMarAprMayJunJulAugSepOctNovDec";
  char mon[4] = {0};
  int day = 1, year = 2026, h = 0, mi = 0, s = 0;
  sscanf(__DATE__, "%3s %d %d", mon, &day, &year);
  sscanf(__TIME__, "%d:%d:%d", &h, &mi, &s);
  struct tm t = {};
  const char *found = strstr(kMonths, mon);
  t.tm_mon = found ? (int)(found - kMonths) / 3 : 0;
  t.tm_mday = day;
  t.tm_year = year - 1900;
  t.tm_hour = h;
  t.tm_min = mi;
  t.tm_sec = s;
  t.tm_isdst = -1;
  fallbackEpoch = mktime(&t);
  fallbackMillis = millis();
}

static time_t nowEpoch() {
  time_t n = time(nullptr);
  if (n > 1700000000UL) return n;
  if (!fallbackEpoch) return 0;
  return fallbackEpoch + (time_t)((millis() - fallbackMillis) / 1000UL);
}

static bool nowTm(struct tm *out) {
  time_t n = nowEpoch();
  if (!n) return false;
  localtime_r(&n, out);
  return true;
}

static int glyphW(int size) { return 6 * size; }

static void printLeft(int x, int y, int size, uint16_t col, const char *s) {
  gfx->setTextSize(size);
  gfx->setTextColor(col);
  gfx->setCursor(x, y);
  gfx->print(s);
}

static void printCenter(int y, int size, uint16_t col, const char *s) {
  int x = (sw() - (int)strlen(s) * glyphW(size)) / 2;
  if (x < 4) {
    x = 4;
  }
  printLeft(x, y, size, col, s);
}

static void drawWrapped(const String &text, int x, int y, int maxW, int lineH, int size) {
  gfx->setTextSize(size);
  String line;
  int start = 0;
  const int n = (int)text.length();
  while (start <= n) {
    int sp = text.indexOf(' ', start);
    if (sp < 0) sp = n;
    String word = text.substring(start, sp);
    String trial = line.length() ? line + " " + word : word;
    int16_t x1, y1;
    uint16_t w, h;
    gfx->getTextBounds(trial.c_str(), 0, 0, &x1, &y1, &w, &h);
    if (w > (uint16_t)maxW && line.length()) {
      gfx->setCursor(x, y);
      gfx->print(line);
      y += lineH;
      line = word;
      if (y + lineH > sh()) {
        return;
      }
    } else {
      line = trial;
    }
    if (sp >= n) {
      break;
    }
    start = sp + 1;
  }
  if (line.length() && y + 4 < sh()) {
    gfx->setCursor(x, y);
    gfx->print(line);
  }
}

static void drawChrome(Page p, bool title) {
  gfx->fillScreen(COL_BG);
  gfx->fillRect(0, 0, sw(), 8, accentFor(p));
  if (!title) {
    return;
  }
  gfx->setTextSize(2);
  gfx->setTextColor(accentFor(p));
  gfx->setCursor(8, 16);
  gfx->print(pageName(p));
}

static bool clockKeys(char *timeKey, int tn, char *dateKey, int dn) {
  struct tm t;
  if (!nowTm(&t)) {
    strncpy(timeKey, "wait", tn);
    strncpy(dateKey, "wait", dn);
    timeKey[tn - 1] = 0;
    dateKey[dn - 1] = 0;
    return false;
  }
  strftime(timeKey, tn, "%I%M%p", &t);
  strftime(dateKey, dn, "%Y%m%d", &t);
  return true;
}

static void rememberClockKeys() {
  clockKeys(lastTimeKey, sizeof(lastTimeKey), lastDateKey, sizeof(lastDateKey));
}

static void paintClockTimeOnly() {
  struct tm t;
  if (!nowTm(&t)) {
    return;
  }
  char hm[8];
  char ap[4];
  strftime(hm, sizeof(hm), "%I:%M", &t);
  const char *hms = (hm[0] == '0' || hm[0] == ' ') ? hm + 1 : hm;
  strftime(ap, sizeof(ap), "%p", &t);
  if (g_landscape) {
    gfx->fillRect(0, 36, sw(), 56, COL_BG);
    printLeft(8, 36, 6, COL_TEXT, hms);
    printLeft(12 + (int)strlen(hms) * glyphW(6), 44, 3, COL_BLUE, ap);
  } else {
    gfx->fillRect(0, 52, sw(), 40, COL_BG);
    printCenter(52, 5, COL_TEXT, hms);
    gfx->fillRect(0, 108, sw(), 24, COL_BG);
    printCenter(108, 3, COL_BLUE, ap);
  }
  rememberClockKeys();
}

static void paintHoldCount() {
  if (!msgHoldUntil || millis() >= msgHoldUntil) {
    return;
  }
  unsigned long left = (msgHoldUntil - millis() + 999UL) / 1000UL;
  if (left == lastHoldShown) {
    return;
  }
  lastHoldShown = left;
  char hold[8];
  snprintf(hold, sizeof(hold), "%lus", left);
  const int size = 2;
  const int boxW = 4 * glyphW(size);
  const int x = sw() - 8 - boxW;
  gfx->fillRect(x, 16, boxW, 8 * size, COL_BG);
  printLeft(sw() - 8 - (int)strlen(hold) * glyphW(size), 16, 2, COL_AMBER, hold);
}

static void drawClock() {
  drawChrome(PAGE_CLOCK, true);
  struct tm t;
  if (!nowTm(&t)) {
    printCenter(g_landscape ? 56 : 120, 3, COL_TEXT, "Waiting");
    printCenter(g_landscape ? 88 : 156, 3, COL_TEXT, "for time");
    rememberClockKeys();
    return;
  }

  char hm[8];
  char ap[4];
  char day[8];
  char md[12];
  char year[8];
  strftime(hm, sizeof(hm), "%I:%M", &t);
  const char *hms = (hm[0] == '0' || hm[0] == ' ') ? hm + 1 : hm;
  strftime(ap, sizeof(ap), "%p", &t);
  strftime(day, sizeof(day), "%a", &t);
  strftime(md, sizeof(md), "%d %b", &t);
  strftime(year, sizeof(year), "%Y", &t);

  if (g_landscape) {
    printLeft(8, 36, 6, COL_TEXT, hms);
    const int timeW = (int)strlen(hms) * glyphW(6);
    printLeft(12 + timeW, 44, 3, COL_BLUE, ap);
    printLeft(8, 100, 3, COL_TEXT, day);
    printLeft(8 + (int)strlen(day) * glyphW(3) + 12, 100, 3, COL_TEXT, md);
    printLeft(8 + (int)strlen(day) * glyphW(3) + 12 + (int)strlen(md) * glyphW(3) + 12,
              108, 2, COL_MUTED, year);
    rememberClockKeys();
    return;
  }

  printCenter(52, 5, COL_TEXT, hms);
  printCenter(108, 3, COL_BLUE, ap);
  printCenter(168, 3, COL_TEXT, day);
  printCenter(204, 3, COL_TEXT, md);
  printCenter(248, 2, COL_MUTED, year);
  rememberClockKeys();
}

static void drawWeather() {
  drawChrome(PAGE_WEATHER, false);
  char tempBuf[12];
  snprintf(tempBuf, sizeof(tempBuf), "%d%c", (int)lroundf(weatherTemp),
           WEATHER_FAHRENHEIT ? 'F' : 'C');
  char windBuf[16];
  snprintf(windBuf, sizeof(windBuf), "WIND %d", weatherWind);
  char rhBuf[16];
  snprintf(rhBuf, sizeof(rhBuf), "RH %d%%", weatherRh);
  const char *cond = wmoText(weatherCode);

  if (g_landscape) {
    printLeft(8, 16, 6, COL_TEXT, tempBuf);
    gfx->setTextColor(COL_CYAN);
    drawWrapped(String(cond), 8, 76, sw() - 16, 28, 3);
    printLeft(8, 140, 2, COL_TEXT, windBuf);
    printLeft(8 + (int)strlen(windBuf) * glyphW(2) + 16, 140, 2, COL_TEXT, rhBuf);
    return;
  }

  printCenter(28, 7, COL_TEXT, tempBuf);
  gfx->setTextColor(COL_CYAN);
  {
    const int size = 3;
    const int maxW = sw() - 12;
    String line;
    int y = 108;
    const String text = String(cond);
    int start = 0;
    const int n = (int)text.length();
    gfx->setTextSize(size);
    while (start <= n) {
      int sp = text.indexOf(' ', start);
      if (sp < 0) {
        sp = n;
      }
      String word = text.substring(start, sp);
      String trial = line.length() ? line + " " + word : word;
      int tw = (int)trial.length() * glyphW(size);
      if (tw > maxW && line.length()) {
        printCenter(y, size, COL_CYAN, line.c_str());
        y += 32;
        line = word;
      } else {
        line = trial;
      }
      if (sp >= n) {
        break;
      }
      start = sp + 1;
    }
    if (line.length()) {
      printCenter(y, size, COL_CYAN, line.c_str());
    }
  }
  printCenter(228, 3, COL_TEXT, windBuf);
  printCenter(268, 3, COL_TEXT, rhBuf);
}

static void drawMessage() {
  drawChrome(PAGE_MSG, true);
  const int wrapY = g_landscape ? 44 : 52;
  const int lineH = 28;
  const int size = 3;
  if (!customMsg.length()) {
    gfx->setTextColor(COL_TEXT);
    drawWrapped("Send a message from the web UI", 8, wrapY, sw() - 16, lineH, size);
    return;
  }
  if (msgHoldUntil && millis() < msgHoldUntil) {
    unsigned long left = (msgHoldUntil - millis() + 999UL) / 1000UL;
    char hold[8];
    snprintf(hold, sizeof(hold), "%lus", left);
    lastHoldShown = left;
    printLeft(sw() - 8 - (int)strlen(hold) * glyphW(2), 16, 2, COL_AMBER, hold);
  }
  gfx->setTextColor(COL_TEXT);
  drawWrapped(customMsg, 8, wrapY, sw() - 16, lineH, size);
}

static void render() {
  setRgbFor(page);
  if (page == PAGE_CLOCK) {
    drawClock();
  } else if (page == PAGE_WEATHER) {
    drawWeather();
  } else {
    drawMessage();
  }
  dirty = false;
}

static void showMessage(const String &raw) {
  String next = raw;
  next.trim();
  if (next.length() > MSG_MAX_LEN) {
    next = next.substring(0, MSG_MAX_LEN);
  }
  if (!next.length()) return;
  if (page != PAGE_MSG) {
    pageBeforeMsg = page;
  }
  customMsg = next;
  msgHoldUntil = millis() + MSG_HOLD_MS;
  page = PAGE_MSG;
  dirty = true;
}

static const char *kFormHtml =
    "<!DOCTYPE html><html><head><meta name=viewport content='width=device-width,initial-scale=1'>"
    "<title>Desk Command</title><style>"
    "body{font-family:sans-serif;background:#0B1020;color:#E8EEFC;padding:16px;max-width:28rem}"
    "h1{font-size:1.2rem;color:#3D7CFF}input,button{font-size:18px;padding:10px;width:100%;"
    "box-sizing:border-box;margin:8px 0;border-radius:8px;border:0}"
    "input{background:#151b30;color:#E8EEFC}button{background:#F5A623;color:#111;font-weight:700}"
    "</style></head><body><h1>Desk Command</h1>"
    "<form method=POST action=/msg>"
    "<input name=msg maxlength=140 placeholder='Message for the slab' autofocus>"
    "<button type=submit>Show on slab</button></form>"
    "<p style=color:#8aa>Shown 60s or until BOOT.</p></body></html>";

static const char *kOkHtml =
    "<!DOCTYPE html><html><head><meta name=viewport content='width=device-width,initial-scale=1'>"
    "<title>Desk Command</title></head><body style='font-family:sans-serif;background:#0B1020;color:#E8EEFC;padding:16px'>"
    "<p>On the slab for 60s.</p><p><a href=/ style=color:#F5A623>Send another</a></p></body></html>";

static void handleRoot() { server.send(200, "text/html", kFormHtml); }

static void handleMsg() {
  showMessage(server.arg("msg"));
  if (!customMsg.length()) {
    server.send(400, "text/plain", "empty msg");
    return;
  }
  server.send(200, "text/html", kOkHtml);
}

static void setupWifi() {
  if (strlen(WIFI_SSID) > 0) {
    WiFi.mode(WIFI_STA);
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
    unsigned long start = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - start < 8000UL) {
      delay(200);
    }
  }
  staOk = WiFi.status() == WL_CONNECTED;
  if (staOk) {
    ipLabel = WiFi.localIP().toString();
    configTime((long)TZ_OFFSET_HOURS * 3600L, (long)DST_OFFSET_HOURS * 3600L,
               "pool.ntp.org", "time.nist.gov");
    Serial.print("STA ");
    Serial.println(ipLabel);
  } else {
    WiFi.mode(WIFI_AP);
    WiFi.softAP(AP_SSID);
    ipLabel = String("AP ") + WiFi.softAPIP().toString();
    Serial.print("Soft-AP ");
    Serial.print(AP_SSID);
    Serial.print(" ");
    Serial.println(WiFi.softAPIP());
  }
}

static void fetchWeather() {
  if (!staOk) {
    weatherFetchDue = false;
    return;
  }
  char url[256];
  snprintf(url, sizeof(url),
           "https://api.open-meteo.com/v1/forecast?latitude=%.4f&longitude=%.4f"
           "&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m"
           "&temperature_unit=%s&timezone=auto",
           WEATHER_LAT, WEATHER_LON, WEATHER_FAHRENHEIT ? "fahrenheit" : "celsius");

  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(8000);
  if (!http.begin(client, url)) {
    Serial.println("weather begin fail");
    weatherFetchDue = false;
    weatherFetchedAt = millis();
    return;
  }
  int code = http.GET();
  if (code != 200) {
    Serial.printf("weather HTTP %d\n", code);
    http.end();
    weatherFetchDue = false;
    weatherFetchedAt = millis();
    return;
  }
  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, http.getString());
  http.end();
  if (err) {
    Serial.println(err.c_str());
    weatherFetchDue = false;
    weatherFetchedAt = millis();
    return;
  }
  JsonObject cur = doc["current"];
  if (cur.isNull()) return;
  weatherTemp = cur["temperature_2m"] | 0.0f;
  weatherCode = cur["weather_code"] | 0;
  weatherWind = (int)lroundf(cur["wind_speed_10m"] | 0.0f);
  weatherRh = cur["relative_humidity_2m"] | 0;
  weatherOk = true;
  weatherFetchedAt = millis();
  weatherFetchDue = false;
  if (page == PAGE_WEATHER) dirty = true;
  Serial.printf("weather %d %s\n", (int)lroundf(weatherTemp), wmoText(weatherCode));
}

static void toggleOrient() {
  g_landscape = !g_landscape;
  applyOrient();
  persistOrient();
  dirty = true;
  Serial.printf("orient %s\n", g_landscape ? "landscape" : "portrait");
}

static void printHelp() {
  Serial.println(F("Desk Command Slab"));
  Serial.println(F("  help    this list"));
  Serial.println(F("  status  wifi / page / message"));
  Serial.println(F("  page N  0 clock | 1 weather | 2 message"));
  Serial.println(F("  rotate  portrait <-> landscape"));
  Serial.println(F("BOOT short = next page. Hold BOOT = rotate."));
}

static void printStatus() {
  Serial.print(F("page: "));
  Serial.println(pageName(page));
  Serial.printf("orient: %s  %dx%d\n", g_landscape ? "landscape" : "portrait", sw(), sh());
  Serial.print(F("wifi: "));
  Serial.println(staOk ? String("STA ") + ipLabel : ipLabel);
  Serial.print(F("rgb: "));
  Serial.println(page == PAGE_WEATHER ? "cyan" : page == PAGE_MSG ? "amber" : "blue");
  struct tm t;
  if (nowTm(&t)) {
    char buf[24];
    strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &t);
    Serial.print(F("time: "));
    Serial.println(buf);
  } else {
    Serial.println(F("time: unset"));
  }
  Serial.print(F("weather: "));
  if (weatherOk) {
    Serial.print((int)lroundf(weatherTemp));
    Serial.print(WEATHER_FAHRENHEIT ? "F " : "C ");
    Serial.println(wmoText(weatherCode));
  } else {
    Serial.println(F("none"));
  }
  Serial.print(F("message: "));
  Serial.println(customMsg.length() ? customMsg : "(none)");
  if (msgHoldUntil && millis() < msgHoldUntil) {
    Serial.print(F("hold: "));
    Serial.print((msgHoldUntil - millis()) / 1000UL);
    Serial.println("s");
  }
}

static void pollSerial() {
  if (!Serial.available()) return;
  String line = Serial.readStringUntil('\n');
  line.trim();
  line.toLowerCase();
  if (line == "help" || line == "?") {
    printHelp();
  } else if (line == "status") {
    printStatus();
  } else if (line.startsWith("page")) {
    int n = line.substring(4).toInt();
    if (n >= 0 && n < PAGE_COUNT) {
      page = (Page)n;
      dirty = true;
    }
  } else if (line == "rotate" || line == "orient") {
    toggleOrient();
  } else if (line.length()) {
    printHelp();
  }
}

static void nextPage() {
  msgHoldUntil = 0;
  page = (Page)((page + 1) % PAGE_COUNT);
  dirty = true;
}

static void pollBoot() {
  const bool down = digitalRead(C6_BOOT_PIN) == LOW;
  const unsigned long now = millis();
  if (down && !g_bootDown) {
    g_bootDown = true;
    g_bootLong = false;
    g_bootDownAt = now;
  }
  if (down && g_bootDown && !g_bootLong && (now - g_bootDownAt) >= BOOT_LONG_MS) {
    g_bootLong = true;
    toggleOrient();
  }
  if (!down && g_bootDown) {
    if (!g_bootLong && (now - g_bootDownAt) > 40) {
      nextPage();
    }
    g_bootDown = false;
  }
}

static void pollMsgHold() {
  if (page != PAGE_MSG || !msgHoldUntil) return;
  if (millis() < msgHoldUntil) return;
  msgHoldUntil = 0;
  page = pageBeforeMsg;
  dirty = true;
}

void setup() {
  Serial.begin(115200);
  delay(200);
  pinMode(C6_BOOT_PIN, INPUT_PULLUP);
  pinMode(C6_LCD_BL_PIN, OUTPUT);
  analogWrite(C6_LCD_BL_PIN, C6_BL_PWM);

  rgb.begin();
  rgb.setBrightness(40);
  rgb.clear();
  rgb.show();

  prefs.begin("deskcmd", false);
  g_landscape = prefs.getUChar("land", 0) != 0;

  if (!gfx->begin()) {
    Serial.println(F("GFX begin failed"));
  }
  gfx->setTextWrap(false);
  applyOrient();

  initFallbackClock();
  setupWifi();
  server.on("/", HTTP_GET, handleRoot);
  server.on("/msg", HTTP_POST, handleMsg);
  server.begin();

  setRgbFor(page);
  dirty = true;
  printHelp();
}

void loop() {
  server.handleClient();
  pollBoot();
  pollSerial();
  pollMsgHold();

  if (weatherFetchDue || (staOk && weatherFetchedAt && millis() - weatherFetchedAt > WEATHER_TTL_MS)) {
    fetchWeather();
  }

  unsigned long now = millis();
  if (page == PAGE_CLOCK && now - lastClockTick >= 1000UL) {
    lastClockTick = now;
    char timeKey[8];
    char dateKey[12];
    clockKeys(timeKey, sizeof(timeKey), dateKey, sizeof(dateKey));
    if (strcmp(dateKey, lastDateKey) != 0) {
      dirty = true;
    } else if (strcmp(timeKey, lastTimeKey) != 0) {
      paintClockTimeOnly();
    }
  }
  if (page == PAGE_MSG && msgHoldUntil && now - lastClockTick >= 250UL) {
    lastClockTick = now;
    paintHoldCount();
  }
  if (dirty) render();
}

Board and housing

Licensed under CC BY-SA 4.0. Check with the author before commercial use.