Split-flap departure slab

A miniature airport FIDS board on the ESP32-C6 slab. Short BOOT flips Departures and Calendar; hold BOOT to rotate.

Split-flap departure slab
DifficultyBeginner
Build time50 min
Est. cost$20
Parts4
Files5
Steps3

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
FIDS frame + landscape cradle
FIDS frame + landscape cradle Airport-board frame with decorative slats. Cradle holds the slab landscape.
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 miniature airport departure board. Rows click over destination, time, and status like split-flap letters in a real terminal. Short BOOT flips DEPARTURES and CALENDAR. Hold BOOT to rotate portrait (5 rows) vs landscape (3 bigger rows).

It works with no Wi-Fi — a canned Chicago (ORD) schedule lives in the program. Later you can point it at a JSON file on the internet if you want live data.

What you will learn

  • How a table of data (an array of rows) drives a screen.
  • How short press vs long press becomes two different buttons.
  • What JSON looks like for a list of flights.
  • How a status word (DELAY) can pick a color — on the screen and on an LED.

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

Split-flap departure slab
Split-flap departure slab on Silhouette slab (ESP32-C6).

Build it

1

Print the frame

0.2 mm, 3 walls. Print the cradle so the long axis of the screen is horizontal. Slide the slab in from the back, USB-C at the end. The cavity is keyed.

Print the frame
2

Flash the slab

Flash means copy the program onto the chip. Easiest path: use the attached split-flap-board.bin with the on-page Flash to board button (Chrome or Edge, USB-C). 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%. This slab has no touch screen and no motion sensor — you press BOOT (the button on GPIO9) to interact.

Flash the slab
3

Page the board

Short BOOT = Departures / Calendar. Hold BOOT = rotate. Leave Wi-Fi empty unless you have a JSON feed.

Page the board
Warning

Published firmware has empty WIFI_SSID. That is correct. The board is a classroom demo without your home password in it.

Lesson 1 — A screen is often just a table

Each flight is a row: destination, time, status. The program stores a list of those rows and draws one line per row. Change the list, the board changes. That is how spreadsheets, scoreboards, and apps work — data first, drawing second.

Canned rows (no internet needed)

split-flap-board.ino
{"LAX", "08:15", "ONTIME"},
{"JFK", "09:40", "DELAY"},
{"SFO", "10:05", "ONTIME"},
{"MIA", "11:20", "GATE"},

Lesson 2 — One button, two meanings

The slab has no touch screen. BOOT is your only button. The firmware times how long you hold it:

  • Shorter than ~0.7 s → next page (Departures ↔ Calendar).
  • Longer → rotate the whole layout.

You just learned edge timing. Remote controls and game controllers do this constantly (tap to jump, hold to charge).

Lesson 3 — Status is a word that picks a color

ONTIME paints green (and the RGB LED). DELAY paints red. GATE is amber. That is an if on a string — a lookup. Designers call this “encoding meaning in color.” In an airport, color is a language people can read from across the hall.

Lesson 4 — Optional JSON, same shape

If you later set a 2.4 GHz SSID and a SCHEDULE_URL, the board GETs a file. The file must look like the canned table, just written as JSON. Same labels, same idea as the radar project: the internet sends a packing list, the program unpacks it.

A live schedule the board understands

flights.json
{
  "airport": "ORD",
  "flights": [
    {"dest": "LAX", "time": "08:15", "status": "ONTIME"},
    {"dest": "JFK", "time": "09:40", "status": "DELAY"}
  ]
}

Try this

  • Hold BOOT and watch 5 fat rows become 3 fatter ones. Same data, new layout.
  • Type page on serial without touching the board.
  • Edit one canned destination to your nearest airport and reflash.

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 · flip · 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.

split-flap-board.ino (complete)

