Building a Small n8n-Powered ESP32 Dashboard

Small embedded displays are often built around a fixed purpose.

A temperature display shows temperature. A monitoring display shows monitoring data. A cryptocurrency display shows cryptocurrency prices.

That works, but it means the firmware decides what the device is for.

For this project I wanted to reverse that relationship.

The ESP32 should know how to draw a dashboard, but it should not know what the dashboard means.

n8n decides that.

The result is a small 320×240 ESP32 display that periodically requests JSON from an n8n webhook and renders whatever cards n8n tells it to render.

Conceptually:

Data Sources
    ↓
n8n
    ↓
Build Dashboard JSON
    ↓
Webhook Response
    ↓
ESP32
    ↓
Render Cards

The same device can therefore be a cryptocurrency display in one workflow execution, an infrastructure status display in another, or a mixture of fuel prices, alerts, monitoring information and anything else that can be reduced to a few lines of JSON.

Keep the ESP32 deliberately dumb

The important design decision was to avoid putting application logic on the ESP32.

The device does not need to know how to query a cryptocurrency API.

It does not need Discord credentials.

It does not need to understand monitoring systems, fuel-price APIs, backup platforms or whatever data source is added next.

Its job is much smaller:

Connect to Wi-Fi
    ↓
GET n8n Webhook
    ↓
Parse JSON
    ↓
Draw Dashboard
    ↓
Wait
    ↓
Repeat

n8n performs the integrations and decides what should be displayed.

This creates a useful boundary.

n8n
- authentication
- API calls
- transformations
- calculations
- business logic
- dashboard composition

ESP32
- networking
- JSON parsing
- layout
- drawing

Changing the purpose of the display then usually means changing an n8n workflow rather than recompiling firmware.

Hardware

The display used for this version is one of the common ESP32-2432S028 boards, usually referred to as the Cheap Yellow Display or CYD.

The relevant hardware for this project is:

ESP32
2.8" 320×240 TFT
ILI9341-compatible display
Wi-Fi

The display is connected using:

#define TFT_MISO 12
#define TFT_MOSI 13
#define TFT_SCLK 14
#define TFT_CS   15
#define TFT_DC    2
#define TFT_RST  -1
#define TFT_BL   21

The project uses:

WiFi
HTTPClient
WiFiClientSecure
ArduinoJson
Arduino_GFX

One hardware-specific detail was important on the board used during development.

The panel required display inversion to be enabled in the ILI9341 constructor:

Arduino_GFX *gfx = new Arduino_ILI9341(
  bus,
  TFT_RST,
  1,
  true
);

Without that setting, colours were inverted: #000000 appeared white and #FFFFFF appeared black.

Other CYD revisions may differ, so this is worth checking if colours look obviously wrong.

Use a grid rather than pixel coordinates

Allowing n8n to specify raw pixel coordinates would work, but it would make dashboard definitions unnecessarily awkward.

Instead, the screen is divided into a logical grid:

12 columns × 6 rows

A card is positioned using four values:

{
  "x": 0,
  "y": 0,
  "w": 4,
  "h": 2
}

Where:

x = starting column
y = starting row
w = width in columns
h = height in rows

Three equal cards across the top of the display can therefore be described as:

[
  {"x": 0, "y": 0, "w": 4, "h": 2},
  {"x": 4, "y": 0, "w": 4, "h": 2},
  {"x": 8, "y": 0, "w": 4, "h": 2}
]

A full-width card beneath them becomes:

{
  "x": 0,
  "y": 2,
  "w": 12,
  "h": 4
}

Conceptually:

┌──────────┬──────────┬──────────┐
│   BTC    │   ETH    │   n8n    │
│          │          │          │
├──────────┴──────────┴──────────┤
│                               │
│        Discord Alerts         │
│                               │
└───────────────────────────────┘

The grid gives n8n considerable control over layout without requiring it to understand the physical dimensions of the display.

