Formatted Dashboard with External Uart Messaging

CrowPanel ESP32 5.0" HMI: Formatted JSON Dashboard with External UART Messaging

01 Overview

This build is a direct continuation of our earlier CrowPanel JSON Dashboard project. It keeps the same touch-driven widget builder — tap "+ Add", pick a widget type, pick a style, and it drops onto an 800x480 canvas where it can be dragged, selected, and deleted — but adds two new ways to control it. The dashboard now runs its own local Wi-Fi access point so it can be opened and controlled from a laptop or phone browser, and it listens on a second UART line so an external microcontroller (or a PC's Serial Monitor) can spawn and clear widgets with plain text commands.

Every action, whether it starts on the touchscreen, in the browser, or over UART, is packaged into one consistent JSON event and pushed out to the USB Serial port, the UART2 line, and every connected browser tab at the same time. That single formatted event stream is what keeps the panel, the browser dashboard, and any external device all showing the same widget layout without needing a separate sync step.

Project Use Case

This is a good fit for anyone building a small control panel or demo HMI that also needs to respond to another system. For example, an Arduino Uno on a test bench could push a widget to the panel every time it logs a sensor event, while a classroom or lab audience watches the same dashboard update live on their own laptop over Wi-Fi. It also works as a self-contained way to practice combining LVGL, ArduinoJson, and ESPAsyncWebServer on one ESP32-S3 board, without needing a second computer just to view the output.

02 Hardware and Software Components

Gather everything below before you start. This is your checklist — names, models, and versions only.

Hardware Components

Component Description
CrowPanel ESP32 5.0" HMI Display All-in-one ESP32-S3 board with an 800x480 RGB parallel display and a built-in GT911 capacitive touch controller. Runs the LVGL UI, the Wi-Fi access point, the WebSocket/web server, and the UART command listener.
External MCU or PC (optional) Any board with a UART port, or a computer's Serial Monitor, used to send widget commands into the CrowPanel over Serial2 or USB.
USB-C cable Powers the CrowPanel, uploads the sketch, and shows the plain-text JSON event log in the Serial Monitor.
Jumper wires (3) Connects an external MCU's TX / RX / GND lines to the CrowPanel's UART2 pins. Only needed if triggering widgets from a second board.
Voltage divider (1kΩ + 2kΩ resistors) Steps a 5V-logic board's TX line down to the CrowPanel's 3.3V RX input. Only needed if the external MCU runs at 5V logic, such as an Arduino Uno.

Software Tools

Software Version / Details
Arduino IDE 2.x
ESP32 Arduino core (board package) v2.0.14 – v2.0.15 (the 3.x core line is not compatible with this sketch)
LVGL 8.3.3
LovyanGFX 1.1.8
ArduinoJson 7.x
ESPAsyncWebServer Latest release via Arduino Library Manager
AsyncTCP Latest release via Arduino Library Manager

Project Files

All required files are available in the project repository. Download the repository before proceeding to the Software Setup section.

Repository:

File Description
dashboard.ino Main firmware: builds the LVGL screens, formats every widget/UART event as JSON, hosts the Wi-Fi access point and WebSocket server, and drives the touch input.
Safety Note: Do not connect an external MCU's 5V-logic TX pin directly to the CrowPanel's UART RX pin. Always use the 1kΩ / 2kΩ voltage divider described in Section 04, or a logic-level shifter, to avoid damaging the ESP32's 3.3V input.

03 Application Discussion

Here is what each core technology does and why it is part of this build.

LVGL (Light and Versatile Graphics Library)

LVGL builds and manages everything drawn on screen: the dashboard header, the widget canvas, the widget-type picker, and the style picker. Each spawned widget (button, switch, label, slider, gauge, chart, checkbox, or bar) is a normal LVGL object tracked in a small internal registry, which is what makes it possible to look a widget up by its ID later and move, restyle, or delete it on command from touch, UART, or the browser.

ArduinoJson

ArduinoJson builds and parses every message this project sends or receives. Instead of hand-formatting text, each event — spawn, move, delete, clear, or an incoming UART command — is assembled as a small JSON document and serialized once, then sent identically to the USB Serial port, the UART2 line, and every connected browser over WebSocket. This is what keeps all three interfaces reading the exact same "formatted" event, which is the core improvement over the earlier version of this dashboard.

ESPAsyncWebServer and AsyncTCP (WebSocket Dashboard)

These two libraries serve the browser-based dashboard page and keep a live, two-way WebSocket connection open with any browser on the same Wi-Fi network. A widget created by tapping the touchscreen appears in the browser automatically, and a widget spawned from the browser's "+ Add" menu appears on the physical panel — both directions are handled through the same JSON event format.

GT911 Capacitive Touch (via LovyanGFX)

The CrowPanel's built-in GT911 touch controller is read directly through LovyanGFX's getTouch() call rather than LVGL's own input driver loop, which gives full control over gesture timing. This is what makes drag-to-move, long-press-to-select, and double-tap-to-edit possible on top of standard LVGL widgets.

UART2 (Serial2) External Messaging

Serial2 is the new external control channel for this iteration. It is read line by line, and each line is matched against a small set of plain text commands such as widget:button or clear. This lets a second board, or a laptop connected over USB, trigger the exact same widget-spawning logic used by the touchscreen, without needing to understand LVGL at all.

04 Hardware Setup

The CrowPanel 5.0" module is all-in-one: the display and the GT911 touch controller are already wired internally, so there is no touch or panel wiring to do. The only external wiring in this build is the optional UART link used to trigger the dashboard from a second board.

Note: Before wiring, sketch a simple two-board connection diagram showing the RX2 / TX2 / GND lines and the voltage divider. A free schematic tool such as Fritzing works well for this. If you do not already have a licensed copy, a free legacy release is available through the FileHorse software site (filehorse.com) — search its catalog for the older 64-bit Fritzing build.

External MCU to CrowPanel UART2

CrowPanel Pin External MCU Pin Description
GPIO44 (RX2) TX (through voltage divider if 5V logic) Receives widget and clear commands sent from the external board
GPIO43 (TX2) RX Echoes acknowledgements or status text back to the external board, if used
GND GND Common ground reference between the two boards

Assembly Instructions

  1. If the external board runs at 5V logic (e.g. an Arduino Uno), build the 1kΩ / 2kΩ voltage divider on its TX line before connecting it to the CrowPanel.
  2. Connect the external board's TX (through the divider, if needed) to the CrowPanel's GPIO44 (RX2).
  3. Connect the CrowPanel's GPIO43 (TX2) to the external board's RX pin.
  4. Tie the GND pins of both boards together.
  5. Power the CrowPanel over USB-C. Power the external board from its own supply, or from the CrowPanel's 5V rail only if the current budget allows.
  6. Leave the display and touch panel alone — both are already wired internally on the CrowPanel module.
Note: If you are only testing UART commands from a PC, you can skip the external MCU wiring entirely and type commands directly into the Arduino IDE's Serial Monitor over USB — the sketch listens on both the USB Serial port and Serial2.

05 Software Setup

Follow these steps in order. Do not skip any step.

Project Repository:
Download this first before proceeding.

Step 1 — Install Arduino IDE and the ESP32 Board Package

  1. Install Arduino IDE 2.x.
  2. Add the ESP32 board manager URL under File > Preferences > Additional Board Manager URLs.
  3. Open Tools > Board > Boards Manager and install ESP32 board package version 2.0.14 or 2.0.15. Do not install the 3.x line — this sketch's graphics and touch stack depends on the 2.x-era core.

Step 2 — Board Settings

Use exactly these settings in the Arduino IDE Tools menu. Wrong settings will cause upload failures or a blank screen.

Setting Value
Board ESP32S3 Dev Module
USB CDC On Boot Enabled
PSRAM OPI PSRAM
Partition Scheme Huge APP (3MB No OTA / 1MB SPIFFS)
Upload Speed 921600

Step 3 — Install Libraries

Install the following libraries via the Library Manager:

  • LVGL 8.3.3 (not the 9.x line)
  • LovyanGFX 1.1.8
  • ArduinoJson 7.x
  • ESPAsyncWebServer
  • AsyncTCP
lv_conf.h: Confirm a working lv_conf.h configured for LVGL 8.3.3 is copied next to the LVGL library folder before compiling, the same file used in the earlier CrowPanel dashboard build.

Library Install Location

path
Documents/Arduino/libraries/

Step 4 — Upload the Code

  1. Open dashboard.ino in the Arduino IDE.
  2. Select the correct board and serial port under the Tools menu.
  3. Click Upload.

06 Code

Copy the file below into a new Arduino sketch folder as described in the Software Setup section. Read the Code Breakdown section to understand what each part does.

dashboard.ino

Arduino / C++
#include <LovyanGFX.hpp>
#include <lgfx/v1/platforms/esp32s3/Panel_RGB.hpp>
#include <lgfx/v1/platforms/esp32s3/Bus_RGB.hpp>
#include <lvgl.h>
#include <ArduinoJson.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

// ---- WiFi AP ----
#define WIFI_SSID "CrowPanel-Dashboard"
#define WIFI_PASS "crowpanel123"
#define WIFI_IP   "192.168.4.1"

// ---- UART ----
#define UART_RX   44
#define UART_TX   43
#define UART_BAUD 115200

String uart_buffer = "";
String usb_buffer  = "";

// ---- Display ----
#define GT911_ADDR1   0x5D
#define SCREEN_WIDTH  800
#define SCREEN_HEIGHT 480

#define COL_PRIMARY   0x1e90ff
#define COL_SECONDARY 0x555555
#define COL_TERTIARY  0xff6b35
#define COL_BG        0x1a1a2e
#define COL_HEADER    0x16213e
#define COL_WHITE     0xffffff
#define COL_DARK_TEXT 0xcccccc
#define COL_DELETE    0xe74c3c
#define COL_SELECTED  0xf1c40f
#define COL_EDIT      0x2ecc71
#define COL_UART      0x9b59b6

#define WIDGET_AREA_Y  65
#define WIDGET_AREA_H  (SCREEN_HEIGHT - WIDGET_AREA_Y)

#define WIDGET_TAG_OTHER    0
#define WIDGET_TAG_LABEL    1
#define WIDGET_TAG_CHECKBOX 2

#define DOUBLE_TAP_MS  400
#define LONG_PRESS_MS  600
#define LP_MOVE_THRESH 8

struct WsCmd {
  char action[16];
  char type[16];
  int  id;
  int  x;
  int  y;
  bool valid;
};

// ---- LGFX ----
class LGFX : public lgfx::LGFX_Device {
public:
  lgfx::Bus_RGB     _bus_instance;
  lgfx::Panel_RGB   _panel_instance;
  lgfx::Touch_GT911 _touch_instance;

  LGFX(void) {
    {
      auto cfg = _panel_instance.config();
      cfg.memory_width  = SCREEN_WIDTH;
      cfg.memory_height = SCREEN_HEIGHT;
      cfg.panel_width   = SCREEN_WIDTH;
      cfg.panel_height  = SCREEN_HEIGHT;
      cfg.offset_x = 0; cfg.offset_y = 0;
      _panel_instance.config(cfg);
    }
    {
      auto cfg = _panel_instance.config_detail();
      cfg.use_psram = 1;
      _panel_instance.config_detail(cfg);
    }
    {
      auto cfg = _bus_instance.config();
      cfg.panel    = &_panel_instance;
      cfg.pin_d0   = GPIO_NUM_8;  cfg.pin_d1  = GPIO_NUM_3;
      cfg.pin_d2   = GPIO_NUM_46; cfg.pin_d3  = GPIO_NUM_9;
      cfg.pin_d4   = GPIO_NUM_1;  cfg.pin_d5  = GPIO_NUM_5;
      cfg.pin_d6   = GPIO_NUM_6;  cfg.pin_d7  = GPIO_NUM_7;
      cfg.pin_d8   = GPIO_NUM_15; cfg.pin_d9  = GPIO_NUM_16;
      cfg.pin_d10  = GPIO_NUM_4;  cfg.pin_d11 = GPIO_NUM_45;
      cfg.pin_d12  = GPIO_NUM_48; cfg.pin_d13 = GPIO_NUM_47;
      cfg.pin_d14  = GPIO_NUM_21; cfg.pin_d15 = GPIO_NUM_14;
      cfg.pin_henable = GPIO_NUM_40;
      cfg.pin_vsync   = GPIO_NUM_41;
      cfg.pin_hsync   = GPIO_NUM_39;
      cfg.pin_pclk    = GPIO_NUM_0;
      cfg.freq_write  = 15000000;
      cfg.hsync_polarity    = 0; cfg.hsync_front_porch = 8;
      cfg.hsync_pulse_width = 4; cfg.hsync_back_porch  = 43;
      cfg.vsync_polarity    = 0; cfg.vsync_front_porch = 8;
      cfg.vsync_pulse_width = 4; cfg.vsync_back_porch  = 12;
      cfg.pclk_active_neg = 1; cfg.de_idle_high = 0; cfg.pclk_idle_high = 0;
      _bus_instance.config(cfg);
    }
    {
      auto cfg = _touch_instance.config();
      cfg.x_min = 0; cfg.x_max = 799;
      cfg.y_min = 0; cfg.y_max = 479;
      cfg.pin_int = -1; cfg.pin_rst = -1;
      cfg.bus_shared = false; cfg.offset_rotation = 0;
      cfg.i2c_port = 0; cfg.i2c_addr = GT911_ADDR1;
      cfg.pin_sda = 19; cfg.pin_scl = 20;
      cfg.freq = 400000;
      _touch_instance.config(cfg);
    }
    _panel_instance.setBus(&_bus_instance);
    _panel_instance.setTouch(&_touch_instance);
    setPanel(&_panel_instance);
  }
};

LGFX lcd;
static lv_disp_draw_buf_t draw_buf;
static lv_color_t buf1[SCREEN_WIDTH * SCREEN_HEIGHT / 10];

void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
  uint32_t w = area->x2 - area->x1 + 1;
  uint32_t h = area->y2 - area->y1 + 1;
  lcd.startWrite();
  lcd.setAddrWindow(area->x1, area->y1, w, h);
  lcd.writePixels((lgfx::rgb565_t *)&color_p->full, w * h);
  lcd.endWrite();
  lv_disp_flush_ready(disp);
}