split-flap-board.ino
// Split-flap departure board — ESP32-C6-LCD-1.47 (Amazon B0DK5J6LX3)
// LCD-only slab: 172x320 ST7789, no touch, no IMU.
// Short BOOT (GPIO9) cycles DEPARTURES <-> CALENDAR.
// Hold BOOT ~0.7s = portrait (5 big rows) / landscape (3 big rows).
// RGB GPIO8: amber idle, green on-time, red delayed.
// Backlight GPIO22 held at 50%. Open-Meteo is the wrong feed — this board uses a
// canned airport schedule, plus optional HTTP JSON if SCHEDULE_URL is set.
//
// Flash:  pio run -t upload --upload-port /dev/cu.usbmodem*
// Serial: 115200   help | status | page | flip | rotate
// Sim:    python .cursor/skills/board-firmware-sim/scripts/serve_sim.py board-sketches/split-flap-board
//
// Optional JSON (GET SCHEDULE_URL), same shape the sim understands:
//   {"airport":"ORD","flights":[{"dest":"LAX","time":"08:15","status":"ONTIME"}]}
// status: ONTIME | DELAY | GATE

#include <Arduino_GFX_Library.h>
#include <Adafruit_NeoPixel.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <time.h>

#include "../../../shared/c6_slab_pins.h"

#ifndef WIFI_SSID
#define WIFI_SSID ""   // YOUR_WIFI_SSID_HERE
#endif
#ifndef WIFI_PASSWORD
#define WIFI_PASSWORD ""  // YOUR_WIFI_PASSWORD_HERE
#endif
#ifndef SCHEDULE_URL
#define SCHEDULE_URL ""   // https://example.com/flights.json
#endif
#ifndef HOME_AIRPORT
#define HOME_AIRPORT "ORD"
#endif
#ifndef TZ_OFFSET_HOURS
#define TZ_OFFSET_HOURS -5
#endif

#define BOOT_LONG_MS 700UL

// Waveshare 172x320 ST7789, column offset 34 — keep this init literal.
Arduino_DataBus *bus = new Arduino_ESP32SPI(
    15 /* DC */, 14 /* CS */, 7 /* SCK */, 6 /* MOSI */, GFX_NOT_DEFINED);
Arduino_GFX *gfx = new Arduino_ST7789(
    bus, 21 /* RST */, 0 /* rotation */, true /* IPS */,
    172, 320, 34, 0, 34, 0);

Adafruit_NeoPixel rgb(1, 8, NEO_GRB + NEO_KHZ800);
Preferences prefs;

static const uint16_t COL_BG = 0x18A2;
static const uint16_t COL_HEADER = 0x20E3;
static const uint16_t COL_GOLD = 0xEE28;
static const uint16_t COL_CREAM = 0xF75A;
static const uint16_t COL_FLAP = 0x1082;
static const uint16_t COL_SPLIT = 0x0000;
static const uint16_t COL_DELAY = 0xC224;
static const uint16_t COL_ONTIME = 0x8E71;
static const uint16_t COL_DIM = 0x9C8A;

static const int MAX_ROWS = 7;
static const int MAX_COLS = 24;
static const uint32_t FLAP_MS = 32;
static const uint32_t WAVE_MS = 28000;

static int nRows = 5;
static int nCols = 13;
static int cellW = 13;
static int cellH = 46;
static int rowH = 52;
static int originX = 2;
static int originY = 56;
static int headerH = 52;
static int titleSize = 2;
static int subSize = 2;
static int flapSize = 2;

static const char FLAP[] = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:-/";

enum Page : uint8_t { PAGE_DEPARTURES = 0, PAGE_CALENDAR = 1 };
enum WifiState : uint8_t { WIFI_IDLE, WIFI_CONNECTING, WIFI_UP, WIFI_SKIP };
enum RgbMode : uint8_t { RGB_AMBER, RGB_GREEN, RGB_RED };

struct RowSpec {
  const char *left;
  const char *when;
  const char *stat;
};