Cards are the interface

Each object in the cards array represents something the firmware knows how to draw.

The current renderer supports:

metric
status
list
text
progress

The meaning of those cards remains entirely up to n8n.

A metric might be:

BTC price
fuel price
temperature
latency
number of alerts
CPU utilisation

A status card might represent:

n8n
APISIX
a backup job
a network peer
a server
an API

This means the firmware exposes display primitives rather than application-specific features.

Dashboard JSON

A basic response from n8n looks like:

{
  "orientation": 1,
  "background": "#000000",
  "gutter": 4,
  "refresh": 30,
  "cards": [
    {
      "type": "status",
      "x": 0,
      "y": 0,
      "w": 12,
      "h": 6,
      "title": "n8n",
      "value": "UP",
      "state": "ok"
    }
  ]
}

The top-level attributes control the display as a whole.

  • orientation — Integer. Display rotation from 0 to 3
  • background — Colour. Screen background in #RRGGBB format
  • gutter — Integer. Space between cards in pixels
  • refresh — Integer. Seconds between requests
  • cards — Array. Cards to render

The firmware constrains the refresh interval to between 5 and 3600 seconds.

Common card attributes

All cards use the same positioning and basic appearance fields.

  • type — String. Card renderer to use
  • x — Integer. Starting grid column
  • y — Integer. Starting grid row
  • w — Integer. Width in grid columns
  • h — Integer. Height in grid rows
  • title — String. Card heading
  • background — Colour. Card background
  • border — Colour. Card border
  • text_color — Colour. Primary text colour
  • accent — Colour. Accent colour
  • title_size — Integer. Title font scale

Colours are supplied using normal hexadecimal RGB notation:

{
  "background": "#080808",
  "border": "#303030",
  "text_color": "#FFFFFF",
  "accent": "#00E676"
}

If a card does not specify colours, the firmware uses its built-in defaults.

Metric cards

A metric card gives a value visual priority.

{
  "type": "metric",
  "x": 0,
  "y": 0,
  "w": 4,
  "h": 2,
  "title": "BTC",
  "value": "$67.2K",
  "subtitle": "+2.4%",
  "background": "#080808",
  "border": "#303030",
  "text_color": "#FFFFFF",
  "accent": "#00E676",
  "title_size": 1,
  "value_size": 2,
  "text_size": 1
}

Metric-specific fields are:

  • value — Main value
  • subtitle — Smaller secondary value
  • value_size — Main value font scale
  • text_size — Subtitle font scale

Status cards

Status cards represent the state of something.

{
  "type": "status",
  "x": 8,
  "y": 0,
  "w": 4,
  "h": 2,
  "title": "n8n",
  "value": "UP",
  "state": "ok",
  "value_size": 2
}

The current states are:

  • ok — Green
  • warning — Yellow
  • critical — Red
  • info — Cyan
  • anything else — Grey

This lets n8n decide the state while the ESP32 decides how that state should be represented visually.

List cards

List cards are useful for alerts, events and short message feeds.

{
  "type": "list",
  "x": 0,
  "y": 2,
  "w": 12,
  "h": 4,
  "title": "Discord Alerts",
  "background": "#080808",
  "border": "#303030",
  "text_color": "#FFFFFF",
  "accent": "#5865F2",
  "text_size": 1,
  "lines": [
    "3 new alerts",
    "Network: BGP peer down",
    "Backup job warning"
  ]
}

The lines field is an array of strings.

The renderer displays as many lines as will fit inside the card. Long lines are clipped.

Text cards

A text card is intended for arbitrary messages.

{
  "type": "text",
  "x": 0,
  "y": 0,
  "w": 12,
  "h": 3,
  "title": "Message",
  "text": "Maintenance will begin at 22:00 tonight.",
  "accent": "#29B6F6",
  "text_size": 2
}

The relevant fields are:

text
text_size

The current renderer performs simple character-based wrapping rather than sophisticated word-aware layout.

