A 64×64 RGB LED matrix that shows whatever aircraft is currently flying overhead — flight number, altitude, airline logo, aircraft type and registration, and route with a flight-progress marker — updated automatically every 30 seconds. When there's nothing overhead, it falls back to a clock.
An n8n workflow polls OpenSky Network and FlightRadar24's live feed for aircraft near a configured home location, enriches the match with route/aircraft-type data from adsbdb (cross-checked against FR24's live data, since adsbdb's callsign→route table can be stale), and POSTs a JSON payload to a small HTTP server running on an ESP32-S3.
![]() |
![]() |
![]() |
![]() |
|---|
Frames pulled straight off the panel's own /debug/screenshot.bmp endpoint — no camera needed.
n8n (every 30s)
-> OpenSky Network + FlightRadar24 (nearest aircraft near HOME_LAT/HOME_LON,
merged/de-duplicated by icao24)
-> adsbdb (route + aircraft type lookup by callsign / icao24)
-> airline_icons (n8n data table: the airline's 24x24 logo, sent inline)
-> FlightRadar24 (cross-checks adsbdb's route; falls back to FR24's live
route/aircraft type when adsbdb has nothing or looks stale)
-> HTTP POST /api/display -> ESP32-S3 -> 64x64 HUB75 panel
If nothing is found nearby, n8n instead calls POST /api/clear and the panel falls back to an NTP-synced clock.
| Product | Qty | Unit Price (INR) | Total (INR) | Total (SGD, approx.) |
|---|---|---|---|---|
| Waveshare RGB Full-Color LED Matrix Panel, 2mm pitch, 64×64 pixels, adjustable brightness | 1 | ₹2,809 | ₹2,809 | ~S$37.70 |
| Waveshare ESP32-S3 RGB Matrix Driver Board, dual mic array, Wi-Fi + BLE 5, AI voice interaction support | 1 | ₹2,669 | ₹2,669 | ~S$35.85 |
| Total | ₹5,478 | ~S$73.55 |
SGD figures are a rough conversion at ~74.5 INR/SGD (August 2026) for reference only — check a live rate before budgeting.
You'll also need:
- A 5V power supply sized for your panel's brightness setting (the sketches default to
setBrightness8(60)to keep current draw modest — raise with care) - A machine running n8n (self-hosted or cloud) to run the workflow
- A free OpenSky Network API client ID/secret (OAuth2 client credentials)
case/ 3D-printable enclosure (STEP): front bezel, back shell, and the assembly
matrix64/
03_aircraft_display/ Aircraft Overhead Display -- production firmware
web/ Admin console SPA (React + MUI) -- deployed to the board's
FATFS partition, not baked into the firmware, see
"Admin web console" below
icons/ Legacy 24x24 raw RGB565 icon set from firmware <=1.2.x's SD card -- unused since 1.3.0
n8n/
matrix64_aircraft_workflow.json n8n workflow: OpenSky+FR24 -> adsbdb (FR24-verified) -> matrix
airline_icons.csv 345 airline logos + the generic fallback, for the airline_icons data table
tools/
import_icons_n8n.py Creates the airline_icons table in your n8n and loads a CSV into it
convert_tiles.py Converts source logos into airline_icons CSV rows (--csv), or legacy .bin files
deploy_web.py Builds web/ and pushes it to a board's FATFS partition
The Waveshare ESP32-S3 RGB Matrix Driver Board is built to plug directly onto the back of the panel — there's no point-to-point jumper wiring to do:
- Panel <-> driver board: connect the driver board to the panel's HUB75 input using the IDC ribbon cable that ships with the driver board. The connector is keyed (notched), so it only seats one way — don't force it.
- Power: feed 5V into the panel's power input (screw terminal or barrel jack, depending on your panel), sized for your brightness setting. The firmware defaults to
setBrightness8(60)(roughly 25% duty) specifically to keep current draw modest until you've confirmed your supply can handle full brightness — a 64×64 panel at full white, full brightness can pull several amps. - USB-C: connect the driver board to your computer for flashing and for Serial Monitor output (WiFi connection status, the assigned IP, etc.).
No SD card is needed (since firmware 1.3.0 -- icons arrive inline with every push). The driver board's GPIO wiring to the panel is fixed by Waveshare and already baked into the sketches — you don't need to configure it, but it's documented here since it explains a couple of the config lines in the code:
| Signal | GPIO | Where it's set |
|---|---|---|
| HUB75 row-address line E (needed for 64-row panels) | 9 | mxconfig.gpio.e in setupMatrix() |
case/ has a two-piece printable enclosure as STEP files, commissioned by Aashish Vivekanand for this project. Most slicers (Bambu Studio, OrcaSlicer, PrusaSlicer) import STEP directly.
| File | Part | Size (mm) |
|---|---|---|
mini_flightwall_case_front.step |
Front bezel: panel window, 6 counterbored panel screws | 143.9 × 143.9 × 20 |
mini_flightwall_case_back.step |
Back shell: room for the driver board and wiring, keyhole wall-mount slot | 143.9 × 143.9 × 38.4 |
mini_flightwall_case_assembly.step |
Both parts in position, for reference only (don't print this one) | 143.9 × 143.9 × 58.4 |
Four corner screws join the two halves. A 35 mm slot on the side, cut across the seam, lets the USB-C cable out sideways so the case can sit flush against a wall.
- Install the Arduino IDE (2.x).
- Add the ESP32 board package: File > Preferences > Additional Boards Manager URLs, add
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json, then install esp32 via Tools > Board > Boards Manager. - Select the board: Tools > Board > esp32 > ESP32S3 Dev Module.
- Under Tools, set:
- USB CDC On Boot: Enabled (so Serial Monitor works over USB-C without a separate UART adapter)
- PSRAM: OPI PSRAM (large allocations such as the TLS buffers and JSON documents land there — required for Aircraft Overhead Display; flashing without it leaves the board heap-starved). This board (Waveshare's ESP32-S3-N32R16 driver board) has 16MB of Octal PSRAM, which is what "OPI" refers to.
- Flash Size: 32MB (256Mb) — this board's chip is the N32R16 variant (32MB flash, 16MB PSRAM). The IDE's default is 4MB regardless of board choice; leaving it on the default doesn't break anything (the firmware still fits fine either way), it just leaves ~28MB of the chip's actual flash completely unpartitioned and unusable.
- Partition Scheme: 32M Flash (4.8MB APP/22MB FATFS) — pick this only after Flash Size above is set to 32MB (a 32MB-sized partition table on a board set to 4MB will fail to flash). Gives 4.8MB of app space instead of ~1.2MB, comfortable headroom for the admin web console and anything added to it later. The 22MB FATFS partition holds the admin web console (see "Admin web console"); config lives in NVS/Preferences.
- Port: whichever
/dev/cu.usbmodem*(macOS) orCOM*(Windows) appears when the board is plugged in
- Install libraries via Tools > Manage Libraries:
ESP32 HUB75 LED MATRIX PANEL DMA Displayby mrfaptasticArduinoJsonby Benoit BlanchonAdafruit GFX Library(Aircraft Overhead Display only)
- Open the
.inofile for the sketch you're building (Arduino IDE will open the whole sketch folder, includingsecrets.h), hit Upload, then open Tools > Serial Monitor at 115200 baud to watch it connect to WiFi and print its IP.
Double-check board-specific settings (exact partition scheme, PSRAM mode) against Waveshare's own wiki page for this exact driver board — defaults above worked for this build, but Waveshare occasionally revises recommended settings between firmware/SDK versions.
- Panel bring-up test (not included here): run Waveshare's own stock examples (
01_SimpleTestShapes/07_Pixel_Mapping_Test) withPANEL_RES_Ychanged to 64, to confirm the panel lights up correctly before writing any custom code. An earlier bring-up sketch of our own (matrix64/02_http_display-- WiFi + a bare HTTP endpoint, validating networking and the double-buffer/flip drawing pattern) has since been removed now that the production firmware is working end-to-end; those config decisions (panel driver, GPIO wiring, buffer-flip timing) are documented inline in03_aircraft_display.inoinstead. - Aircraft Overhead Display —
matrix64/03_aircraft_display: the production firmware — renders the aircraft layout from each n8n push (airline logo included inline), and falls back to an NTP clock when idle. Also serves/debug/screenshot.bmpso you can see exactly what's on the panel from a browser, no camera needed.
For each sketch:
cp secrets.h.example secrets.h
# edit secrets.h with your real WiFi SSID/passwordsecrets.h is gitignored — never commit it.
The board stores no icons. The n8n workflow's Build Payload (Matrix64) node resolves which logo to show from the airline_icons data table (see "n8n workflow setup" for loading it): the airline's ICAO designator first, then its IATA code, then the generic 00 row (a blank tail) — so a missing logo reads as "no art yet" instead of a stale or wrong one. Every push carries the pixels inline as iconData: the row's icon24 column, 24×24 raw little-endian RGB565 (1,152 bytes, no header — the simplest thing the ESP32 can draw straight from a uint16_t buffer), base64-encoded to 1,536 chars. The firmware decodes it once per push; anything that isn't exactly 1,152 bytes after decoding blanks the icon slot and logs why.
Adding or changing a logo means adding or updating a table row. Neither the workflow nor the firmware changes, and the new logo shows on the very next push:
python3 -m venv venv && venv/bin/pip install pillow numpy
# name each source logo after the airline code: sq.png (IATA) or asy.webp (ICAO-only operator)
venv/bin/python tools/convert_tiles.py --input logos/ --csv new_icons.csv
# fill in the icao/iata/name columns the filename didn't cover, then:
python3 tools/import_icons_n8n.py --csv new_icons.csvconvert_tiles.py uses emblem-focus detection: it crops tightly around the logo's actual mark (bird, crane, flag, flower) so it fills the 24×24 tile instead of sitting small in a letterboxed square. Rows are upserted by code, so re-importing replaces an existing logo. Each icao must belong to only one row; a wrong ICAO code puts the wrong logo on screen, so leave it blank if unsure and the row matches on IATA alone.
Airline logos are trademarks of their respective owners. They're included for personal, non-commercial display only.
Firmware up to 1.2.x instead preloaded icons/*.bin from an SD card and synced them from a remote manifest (tools/sync_icons_r2.sh); that path is gone as of 1.3.0. icons/ is kept for reference only.
Five sections of 6×8 px text (size 1), separated by 2 px gaps. The logo sits at the left edge of the middle section, and the text beside it is clipped to its own column so a scrolling name never runs over the logo:
y 0-7 Singapore Airlines airline name
y 10-17 SQ962 1950ft▲ 174kt flight, altitude, climb/descend, speed
y 20-43 ┌──────┐ Boeing make (y 20)
│ logo │ B38M ICAO type (y 28)
└──────┘ 9V-MBL registration (y 36)
y 46-53 SIN ━━━►┈┈┈┈┈┈┈ CGK route + progress marker
y 56-63 Singapore Changi -> Soekarno-Hatta city names (always scrolls)
- Logo: 24×24 px at x 0–23, y 20–43, drawn from the push's
iconData(see "Icons" above). If a push has no valid icon, that space stays blank and the text column doesn't move. - Beside the logo: make, ICAO type and registration on three 8px rows starting at x 26, which exactly fill the logo's 24px height (a 6-character registration like
9V-SMAexactly fills the column; anything longer scrolls within it). Before 1.3.1 there were only make and type, spaced out at y 24 and y 34. - Other rows stay still when the text fits, and scroll when it doesn't. The flight/altitude row is usually long enough to scroll.
- Climb/descend: a green ▲ (climbing) or amber ▼ (descending) after the altitude, omitted within ±200 ft/min of level. Both are CP437 glyphs (
0x1E/0x1F) from Adafruit GFX's built-in font — no custom bitmaps. - Route row: origin code flush left, destination flush right, and a track between them with a green ► (
0x10) at the payload'sprogress— solid over the part already flown, dotted over what's left. Withoutprogress(an FR24-only route, which has no airport coordinates) the marker sits centered on an all-dotted track.
n8n/matrix64_aircraft_workflow.json here is a standalone, self-contained
template — just the matrix panel's branch, no Ulanzi AWTRIX/TC002 clock
nodes. It's the same logic this build's actual feeder runs for the matrix
(the real feeder's workflow additionally drives two Ulanzi clocks off the
same position/route data — see the sibling Ulanzi Feeder repo's
n8n_aircraft_workflow.json if you want that combined setup instead).
Nodes, in order:
Every 30s
-> Config (edit per location)
-> Get/Refresh OpenSky Token -> Fetch Nearby States (OpenSky) -\
Fetch Nearby States (FR24) >-- Merge Position Data -> Nearest Aircraft
-> Aircraft Found?
true -> Lookup Route (adsbdb) -\
Verify Route (FR24) >-- Merge Route Data -> Compare Routes -> Lookup Icon -\
Lookup Aircraft (adsbdb) ---------------------------------------------------- >-- Merge Matrix Data -> Build Payload (Matrix64) -> Push to Matrix
false -> Clear Matrix
- Position: OpenSky and FlightRadar24's live feed are merged and
de-duplicated by
icao24— the two networks' ADS-B ground receivers overlap but aren't identical, so combining them catches aircraft one network's receivers missed but the other's caught. - Route: adsbdb resolves a callsign to an airline/origin/destination,
but that table is static and can go stale (flight numbers get reused
across seasons/codeshares).
Compare Routescross-checks it against FlightRadar24's live feed for the same callsign and trusts the FR24 match when one exists; adsbdb's route is only used when FR24 has nothing and the aircraft's current position is geographically plausible for adsbdb's claimed origin/destination. - Aircraft type: adsbdb's
/v0/aircraft/{icao24}lookup is the richer source (full manufacturer name + ICAO type), but doesn't have every hex (private/GA aircraft, newly registered, or a hex reassigned since adsbdb's table was last refreshed). When it has nothing,Build Payload (Matrix64)falls back to the bare type code FlightRadar24's live feed reports for thaticao24(no manufacturer name, just e.g.B738). - Logo:
Lookup Iconreads theairline_iconsdata table, matching the airline's ICAO code, its IATA code, or the generic00row, andBuild Payload (Matrix64)picks the best match (see "Icons" above).
Setup (needs an n8n version with Data tables, and the n8n API enabled for step 1):
- Load the logos. Create an API key under Settings > n8n API, then:
This creates the
export N8N_URL=http://<your-n8n-host>:5678 export N8N_API_KEY=<your key> python3 tools/import_icons_n8n.py
airline_iconstable and loads 345 airline logos plus the generic fallback tail fromn8n/airline_icons.csv. It's safe to re-run. - Import
n8n/matrix64_aircraft_workflow.json(Workflows > Import from File). - Open the
Lookup Iconnode and select theairline_iconstable. Table IDs are different on every n8n instance, so the template ships with none selected. Without this step, no logo appears on the panel. - Fill in
Config (edit per location):HOME_LAT/HOME_LON— your location's coordinates (placeholders are0.0— replace before running)RADIUS_DEG— search radius in degrees (default0.15≈ ~16km)MATRIX_IP— your ESP32's LAN IP (placeholder192.168.1.50)OPENSKY_CLIENT_ID/OPENSKY_CLIENT_SECRET— from your OpenSky API client credentials
Then activate it. It polls every 30 seconds, finds the nearest airborne aircraft, looks up and verifies its route and aircraft type, and pushes a display payload to the matrix. FlightRadar24's feed here is its unofficial public data-cloud.flightradar24.com/zones/fcgi/feed.js endpoint. It isn't an official or documented API: FR24 can change, rate-limit or block it at any time, and using it may go against their terms of service. Keep it to personal use and proceed with caution, or use FR24's official API for anything dependable or commercial. If it's ever unreachable, the workflow just falls back to OpenSky-only positioning and adsbdb-only routing (via the plausibility check above), same as before FR24 was added.
{
"flight": "SQ123",
"altitudeFt": 35000,
"speedKt": 460, "verticalRateFpm": 1200,
"airline": "Singapore Airlines",
"originCode": "SIN", "destCode": "KUL", "progress": 0.42,
"originCity": "Singapore Changi Airport",
"destCity": "Kuala Lumpur International Airport",
"icon": "sq_logo", "iconData": "<1536 base64 chars>",
"make": "Airbus", "modelShort": "A388", "tail": "9V-SKA"
}tail is the registration (FR24 first, then adsbdb), shown under the type
beside the logo; omitted when neither source has it (typically private/GA).
progress (0–1, share of the trip flown) is distance-from-origin over
origin + remaining distance, and is only sent when adsbdb supplied both
airports' coordinates. iconData is described under "Icons" above; icon
(the logo code) is ignored by firmware 1.3.0+ and kept for older builds.
speedKt / verticalRateFpm are null when the source feed didn't report
them for that aircraft (firmware omits the line rather than showing 0).
originCity/destCity/airline are only present when adsbdb's route data
was trusted (see "n8n workflow setup" above) — a FR24-sourced route still
sets originCode/destCode/iconData, just without the city names or airline
name adsbdb would have added.
| Endpoint | Method | Purpose |
|---|---|---|
/api/display |
POST | Push a new display payload (JSON) -- this is what the n8n workflow calls every 30s |
/api/clear |
POST | Clear the display, fall back to the NTP clock |
/debug/screenshot.bmp |
GET | Returns the current frame as a BMP -- no camera needed to see what's on the panel |
The endpoints below back the admin web console at http://<board-ip>/ (see
"Admin web console" below) -- none of them need to be called directly for
normal operation, they exist for the page's own JS to call.
| Endpoint | Method | Purpose |
|---|---|---|
/ |
GET | Admin dashboard -- live preview, status, WiFi scan, config, log, reboot, firmware update |
/api/status |
GET | JSON status snapshot (uptime, heap/PSRAM, WiFi, whether an icon is showing, current config, what's showing) |
/api/config |
POST | Update hostname / brightness / timezone override / night mode / NTP server (form-encoded, persisted to NVS). Requires HTTP Basic Auth |
/api/reboot |
POST | Reboot the board |
/api/log |
GET | Tail of the in-memory log ring buffer (plain text) |
/api/wifi/scan |
GET | JSON list of nearby SSIDs (blocks ~1-2s; the panel's scroll will visibly pause) |
/api/ota |
POST | Flash a new firmware .bin (multipart/form-data) and reboot into it. Requires HTTP Basic Auth -- see "OTA firmware updates" below |
/api/web/upload |
POST | Write one file (multipart/form-data, field file, uploaded with its filename set to the relative target path, e.g. assets/index-XYZ.js.gz) to /web/<filename> on the FATFS partition. Requires HTTP Basic Auth. Used by tools/deploy_web.py, not meant to be called by hand |
/api/web/clear |
POST | Recursively delete everything under /web/ on FATFS, ahead of a fresh deploy. Requires HTTP Basic Auth |
http://<board-ip>/ -- a React + MUI single-page app (source under
matrix64/03_aircraft_display/web/), not a status page: the panel preview
and status numbers auto-refresh via JS polling /api/status and
/debug/screenshot.bmp (no need to reload the page). From it you can scan
for WiFi networks, watch the
live log, edit hostname/brightness/timezone-override, reboot, and flash new
firmware -- all documented in the API table above.
Unlike the previous plain-HTML version, the built app isn't baked into the
firmware image at all -- it's deployed separately as static files on the
board's internal FATFS partition (/web/, the same 22MB FATFS partition
described in "Arduino IDE setup"), served by handleWebStatic() in the
firmware. That partition has no DMA-unsafe window, so
it's safe to read/write at any time, including while the display is
running. This means UI changes can be redeployed without a full firmware
reflash, and there's no realistic firmware-size pressure from the UI's
bundle size (React + MUI, gzipped, is well under 1% of the partition).
To build and deploy it:
python3 tools/deploy_web.py <board-ip>deploy_web.py runs npm install/npm run build in web/ (skip with
--skip-build if you've already built it), gzips every output file (the
firmware only ever looks up <path>.gz on FATFS), clears /web/ on the board first
(Vite hashes output filenames per build, so stale files from a previous
deploy would otherwise accumulate forever), then uploads everything via
/api/web/upload. It prompts for the admin credentials the same way the
OTA/config endpoints do -- see below.
Like every other endpoint on this board, most of the console has no
authentication -- it trusts the LAN the same way /api/display always
has. That's an explicit, accepted tradeoff for a board that's meant to
never be exposed to the WAN, not an oversight. Changing config, flashing
firmware, and deploying the web console itself are the exceptions, gated
behind HTTP Basic Auth; see "OTA firmware updates" below for why.
The Firmware update section of / accepts a compiled .bin (Arduino IDE:
Sketch > Export Compiled Binary, or `arduino-cli compile --output-dir
Unlike every other endpoint on this board, /api/ota, /api/web/upload,
/api/web/clear (all three: arbitrary writes to internal flash), and
/api/config (which can change the hostname or force a timezone) require
HTTP Basic Auth -- your browser will prompt once and cache the credentials
for the origin. Set ADMIN_USER/ADMIN_PASSWORD in secrets.h (see
secrets.h.example); left unset, both fall back to admin/changeme,
which is fine for a private LAN but the firmware logs a warning at boot if
you're still on it. Every other endpoint stays unauthenticated, same
LAN-only trust model as always -- these specifically were judged too risky
to leave open (a bad /api/display push shows a wrong flight number; a bad
/api/ota or /api/web/upload push replaces the firmware or the console).
Automatic rollback: if a flashed firmware image fails to reach the end
of setup() three boots in a row -- crashing or hanging every time, before
WiFi/matrix/NTP/the web server are all confirmed up -- the board reverts to
whichever partition last booted successfully and reboots into that instead,
recovering from a bad flash without needing physical USB access. This is a
software-level reimplementation of the idea (a boot-attempt counter and a
"last known good partition" label, both in NVS), not ESP-IDF's own
bootloader app-rollback feature -- the precompiled Arduino bootloader
doesn't have that enabled, and toggling it isn't exposed through the IDE.
A few things exist specifically to keep this board running unattended for months, not weeks:
- Watchdog: if
loop()ever stops running for 30s straight (a hung blocking call, a wedged request), the board reboots itself automatically instead of sitting frozen until someone notices. - Stale-data fallback: if n8n stops pushing entirely -- the workflow
deactivated, the n8n host down, a sustained network problem -- the panel
falls back to the clock after 5 minutes of silence instead of showing an
aircraft that flew off long ago with no indication anything's wrong. A
routine
/api/clear(no aircraft currently in range) counts as contact and doesn't trigger this -- only nothing at all arriving does. Visible in the admin page as "Last display push." - Boot diagnostics: the reset reason (power-on, brownout, panic, task
watchdog, ...) is logged at the top of every boot, visible in
/api/log-- useful for diagnosing an unexpected reboot after the fact without physical USB access. - Night mode: optional brightness schedule (Config section of
/) -- dims to a lower brightness during a configured local-time window (default 23:00-06:00) instead of running at full brightness in a dark room all night. Applies live; the window can wrap past midnight.
GPL-3.0 — see LICENSE.