static const RowSpec kFlightsA[] = {
    {"LAX", "08:15", "ONTIME"}, {"JFK", "09:40", "DELAY"},
    {"SFO", "10:05", "ONTIME"}, {"MIA", "11:20", "ONTIME"},
    {"SEA", "12:55", "DELAY"},  {"BOS", "14:10", "ONTIME"},
    {"DEN", "15:45", "GATE"},
};
static const RowSpec kFlightsB[] = {
    {"LAX", "08:22", "DELAY"},  {"DFW", "09:05", "ONTIME"},
    {"SFO", "10:05", "ONTIME"}, {"MIA", "11:35", "GATE"},
    {"SEA", "12:55", "ONTIME"}, {"BOS", "14:10", "ONTIME"},
    {"ATL", "16:00", "ONTIME"},
};
static const RowSpec kCalendar[] = {
    {"STANDUP", "09:00", "NOW"}, {"DESIGN", "10:30", "NEXT"},
    {"LUNCH", "12:00", ""},      {"SYNC", "13:15", ""},
    {"REVIEW", "14:00", ""},     {"SHIP", "16:30", ""},
    {"DINNER", "19:00", ""},
};

static Page page = PAGE_DEPARTURES;
static bool g_landscape = false;
static char shown[MAX_ROWS][MAX_COLS + 1];
static char target[MAX_ROWS][MAX_COLS + 1];
static char rowStat[MAX_ROWS][8];
static uint8_t stagger[MAX_ROWS][MAX_COLS];
static char httpDest[MAX_ROWS][12];
static char httpWhen[MAX_ROWS][8];
static char httpStat[MAX_ROWS][8];
static int httpCount = 0;
static bool settled = false;
static uint8_t wave = 0;
static bool usedHttp = false;
static char airport[8] = HOME_AIRPORT;
static WifiState wifiState = WIFI_IDLE;
static uint32_t wifiStartMs = 0;
static uint32_t lastFlapMs = 0;
static uint32_t lastWaveMs = 0;
static uint32_t lastClockMs = 0;
static char lastClock[8] = "";
static bool g_bootDown = false;
static bool g_bootLong = false;
static uint32_t g_bootDownAt = 0;
static uint32_t bootMs = 0;
static RgbMode rgbMode = RGB_AMBER;
static bool rgbLit = false;
static const char *rgbName = "amber";

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 void applyLayout() {
  if (g_landscape) {
    nRows = 3;
    nCols = 16;
    cellW = 19;
    cellH = 32;
    rowH = 37;
    originX = 8;
    originY = 60;
    headerH = 58;
    titleSize = 3;
    subSize = 2;
    flapSize = 2;
  } else {
    nRows = 5;
    nCols = 13;
    cellW = 13;
    cellH = 46;
    rowH = 52;
    originX = 2;
    originY = 56;
    headerH = 52;
    titleSize = 2;
    subSize = 2;
    flapSize = 2;
  }
}

static int flapIndex(char ch) {
  const char *p = strchr(FLAP, ch);
  if (!p) {
    return 0;
  }
  return (int)(p - FLAP);
}

static char nextFlap(char ch) {
  const int n = (int)sizeof(FLAP) - 1;
  return FLAP[(flapIndex(ch) + 1) % n];
}

static uint16_t statusColor(const char *st) {
  if (strstr(st, "DELAY")) {
    return COL_DELAY;
  }
  if (strstr(st, "ONTIME") || strstr(st, "NOW")) {
    return COL_ONTIME;
  }
  if (strstr(st, "GATE") || strstr(st, "NEXT")) {
    return COL_GOLD;
  }
  return COL_CREAM;
}

static void shortStat(const char *st, char *out, int n) {
  if (!st || !st[0]) {
    out[0] = 0;
    return;
  }
  if (strstr(st, "DELAY")) strncpy(out, "DLY", n);
  else if (strstr(st, "ONTIME") || strcmp(st, "ON") == 0) strncpy(out, "ON", n);
  else if (strstr(st, "GATE")) strncpy(out, "GT", n);
  else if (strstr(st, "NEXT")) strncpy(out, "NXT", n);
  else if (strstr(st, "NOW")) strncpy(out, "NOW", n);
  else strncpy(out, st, n);
  out[n - 1] = 0;
}

static int statusCol0() {
  if (g_landscape) {
    return (page == PAGE_DEPARTURES) ? 10 : 13;
  }
  return (page == PAGE_DEPARTURES) ? 10 : 13;
}