Progress cards

Progress cards display a numeric percentage as a bar.

{
  "type": "progress",
  "x": 0,
  "y": 2,
  "w": 6,
  "h": 2,
  "title": "Storage",
  "value": 91,
  "suffix": "%",
  "accent": "#FFC107"
}

The value is constrained to the range 0 to 100.

The suffix is appended to the displayed value, allowing values such as:

91%
37%

The accent colour is also used for the progress indicator.

A mixed dashboard

Because the cards share the same grid, unrelated information can coexist on the same display.

For example:

{
  "orientation": 1,
  "background": "#000000",
  "gutter": 4,
  "refresh": 15,
  "cards": [
    {
      "type": "metric",
      "x": 0,
      "y": 0,
      "w": 4,
      "h": 2,
      "title": "BTC",
      "value": "$67.2K",
      "subtitle": "+2.4%",
      "background": "#080808",
      "border": "#303030",
      "text_color": "#FFFFFF",
      "accent": "#00E676",
      "value_size": 2
    },
    {
      "type": "metric",
      "x": 4,
      "y": 0,
      "w": 4,
      "h": 2,
      "title": "E10",
      "value": "179.9c",
      "subtitle": "Wadalba",
      "background": "#080808",
      "border": "#303030",
      "text_color": "#FFFFFF",
      "accent": "#29B6F6",
      "value_size": 2
    },
    {
      "type": "status",
      "x": 8,
      "y": 0,
      "w": 4,
      "h": 2,
      "title": "n8n",
      "value": "UP",
      "state": "ok",
      "background": "#080808",
      "border": "#303030",
      "text_color": "#FFFFFF",
      "value_size": 2
    },
    {
      "type": "progress",
      "x": 0,
      "y": 2,
      "w": 5,
      "h": 2,
      "title": "Storage",
      "value": 91,
      "suffix": "%",
      "background": "#080808",
      "border": "#303030",
      "text_color": "#FFFFFF",
      "accent": "#FFC107"
    },
    {
      "type": "status",
      "x": 0,
      "y": 4,
      "w": 5,
      "h": 2,
      "title": "API",
      "value": "UP",
      "state": "ok",
      "background": "#080808",
      "border": "#303030",
      "text_color": "#FFFFFF"
    },
    {
      "type": "list",
      "x": 5,
      "y": 2,
      "w": 7,
      "h": 4,
      "title": "Discord",
      "background": "#080808",
      "border": "#303030",
      "text_color": "#FFFFFF",
      "accent": "#5865F2",
      "lines": [
        "Network alert received",
        "BGP peer restored",
        "Backup warning: VM-042"
      ]
    }
  ]
}

The interesting part is not any individual card.

It is that none of these data sources require display-specific firmware.

Building the response in n8n

n8n can return a static dashboard definition with expressions inserted into individual fields.

For example:

{
  "type": "metric",
  "title": "BTC",
  "value": "{{$json.btcPrice}}"
}

A status card could similarly use:

{
  "type": "status",
  "title": "API",
  "value": "{{$json.status}}",
  "state": "{{$json.state}}"
}

For more dynamic layouts, a Code node can construct the complete cards array.

That allows the layout itself to become conditional.

For example:

Normal operation
    ↓
Crypto + fuel + alerts

Incident detected
    ↓
Large critical status card

Overnight
    ↓
Backup jobs + infrastructure status

The display has not changed.

Only the JSON has.

The ESP32 firmware

The firmware below implements the card renderer described above.

Replace the Wi-Fi credentials and webhook URL before uploading.

#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <Arduino_GFX_Library.h>

struct CardGeometry;

const char* WIFI_SSID = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* WEBHOOK_URL = "https://your-n8n-host/webhook/display";

#define TFT_MISO 12
#define TFT_MOSI 13
#define TFT_SCLK 14
#define TFT_CS   15
#define TFT_DC    2
#define TFT_RST  -1
#define TFT_BL   21