void my_touch_read(lv_indev_drv_t *indev, lv_indev_data_t *data) {
  uint16_t x, y;
  if (lcd.getTouch(&x, &y)) {
    data->state = LV_INDEV_STATE_PR;
    data->point.x = x; data->point.y = y;
  } else {
    data->state = LV_INDEV_STATE_REL;
  }
}

// ---- Widget registry ----
#define MAX_WIDGETS 50

struct WidgetInfo {
  lv_obj_t *obj;
  int       id;
  char      type[16];
  bool      active;
};

WidgetInfo widget_registry[MAX_WIDGETS];
int next_widget_id = 1;

void registry_clear() {
  for (int i = 0; i < MAX_WIDGETS; i++) {
    widget_registry[i].active = false;
    widget_registry[i].obj    = NULL;
  }
}

void registry_add(lv_obj_t *obj, int id, const char* type) {
  for (int i = 0; i < MAX_WIDGETS; i++) {
    if (!widget_registry[i].active) {
      widget_registry[i].obj    = obj;
      widget_registry[i].id     = id;
      widget_registry[i].active = true;
      strncpy(widget_registry[i].type, type, 15);
      return;
    }
  }
}

void registry_remove(lv_obj_t *obj) {
  for (int i = 0; i < MAX_WIDGETS; i++) {
    if (widget_registry[i].active && widget_registry[i].obj == obj) {
      widget_registry[i].active = false;
      widget_registry[i].obj    = NULL;
      return;
    }
  }
}

struct WidgetInfo* registry_find(lv_obj_t *obj) {
  for (int i = 0; i < MAX_WIDGETS; i++) {
    if (widget_registry[i].active && widget_registry[i].obj == obj) {
      return &widget_registry[i];
    }
  }
  return NULL;
}

// ---- WebSocket + Web Server ----
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

// Forward decls
void spawn_widget(const char* type, int style_idx);
void spawn_widget_at(const char* type, int style_idx, int wx, int wy);
void clear_all_widgets();
void ws_broadcast(const char* json_str);

void ws_broadcast(const char* json_str) {
  ws.textAll(json_str);
}

// ---- JSON Serial + WS logging ----
void log_spawn(int id, const char* type, int x, int y, int widget_count) {
  JsonDocument doc;
  doc["event"]        = "spawn";
  doc["id"]           = id;
  doc["type"]         = type;
  doc["x"]            = x;
  doc["y"]            = y;
  doc["widget_count"] = widget_count;
  String out;
  serializeJson(doc, out);
  Serial.println(out);
  Serial2.println(out);
  ws_broadcast(out.c_str());
}

void log_move(int id, const char* type, int x, int y, int widget_count) {
  JsonDocument doc;
  doc["event"]        = "move";
  doc["id"]           = id;
  doc["type"]         = type;
  doc["x"]            = x;
  doc["y"]            = y;
  doc["widget_count"] = widget_count;
  String out;
  serializeJson(doc, out);
  Serial.println(out);
  Serial2.println(out);
  ws_broadcast(out.c_str());
}

void log_delete(int id, const char* type, int widget_count) {
  JsonDocument doc;
  doc["event"]        = "delete";
  doc["id"]           = id;
  doc["type"]         = type;
  doc["widget_count"] = widget_count;
  String out;
  serializeJson(doc, out);
  Serial.println(out);
  Serial2.println(out);
  ws_broadcast(out.c_str());
}

void log_clear(int widget_count) {
  JsonDocument doc;
  doc["event"]        = "clear";
  doc["widget_count"] = widget_count;
  String out;
  serializeJson(doc, out);
  Serial.println(out);
  Serial2.println(out);
  ws_broadcast(out.c_str());
}

void log_uart(const char* cmd, const char* source) {
  JsonDocument doc;
  doc["event"]  = "uart";
  doc["cmd"]    = cmd;
  doc["source"] = source;
  String out;
  serializeJson(doc, out);
  Serial.println(out);
  Serial2.println(out);
  ws_broadcast(out.c_str());
}

// ---- Web Dashboard HTML ----
const char DASHBOARD_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CrowPanel Dashboard</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body { background: #1a1a2e; color: #fff; font-family: Arial, sans-serif; height: 100vh; display: flex; flex-direction: column; }
  #header {
    background: #16213e; height: 60px; display: flex; align-items: center;
    padding: 0 20px; gap: 16px; flex-shrink: 0;
  }
  #header h1 { font-size: 18px; color: #fff; }
  #widget-count { font-size: 13px; color: #ccc; }
  #status { font-size: 12px; color: #9b59b6; margin-left: auto; }
  #add-btn {
    background: #1e90ff; color: #fff; border: none; padding: 8px 18px;
    border-radius: 6px; cursor: pointer; font-size: 14px;
  }
  #clear-btn {
    background: #e74c3c; color: #fff; border: none; padding: 8px 18px;
    border-radius: 6px; cursor: pointer; font-size: 14px;
  }
  #canvas {
    flex: 1; position: relative; overflow: hidden;
    background: #1a1a2e;
  }
  .widget {
    position: absolute; border-radius: 8px; cursor: move;
    display: flex; flex-direction: column; align-items: center;
    justify-content: center; user-select: none; border: 2px solid transparent;
    transition: box-shadow 0.15s;
  }
  .widget:hover { box-shadow: 0 0 0 2px #f1c40f44; }
  .widget.selected { border-color: #f1c40f; transform: scale(0.95); }
  .widget-label { font-size: 13px; font-weight: bold; color: #fff; text-align: center; padding: 4px; }
  .widget-sub { font-size: 11px; color: #ccc; }
  .del-btn {
    position: absolute; top: 4px; right: 4px; background: #e74c3c;
    border: none; color: #fff; border-radius: 4px; width: 20px; height: 20px;
    font-size: 12px; cursor: pointer; display: none; line-height: 20px; text-align: center;
  }
  .widget.selected .del-btn { display: block; }
  #modal {
    display: none; position: fixed; inset: 0; background: #0008;
    align-items: center; justify-content: center; z-index: 100;
  }
  #modal.show { display: flex; }
  #modal-box {
    background: #16213e; border-radius: 12px; padding: 24px;
    width: 360px; border: 1px solid #1e90ff;
  }
  #modal-box h2 { margin-bottom: 16px; font-size: 16px; }
  .type-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 16px; }
  .type-btn {
    padding: 12px; border: none; border-radius: 8px; cursor: pointer;
    color: #fff; font-size: 13px; font-weight: bold;
  }
  .cancel-btn {
    background: #333; color: #fff; border: none; padding: 10px 20px;
    border-radius: 6px; cursor: pointer; width: 100%;
  }
  #log {
    position: fixed; bottom: 0; left: 0; right: 0;
    background: #0d0d1a; border-top: 1px solid #333;
    height: 120px; overflow-y: auto; padding: 8px 12px;
    font-size: 11px; font-family: monospace; color: #aaa;
  }
  #log-toggle {
    position: fixed; bottom: 120px; right: 12px;
    background: #16213e; border: 1px solid #333; color: #ccc;
    padding: 4px 10px; border-radius: 6px 6px 0 0; cursor: pointer;
    font-size: 11px;
  }
  #canvas { margin-bottom: 120px; }