static void formatRow(char *line, const RowSpec &row, bool departures) {
  char st[8];
  shortStat(row.stat, st, sizeof(st));
  if (g_landscape) {
    if (departures) {
      snprintf(line, nCols + 1, "%-3.3s %-5.5s %-6.6s", row.left, row.when, row.stat);
    } else {
      snprintf(line, nCols + 1, "%-6.6s %-5.5s %-3.3s", row.left, row.when, st);
    }
  } else if (departures) {
    snprintf(line, nCols + 1, "%-3.3s %-5.5s %-3.3s", row.left, row.when, st);
  } else {
    snprintf(line, nCols + 1, "%-7.7s %-5.5s", row.left, row.when);
  }
  line[nCols] = 0;
  for (int i = 0; i < nCols; i++) {
    if (line[i] >= 'a' && line[i] <= 'z') {
      line[i] = (char)(line[i] - 32);
    }
  }
}

static void setRow(int r, const char *line, const char *stat) {
  memset(target[r], ' ', nCols);
  int n = (int)strlen(line);
  if (n > nCols) {
    n = nCols;
  }
  memcpy(target[r], line, n);
  target[r][nCols] = 0;
  strncpy(rowStat[r], stat, sizeof(rowStat[r]) - 1);
  rowStat[r][sizeof(rowStat[r]) - 1] = 0;
}

static void loadDepartures(const RowSpec *rows) {
  for (int r = 0; r < nRows; r++) {
    char line[MAX_COLS + 1];
    formatRow(line, rows[r], true);
    setRow(r, line, rows[r].stat);
  }
}

static void loadCalendar() {
  for (int r = 0; r < nRows; r++) {
    char line[MAX_COLS + 1];
    formatRow(line, kCalendar[r], false);
    setRow(r, line, kCalendar[r].stat);
  }
}

static void loadHttpRows() {
  for (int r = 0; r < nRows; r++) {
    if (r < httpCount) {
      RowSpec row;
      row.left = httpDest[r];
      row.when = httpWhen[r];
      row.stat = httpStat[r];
      char line[MAX_COLS + 1];
      formatRow(line, row, true);
      setRow(r, line, httpStat[r]);
    } else {
      setRow(r, "", "");
    }
  }
}

static void loadCurrent() {
  if (page == PAGE_DEPARTURES) {
    if (usedHttp && httpCount > 0) {
      loadHttpRows();
    } else {
      loadDepartures(wave ? kFlightsB : kFlightsA);
    }
  } else {
    loadCalendar();
  }
}

static void formatClock(char *buf) {
  time_t now = time(nullptr);
  if (now > 1600000000) {
    now += (time_t)TZ_OFFSET_HOURS * 3600;
    struct tm t;
    gmtime_r(&now, &t);
    snprintf(buf, 8, "%02d:%02d", t.tm_hour, t.tm_min);
    return;
  }
  const uint32_t elapsedMin = (millis() - bootMs) / 60000;
  const int mins = (8 * 60 + 14 + (int)elapsedMin) % (24 * 60);
  snprintf(buf, 8, "%02d:%02d", mins / 60, mins % 60);
}

static void paintSubtitle() {
  const int titleH = titleSize * 8;
  const int subY = titleH + 8;
  gfx->fillRect(0, subY - 2, sw(), headerH - (subY - 2), COL_HEADER);
  gfx->setTextSize(subSize);
  gfx->setTextColor(COL_DIM);
  char clock[8];
  formatClock(clock);
  strncpy(lastClock, clock, sizeof(lastClock) - 1);
  lastClock[sizeof(lastClock) - 1] = 0;
  gfx->setCursor(6, subY);
  if (page == PAGE_DEPARTURES) {
    gfx->print(airport);
    gfx->print(g_landscape ? "  " : " ");
    gfx->print(clock);
    if (g_landscape) {
      gfx->print(usedHttp ? "  LIVE" : "  LOCAL");
    } else {
      gfx->print(usedHttp ? " LIVE" : " LOC");
    }
  } else {
    gfx->print("TODAY ");
    gfx->print(clock);
  }
}