#define GRID_COLUMNS 12
#define GRID_ROWS     6

#define C_BLACK       0x0000
#define C_WHITE       0xFFFF
#define C_GREY        0x8410
#define C_DARKGREY    0x2104
#define C_GREEN       0x07E0
#define C_RED         0xF800
#define C_YELLOW      0xFFE0
#define C_CYAN        0x07FF

Arduino_DataBus *bus = new Arduino_ESP32SPI(
  TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, TFT_MISO
);

Arduino_GFX *gfx = new Arduino_ILI9341(
  bus,
  TFT_RST,
  1,
  true
);

struct CardGeometry
{
  int x;
  int y;
  int w;
  int h;
};

unsigned long lastUpdate = 0;
unsigned long refreshInterval = 30000;

uint8_t currentOrientation = 1;
uint8_t gridGutter = 4;

uint16_t screenBackground = C_BLACK;

uint16_t rgb565(uint8_t r, uint8_t g, uint8_t b)
{
  return ((r & 0xF8) << 8) |
         ((g & 0xFC) << 3) |
         (b >> 3);
}

uint16_t parseColor(String colour, uint16_t fallback)
{
  if (!colour.startsWith("#") || colour.length() != 7)
    return fallback;

  long value = strtol(colour.substring(1).c_str(), nullptr, 16);

  uint8_t r = (value >> 16) & 0xFF;
  uint8_t g = (value >> 8) & 0xFF;
  uint8_t b = value & 0xFF;

  return rgb565(r, g, b);
}

uint16_t stateColor(String state)
{
  state.toLowerCase();

  if (state == "ok")
    return C_GREEN;

  if (state == "warning")
    return C_YELLOW;

  if (state == "critical")
    return C_RED;

  if (state == "info")
    return C_CYAN;

  return C_GREY;
}

void setOrientation(uint8_t orientation)
{
  currentOrientation = orientation % 4;
  gfx->setRotation(currentOrientation);
}

CardGeometry getCardGeometry(
  int gridX,
  int gridY,
  int gridW,
  int gridH
)
{
  int screenW = gfx->width();
  int screenH = gfx->height();

  float cellW = (float)screenW / GRID_COLUMNS;
  float cellH = (float)screenH / GRID_ROWS;

  CardGeometry g;

  g.x = round(gridX * cellW) + gridGutter;
  g.y = round(gridY * cellH) + gridGutter;

  g.w = round(gridW * cellW) - (gridGutter * 2);
  g.h = round(gridH * cellH) - (gridGutter * 2);

  return g;
}

String clipText(
  String value,
  int availableWidth,
  int textSize
)
{
  int approxCharacterWidth = 6 * textSize;

  if (approxCharacterWidth <= 0)
    return value;

  int maxCharacters = availableWidth / approxCharacterWidth;

  if ((int)value.length() <= maxCharacters)
    return value;

  if (maxCharacters <= 3)
    return value.substring(0, maxCharacters);

  return value.substring(0, maxCharacters - 3) + "...";
}

void drawCardFrame(
  CardGeometry g,
  uint16_t background,
  uint16_t border,
  uint16_t accent
)
{
  gfx->fillRoundRect(
    g.x,
    g.y,
    g.w,
    g.h,
    6,
    background
  );

  gfx->drawRoundRect(
    g.x,
    g.y,
    g.w,
    g.h,
    6,
    border
  );

  gfx->fillRect(
    g.x,
    g.y + 6,
    4,
    g.h - 12,
    accent
  );
}

int drawCardTitle(
  CardGeometry g,
  String title,
  uint16_t colour,
  int textSize
)
{
  if (title.length() == 0)
    return g.y + 8;

  gfx->setTextColor(colour);
  gfx->setTextSize(textSize);

  String clipped = clipText(
    title,
    g.w - 20,
    textSize
  );

  gfx->setCursor(g.x + 10, g.y + 8);
  gfx->print(clipped);

  return g.y + 8 + (8 * textSize) + 5;
}