</style>
</head>
<body>
<div id="header">
  <h1>CrowPanel Dashboard</h1>
  <span id="widget-count">Widgets: 0</span>
  <span id="status">Connecting...</span>
  <button id="add-btn" onclick="showModal()">+ Add</button>
  <button id="clear-btn" onclick="sendClear()">Clear All</button>
</div>
<div id="canvas"></div>
<button id="log-toggle" onclick="toggleLog()">▲ Log</button>
<div id="log"></div>

<!-- Widget type picker modal -->
<div id="modal">
  <div id="modal-box">
    <h2>Choose Widget Type</h2>
    <div class="type-grid">
      <button class="type-btn" style="background:#1e90ff" onclick="sendSpawn('button')">Button</button>
      <button class="type-btn" style="background:#2ecc71" onclick="sendSpawn('switch')">Switch</button>
      <button class="type-btn" style="background:#f39c12" onclick="sendSpawn('label')">Label</button>
      <button class="type-btn" style="background:#9b59b6" onclick="sendSpawn('slider')">Slider</button>
      <button class="type-btn" style="background:#e74c3c" onclick="sendSpawn('gauge')">Gauge</button>
      <button class="type-btn" style="background:#1abc9c" onclick="sendSpawn('chart')">Chart</button>
      <button class="type-btn" style="background:#e67e22" onclick="sendSpawn('checkbox')">Checkbox</button>
      <button class="type-btn" style="background:#8e44ad" onclick="sendSpawn('bar')">Bar</button>
    </div>
    <button class="cancel-btn" onclick="hideModal()">Cancel</button>
  </div>
</div>

<script>
let ws;
let widgets = {};
let selectedId = null;
let logVisible = true;

const COLORS = {
  button:'#1e90ff', switch:'#2ecc71', label:'#f39c12',
  slider:'#9b59b6', gauge:'#e74c3c', chart:'#1abc9c',
  checkbox:'#e67e22', bar:'#8e44ad'
};
const SIZES = {
  button:[200,80], switch:[180,115], label:[200,80],
  slider:[240,115], gauge:[220,120], chart:[260,140],
  checkbox:[200,80], bar:[240,100]
};

function connectWS() {
  ws = new WebSocket('ws://' + location.hostname + '/ws');
  ws.onopen = () => {
    document.getElementById('status').textContent = 'Connected';
    document.getElementById('status').style.color = '#2ecc71';
    addLog('WebSocket connected');
  };
  ws.onclose = () => {
    document.getElementById('status').textContent = 'Disconnected - retrying...';
    document.getElementById('status').style.color = '#e74c3c';
    setTimeout(connectWS, 2000);
  };
  ws.onmessage = (e) => {
    try {
      const msg = JSON.parse(e.data);
      handleMessage(msg);
    } catch(err) {
      addLog('Parse error: ' + e.data);
    }
  };
}

function handleMessage(msg) {
  addLog(JSON.stringify(msg));
  if (msg.event === 'spawn') {
    createWidget(msg.id, msg.type, msg.x, msg.y, false);
    updateCount(msg.widget_count);
  } else if (msg.event === 'move') {
    moveWidget(msg.id, msg.x, msg.y);
    updateCount(msg.widget_count);
  } else if (msg.event === 'delete') {
    removeWidget(msg.id);
    updateCount(msg.widget_count);
  } else if (msg.event === 'clear') {
    clearAllWidgets();
    updateCount(0);
  }
}

function updateCount(n) {
  document.getElementById('widget-count').textContent = 'Widgets: ' + n;
}