static void paintChrome() {
  gfx->fillScreen(COL_BG);
  gfx->fillRect(0, 0, sw(), headerH, COL_HEADER);
  gfx->setTextSize(titleSize);
  gfx->setTextColor(COL_GOLD);
  gfx->setCursor(6, 4);
  gfx->print(page == PAGE_DEPARTURES ? "DEPARTURES" : "CALENDAR");
  paintSubtitle();
  gfx->drawFastHLine(0, headerH - 1, sw(), COL_GOLD);
}

static void drawCell(int r, int c, bool motion) {
  const int x = originX + c * cellW;
  const int y = originY + r * rowH;
  const uint16_t fg = (c >= statusCol0()) ? statusColor(rowStat[r]) : COL_CREAM;
  gfx->fillRect(x, y, cellW - 1, cellH, COL_FLAP);
  if (motion) {
    gfx->fillRect(x, y + cellH / 2 - 4, cellW - 1, 8, COL_SPLIT);
  }
  char s[2] = {shown[r][c], 0};
  const int gw = 6 * flapSize;
  const int gh = 8 * flapSize;
  gfx->setTextSize(flapSize);
  gfx->setTextColor(fg);
  gfx->setCursor(x + (cellW - 1 - gw) / 2, y + (cellH - gh) / 2);
  gfx->print(s);
  gfx->drawFastHLine(x, y + cellH / 2, cellW - 1, COL_SPLIT);
}

static void paintAllCells() {
  for (int r = 0; r < nRows; r++) {
    for (int c = 0; c < nCols; c++) {
      drawCell(r, c, false);
    }
  }
}

static void startFlip(bool blank) {
  for (int r = 0; r < nRows; r++) {
    for (int c = 0; c < nCols; c++) {
      if (blank) {
        shown[r][c] = ' ';
      }
      stagger[r][c] = (uint8_t)(r * 3 + c);
    }
    shown[r][nCols] = 0;
    target[r][nCols] = 0;
  }
  settled = false;
  lastWaveMs = millis();
  paintAllCells();
}

static bool anyDelay() {
  for (int r = 0; r < nRows; r++) {
    if (strstr(rowStat[r], "DELAY")) {
      return true;
    }
  }
  return false;
}

static void setRgb(RgbMode mode) {
  if (rgbLit && mode == rgbMode) {
    return;
  }
  rgbLit = true;
  rgbMode = mode;
  if (mode == RGB_RED) {
    rgb.setPixelColor(0, rgb.Color(220, 24, 16));
    rgbName = "red";
  } else if (mode == RGB_GREEN) {
    rgb.setPixelColor(0, rgb.Color(16, 200, 48));
    rgbName = "green";
  } else {
    rgb.setPixelColor(0, rgb.Color(255, 140, 0));
    rgbName = "amber";
  }
  rgb.show();
}

static void updateRgb() {
  if (!settled) {
    setRgb(RGB_AMBER);
    return;
  }
  if (page == PAGE_DEPARTURES && anyDelay()) {
    setRgb(RGB_RED);
  } else {
    setRgb(RGB_GREEN);
  }
}

static void tickFlaps() {
  const uint32_t now = millis();
  if (now - lastFlapMs < FLAP_MS) {
    return;
  }
  lastFlapMs = now;
  bool any = false;
  for (int r = 0; r < nRows; r++) {
    for (int c = 0; c < nCols; c++) {
      if (stagger[r][c] > 0) {
        stagger[r][c]--;
        any = true;
        continue;
      }
      if (shown[r][c] != target[r][c]) {
        shown[r][c] = nextFlap(shown[r][c]);
        drawCell(r, c, true);
        any = true;
      }
    }
  }
  settled = !any;
}

static void updateHeaderClock() {
  const uint32_t now = millis();
  if (now - lastClockMs < 1000) {
    return;
  }
  lastClockMs = now;
  char clock[8];
  formatClock(clock);
  if (strcmp(clock, lastClock) != 0) {
    paintSubtitle();
  }
}