void drawMetricCard(
  JsonObject card,
  CardGeometry g,
  uint16_t textColour,
  uint16_t accent
)
{
  String title = card["title"] | "";
  String value = card["value"] | "";
  String subtitle = card["subtitle"] | "";

  int titleSize = card["title_size"] | 1;
  int valueSize = card["value_size"] | 2;
  int textSize = card["text_size"] | 1;

  int y = drawCardTitle(
    g,
    title,
    textColour,
    titleSize
  );

  gfx->setTextColor(textColour);
  gfx->setTextSize(valueSize);
  gfx->setCursor(g.x + 10, y + 4);

  gfx->print(
    clipText(
      value,
      g.w - 20,
      valueSize
    )
  );

  if (subtitle.length())
  {
    gfx->setTextColor(accent);
    gfx->setTextSize(textSize);

    gfx->setCursor(
      g.x + 10,
      y + 12 + (8 * valueSize)
    );

    gfx->print(
      clipText(
        subtitle,
        g.w - 20,
        textSize
      )
    );
  }
}

void drawStatusCard(
  JsonObject card,
  CardGeometry g,
  uint16_t textColour
)
{
  String title = card["title"] | "";
  String value = card["value"] | "";
  String state = card["state"] | "";

  int titleSize = card["title_size"] | 1;
  int valueSize = card["value_size"] | 2;

  uint16_t statusColour = stateColor(state);

  int y = drawCardTitle(
    g,
    title,
    textColour,
    titleSize
  );

  gfx->fillCircle(
    g.x + 14,
    y + 12,
    5,
    statusColour
  );

  gfx->setTextColor(statusColour);
  gfx->setTextSize(valueSize);
  gfx->setCursor(g.x + 26, y + 5);

  gfx->print(
    clipText(
      value,
      g.w - 36,
      valueSize
    )
  );
}

void drawListCard(
  JsonObject card,
  CardGeometry g,
  uint16_t textColour
)
{
  String title = card["title"] | "";
  int titleSize = card["title_size"] | 1;
  int textSize = card["text_size"] | 1;

  int y = drawCardTitle(
    g,
    title,
    textColour,
    titleSize
  );

  JsonArray lines = card["lines"].as<JsonArray>();

  int lineHeight = 8 * textSize + 4;

  gfx->setTextColor(textColour);
  gfx->setTextSize(textSize);

  for (JsonVariant line : lines)
  {
    if (y + lineHeight > g.y + g.h - 5)
      break;

    String text = line.as<String>();

    gfx->setCursor(g.x + 10, y + 3);

    gfx->print(
      clipText(
        text,
        g.w - 20,
        textSize
      )
    );

    y += lineHeight;
  }
}

void drawTextCard(
  JsonObject card,
  CardGeometry g,
  uint16_t textColour
)
{
  String title = card["title"] | "";
  String text = card["text"] | "";

  int titleSize = card["title_size"] | 1;
  int textSize = card["text_size"] | 1;

  int y = drawCardTitle(
    g,
    title,
    textColour,
    titleSize
  );

  int approxCharacterWidth = 6 * textSize;
  int maxCharacters = (g.w - 20) / approxCharacterWidth;

  if (maxCharacters < 1)
    return;

  gfx->setTextColor(textColour);
  gfx->setTextSize(textSize);

  int position = 0;
  int lineHeight = 8 * textSize + 4;

  while (position < (int)text.length())
  {
    if (y + lineHeight > g.y + g.h - 5)
      break;

    String line = text.substring(
      position,
      position + maxCharacters
    );

    gfx->setCursor(g.x + 10, y + 3);
    gfx->print(line);

    position += maxCharacters;
    y += lineHeight;
  }
}