function createWidget(id, type, x, y, fromBrowser) {
  if (widgets[id]) return;
  const canvas = document.getElementById('canvas');
  const [w, h] = SIZES[type] || [160, 80];
  const color  = COLORS[type] || '#555';

  const div = document.createElement('div');
  div.className = 'widget';
  div.id = 'w' + id;
  div.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;background:${color}22;border-color:${color};`;
  div.innerHTML = `
    <button class="del-btn" onclick="deleteWidget(${id})">x</button>
    <div class="widget-label">${type.charAt(0).toUpperCase()+type.slice(1)} ${id}</div>
    <div class="widget-sub">${type} · id:${id}</div>
  `;

  // Click to select
  div.addEventListener('mousedown', (e) => {
    if (e.target.classList.contains('del-btn')) return;
    selectWidget(id);
    startDrag(e, id);
  });

  canvas.appendChild(div);
  widgets[id] = { id, type, x, y, el: div };
}

function moveWidget(id, x, y) {
  if (!widgets[id]) return;
  widgets[id].x = x;
  widgets[id].y = y;
  widgets[id].el.style.left = x + 'px';
  widgets[id].el.style.top  = y + 'px';
}

function removeWidget(id) {
  if (!widgets[id]) return;
  widgets[id].el.remove();
  delete widgets[id];
  if (selectedId === id) selectedId = null;
}

function clearAllWidgets() {
  Object.keys(widgets).forEach(id => {
    widgets[id].el.remove();
    delete widgets[id];
  });
  selectedId = null;
}

function selectWidget(id) {
  if (selectedId !== null && widgets[selectedId]) {
    widgets[selectedId].el.classList.remove('selected');
  }
  selectedId = id;
  if (widgets[id]) widgets[id].el.classList.add('selected');
}

function deleteWidget(id) {
  ws.send(JSON.stringify({ action: 'delete', id: id }));
}

// Drag logic
let dragging = null;
let dragStartX, dragStartY, widgetStartX, widgetStartY;

function startDrag(e, id) {
  dragging = id;
  dragStartX = e.clientX;
  dragStartY = e.clientY;
  widgetStartX = widgets[id].x;
  widgetStartY = widgets[id].y;
  e.preventDefault();
}

document.addEventListener('mousemove', (e) => {
  if (!dragging || !widgets[dragging]) return;
  const dx = e.clientX - dragStartX;
  const dy = e.clientY - dragStartY;
  const nx = Math.max(0, widgetStartX + dx);
  const ny = Math.max(0, widgetStartY + dy);
  widgets[dragging].el.style.left = nx + 'px';
  widgets[dragging].el.style.top  = ny + 'px';
  widgets[dragging].x = nx;
  widgets[dragging].y = ny;
});

document.addEventListener('mouseup', (e) => {
  if (!dragging) return;
  const id = dragging;
  dragging = null;
  if (!widgets[id]) return;
  // Send final position to CrowPanel
  ws.send(JSON.stringify({
    action: 'move',
    id: id,
    x: Math.round(widgets[id].x),
    y: Math.round(widgets[id].y)
  }));
});

// Click outside deselects
document.getElementById('canvas').addEventListener('mousedown', (e) => {
  if (e.target === document.getElementById('canvas')) {
    if (selectedId !== null && widgets[selectedId]) {
      widgets[selectedId].el.classList.remove('selected');
      selectedId = null;
    }
  }
});

function sendSpawn(type) {
  ws.send(JSON.stringify({ action: 'spawn', type: type }));
  hideModal();
}

function sendClear() {
  ws.send(JSON.stringify({ action: 'clear' }));
}

function showModal() { document.getElementById('modal').classList.add('show'); }
function hideModal() { document.getElementById('modal').classList.remove('show'); }

function addLog(msg) {
  const log = document.getElementById('log');
  const line = document.createElement('div');
  const t = new Date().toLocaleTimeString();
  line.textContent = '[' + t + '] ' + msg;
  log.appendChild(line);
  log.scrollTop = log.scrollHeight;
}

function toggleLog() {
  const log = document.getElementById('log');
  const btn = document.getElementById('log-toggle');
  const canvas = document.getElementById('canvas');
  logVisible = !logVisible;
  log.style.display = logVisible ? 'block' : 'none';
  canvas.style.marginBottom = logVisible ? '120px' : '0';
  btn.textContent = logVisible ? '▲ Log' : '▼ Log';
}

connectWS();
</script>
</body>
</html>
)rawliteral";

// ---- Globals ----
lv_obj_t *screen_dashboard;
lv_obj_t *screen_picker;
lv_obj_t *screen_style;
lv_obj_t *dashboard_area;
lv_obj_t *widget_count_label;
lv_obj_t *uart_status_label;
lv_obj_t *wifi_status_label;
lv_obj_t *delete_btn;
lv_obj_t *selected_widget_obj = NULL;

int widget_count = 0;
String selected_type = "";

// Spawn grid
#define COLS       4
#define SPAWN_PADX 15
#define SPAWN_PADY 15
int spawn_col = 0;
int spawn_row = 0;

// Drag state
bool       is_dragging    = false;
lv_obj_t  *drag_obj       = NULL;
lv_coord_t drag_off_x     = 0;
lv_coord_t drag_off_y     = 0;
bool       touch_was_down  = false;
lv_obj_t  *touch_down_obj  = NULL;

// Long press
bool       lp_pending   = false;
uint32_t   lp_start_ms  = 0;
lv_coord_t lp_start_x   = 0;
lv_coord_t lp_start_y   = 0;

// Double tap
lv_obj_t *last_tapped   = NULL;
uint32_t  last_tap_time = 0;

// UART flash
uint32_t uart_flash_ms = 0;
bool     uart_flashing = false;

#define WS_QUEUE_SIZE 10
WsCmd ws_queue[WS_QUEUE_SIZE];
int   ws_queue_head = 0;
int   ws_queue_tail = 0;

void ws_enqueue(WsCmd cmd) {
  int next = (ws_queue_tail + 1) % WS_QUEUE_SIZE;
  if (next != ws_queue_head) {
    ws_queue[ws_queue_tail] = cmd;
    ws_queue_tail = next;
  }
}

bool ws_dequeue(WsCmd &cmd) {
  if (ws_queue_head == ws_queue_tail) return false;
  cmd = ws_queue[ws_queue_head];
  ws_queue_head = (ws_queue_head + 1) % WS_QUEUE_SIZE;
  return true;
}

struct WidgetStyle {
  const char* name;
  uint32_t color;
  int w, h;
};
WidgetStyle styles[3] = {
  { "Primary",   COL_PRIMARY,   200, 80 },
  { "Secondary", COL_SECONDARY, 160, 60 },
  { "Tertiary",  COL_TERTIARY,  120, 50 },
};

// ---- Forward decls ----
void build_dashboard();
void build_picker();
void build_style_picker();
void deselect_widget();
void show_delete_confirm();
void show_edit_keyboard(lv_obj_t *widget);
void spawn_widget(const char* type, int style_idx);
void spawn_widget_at(const char* type, int style_idx, int wx, int wy);
void clear_all_widgets();
void delete_widget_by_id(int id);
void move_widget_by_id(int id, int x, int y);

// ---- Spawn position ----
void get_spawn_pos(int &ox, int &oy) {
  int col_w = (SCREEN_WIDTH - SPAWN_PADX) / COLS;
  ox = SPAWN_PADX + spawn_col * col_w;
  oy = SPAWN_PADY + spawn_row * 120;
  spawn_col++;
  if (spawn_col >= COLS) { spawn_col = 0; spawn_row++; }
  if (oy > WIDGET_AREA_H - 60) { spawn_col = 0; spawn_row = 0; }
}

// ---- Hit test ----
bool hit_test(lv_obj_t *obj, lv_coord_t tx, lv_coord_t ty) {
  lv_coord_t ox    = lv_obj_get_x(obj);
  lv_coord_t oy    = lv_obj_get_y(obj);
  lv_coord_t ow    = lv_obj_get_width(obj);
  lv_coord_t oh    = lv_obj_get_height(obj);
  lv_coord_t rel_y = ty - WIDGET_AREA_Y;
  return (tx >= ox && tx <= ox + ow && rel_y >= oy && rel_y <= oy + oh);
}

lv_obj_t* find_widget_at(lv_coord_t tx, lv_coord_t ty) {
  uint32_t count = lv_obj_get_child_cnt(dashboard_area);
  for (int i = (int)count - 1; i >= 0; i--) {
    lv_obj_t *child = lv_obj_get_child(dashboard_area, i);
    if (hit_test(child, tx, ty)) return child;
  }
  return NULL;
}

// ---- Select / deselect ----
void select_widget(lv_obj_t *obj) {
  if (selected_widget_obj && selected_widget_obj != obj) deselect_widget();
  selected_widget_obj = obj;
  lv_obj_set_style_transform_zoom(obj, 230, 0);
  lv_obj_set_style_border_color(obj, lv_color_hex(COL_SELECTED), 0);
  lv_obj_set_style_border_width(obj, 3, 0);
  lv_obj_clear_flag(delete_btn, LV_OBJ_FLAG_HIDDEN);
}

void deselect_widget() {
  if (!selected_widget_obj) return;
  lv_obj_set_style_transform_zoom(selected_widget_obj, 256, 0);
  lv_obj_set_style_border_width(selected_widget_obj, 0, 0);
  selected_widget_obj = NULL;
  lv_obj_add_flag(delete_btn, LV_OBJ_FLAG_HIDDEN);
}

// ---- UART indicator ----
void flash_uart_indicator(const char* cmd) {
  char buf[64];
  sprintf(buf, "UART: %s", cmd);
  lv_label_set_text(uart_status_label, buf);
  lv_obj_set_style_text_color(uart_status_label, lv_color_hex(COL_UART), 0);
  uart_flash_ms = millis();
  uart_flashing = true;
}

void update_uart_indicator() {
  if (uart_flashing && (millis() - uart_flash_ms) > 3000) {
    lv_label_set_text(uart_status_label, "UART: waiting...");
    lv_obj_set_style_text_color(uart_status_label, lv_color_hex(COL_DARK_TEXT), 0);
    uart_flashing = false;
  }
}

// ---- Delete by ID ----
void delete_widget_by_id(int id) {
  for (int i = 0; i < MAX_WIDGETS; i++) {
    if (widget_registry[i].active && widget_registry[i].id == id) {
      lv_obj_t *obj = widget_registry[i].obj;
      if (obj == selected_widget_obj) deselect_widget();
      log_delete(widget_registry[i].id, widget_registry[i].type, widget_count - 1);
      registry_remove(obj);
      lv_obj_del(obj);
      widget_count--;
      char buf[32];
      sprintf(buf, "Widgets: %d", widget_count);
      lv_label_set_text(widget_count_label, buf);
      lv_obj_add_flag(delete_btn, LV_OBJ_FLAG_HIDDEN);
      return;
    }
  }
}

// ---- Move by ID ----
void move_widget_by_id(int id, int x, int y) {
  for (int i = 0; i < MAX_WIDGETS; i++) {
    if (widget_registry[i].active && widget_registry[i].id == id) {
      lv_obj_t *obj = widget_registry[i].obj;
      lv_coord_t max_x = SCREEN_WIDTH  - lv_obj_get_width(obj);
      lv_coord_t max_y = WIDGET_AREA_H - lv_obj_get_height(obj);
      x = LV_MAX(0, LV_MIN(x, max_x));
      y = LV_MAX(0, LV_MIN(y, max_y));
      lv_obj_set_pos(obj, x, y);
      log_move(id, widget_registry[i].type, x, y, widget_count);
      return;
    }
  }
}

// ---- Clear all ----
void clear_all_widgets() {
  deselect_widget();
  uint32_t count = lv_obj_get_child_cnt(dashboard_area);
  for (int i = (int)count - 1; i >= 0; i--) {
    lv_obj_t *child = lv_obj_get_child(dashboard_area, i);
    registry_remove(child);
    lv_obj_del(child);
  }
  widget_count = 0;
  spawn_col    = 0;
  spawn_row    = 0;
  lv_label_set_text(widget_count_label, "Widgets: 0");
  log_clear(0);
}

// ---- Process WebSocket queue ----
void process_ws_queue() {
  WsCmd cmd;
  while (ws_dequeue(cmd)) {
    if (!cmd.valid) continue;

    if (strcmp(cmd.action, "spawn") == 0) {
      spawn_widget(cmd.type, 0);
    } else if (strcmp(cmd.action, "move") == 0) {
      move_widget_by_id(cmd.id, cmd.x, cmd.y);
    } else if (strcmp(cmd.action, "delete") == 0) {
      delete_widget_by_id(cmd.id);
    } else if (strcmp(cmd.action, "clear") == 0) {
      clear_all_widgets();
    }
  }
}

// ---- WebSocket event handler ----
void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client,
               AwsEventType type, void *arg, uint8_t *data, size_t len) {
  if (type == WS_EVT_CONNECT) {
    Serial.printf("{\"event\":\"ws_connect\",\"client\":%u}\n", client->id());

    // Send all current widgets to newly connected browser
    for (int i = 0; i < MAX_WIDGETS; i++) {
      if (widget_registry[i].active) {
        JsonDocument doc;
        doc["event"] = "spawn";
        doc["id"]    = widget_registry[i].id;
        doc["type"]  = widget_registry[i].type;
        doc["x"]     = lv_obj_get_x(widget_registry[i].obj);
        doc["y"]     = lv_obj_get_y(widget_registry[i].obj);
        doc["widget_count"] = widget_count;
        String out;
        serializeJson(doc, out);
        client->text(out);
      }
    }

  } else if (type == WS_EVT_DISCONNECT) {
    Serial.printf("{\"event\":\"ws_disconnect\",\"client\":%u}\n", client->id());

  } else if (type == WS_EVT_DATA) {
    AwsFrameInfo *info = (AwsFrameInfo*)arg;
    if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
      String msg = String((char*)data, len);
      JsonDocument doc;
      DeserializationError err = deserializeJson(doc, msg);
      if (err) return;

      WsCmd cmd;
      cmd.valid = true;
      cmd.id    = doc["id"] | 0;
      cmd.x     = doc["x"] | 0;
      cmd.y     = doc["y"] | 0;
      strncpy(cmd.action, doc["action"] | "unknown", 15);
      strncpy(cmd.type,   doc["type"]   | "button",  15);
      ws_enqueue(cmd);
    }
  }
}

// ---- Process UART command ----
void process_command(String cmd, String source) {
  cmd.trim();
  if (cmd.length() == 0) return;

  log_uart(cmd.c_str(), source.c_str());
  flash_uart_indicator(cmd.c_str());

  if (lv_scr_act() != screen_dashboard) lv_scr_load(screen_dashboard);

  if      (cmd == "widget:button")   spawn_widget("button",   0);
  else if (cmd == "widget:switch")   spawn_widget("switch",   0);
  else if (cmd == "widget:label")    spawn_widget("label",    0);
  else if (cmd == "widget:slider")   spawn_widget("slider",   0);
  else if (cmd == "widget:gauge")    spawn_widget("gauge",    0);
  else if (cmd == "widget:chart")    spawn_widget("chart",    0);
  else if (cmd == "widget:checkbox") spawn_widget("checkbox", 0);
  else if (cmd == "widget:bar")      spawn_widget("bar",      0);
  else if (cmd == "clear")           clear_all_widgets();
}

// ---- Handle UART ----
void handle_uart() {
  while (Serial2.available()) {
    char c = (char)Serial2.read();
    if (c == '\n' || c == '\r') {
      if (uart_buffer.length() > 0) {
        process_command(uart_buffer, "UART");
        uart_buffer = "";
      }
    } else { uart_buffer += c; }
  }
  while (Serial.available()) {
    char c = (char)Serial.read();
    if (c == '\n' || c == '\r') {
      if (usb_buffer.length() > 0) {
        process_command(usb_buffer, "USB");
        usb_buffer = "";
      }
    } else { usb_buffer += c; }
  }
}

// ---- Raw touch drag handler ----
void handle_touch() {
  uint16_t tx, ty;
  bool touched = lcd.getTouch(&tx, &ty);

  if (lv_scr_act() != screen_dashboard) {
    touch_was_down = false;
    is_dragging    = false;
    lp_pending     = false;
    touch_down_obj = NULL;
    return;
  }

  if (touched) {
    if (!touch_was_down) {
      is_dragging    = false;
      touch_down_obj = NULL;
      if (ty > WIDGET_AREA_Y) {
        lv_obj_t *hit  = find_widget_at(tx, ty);
        touch_down_obj = hit;
        if (hit) {
          lp_pending  = true;
          lp_start_ms = millis();
          lp_start_x  = tx;
          lp_start_y  = ty;
          drag_obj    = hit;
        }
      }
      touch_was_down = true;
    } else {
      if (lp_pending) {
        lv_coord_t dx = abs((lv_coord_t)tx - lp_start_x);
        lv_coord_t dy = abs((lv_coord_t)ty - lp_start_y);
        if (dx > LP_MOVE_THRESH || dy > LP_MOVE_THRESH) {
          lp_pending = false;
          if (selected_widget_obj && drag_obj == selected_widget_obj) {
            is_dragging = true;
            drag_off_x  = (lv_coord_t)tx - lv_obj_get_x(drag_obj);
            drag_off_y  = (lv_coord_t)ty - WIDGET_AREA_Y - lv_obj_get_y(drag_obj);
          } else { drag_obj = NULL; }
        } else if ((millis() - lp_start_ms) >= LONG_PRESS_MS) {
          lp_pending = false;
          if (drag_obj) {
            select_widget(drag_obj);
            is_dragging = true;
            drag_off_x  = (lv_coord_t)tx - lv_obj_get_x(drag_obj);
            drag_off_y  = (lv_coord_t)ty - WIDGET_AREA_Y - lv_obj_get_y(drag_obj);
          }
        }
      }
      if (is_dragging && drag_obj) {
        lv_coord_t new_x = (lv_coord_t)tx - drag_off_x;
        lv_coord_t new_y = (lv_coord_t)ty - WIDGET_AREA_Y - drag_off_y;
        lv_coord_t max_x = SCREEN_WIDTH  - lv_obj_get_width(drag_obj);
        lv_coord_t max_y = WIDGET_AREA_H - lv_obj_get_height(drag_obj);
        new_x = LV_MAX(0, LV_MIN(new_x, max_x));
        new_y = LV_MAX(0, LV_MIN(new_y, max_y));
        lv_obj_set_pos(drag_obj, new_x, new_y);
      }
    }
  } else {
    if (touch_was_down && !is_dragging) {
      lv_obj_t *hit = touch_down_obj;
      if (hit) {
        uint32_t now = millis();
        int tag = (int)(intptr_t)lv_obj_get_user_data(hit);
        if ((tag == WIDGET_TAG_LABEL || tag == WIDGET_TAG_CHECKBOX)
            && hit == last_tapped && (now - last_tap_time) < DOUBLE_TAP_MS) {
          deselect_widget();
          show_edit_keyboard(hit);
          last_tapped   = NULL;
          last_tap_time = 0;
        } else {
          last_tapped   = hit;
          last_tap_time = now;
          if (selected_widget_obj == hit) deselect_widget();
        }
      }
    }
    if (touch_was_down && is_dragging && drag_obj) {
      struct WidgetInfo *info = registry_find(drag_obj);
      if (info) {
        int fx = lv_obj_get_x(drag_obj);
        int fy = lv_obj_get_y(drag_obj);
        log_move(info->id, info->type, fx, fy, widget_count);
      }
    }
    touch_was_down = false;
    is_dragging    = false;
    lp_pending     = false;
    drag_obj       = NULL;
    touch_down_obj = NULL;
  }
}

// ---- Keyboard popup ----
void on_kb_ready(lv_event_t *e) {
  lv_obj_t *ta     = (lv_obj_t*)lv_event_get_user_data(e);
  lv_obj_t *popup  = lv_obj_get_parent(ta);
  lv_obj_t *target = (lv_obj_t*)lv_obj_get_user_data(popup);
  int tag = (int)(intptr_t)lv_obj_get_user_data(target);
  const char *txt = lv_textarea_get_text(ta);
  if (tag == WIDGET_TAG_LABEL) {
    lv_obj_t *lbl = lv_obj_get_child(target, 0);
    if (lbl) lv_label_set_text(lbl, txt);
  } else if (tag == WIDGET_TAG_CHECKBOX) {
    lv_obj_t *cb = lv_obj_get_child(target, 0);
    if (cb) lv_checkbox_set_text(cb, txt);
  }
  lv_obj_del(popup);
}

void on_kb_cancel(lv_event_t *e) {
  lv_obj_t *ta    = (lv_obj_t*)lv_event_get_user_data(e);
  lv_obj_t *popup = lv_obj_get_parent(ta);
  lv_obj_del(popup);
}

void show_edit_keyboard(lv_obj_t *widget) {
  int tag = (int)(intptr_t)lv_obj_get_user_data(widget);
  if (tag != WIDGET_TAG_LABEL && tag != WIDGET_TAG_CHECKBOX) return;
  const char *current = "";
  if (tag == WIDGET_TAG_LABEL) {
    lv_obj_t *lbl = lv_obj_get_child(widget, 0);
    if (lbl) current = lv_label_get_text(lbl);
  } else {
    lv_obj_t *cb = lv_obj_get_child(widget, 0);
    if (cb) current = lv_checkbox_get_text(cb);
  }
  lv_obj_t *popup = lv_obj_create(screen_dashboard);
  lv_obj_set_size(popup, SCREEN_WIDTH, SCREEN_HEIGHT);
  lv_obj_set_pos(popup, 0, 0);
  lv_obj_set_style_bg_color(popup, lv_color_hex(0x0d0d1a), 0);
  lv_obj_set_style_bg_opa(popup, LV_OPA_90, 0);
  lv_obj_set_style_border_width(popup, 0, 0);
  lv_obj_set_style_radius(popup, 0, 0);
  lv_obj_clear_flag(popup, LV_OBJ_FLAG_SCROLLABLE);
  lv_obj_set_user_data(popup, widget);
  lv_obj_t *title = lv_label_create(popup);
  lv_label_set_text(title, "Edit Text -- OK to save   X to cancel");
  lv_obj_set_style_text_color(title, lv_color_hex(COL_WHITE), 0);
  lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0);
  lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 12);
  lv_obj_t *ta = lv_textarea_create(popup);
  lv_obj_set_size(ta, 680, 50);
  lv_obj_align(ta, LV_ALIGN_TOP_MID, 0, 45);
  lv_textarea_set_one_line(ta, true);
  lv_textarea_set_text(ta, current);
  lv_obj_set_style_bg_color(ta, lv_color_hex(0x222244), 0);
  lv_obj_set_style_text_color(ta, lv_color_hex(COL_WHITE), 0);
  lv_obj_set_style_border_color(ta, lv_color_hex(COL_EDIT), 0);
  lv_obj_set_style_border_width(ta, 2, 0);
  lv_obj_t *kb = lv_keyboard_create(popup);
  lv_obj_set_size(kb, SCREEN_WIDTH, 290);
  lv_obj_align(kb, LV_ALIGN_BOTTOM_MID, 0, 0);
  lv_keyboard_set_textarea(kb, ta);
  lv_obj_set_style_bg_color(kb, lv_color_hex(COL_HEADER), 0);
  lv_obj_set_style_bg_color(kb, lv_color_hex(0x2a2a4a), LV_PART_ITEMS);
  lv_obj_set_style_text_color(kb, lv_color_hex(COL_WHITE), LV_PART_ITEMS);
  lv_obj_set_style_border_color(kb, lv_color_hex(0x444466), LV_PART_ITEMS);
  lv_obj_add_event_cb(kb, on_kb_ready,  LV_EVENT_READY,  ta);
  lv_obj_add_event_cb(kb, on_kb_cancel, LV_EVENT_CANCEL, ta);
}

// ---- Delete popup ----
void on_confirm_delete(lv_event_t *e) {
  lv_obj_t *popup = (lv_obj_t*)lv_event_get_user_data(e);
  if (selected_widget_obj) {
    struct WidgetInfo *info = registry_find(selected_widget_obj);
    if (info) log_delete(info->id, info->type, widget_count - 1);
    registry_remove(selected_widget_obj);
    lv_obj_del(selected_widget_obj);
    selected_widget_obj = NULL;
    widget_count--;
    char buf[32];
    sprintf(buf, "Widgets: %d", widget_count);
    lv_label_set_text(widget_count_label, buf);
    lv_obj_add_flag(delete_btn, LV_OBJ_FLAG_HIDDEN);
  }
  lv_obj_del(popup);
}

void on_cancel_delete(lv_event_t *e) {
  lv_obj_t *popup = (lv_obj_t*)lv_event_get_user_data(e);
  lv_obj_del(popup);
}

void show_delete_confirm() {
  lv_obj_t *popup = lv_obj_create(screen_dashboard);
  lv_obj_set_size(popup, 320, 160);
  lv_obj_align(popup, LV_ALIGN_CENTER, 0, 0);
  lv_obj_set_style_bg_color(popup, lv_color_hex(COL_HEADER), 0);
  lv_obj_set_style_border_color(popup, lv_color_hex(COL_DELETE), 0);
  lv_obj_set_style_border_width(popup, 2, 0);
  lv_obj_set_style_radius(popup, 12, 0);
  lv_obj_clear_flag(popup, LV_OBJ_FLAG_SCROLLABLE);
  lv_obj_t *msg = lv_label_create(popup);
  lv_label_set_text(msg, "Delete this widget?");
  lv_obj_set_style_text_color(msg, lv_color_hex(COL_WHITE), 0);
  lv_obj_set_style_text_font(msg, &lv_font_montserrat_16, 0);
  lv_obj_align(msg, LV_ALIGN_TOP_MID, 0, 20);
  lv_obj_t *confirm = lv_btn_create(popup);
  lv_obj_set_size(confirm, 110, 50);
  lv_obj_align(confirm, LV_ALIGN_BOTTOM_LEFT, 20, -15);
  lv_obj_set_style_bg_color(confirm, lv_color_hex(COL_DELETE), 0);
  lv_obj_add_event_cb(confirm, on_confirm_delete, LV_EVENT_CLICKED, popup);
  lv_obj_t *clbl = lv_label_create(confirm);
  lv_label_set_text(clbl, "Delete");
  lv_obj_align(clbl, LV_ALIGN_CENTER, 0, 0);
  lv_obj_t *cancel = lv_btn_create(popup);
  lv_obj_set_size(cancel, 110, 50);
  lv_obj_align(cancel, LV_ALIGN_BOTTOM_RIGHT, -20, -15);
  lv_obj_set_style_bg_color(cancel, lv_color_hex(0x444444), 0);
  lv_obj_add_event_cb(cancel, on_cancel_delete, LV_EVENT_CLICKED, popup);
  lv_obj_t *cancellbl = lv_label_create(cancel);
  lv_label_set_text(cancellbl, "Cancel");
  lv_obj_align(cancellbl, LV_ALIGN_CENTER, 0, 0);
}

void on_delete_btn(lv_event_t *e) {
  if (selected_widget_obj) show_delete_confirm();
}

// ---- Spawn widget ----
void spawn_widget(const char* type, int style_idx) {
  int wx, wy;
  get_spawn_pos(wx, wy);
  spawn_widget_at(type, style_idx, wx, wy);
}

void spawn_widget_at(const char* type, int style_idx, int wx, int wy) {
  WidgetStyle &s = styles[style_idx];
  widget_count++;
  int this_id = next_widget_id++;
  lv_obj_t *w = NULL;
  int tag = WIDGET_TAG_OTHER;

  if (strcmp(type, "button") == 0) {
    w = lv_btn_create(dashboard_area);
    lv_obj_set_size(w, s.w, s.h);
    lv_obj_set_style_bg_color(w, lv_color_hex(s.color), 0);
    lv_obj_t *lbl = lv_label_create(w);
    char buf[32]; sprintf(buf, "Button %d", this_id);
    lv_label_set_text(lbl, buf);
    lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0);

  } else if (strcmp(type, "switch") == 0) {
    w = lv_obj_create(dashboard_area);
    lv_obj_set_size(w, s.w + 20, s.h + 35);
    lv_obj_set_style_bg_color(w, lv_color_hex(0x222244), 0);
    lv_obj_set_style_border_color(w, lv_color_hex(s.color), 0);
    lv_obj_set_style_border_width(w, 2, 0);
    lv_obj_set_style_radius(w, 10, 0);
    lv_obj_clear_flag(w, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_t *lbl = lv_label_create(w);
    char buf[32]; sprintf(buf, "Switch %d", this_id);
    lv_label_set_text(lbl, buf);
    lv_obj_set_style_text_color(lbl, lv_color_hex(COL_WHITE), 0);
    lv_obj_align(lbl, LV_ALIGN_TOP_MID, 0, 5);
    lv_obj_t *sw = lv_switch_create(w);
    lv_obj_set_size(sw, s.w - 10, s.h - 15);
    lv_obj_set_style_bg_color(sw, lv_color_hex(s.color), LV_PART_INDICATOR | LV_STATE_CHECKED);
    lv_obj_align(sw, LV_ALIGN_BOTTOM_MID, 0, -5);

  } else if (strcmp(type, "label") == 0) {
    tag = WIDGET_TAG_LABEL;
    w = lv_obj_create(dashboard_area);
    lv_obj_set_size(w, s.w, s.h);
    lv_obj_set_style_bg_color(w, lv_color_hex(0x222244), 0);
    lv_obj_set_style_border_color(w, lv_color_hex(s.color), 0);
    lv_obj_set_style_border_width(w, 2, 0);
    lv_obj_set_style_radius(w, 8, 0);
    lv_obj_clear_flag(w, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_t *lbl = lv_label_create(w);
    char buf[32]; sprintf(buf, "Label %d", this_id);
    lv_label_set_text(lbl, buf);
    lv_obj_set_style_text_color(lbl, lv_color_hex(s.color), 0);
    lv_obj_set_style_text_font(lbl,
      style_idx == 0 ? &lv_font_montserrat_20 :
      style_idx == 1 ? &lv_font_montserrat_16 :
                       &lv_font_montserrat_14, 0);
    lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0);

  } else if (strcmp(type, "slider") == 0) {
    w = lv_obj_create(dashboard_area);
    lv_obj_set_size(w, s.w + 40, s.h + 35);
    lv_obj_set_style_bg_color(w, lv_color_hex(0x222244), 0);
    lv_obj_set_style_border_color(w, lv_color_hex(s.color), 0);
    lv_obj_set_style_border_width(w, 2, 0);
    lv_obj_set_style_radius(w, 10, 0);
    lv_obj_clear_flag(w, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_t *lbl = lv_label_create(w);
    char buf[32]; sprintf(buf, "Slider %d", this_id);
    lv_label_set_text(lbl, buf);
    lv_obj_set_style_text_color(lbl, lv_color_hex(COL_WHITE), 0);
    lv_obj_align(lbl, LV_ALIGN_TOP_MID, 0, 5);
    lv_obj_t *sl = lv_slider_create(w);
    lv_obj_set_size(sl, s.w, s.h / 2);
    lv_obj_set_style_bg_color(sl, lv_color_hex(s.color), LV_PART_INDICATOR);
    lv_obj_set_style_bg_color(sl, lv_color_hex(s.color), LV_PART_KNOB);
    lv_obj_align(sl, LV_ALIGN_BOTTOM_MID, 0, -8);

  } else if (strcmp(type, "gauge") == 0) {
    w = lv_obj_create(dashboard_area);
    lv_obj_set_size(w, s.w + 20, s.h + 20);
    lv_obj_set_style_bg_color(w, lv_color_hex(0x222244), 0);
    lv_obj_set_style_border_color(w, lv_color_hex(s.color), 0);
    lv_obj_set_style_border_width(w, 2, 0);
    lv_obj_set_style_radius(w, 10, 0);
    lv_obj_clear_flag(w, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_t *arc = lv_arc_create(w);
    lv_arc_set_rotation(arc, 135);
    lv_arc_set_bg_angles(arc, 0, 270);
    lv_arc_set_value(arc, 60);
    lv_obj_set_size(arc, s.w - 10, s.w - 10);
    lv_obj_set_style_arc_color(arc, lv_color_hex(s.color), LV_PART_INDICATOR);
    lv_obj_align(arc, LV_ALIGN_CENTER, 0, 5);
    lv_obj_t *lbl = lv_label_create(w);
    char buf[32]; sprintf(buf, "Gauge %d", this_id);
    lv_label_set_text(lbl, buf);
    lv_obj_set_style_text_color(lbl, lv_color_hex(COL_WHITE), 0);
    lv_obj_align(lbl, LV_ALIGN_TOP_MID, 0, 3);

  } else if (strcmp(type, "chart") == 0) {
    w = lv_obj_create(dashboard_area);
    lv_obj_set_size(w, s.w + 60, s.h + 40);
    lv_obj_set_style_bg_color(w, lv_color_hex(0x222244), 0);
    lv_obj_set_style_border_color(w, lv_color_hex(s.color), 0);
    lv_obj_set_style_border_width(w, 2, 0);
    lv_obj_set_style_radius(w, 10, 0);
    lv_obj_clear_flag(w, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_t *lbl = lv_label_create(w);
    char buf[32]; sprintf(buf, "Chart %d", this_id);
    lv_label_set_text(lbl, buf);
    lv_obj_set_style_text_color(lbl, lv_color_hex(COL_WHITE), 0);
    lv_obj_align(lbl, LV_ALIGN_TOP_MID, 0, 3);
    lv_obj_t *chart = lv_chart_create(w);
    lv_obj_set_size(chart, s.w + 40, s.h + 10);
    lv_chart_set_type(chart, LV_CHART_TYPE_LINE);
    lv_obj_set_style_bg_color(chart, lv_color_hex(0x111133), 0);
    lv_chart_series_t *ser = lv_chart_add_series(chart, lv_color_hex(s.color), LV_CHART_AXIS_PRIMARY_Y);
    lv_chart_set_next_value(chart, ser, 30);
    lv_chart_set_next_value(chart, ser, 60);
    lv_chart_set_next_value(chart, ser, 45);
    lv_chart_set_next_value(chart, ser, 80);
    lv_chart_set_next_value(chart, ser, 55);
    lv_chart_refresh(chart);
    lv_obj_align(chart, LV_ALIGN_BOTTOM_MID, 0, -5);

  } else if (strcmp(type, "checkbox") == 0) {
    tag = WIDGET_TAG_CHECKBOX;
    w = lv_obj_create(dashboard_area);
    lv_obj_set_size(w, s.w, s.h);
    lv_obj_set_style_bg_color(w, lv_color_hex(0x222244), 0);
    lv_obj_set_style_border_color(w, lv_color_hex(s.color), 0);
    lv_obj_set_style_border_width(w, 2, 0);
    lv_obj_set_style_radius(w, 8, 0);
    lv_obj_clear_flag(w, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_t *cb = lv_checkbox_create(w);
    char buf[32]; sprintf(buf, "Check %d", this_id);
    lv_checkbox_set_text(cb, buf);
    lv_obj_set_style_text_color(cb, lv_color_hex(COL_WHITE), 0);
    lv_obj_set_style_bg_color(cb, lv_color_hex(s.color), LV_PART_INDICATOR | LV_STATE_CHECKED);
    lv_obj_align(cb, LV_ALIGN_CENTER, 0, 0);

  } else if (strcmp(type, "bar") == 0) {
    w = lv_obj_create(dashboard_area);
    lv_obj_set_size(w, s.w + 40, s.h + 20);
    lv_obj_set_style_bg_color(w, lv_color_hex(0x222244), 0);
    lv_obj_set_style_border_color(w, lv_color_hex(s.color), 0);
    lv_obj_set_style_border_width(w, 2, 0);
    lv_obj_set_style_radius(w, 10, 0);
    lv_obj_clear_flag(w, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_t *lbl = lv_label_create(w);
    char buf[32]; sprintf(buf, "Bar %d", this_id);
    lv_label_set_text(lbl, buf);
    lv_obj_set_style_text_color(lbl, lv_color_hex(COL_WHITE), 0);
    lv_obj_align(lbl, LV_ALIGN_TOP_MID, 0, 3);
    lv_obj_t *bar = lv_bar_create(w);
    lv_obj_set_size(bar, s.w + 20, s.h / 3);
    lv_bar_set_value(bar, 65, LV_ANIM_ON);
    lv_obj_set_style_bg_color(bar, lv_color_hex(s.color), LV_PART_INDICATOR);
    lv_obj_align(bar, LV_ALIGN_BOTTOM_MID, 0, -8);
  }

  if (w) {
    lv_obj_set_pos(w, wx, wy);
    lv_obj_set_user_data(w, (void*)(intptr_t)tag);
    lv_obj_set_style_transform_pivot_x(w, lv_obj_get_width(w) / 2, 0);
    lv_obj_set_style_transform_pivot_y(w, lv_obj_get_height(w) / 2, 0);
    registry_add(w, this_id, type);
    log_spawn(this_id, type, wx, wy, widget_count);
  }

  char cnt[32];
  sprintf(cnt, "Widgets: %d", widget_count);
  lv_label_set_text(widget_count_label, cnt);
}

// ---- Nav ----
void on_add_btn(lv_event_t *e) { deselect_widget(); lv_scr_load(screen_picker); }
void on_widget_type_btn(lv_event_t *e) {
  selected_type = String((const char*)lv_event_get_user_data(e));
  lv_scr_load(screen_style);
}
void on_style_btn(lv_event_t *e) {
  int idx = (int)(intptr_t)lv_event_get_user_data(e);
  spawn_widget(selected_type.c_str(), idx);
  lv_scr_load(screen_dashboard);
}
void on_back_to_dashboard(lv_event_t *e) { lv_scr_load(screen_dashboard); }
void on_back_to_picker(lv_event_t *e)    { lv_scr_load(screen_picker); }

// ---- Build Dashboard ----
void build_dashboard() {
  screen_dashboard = lv_obj_create(NULL);
  lv_obj_set_style_bg_color(screen_dashboard, lv_color_hex(COL_BG), 0);

  lv_obj_t *header = lv_obj_create(screen_dashboard);
  lv_obj_set_size(header, SCREEN_WIDTH, 60);
  lv_obj_set_pos(header, 0, 0);
  lv_obj_set_style_bg_color(header, lv_color_hex(COL_HEADER), 0);
  lv_obj_set_style_border_width(header, 0, 0);
  lv_obj_set_style_radius(header, 0, 0);
  lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE);

  lv_obj_t *title = lv_label_create(header);
  lv_label_set_text(title, "JSON Dashboard");
  lv_obj_set_style_text_color(title, lv_color_hex(COL_WHITE), 0);
  lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
  lv_obj_align(title, LV_ALIGN_LEFT_MID, 15, 0);

  widget_count_label = lv_label_create(header);
  lv_label_set_text(widget_count_label, "Widgets: 0");
  lv_obj_set_style_text_color(widget_count_label, lv_color_hex(COL_DARK_TEXT), 0);
  lv_obj_align(widget_count_label, LV_ALIGN_LEFT_MID, 185, 0);

  uart_status_label = lv_label_create(header);
  lv_label_set_text(uart_status_label, "UART: waiting...");
  lv_obj_set_style_text_color(uart_status_label, lv_color_hex(COL_DARK_TEXT), 0);
  lv_obj_set_style_text_font(uart_status_label, &lv_font_montserrat_14, 0);
  lv_obj_align(uart_status_label, LV_ALIGN_LEFT_MID, 320, 0);

  wifi_status_label = lv_label_create(header);
  lv_label_set_text(wifi_status_label, "WiFi: starting...");
  lv_obj_set_style_text_color(wifi_status_label, lv_color_hex(COL_DARK_TEXT), 0);
  lv_obj_set_style_text_font(wifi_status_label, &lv_font_montserrat_14, 0);
  lv_obj_align(wifi_status_label, LV_ALIGN_LEFT_MID, 480, 0);

  delete_btn = lv_btn_create(header);
  lv_obj_set_size(delete_btn, 100, 40);
  lv_obj_align(delete_btn, LV_ALIGN_RIGHT_MID, -110, 0);
  lv_obj_set_style_bg_color(delete_btn, lv_color_hex(COL_DELETE), 0);
  lv_obj_add_event_cb(delete_btn, on_delete_btn, LV_EVENT_CLICKED, NULL);
  lv_obj_add_flag(delete_btn, LV_OBJ_FLAG_HIDDEN);
  lv_obj_t *dlbl = lv_label_create(delete_btn);
  lv_label_set_text(dlbl, "Delete");
  lv_obj_align(dlbl, LV_ALIGN_CENTER, 0, 0);

  lv_obj_t *add_btn = lv_btn_create(header);
  lv_obj_set_size(add_btn, 90, 40);
  lv_obj_align(add_btn, LV_ALIGN_RIGHT_MID, -10, 0);
  lv_obj_set_style_bg_color(add_btn, lv_color_hex(COL_PRIMARY), 0);
  lv_obj_add_event_cb(add_btn, on_add_btn, LV_EVENT_CLICKED, NULL);
  lv_obj_t *albl = lv_label_create(add_btn);
  lv_label_set_text(albl, "+ Add");
  lv_obj_align(albl, LV_ALIGN_CENTER, 0, 0);

  dashboard_area = lv_obj_create(screen_dashboard);
  lv_obj_set_pos(dashboard_area, 0, WIDGET_AREA_Y);
  lv_obj_set_size(dashboard_area, SCREEN_WIDTH, WIDGET_AREA_H);
  lv_obj_set_style_bg_color(dashboard_area, lv_color_hex(COL_BG), 0);
  lv_obj_set_style_border_width(dashboard_area, 0, 0);
  lv_obj_set_style_radius(dashboard_area, 0, 0);
  lv_obj_clear_flag(dashboard_area, LV_OBJ_FLAG_SCROLLABLE);
}

// ---- Build Picker ----
void build_picker() {
  screen_picker = lv_obj_create(NULL);
  lv_obj_set_style_bg_color(screen_picker, lv_color_hex(COL_BG), 0);

  lv_obj_t *header = lv_obj_create(screen_picker);
  lv_obj_set_size(header, SCREEN_WIDTH, 60);
  lv_obj_set_pos(header, 0, 0);
  lv_obj_set_style_bg_color(header, lv_color_hex(COL_HEADER), 0);
  lv_obj_set_style_border_width(header, 0, 0);
  lv_obj_set_style_radius(header, 0, 0);

  lv_obj_t *title = lv_label_create(header);
  lv_label_set_text(title, "Choose Widget Type");
  lv_obj_set_style_text_color(title, lv_color_hex(COL_WHITE), 0);
  lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
  lv_obj_align(title, LV_ALIGN_CENTER, 0, 0);

  lv_obj_t *back_btn = lv_btn_create(header);
  lv_obj_set_size(back_btn, 80, 40);
  lv_obj_align(back_btn, LV_ALIGN_LEFT_MID, 10, 0);
  lv_obj_set_style_bg_color(back_btn, lv_color_hex(0x333333), 0);
  lv_obj_add_event_cb(back_btn, on_back_to_dashboard, LV_EVENT_CLICKED, NULL);
  lv_obj_t *blbl = lv_label_create(back_btn);
  lv_label_set_text(blbl, "< Back");
  lv_obj_align(blbl, LV_ALIGN_CENTER, 0, 0);

  const char* types[]  = { "button","switch","label","slider","gauge","chart","checkbox","bar" };
  const char* labels[] = { "Button","Switch","Label","Slider","Gauge","Chart","Checkbox","Bar" };
  uint32_t colors[]    = { 0x1e90ff,0x2ecc71,0xf39c12,0x9b59b6,0xe74c3c,0x1abc9c,0xe67e22,0x8e44ad };

  for (int i = 0; i < 8; i++) {
    int col = i % 4;
    int row = i / 4;
    lv_obj_t *btn = lv_btn_create(screen_picker);
    lv_obj_set_size(btn, 170, 130);
    lv_obj_set_pos(btn, 20 + col * 190, 90 + row * 150);
    lv_obj_set_style_bg_color(btn, lv_color_hex(colors[i]), 0);
    lv_obj_set_style_radius(btn, 12, 0);
    lv_obj_add_event_cb(btn, on_widget_type_btn, LV_EVENT_CLICKED, (void*)types[i]);
    lv_obj_t *lbl = lv_label_create(btn);
    lv_label_set_text(lbl, labels[i]);
    lv_obj_set_style_text_font(lbl, &lv_font_montserrat_16, 0);
    lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0);
  }
}

// ---- Build Style Picker ----
void build_style_picker() {
  screen_style = lv_obj_create(NULL);
  lv_obj_set_style_bg_color(screen_style, lv_color_hex(COL_BG), 0);

  lv_obj_t *header = lv_obj_create(screen_style);
  lv_obj_set_size(header, SCREEN_WIDTH, 60);
  lv_obj_set_pos(header, 0, 0);
  lv_obj_set_style_bg_color(header, lv_color_hex(COL_HEADER), 0);
  lv_obj_set_style_border_width(header, 0, 0);
  lv_obj_set_style_radius(header, 0, 0);

  lv_obj_t *title = lv_label_create(header);
  lv_label_set_text(title, "Choose Style");
  lv_obj_set_style_text_color(title, lv_color_hex(COL_WHITE), 0);
  lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0);
  lv_obj_align(title, LV_ALIGN_CENTER, 0, 0);

  lv_obj_t *back_btn = lv_btn_create(header);
  lv_obj_set_size(back_btn, 80, 40);
  lv_obj_align(back_btn, LV_ALIGN_LEFT_MID, 10, 0);
  lv_obj_set_style_bg_color(back_btn, lv_color_hex(0x333333), 0);
  lv_obj_add_event_cb(back_btn, on_back_to_picker, LV_EVENT_CLICKED, NULL);
  lv_obj_t *blbl = lv_label_create(back_btn);
  lv_label_set_text(blbl, "< Back");
  lv_obj_align(blbl, LV_ALIGN_CENTER, 0, 0);

  const char* style_names[] = { "Primary", "Secondary", "Tertiary" };
  const char* size_labels[] = { "Large\n200 x 80", "Medium\n160 x 60", "Small\n120 x 50" };

  for (int i = 0; i < 3; i++) {
    lv_obj_t *card = lv_obj_create(screen_style);
    lv_obj_set_size(card, 210, 220);
    lv_obj_set_pos(card, 85 + i * 230, 110);
    lv_obj_set_style_bg_color(card, lv_color_hex(COL_HEADER), 0);
    lv_obj_set_style_border_color(card, lv_color_hex(styles[i].color), 0);
    lv_obj_set_style_border_width(card, 3, 0);
    lv_obj_set_style_radius(card, 14, 0);
    lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_add_event_cb(card, on_style_btn, LV_EVENT_CLICKED, (void*)(intptr_t)i);

    lv_obj_t *name = lv_label_create(card);
    lv_label_set_text(name, style_names[i]);
    lv_obj_set_style_text_color(name, lv_color_hex(styles[i].color), 0);
    lv_obj_set_style_text_font(name, &lv_font_montserrat_20, 0);
    lv_obj_align(name, LV_ALIGN_TOP_MID, 0, 15);

    lv_obj_t *swatch = lv_obj_create(card);
    lv_obj_set_size(swatch, 90, 45);
    lv_obj_set_style_bg_color(swatch, lv_color_hex(styles[i].color), 0);
    lv_obj_set_style_border_width(swatch, 0, 0);
    lv_obj_set_style_radius(swatch, 8, 0);
    lv_obj_align(swatch, LV_ALIGN_CENTER, 0, -5);

    lv_obj_t *sizelbl = lv_label_create(card);
    lv_label_set_text(sizelbl, size_labels[i]);
    lv_obj_set_style_text_color(sizelbl, lv_color_hex(COL_DARK_TEXT), 0);
    lv_obj_set_style_text_align(sizelbl, LV_TEXT_ALIGN_CENTER, 0);
    lv_obj_align(sizelbl, LV_ALIGN_BOTTOM_MID, 0, -15);
  }
}

// ---- Setup ----
void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("{\"event\":\"boot\",\"msg\":\"JSON Dashboard + UART + WiFi\"}");

  pinMode(2, OUTPUT); digitalWrite(2, HIGH);
  pinMode(38, OUTPUT); digitalWrite(38, LOW);

  // WiFi AP
  WiFi.mode(WIFI_AP);
  WiFi.softAP(WIFI_SSID, WIFI_PASS);
  Serial.printf("{\"event\":\"wifi\",\"ssid\":\"%s\",\"ip\":\"%s\"}\n",
                WIFI_SSID, WiFi.softAPIP().toString().c_str());

  // WebSocket
  ws.onEvent(onWsEvent);
  server.addHandler(&ws);

  // Web page
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send_P(200, "text/html", DASHBOARD_HTML);
  });

  server.begin();
  Serial.println("{\"event\":\"server\",\"msg\":\"Web server started on port 80\"}");

  // Display
  lcd.begin();
  lv_init();

  lv_disp_draw_buf_init(&draw_buf, buf1, NULL, SCREEN_WIDTH * SCREEN_HEIGHT / 10);

  static lv_disp_drv_t disp_drv;
  lv_disp_drv_init(&disp_drv);
  disp_drv.hor_res  = SCREEN_WIDTH;
  disp_drv.ver_res  = SCREEN_HEIGHT;
  disp_drv.flush_cb = my_disp_flush;
  disp_drv.draw_buf = &draw_buf;
  lv_disp_drv_register(&disp_drv);

  static lv_indev_drv_t indev_drv;
  lv_indev_drv_init(&indev_drv);
  indev_drv.type    = LV_INDEV_TYPE_POINTER;
  indev_drv.read_cb = my_touch_read;
  lv_indev_drv_register(&indev_drv);

  Serial2.begin(UART_BAUD, SERIAL_8N1, UART_RX, UART_TX);
  registry_clear();

  build_dashboard();
  build_picker();
  build_style_picker();

  lv_scr_load(screen_dashboard);

  // Update WiFi label on screen
  lv_label_set_text(wifi_status_label, "WiFi: 192.168.4.1");
  lv_obj_set_style_text_color(wifi_status_label, lv_color_hex(COL_EDIT), 0);

  Serial.println("{\"event\":\"ready\",\"msg\":\"Dashboard live\"}");
}

void loop() {
  ws.cleanupClients();
  handle_uart();
  handle_touch();
  process_ws_queue();
  update_uart_indicator();
  lv_timer_handler();
  delay(5);
}

07 Code Breakdown

Here is what each part of the code does. Read this after uploading.

Libraries

Library Purpose
LovyanGFX.hpp / Panel_RGB.hpp / Bus_RGB.hpp Drives the RGB parallel display bus and reads the built-in GT911 touch controller.
lvgl.h Builds and manages every on-screen widget, screen, and gesture.
ArduinoJson.h Formats every spawn / move / delete / clear / uart event as JSON before sending it out.
WiFi.h Starts the CrowPanel as its own Wi-Fi access point (soft AP).
AsyncTCP.h / ESPAsyncWebServer.h Serves the browser dashboard page and keeps a live WebSocket link with any connected browser.

Key Functions

spawn_widget_at()

Creates one LVGL widget of the requested type and style at a given position, registers it with an ID in the widget registry, and immediately calls the matching log_spawn() so the new widget is broadcast to Serial, UART2, and every browser tab.

handle_touch()

Reads raw GT911 touch coordinates every loop and turns them into drag, long-press-to-select, and double-tap-to-edit gestures on whichever widget is under the finger, independent of LVGL's default input handling.

process_command()

Takes one trimmed line of text from either UART2 or USB Serial, logs it as a uart event, flashes the on-screen UART status label, and matches it against the supported widget:* and clear commands to trigger the same spawn/clear logic used by the touchscreen.

onWsEvent()

Handles WebSocket connect, disconnect, and incoming-message events from the browser dashboard. On connect, it replays the full current widget layout to the new browser tab; on an incoming message, it queues the requested spawn, move, delete, or clear action for the main loop to process.

log_spawn() / log_move() / log_delete() / log_clear() / log_uart()

This family of functions is the shared "formatting" layer behind every interface in this project. Each one builds one small JSON document for its event type and sends the exact same serialized string to the USB Serial Monitor, the UART2 line, and every WebSocket client through ws_broadcast().

build_dashboard() / build_picker() / build_style_picker()

Construct the three LVGL screens used in this project: the main dashboard canvas with its header and delete button, the widget-type picker grid, and the Primary / Secondary / Tertiary style picker.

General Program Workflow

  1. On boot, the CrowPanel starts its Wi-Fi access point and prints its SSID and IP address as a JSON event.
  2. The WebSocket handler and web server are registered and started so the browser dashboard page becomes reachable at 192.168.4.1.
  3. The display, LVGL, and the GT911 touch driver are initialized, and UART2 is opened at 115200 baud.
  4. The three LVGL screens are built, and the dashboard screen is loaded.
  5. The main loop continuously: reads any pending UART2 or USB Serial command, reads raw touch input for drag/select/edit gestures, drains any queued WebSocket actions, refreshes the UART status indicator, and runs the LVGL timer handler to redraw the screen.

08 Testing and Calibration

After uploading, verify each of the following to confirm the system is working correctly.

Touch Test

Tap + Add, choose a widget type, then a style, and confirm it appears on the canvas. Long-press a widget to select it (a yellow border and a Delete button should appear), drag it around, and confirm double-tapping a label or checkbox opens the on-screen keyboard for renaming it.

UART Command Test

Open the Arduino IDE Serial Monitor at 115200 baud and type widget:button, then press Enter. A new button widget should appear on the panel, and a matching JSON spawn event should print in the Serial Monitor. Type clear to confirm the canvas empties and a clear event is printed. Repeat over Serial2 from an external board if one is wired in.

Wi-Fi Browser Dashboard Test

On a laptop or phone, connect to the Wi-Fi network CrowPanel-Dashboard using the password crowpanel123, then open 192.168.4.1 in a browser. The status indicator should switch to "Connected." Add a widget from the browser and confirm it appears on the physical panel, then add one on the panel and confirm it appears in the browser.

Common Issue: A blank or white screen on boot is usually caused by a missing touch driver header or a PSRAM setting mismatch. Double-check that PSRAM is set to OPI PSRAM in the board settings and that the GT911 pins (SDA = 19, SCL = 20) match the physical module before re-uploading.

09 System Demonstration

The images below show the working system. Use these to verify your output matches what is expected.

Dashboard Idle Screen


Widget Type and Style Picker


Browser Dashboard Synced with the Panel


Video Demonstration

10 Conclusion

This iteration turns the original touch-only JSON dashboard into a three-way synced control surface. The touchscreen, an external UART link, and a Wi-Fi browser tab all trigger the same widget logic and all agree on the current layout, because every action passes through one consistent JSON event format sent to Serial, UART2, and WebSocket at the same time.

Possible Improvements and Future Enhancements

  • Add a login or access-code gate to the browser dashboard before exposing it on a shared network.
  • Persist the widget layout to SPIFFS or LittleFS so the dashboard restores itself after a power cycle.
  • Add more widget types, such as a dropdown, roller, or spinner, and forward their live values (not just position) over UART and WebSocket.
  • Bridge the UART command channel to MQTT so the panel can be triggered from a wider home or lab automation network.
  • Switch from access-point mode to station mode with mDNS so several CrowPanel units can share one existing Wi-Fi network.

11 References

  • LVGL Documentation (docs.lvgl.io)
  • ArduinoJson Documentation (arduinojson.org)
  • ESPAsyncWebServer library documentation (Arduino Library Manager listing)
  • LovyanGFX library documentation (Arduino Library Manager listing)
  • Espressif ESP32 Arduino Core documentation
  • Elecrow CrowPanel ESP32 5.0" HMI product documentation

12 Project Authors

  • Christian P. Agustin
  • Janos Lei A. Araña
CrowPanel ESP32 5.0" HMI Dashboard – CreateLabz

CrowpanelDashboardUart

Leave a comment

All comments are moderated before being published