static void cyclePage() {
  page = (page == PAGE_DEPARTURES) ? PAGE_CALENDAR : PAGE_DEPARTURES;
  loadCurrent();
  paintChrome();
  startFlip(true);
  Serial.print(F("page="));
  Serial.println(page == PAGE_DEPARTURES ? F("DEPARTURES") : F("CALENDAR"));
}

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

static void pollBoot() {
  const bool down = digitalRead(C6_BOOT_PIN) == LOW;
  const uint32_t 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) {
      cyclePage();
    }
    g_bootDown = false;
  }
}

static bool extractQuoted(const String &body, const char *key, int from, String &out) {
  const String pat = String("\"") + key + "\"";
  const int k = body.indexOf(pat, from);
  if (k < 0) {
    return false;
  }
  const int colon = body.indexOf(':', k + pat.length());
  const int q1 = body.indexOf('"', colon + 1);
  const int q2 = body.indexOf('"', q1 + 1);
  if (colon < 0 || q1 < 0 || q2 < 0) {
    return false;
  }
  out = body.substring(q1 + 1, q2);
  return true;
}

static void normalizeStatus(String &st) {
  st.toUpperCase();
  st.replace(" ", "");
  if (st == "ON" || st == "ONTIME") {
    st = "ONTIME";
  } else if (st.startsWith("DEL")) {
    st = "DELAY";
  } else if (st.startsWith("GAT")) {
    st = "GATE";
  }
}

static void applyFetchedJson(const String &body) {
  String ap;
  if (extractQuoted(body, "airport", 0, ap) && ap.length() > 0) {
    ap.toUpperCase();
    strncpy(airport, ap.c_str(), sizeof(airport) - 1);
    airport[sizeof(airport) - 1] = 0;
  }
  int count = 0;
  int pos = 0;
  while (count < MAX_ROWS) {
    const int d = body.indexOf("\"dest\"", pos);
    if (d < 0) {
      break;
    }
    String dest, tim, st;
    if (!extractQuoted(body, "dest", d, dest)) {
      break;
    }
    extractQuoted(body, "time", d, tim);
    extractQuoted(body, "status", d, st);
    dest.toUpperCase();
    tim.toUpperCase();
    normalizeStatus(st);
    strncpy(httpDest[count], dest.c_str(), sizeof(httpDest[count]) - 1);
    httpDest[count][sizeof(httpDest[count]) - 1] = 0;
    strncpy(httpWhen[count], tim.c_str(), sizeof(httpWhen[count]) - 1);
    httpWhen[count][sizeof(httpWhen[count]) - 1] = 0;
    strncpy(httpStat[count], st.c_str(), sizeof(httpStat[count]) - 1);
    httpStat[count][sizeof(httpStat[count]) - 1] = 0;
    count++;
    pos = d + 6;
  }
  if (count == 0) {
    return;
  }
  httpCount = count;
  usedHttp = true;
  if (page == PAGE_DEPARTURES) {
    loadHttpRows();
  }
  if (page == PAGE_DEPARTURES) {
    paintSubtitle();
    startFlip(false);
  }
  Serial.print(F("http rows="));
  Serial.println(count);
}

static void fetchSchedule() {
  if (SCHEDULE_URL[0] == 0) {
    return;
  }
  HTTPClient http;
  http.setTimeout(4000);
  if (!http.begin(SCHEDULE_URL)) {
    Serial.println(F("http begin failed — canned stays"));
    return;
  }
  const int code = http.GET();
  if (code == 200) {
    applyFetchedJson(http.getString());
  } else {
    Serial.print(F("http "));
    Serial.print(code);
    Serial.println(F(" — canned stays"));
  }
  http.end();
}