void drawProgressCard(
  JsonObject card,
  CardGeometry g,
  uint16_t textColour,
  uint16_t accent
)
{
  String title = card["title"] | "";
  String suffix = card["suffix"] | "";

  int value = card["value"] | 0;

  if (value < 0)
    value = 0;

  if (value > 100)
    value = 100;

  int titleSize = card["title_size"] | 1;
  int valueSize = card["value_size"] | 2;

  int y = drawCardTitle(
    g,
    title,
    textColour,
    titleSize
  );

  String valueText =
    String(value) + suffix;

  gfx->setTextColor(textColour);
  gfx->setTextSize(valueSize);
  gfx->setCursor(g.x + 10, y + 2);
  gfx->print(valueText);

  int barX = g.x + 10;
  int barW = g.w - 20;
  int barH = 10;
  int barY = g.y + g.h - 18;

  gfx->drawRect(
    barX,
    barY,
    barW,
    barH,
    C_GREY
  );

  int fillWidth =
    ((barW - 2) * value) / 100;

  if (fillWidth > 0)
  {
    gfx->fillRect(
      barX + 1,
      barY + 1,
      fillWidth,
      barH - 2,
      accent
    );
  }
}

void drawCard(JsonObject card)
{
  int gridX = card["x"] | 0;
  int gridY = card["y"] | 0;
  int gridW = card["w"] | 12;
  int gridH = card["h"] | 1;

  gridX = constrain(gridX, 0, GRID_COLUMNS - 1);
  gridY = constrain(gridY, 0, GRID_ROWS - 1);

  gridW = constrain(
    gridW,
    1,
    GRID_COLUMNS - gridX
  );

  gridH = constrain(
    gridH,
    1,
    GRID_ROWS - gridY
  );

  CardGeometry g = getCardGeometry(
    gridX,
    gridY,
    gridW,
    gridH
  );

  String type = card["type"] | "metric";

  String bgString =
    card["background"] |
    "#171B24";

  String borderString =
    card["border"] |
    "#333A48";

  String textString =
    card["text_color"] |
    "#FFFFFF";

  String accentString =
    card["accent"] |
    "#33AAFF";

  uint16_t background =
    parseColor(
      bgString,
      rgb565(23, 27, 36)
    );

  uint16_t border =
    parseColor(
      borderString,
      rgb565(51, 58, 72)
    );

  uint16_t textColour =
    parseColor(
      textString,
      C_WHITE
    );

  uint16_t accent =
    parseColor(
      accentString,
      C_CYAN
    );

  if (
    type == "status" &&
    !card["accent"].is<const char*>()
  )
  {
    String state = card["state"] | "";
    accent = stateColor(state);
  }

  drawCardFrame(
    g,
    background,
    border,
    accent
  );

  if (type == "metric")
  {
    drawMetricCard(
      card,
      g,
      textColour,
      accent
    );
  }
  else if (type == "status")
  {
    drawStatusCard(
      card,
      g,
      textColour
    );
  }
  else if (type == "list")
  {
    drawListCard(
      card,
      g,
      textColour
    );
  }
  else if (type == "text")
  {
    drawTextCard(
      card,
      g,
      textColour
    );
  }
  else if (type == "progress")
  {
    drawProgressCard(
      card,
      g,
      textColour,
      accent
    );
  }
}

void renderDashboard(JsonDocument &doc)
{
  uint8_t orientation =
    doc["orientation"] |
    currentOrientation;

  setOrientation(orientation);

  gridGutter =
    doc["gutter"] |
    gridGutter;

  String backgroundString =
    doc["background"] |
    "#000000";

  screenBackground =
    parseColor(
      backgroundString,
      C_BLACK
    );

  gfx->fillScreen(screenBackground);

  JsonArray cards =
    doc["cards"].as<JsonArray>();

  for (JsonObject card : cards)
  {
    drawCard(card);
  }
}

void connectWiFi()
{
  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);

  WiFi.begin(
    WIFI_SSID,
    WIFI_PASSWORD
  );

  while (WiFi.status() != WL_CONNECTED)
  {
    delay(500);
  }
}

