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. No battery charger. Power comes from USB-C. 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, and it reads LOW when you press it (active low). 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"},
{"DEN", "15:45", "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. DELAY paints red. GATE paints amber (gold). That is an if on a string — a lookup. The one RGB LED follows the room: amber while letters are still flipping, red if any row says DELAY, green once Departures has settled with no delays. 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 portrait rows become 3 bigger landscape rows. 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.