static void pollWifi() {
  if (wifiState == WIFI_SKIP || wifiState == WIFI_UP) {
    return;
  }
  if (WIFI_SSID[0] == 0) {
    wifiState = WIFI_SKIP;
    return;
  }
  if (wifiState == WIFI_IDLE) {
    WiFi.mode(WIFI_STA);
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
    wifiState = WIFI_CONNECTING;
    wifiStartMs = millis();
    Serial.println(F("wifi connecting"));
    return;
  }
  if (WiFi.status() == WL_CONNECTED) {
    wifiState = WIFI_UP;
    configTime(0, 0, "pool.ntp.org");
    Serial.println(WiFi.localIP());
    fetchSchedule();
    return;
  }
  if (millis() - wifiStartMs > 8000) {
    wifiState = WIFI_SKIP;
    WiFi.disconnect(true);
    Serial.println(F("wifi timeout — canned stays"));
  }
}

static void maybeRefreshWave() {
  if (page != PAGE_DEPARTURES || usedHttp || !settled) {
    return;
  }
  if (millis() - lastWaveMs < WAVE_MS) {
    return;
  }
  wave = wave ? 0 : 1;
  loadDepartures(wave ? kFlightsB : kFlightsA);
  startFlip(false);
}

static void printHelp() {
  Serial.println(F("split-flap-board  C6-LCD-1.47  (no touch / no IMU)"));
  Serial.println(F("  help    this text"));
  Serial.println(F("  status  page / wifi / rgb / rows"));
  Serial.println(F("  page    cycle DEPARTURES | CALENDAR"));
  Serial.println(F("  flip    blank and re-roll flaps"));
  Serial.println(F("  rotate  portrait <-> landscape"));
  Serial.println(F("BOOT short = page. Hold BOOT = rotate."));
}

static void printStatus() {
  Serial.printf("orient: %s  %dx%d  %dx%d\n",
                g_landscape ? "landscape" : "portrait", sw(), sh(), nRows, nCols);
  Serial.print(F("page="));
  Serial.print(page == PAGE_DEPARTURES ? F("DEPARTURES") : F("CALENDAR"));
  Serial.print(F(" settled="));
  Serial.print(settled ? 1 : 0);
  Serial.print(F(" wifi="));
  if (wifiState == WIFI_UP) {
    Serial.print(F("up"));
  } else if (wifiState == WIFI_CONNECTING) {
    Serial.print(F("connecting"));
  } else {
    Serial.print(F("off"));
  }
  Serial.print(F(" source="));
  Serial.print(usedHttp ? F("http") : F("canned"));
  Serial.print(F(" rgb="));
  Serial.println(rgbName);
  for (int r = 0; r < nRows; r++) {
    Serial.print(shown[r]);
    Serial.print(F("  ->  "));
    Serial.println(target[r]);
  }
}

static void pollSerial() {
  if (!Serial.available()) {
    return;
  }
  String line = Serial.readStringUntil('\n');
  line.trim();
  line.toLowerCase();
  if (line.length() == 0) {
    return;
  }
  if (line == "help" || line == "?") {
    printHelp();
  } else if (line == "status") {
    printStatus();
  } else if (line == "page") {
    cyclePage();
  } else if (line == "flip") {
    startFlip(true);
  } else if (line == "rotate" || line == "orient") {
    toggleOrient();
  } else {
    Serial.println(F("unknown — help"));
  }
}

void setup() {
  bootMs = millis();
  pinMode(C6_BOOT_PIN, INPUT_PULLUP);
  pinMode(C6_LCD_BL_PIN, OUTPUT);
  analogWrite(C6_LCD_BL_PIN, C6_BL_PWM);

  Serial.begin(115200);
  delay(200);

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

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

  rgb.begin();
  rgb.setBrightness(48);
  rgb.clear();
  rgb.show();
  setRgb(RGB_AMBER);

  for (int r = 0; r < MAX_ROWS; r++) {
    memset(shown[r], ' ', MAX_COLS);
    shown[r][MAX_COLS] = 0;
    target[r][MAX_COLS] = 0;
    rowStat[r][0] = 0;
  }

  loadCurrent();
  paintChrome();
  startFlip(true);

  Serial.println(F("split-flap-board ready  type help"));
}

void loop() {
  pollSerial();
  pollBoot();
  pollWifi();
  tickFlaps();
  updateHeaderClock();
  updateRgb();
  maybeRefreshWave();
}

Board and housing

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