bool updateFromN8N()
{
  if (WiFi.status() != WL_CONNECTED)
    return false;

  WiFiClientSecure client;
  client.setInsecure();

  HTTPClient http;

  if (!http.begin(client, WEBHOOK_URL))
    return false;

  int responseCode = http.GET();

  if (responseCode != HTTP_CODE_OK)
  {
    http.end();
    return false;
  }

  String payload = http.getString();

  JsonDocument doc;

  DeserializationError error =
    deserializeJson(doc, payload);

  http.end();

  if (error)
    return false;

  int refreshSeconds =
    doc["refresh"] |
    (refreshInterval / 1000);

  refreshSeconds =
    constrain(
      refreshSeconds,
      5,
      3600
    );

  refreshInterval =
    refreshSeconds * 1000UL;

  renderDashboard(doc);

  return true;
}

void setup()
{
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, HIGH);

  gfx->begin();

  setOrientation(
    currentOrientation
  );

  gfx->fillScreen(C_BLACK);
  gfx->setTextColor(C_WHITE);
  gfx->setTextSize(2);
  gfx->setCursor(10, 10);
  gfx->print("Connecting...");

  connectWiFi();

  updateFromN8N();

  lastUpdate = millis();
}

void loop()
{
  if (WiFi.status() != WL_CONNECTED)
  {
    connectWiFi();
  }

  if (
    millis() - lastUpdate >=
    refreshInterval
  )
  {
    updateFromN8N();
    lastUpdate = millis();
  }

  delay(50);
}

The struct CardGeometry; declaration near the top is intentional.

Arduino automatically generates function prototypes for .ino files. During development this caused generated prototypes referring to CardGeometry to appear before the structure itself, producing errors such as:

'CardGeometry' does not name a type

The forward declaration prevents that preprocessing problem.

Failure behaviour

A display like this should not become useless simply because its data source is temporarily unavailable.

The firmware therefore only redraws the dashboard after:

HTTP request succeeds
    ↓
HTTP 200 returned
    ↓
JSON successfully parsed
    ↓
Render new dashboard

If the webhook request fails, the current screen is left untouched.

That means a brief Wi-Fi outage or unavailable n8n instance does not replace useful information with a blank display.

It does mean that the displayed information can become stale. A future extension could expose the age of the last successful update or draw a stale-data indicator without discarding the existing dashboard.

Current limitations

The renderer is deliberately small.

It currently does not implement:

  • images or icons
  • arbitrary fonts
  • HTML or CSS
  • automatic font scaling
  • sophisticated word wrapping
  • charts
  • sparklines
  • touch interaction
  • scrolling cards
  • transparency
  • independent refresh intervals per card
  • global theme inheritance

Those are potential extensions rather than requirements for the basic design.

The goal is not to turn an ESP32 into a web browser.

It is to expose enough drawing primitives that n8n can describe useful dashboards.

The display is an output device, not an application

The most useful part of this project is the separation between presentation and information gathering.

A traditional embedded dashboard might contain code resembling:

Query Bitcoin API
Query monitoring API
Query fuel API
Parse each response
Decide what is important
Draw each result

Every new integration then requires another firmware change.

This design instead becomes:

GET JSON
    ↓
Render JSON

The integration problem moves to n8n, where APIs, credentials, transformations, schedules and conditional logic are considerably easier to manage.

The ESP32 only implements the stable part of the problem: displaying information.

That also means the physical device has no fixed purpose.

Today it might show:

BTC
Fuel
n8n status
Discord alerts

Tomorrow n8n can return:

Backup status
Storage utilisation
Network alerts
Maintenance notices

No firmware change is required.

The small screen effectively becomes another output target for an n8n workflow.

And once the interface between n8n and the display is generic enough, building a new dashboard stops being an embedded-development task and becomes what it should be: arranging some JSON.