Перевод статьи ESP32: WS2812B 8×8 Matrix Shape Editor Web Server (Arduino IDE) — Rui Santos & Sara Santos, Random Nerd Tutorials.

В этом проекте мы сделаем редактор фигур для светодиодной матрицы 8×8 на WS2812B, работающий на ESP32. Плата подключается к вашей Wi-Fi-сети и отдаёт веб-страницу, на которой можно рисовать фигуры и сразу выводить их на физическую матрицу. В редакторе задаётся цвет каждого пикселя и общая яркость, а все нарисованные фигуры хранятся в памяти ESP32 и переживают перезагрузку.

Сохранённые фигуры видны в галерее: по клику фигура мгновенно появляется на матрице и подгружается обратно в редактор для правок. Есть и слайд-шоу, прокручивающее последовательность фигур.

Что получится#

Веб-страница редактора фигур для матрицы 8×8

Так выглядит веб-страница проекта

Основные возможности:

  • редактор сетки 8×8 — свой цвет для каждого пикселя;
  • регулировка яркости — ползунок меняет яркость всей фигуры;
  • 10 слотов памяти — фигуры хранятся в LittleFS в виде JSON и сохраняются после перезагрузки;
  • загрузка для правки — клик по фигуре в галерее выводит её на матрицу и одновременно возвращает в редактор;
  • превью в галерее — миниатюра показывает итоговый вид фигуры;
  • мгновенный предпросмотр — можно проверить рисунок на светодиодах, не сохраняя его в слот;
  • удаление — при удалении фигуры гаснет и физическая матрица;
  • слайд-шоу — прокрутка всех сохранённых фигур с настраиваемым интервалом.

Что нужно знать заранее#

Понадобится Arduino IDE с установленной поддержкой плат ESP32:

Полезно будет заглянуть и в эти руководства:

Матрица 8×8 на WS2812B#

Панель содержит 64 RGB-светодиода (они же neopixels); каждым можно управлять независимо, задавая любой цвет и яркость, — и всё это по одному цифровому выводу ESP32.

Панель-матрица 8×8 из 64 светодиодов WS2812B

Панель 8×8 на 64 светодиодах WS2812B

Питание панели#

Матрицу следует питать от источника 5 В. При 5 В каждый светодиод на полной яркости потребляет около 50 мА, то есть на полной яркости белым каждые 30 светодиодов могут взять до 1,5 А. Адаптера 5 В 2 А достаточно.

Если используете внешний источник, не забудьте соединить его землю с выводом GND платы ESP32. В этом проекте мы управляем 64 светодиодами от внешнего источника.

Что понадобится#

  • плата ESP32 — любая модель;
  • панель-матрица 8×8 на 64 светодиодах WS2812B;
  • адаптер питания 5 В 2 А;
  • соединительные провода.

Подключение#

Подключение простое: нужен всего один цифровой вывод (DIN) для управления всеми светодиодами.

Матрица 8×8ESP32
V−GND
DINлюбой цифровой вывод (например, GPIO 2)
Адаптер питания
GNDGND платы ESP32
GNDV− матрицы
5 ВV+ матрицы
Схема подключения матрицы 8×8 к ESP32

Схема подключения с внешним источником питания

Если вы не собираетесь светить белым на полной яркости, можно запитать матрицу и от 5 В самой платы ESP32.

Устанавливаем библиотеки#

Через менеджер библиотек Arduino IDE поставьте четыре библиотеки:

  • FastLED авторства Daniel Garcia — управление светодиодами;
  • ESPAsyncWebServer от ESP32Async — асинхронный веб-сервер;
  • AsyncTCP от ESP32Async — зависимость предыдущей;
  • ArduinoJson 7.X авторства Benoit Blanchon — разбор и сборка данных фигур.

Скетч#

Код ниже поднимает веб-сервер со страницей редактора, позволяющей рисовать фигуры и запускать анимацию.

/*
  Rui Santos & Sara Santos - Random Nerd Tutorials
  Complete project details at https://RandomNerdTutorials.com/esp32-8x8-matrix-shape-editor-web-server/
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <FastLED.h>

// REPLACE WITH YOUR NETWORK CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

#define DIN_PIN       2
#define MATRIX_WIDTH  8
#define MATRIX_HEIGHT 8
#define NUM_LEDS      (MATRIX_WIDTH * MATRIX_HEIGHT)
#define BRIGHTNESS    200        // Brightness (0-255)
#define LED_TYPE      WS2812B
#define COLOR_ORDER   GRB
#define ZIGZAG        false      // Set false if your panel is wired in straight rows instead of zigzag
#define FLIP_H        true       // Set true if the image displays mirrored left to right
#define FLIP_V        true       // Set true if the image displays upside down

#define NUM_SLOTS     10         // Number of stored shape slots
#define SHAPES_DIR    "/shapes"

CRGB leds[NUM_LEDS];
AsyncWebServer server(80);

CRGB    currentPixelColors[MATRIX_HEIGHT][MATRIX_WIDTH]; // Colors per pixel for the shape currently loaded into memory
uint8_t currentBrightness = 128;
bool    haveShapeLoaded   = false;
int     currentSlot       = -1;    // Which saved slot is on screen (-1 = preview/none)

// Slideshow state
bool     slideshowActive   = false;
uint32_t slideshowInterval = 2000;   // Delay in ms between shapes
uint32_t slideshowLastTick = 0;
int      slideshowIndex    = -1;     // Last slot shown (-1 = none)
int      slideshowOrder[NUM_SLOTS];  // Slots that currently hold a shape
int      slideshowCount    = 0;

// Convert (x,y) coordinates to the corresponding index in the matrix
uint16_t coordToIndex(uint8_t x, uint8_t y) {
  if (FLIP_H) x = (MATRIX_WIDTH - 1) - x;
  if (FLIP_V) y = (MATRIX_HEIGHT - 1) - y;
  if (ZIGZAG && (y & 0x01)) {
    x = (MATRIX_WIDTH - 1) - x;
  }
  return (y * MATRIX_WIDTH) + x;
}

// Set the color of a pixel at (x,y)
void setPixel(uint8_t x, uint8_t y, CRGB color) {
  if (x >= MATRIX_WIDTH || y >= MATRIX_HEIGHT) return;
  leds[coordToIndex(x, y)] = color;
}

// Renders currentPixelColors (scaled by currentBrightness) to the physical LEDs
void renderCurrentShape() {
  FastLED.clear();
  if (haveShapeLoaded) {
    for (uint8_t y = 0; y < MATRIX_HEIGHT; y++) {
      for (uint8_t x = 0; x < MATRIX_WIDTH; x++) {
        CRGB c = currentPixelColors[y][x];
        if (c == CRGB::Black) continue;
        c.nscale8_video(currentBrightness);
        setPixel(x, y, c);
      }
    }
  }
  FastLED.show();
}

// Clears the matrix and resets the current shape state
void clearMatrix() {
  haveShapeLoaded = false;
  currentSlot = -1;
  FastLED.clear();
  FastLED.show();
}

// Converts a JSON array of {r,g,b} pixel objects into a 2D CRGB LED matrix grid
bool jsonPixelsToGrid(JsonArray pixels, CRGB out[MATRIX_HEIGHT][MATRIX_WIDTH], bool clampUntrusted) {
  if (pixels.isNull() || pixels.size() != NUM_LEDS) return false;

  int i = 0;
  for (uint8_t y = 0; y < MATRIX_HEIGHT; y++) {
    for (uint8_t x = 0; x < MATRIX_WIDTH; x++) {
      JsonObject px = pixels[i].as<JsonObject>();
      if (px.isNull()) return false;
      int r = px["r"] | 0;
      int g = px["g"] | 0;
      int b = px["b"] | 0;
      if (clampUntrusted) {
        r = constrain(r, 0, 255);
        g = constrain(g, 0, 255);
        b = constrain(b, 0, 255);
      }
      out[y][x] = CRGB(r, g, b);
      i++;
    }
  }
  return true;
}

// Returns the filesystem path for a given slot number (0-9)
bool slotPath(int slot, char* outPath, size_t outLen) {
  if (slot < 0 || slot >= NUM_SLOTS) return false;
  snprintf(outPath, outLen, "%s/%d.json", SHAPES_DIR, slot);
  return true;
}

// Checks if a given slot has a saved shape (file exists)
bool slotExists(int slot) {
  char path[32];
  if (!slotPath(slot, path, sizeof(path))) return false;
  return LittleFS.exists(path);
}

// Loads a saved shape from a slot into currentPixelColors/currentBrightness
bool loadSlotToCurrent(int slot) {
  char path[32];
  if (!slotPath(slot, path, sizeof(path))) return false;
  if (!LittleFS.exists(path)) return false;

  File f = LittleFS.open(path, "r");
  if (!f) return false;

  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, f);
  f.close();
  if (err) {
    Serial.printf("loadSlotToCurrent: JSON parse failed for slot %d: %s\n", slot, err.c_str());
    return false;
  }

  if (!jsonPixelsToGrid(doc["pixels"].as<JsonArray>(), currentPixelColors, false)) {
    Serial.printf("loadSlotToCurrent: slot %d has bad pixel array\n", slot);
    return false;
  }

  currentBrightness = doc["brightness"] | 128;
  haveShapeLoaded = true;
  currentSlot = slot;
  return true;
}

// Saves a shape to a slot, validating the input first
bool saveSlot(int slot, const char* name, JsonArray pixels, uint8_t brightness) {
  char path[32];
  if (!slotPath(slot, path, sizeof(path))) return false;
  if (pixels.size() != NUM_LEDS) return false;

  for (JsonVariant v : pixels) {
    JsonObject o = v.as<JsonObject>();
    if (o.isNull() || !o["r"].is<int>() || !o["g"].is<int>() || !o["b"].is<int>()) {
      return false;
    }
  }

  JsonDocument doc;
  doc["name"] = name;
  JsonArray px = doc["pixels"].to<JsonArray>();
  for (JsonVariant v : pixels) {
    JsonObject src = v.as<JsonObject>();
    JsonObject dst = px.add<JsonObject>();
    dst["r"] = constrain(src["r"].as<int>(), 0, 255);
    dst["g"] = constrain(src["g"].as<int>(), 0, 255);
    dst["b"] = constrain(src["b"].as<int>(), 0, 255);
  }
  doc["brightness"] = brightness;

  File f = LittleFS.open(path, "w");
  if (!f) return false;
  bool ok = serializeJson(doc, f) > 0;
  f.close();
  return ok;
}

// Deletes a saved shape from a slot
bool deleteSlot(int slot) {
  char path[32];
  if (!slotPath(slot, path, sizeof(path))) return false;
  if (!LittleFS.exists(path)) return true; // already gone
  return LittleFS.remove(path);
}

// Refreshes the slideshowOrder array with the current occupied slots
void refreshSlideshowOrder() {
  slideshowCount = 0;
  for (int s = 0; s < NUM_SLOTS; s++) {
    if (slotExists(s)) {
      slideshowOrder[slideshowCount++] = s;
    }
  }
}

// Builds a JSON array describing every slot (used to populate the gallery)
String buildSlotListJson() {
  JsonDocument doc;
  JsonArray arr = doc.to<JsonArray>();

  for (int s = 0; s < NUM_SLOTS; s++) {
    JsonObject o = arr.add<JsonObject>();
    o["slot"] = s;
    char path[32];
    slotPath(s, path, sizeof(path));

    if (LittleFS.exists(path)) {
      File f = LittleFS.open(path, "r");
      JsonDocument shapeDoc;
      DeserializationError err = deserializeJson(shapeDoc, f);
      f.close();
      if (!err) {
        o["occupied"] = true;
        o["name"] = shapeDoc["name"] | "";
        o["brightness"] = shapeDoc["brightness"] | 0;
        o["pixels"] = shapeDoc["pixels"];
      } else {
        o["occupied"] = false;
      }
    } else {
      o["occupied"] = false;
    }
  }

  String out;
  serializeJson(doc, out);
  return out;
}

// Web page served for the UI to control the 8x8 matrix
const char INDEX_HTML[] PROGMEM = R"HTMLPAGE(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>8x8 Matrix Shape Editor</title>
<style>
  :root { --bg: #14161a; --panel: #1d2025; --border: #2c3038; --accent: #1b78e2; --text: #eceef1; --text-dim: #9aa0ab; }
  * { box-sizing: border-box; }
  body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); padding: 20px; }
  h1 { font-size: 30px; margin: 0 0 20px; }
  p.sub { color: var(--text-dim); margin: 0 0 20px; font-size: 13px; }
  .layout { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; gap: 20px; max-width: 900px; }
  @media (max-width: 700px) { .layout { grid-template-columns: 1fr; } }
  .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
  .panel + .panel { margin-top: 16px; }
  .grid8 { display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px; aspect-ratio: 1; width: 100%; max-width: 320px; margin: 0 auto; }
  .cell { background: #2a2d33; border-radius: 3px; cursor: pointer; aspect-ratio: 1; border: 1px solid #33373f; }
  .cell.on { border-color: white; }
  .row { display: flex; align-items: center; gap: 10px; margin: 10px 0; }
  .row label { width: 90px; font-size: 13px; color: var(--text-dim); flex-shrink: 0; }
  input[type=range] { flex: 1; }
  input[type=color] { width: 42px; height: 30px; border: none; background: none; padding: 0; cursor: pointer; }
  input[type=text] { flex: 1; background: #14161a; border: 1px solid var(--border); color: var(--text); padding: 7px 9px; border-radius: 6px; font-size: 13px; }
  select { flex: 1; background: #14161a; border: 1px solid var(--border); color: var(--text); padding: 7px 9px; border-radius: 6px; font-size: 13px; }
  button { background: var(--accent); color: #1a0d12; border: none; padding: 9px 14px; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 13px; }
  button.secondary { background: #2a2d33; color: var(--text); }
  button.danger { background: #3a2229; color: #ff8fa3; }
  .btn-row { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
  .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 8px; }
  .slot-card { background: #14161a; border: 1px solid var(--border); border-radius: 8px; padding: 8px; text-align: center; cursor: pointer; font-size: 11px; }
  .slot-card:hover { border-color: var(--accent); }
  .slot-card .mini { display: grid; grid-template-columns: repeat(8, 1fr); gap: 1px; width: 60px; height: 60px; margin: 0 auto 6px; background: #000; border-radius: 3px; overflow: hidden; }
  .slot-card .mini div { background: #222; }
  .slot-card .label { color: var(--text-dim); }
  .slot-card.empty { opacity: 0.4; }
  .status { font-size: 12px; color: var(--text-dim); min-height: 16px; margin-top: 8px; }
  .toggle-row { display: flex; align-items: center; justify-content: space-between; }
  .switch { position: relative; width: 42px; height: 24px; }
  .switch input { opacity: 0; width: 0; height: 0; }
  .slider-toggle { position: absolute; cursor: pointer; inset: 0; background: #33373f; border-radius: 24px; transition: .2s; }
  .slider-toggle:before { content: ""; position: absolute; height: 18px; width: 18px; left: 3px; bottom: 3px; background: white; border-radius: 50%; transition: .2s; }
  input:checked + .slider-toggle { background: var(--accent); }
  input:checked + .slider-toggle:before { transform: translateX(18px); }
</style>
</head>
<body>
<h1>8x8 Matrix Shape Editor</h1>
<div class="layout">
  <div>
    <div class="panel">
      <div class="grid8" id="grid"></div>
      <div class="row">
        <label>Color</label>
        <input type="color" id="colorPicker" value="#ff0000">
      </div>
      <div class="row">
        <label>Brightness</label>
        <input type="range" id="brightness" min="0" max="255" value="128">
        <span id="brightnessVal" style="width:34px; text-align:right; font-size:12px; color:var(--text-dim);">128</span>
      </div>
      <div class="row">
        <label>Shape Name</label>
        <input type="text" id="shapeName" placeholder="e.g. Heart" maxlength="20">
      </div>
      <div class="row">
        <label>Slot</label>
        <select id="slotSelect"></select>
      </div>
      <div class="btn-row">
        <button id="btnPreview" class="secondary">Preview on Matrix</button>
        <button id="btnSave">Save to Slot</button>
        <button id="btnClearGrid" class="secondary">Clear Grid</button>
        <button id="btnTurnOff" class="danger">Turn Off Matrix</button>
      </div>
      <div class="status" id="drawStatus"></div>
    </div>
  </div>
  <div>
    <div class="panel">
      <div class="toggle-row">
        <strong style="font-size:14px;">Saved Shapes</strong>
      </div>
      <div class="gallery" id="gallery" style="margin-top:12px;"></div>
    </div>
    <div class="panel">
      <div class="toggle-row">
        <strong style="font-size:14px;">Slideshow Animation</strong>
        <label class="switch">
          <input type="checkbox" id="slideshowToggle">
          <span class="slider-toggle"></span>
        </label>
      </div>
      <div class="row" style="margin-top:14px;">
        <label>Interval</label>
        <input type="range" id="slideshowInterval" min="100" max="10000" step="100" value="1000">
        <span id="slideshowIntervalVal" style="width:50px; text-align:right; font-size:12px; color:var(--text-dim);">2000ms</span>
      </div>
      <div class="status" id="slideshowStatus"></div>
    </div>
  </div>
</div>

<script>
const W = 8, H = 8;
let pixelColors = new Array(W * H).fill(null);
let slots = [];
let loadedFromSlot = null;

const gridEl = document.getElementById('grid');
function buildGrid() {
  gridEl.innerHTML = '';
  for (let i = 0; i < W * H; i++) {
    const cell = document.createElement('div');
    cell.className = 'cell';
    cell.dataset.index = i;
    cell.addEventListener('click', () => {
      if (pixelColors[i]) {
        pixelColors[i] = null;
      } else {
        pixelColors[i] = hexToRgb(document.getElementById('colorPicker').value);
      }
      paintCell(cell, pixelColors[i]);
    });
    gridEl.appendChild(cell);
  }
}

function paintCell(cellEl, color) {
  if (color) {
    cellEl.classList.add('on');
    cellEl.style.background = rgbToHex(color.r, color.g, color.b);
  } else {
    cellEl.classList.remove('on');
    cellEl.style.background = '';
  }
}
function renderGridFromPixels() {
  const cells = gridEl.children;
  for (let i = 0; i < pixelColors.length; i++) {
    paintCell(cells[i], pixelColors[i]);
  }
}
buildGrid();

document.getElementById('btnClearGrid').addEventListener('click', () => {
  pixelColors = new Array(W * H).fill(null);
  renderGridFromPixels();
  loadedFromSlot = null;
});

const brightnessInput = document.getElementById('brightness');
const brightnessVal = document.getElementById('brightnessVal');
brightnessInput.addEventListener('input', () => {
  brightnessVal.textContent = brightnessInput.value;
});

const slotSelect = document.getElementById('slotSelect');
function populateSlotSelect() {
  slotSelect.innerHTML = '';
  for (let s = 0; s < 10; s++) {
    const opt = document.createElement('option');
    opt.value = s;
    const meta = slots.find(x => x.slot === s);
    opt.textContent = 'Slot ' + s + (meta && meta.occupied ? ' ' + (meta.name || 'unnamed') : ' (empty)');
    slotSelect.appendChild(opt);
  }
}

function findFirstEmptySlot() {
  for (let s = 0; s < 10; s++) {
    const meta = slots.find(x => x.slot === s);
    if (!meta || !meta.occupied) return s;
  }
  return null;
}

function loadShapeIntoEditor(slotData) {
  pixelColors = slotData.pixels.map(p => (p.r === 0 && p.g === 0 && p.b === 0) ? null : { r: p.r, g: p.g, b: p.b });
  renderGridFromPixels();

  document.getElementById('shapeName').value = slotData.name || '';
  brightnessInput.value = slotData.brightness;
  brightnessVal.textContent = slotData.brightness;

  loadedFromSlot = slotData.slot;
  const emptySlot = findFirstEmptySlot();
  slotSelect.value = (emptySlot !== null) ? emptySlot : slotData.slot;

  setStatus('drawStatus', 'Loaded "' + (slotData.name || ('Slot ' + slotData.slot)) + '" into the editor.');
}

async function apiGet(path) {
  const res = await fetch(path);
  if (!res.ok) throw new Error('Request failed: ' + path);
  return res.json();
}
async function apiPost(path, body) {
  const res = await fetch(path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  });
  if (!res.ok) throw new Error('Request failed: ' + path);
  return res.json();
}

function hexToRgb(hex) {
  const v = parseInt(hex.slice(1), 16);
  return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 };
}
function rgbToHex(r, g, b) {
  return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
}

function pixelsForWire() {
  return pixelColors.map(c => c ? c : { r: 0, g: 0, b: 0 });
}

document.getElementById('btnPreview').addEventListener('click', async () => {
  const brightness = parseInt(brightnessInput.value, 10);
  setStatus('drawStatus', 'Sending preview...');
  try {
    await apiPost('/api/preview', { pixels: pixelsForWire(), brightness });
    document.getElementById('slideshowToggle').checked = false;
    setStatus('drawStatus', 'Preview sent.');
  } catch (e) {
    setStatus('drawStatus', 'Error sending preview.');
  }
});

document.getElementById('btnSave').addEventListener('click', async () => {
  const brightness = parseInt(brightnessInput.value, 10);
  const name = document.getElementById('shapeName').value || 'Shape';
  const slot = parseInt(slotSelect.value, 10);
  setStatus('drawStatus', 'Saving...');
  try {
    const result = await apiPost('/api/shape/save', { slot, name, pixels: pixelsForWire(), brightness });
    if (result.ok) {
      setStatus('drawStatus', 'Saved to slot ' + slot + '.');
      loadedFromSlot = slot;
      await apiPost('/api/preview', { pixels: pixelsForWire(), brightness });
      document.getElementById('slideshowToggle').checked = false;
      await loadGallery();
    } else {
      setStatus('drawStatus', 'Save failed: ' + (result.error || 'unknown error'));
    }
  } catch (e) {
    setStatus('drawStatus', 'Error saving shape.');
  }
});

document.getElementById('btnTurnOff').addEventListener('click', async () => {
  await apiPost('/api/clear', {});
  setStatus('slideshowStatus', 'Slideshow animation turned off.');
  setStatus('drawStatus', 'Matrix turned off.');
  document.getElementById('slideshowToggle').checked = false;
});

const galleryEl = document.getElementById('gallery');
async function loadGallery() {
  slots = await apiGet('/api/shape/list');
  populateSlotSelect();
  galleryEl.innerHTML = '';
  slots.forEach(s => {
    const card = document.createElement('div');
    card.className = 'slot-card' + (s.occupied ? '' : ' empty');

    const mini = document.createElement('div');
    mini.className = 'mini';
    for (let i = 0; i < 64; i++) {
      const px = document.createElement('div');
      if (s.occupied && s.pixels && s.pixels[i]) {
        const p = s.pixels[i];
        if (!(p.r === 0 && p.g === 0 && p.b === 0)) {
          px.style.background = rgbToHex(p.r, p.g, p.b);
        }
      }
      mini.appendChild(px);
    }

    const label = document.createElement('div');
    label.className = 'label';
    label.textContent = s.occupied ? (s.name || ('Slot ' + s.slot)) : ('Slot ' + s.slot + ' (empty)');

    card.appendChild(mini);
    card.appendChild(label);

    if (s.occupied) {
      card.style.position = 'relative';

      const delBtn = document.createElement('div');
      delBtn.textContent = '✕';
      delBtn.title = 'Delete this shape';
      delBtn.style.cssText = 'position:absolute; top:2px; right:4px; color:#ff8fa3; font-size:11px; cursor:pointer; padding:2px 4px;';
      delBtn.addEventListener('click', async (e) => {
        e.stopPropagation();
        await apiPost('/api/shape/delete', { slot: s.slot });
        setStatus('drawStatus', 'Deleted slot ' + s.slot + '.');
        await loadGallery();
      });
      card.appendChild(delBtn);

      card.title = 'Click to display on the matrix and load into the editor';
      card.addEventListener('click', async () => {
        await apiPost('/api/shape/display', { slot: s.slot });
        document.getElementById('slideshowToggle').checked = false;
        loadShapeIntoEditor(s);
      });
    }
    galleryEl.appendChild(card);
  });
}

const slideshowToggle = document.getElementById('slideshowToggle');
const slideshowIntervalInput = document.getElementById('slideshowInterval');
const slideshowIntervalVal = document.getElementById('slideshowIntervalVal');

slideshowIntervalInput.addEventListener('input', () => {
  slideshowIntervalVal.textContent = slideshowIntervalInput.value + 'ms';
});

async function pushSlideshowState() {
  const active = slideshowToggle.checked;
  const interval = parseInt(slideshowIntervalInput.value, 10);
  try {
    await apiPost('/api/slideshow', { active, interval });
    setStatus('slideshowStatus', active ? 'Slideshow running (' + interval + 'ms).' : 'Slideshow stopped.');
  } catch (e) {
    setStatus('slideshowStatus', 'Error updating slideshow.');
  }
}
slideshowToggle.addEventListener('change', pushSlideshowState);
slideshowIntervalInput.addEventListener('change', pushSlideshowState);

function setStatus(elId, msg) {
  document.getElementById(elId).textContent = msg;
}

loadGallery();
</script>
</body>
</html>
)HTMLPAGE";

// Applies a JSON payload containing "pixels" and "brightness" to the current shape in memory
bool applyPixelPayload(JsonVariant& doc) {
  CRGB tmp[MATRIX_HEIGHT][MATRIX_WIDTH];
  if (!jsonPixelsToGrid(doc["pixels"].as<JsonArray>(), tmp, true)) return false;

  ::memcpy(currentPixelColors, tmp, sizeof(tmp));

  currentBrightness = doc["brightness"] | 128;
  haveShapeLoaded = true;
  currentSlot = -1;
  return true;
}

// Sends JSON response: {"ok":true} 
void sendOk(AsyncWebServerRequest* request) {
  request->send(200, "application/json", "{\"ok\":true}");
}

// Sends JSON response: {"ok":false,"error":"..."} 
void sendError(AsyncWebServerRequest* request, const char* msg) {
  JsonDocument resp;
  resp["ok"] = false;
  resp["error"] = msg;
  String out;
  serializeJson(resp, out);
  request->send(200, "application/json", out);
}

// Parses a JSON body containing "slot" and validates it is in the range 0-9
bool parseSlot(JsonObject doc, int& outSlot) {
  int slot = doc["slot"] | -1;
  if (slot < 0 || slot >= NUM_SLOTS) return false;
  outSlot = slot;
  return true;
}

// Sets up all the web server routes for the UI and API
void setupWebServerRoutes() {
  // Serve the UI web page
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send_P(200, "text/html", INDEX_HTML);
  });

  // GET /api/shape/list -> JSON array describing all 10 shape slots
  server.on("/api/shape/list", HTTP_GET, [](AsyncWebServerRequest *request) {
    request->send(200, "application/json", buildSlotListJson());
  });

  // POST /api/shape/save { slot, name, pixels[64], brightness } -> saves the shape to that slot
  AsyncCallbackJsonWebHandler* saveHandler = new AsyncCallbackJsonWebHandler(
    "/api/shape/save",
    [](AsyncWebServerRequest *request, JsonVariant &json) {
      JsonObject doc = json.as<JsonObject>();

      int slot;
      const char* name = doc["name"] | "Shape";
      JsonArray pixels = doc["pixels"].as<JsonArray>();
      uint8_t brightness = doc["brightness"] | 128;

      if (!parseSlot(doc, slot)) {
        sendError(request, "invalid slot");
      } else if (pixels.isNull() || pixels.size() != NUM_LEDS) {
        sendError(request, "invalid pixel data");
      } else if (!saveSlot(slot, name, pixels, brightness)) {
        sendError(request, "write failed");
      } else {
        refreshSlideshowOrder();
        sendOk(request);
      }
    }
  );
  server.addHandler(saveHandler);

  // POST /api/shape/display { slot } -> displays the shape from that slot
  AsyncCallbackJsonWebHandler* displayHandler = new AsyncCallbackJsonWebHandler(
    "/api/shape/display",
    [](AsyncWebServerRequest *request, JsonVariant &json) {
      JsonObject doc = json.as<JsonObject>();
      int slot;

      slideshowActive = false;

      if (!parseSlot(doc, slot) || !loadSlotToCurrent(slot)) {
        sendError(request, "slot empty or invalid");
        return;
      }
      renderCurrentShape();
      sendOk(request);
    }
  );
  server.addHandler(displayHandler);

  // POST /api/shape/delete { slot } -> deletes the shape from that slot
  AsyncCallbackJsonWebHandler* deleteHandler = new AsyncCallbackJsonWebHandler(
    "/api/shape/delete",
    [](AsyncWebServerRequest *request, JsonVariant &json) {
      JsonObject doc = json.as<JsonObject>();
      int slot;

      if (!parseSlot(doc, slot)) {
        sendError(request, "invalid slot");
        return;
      }

      bool ok = deleteSlot(slot);
      refreshSlideshowOrder();

      if (slot == currentSlot) {
        clearMatrix();
      }

      if (ok) sendOk(request);
      else sendError(request, "delete failed");
    }
  );
  server.addHandler(deleteHandler);

  // POST /api/preview { pixels[64] (each {r,g,b}), brightness } -> preview the shape
  AsyncCallbackJsonWebHandler* previewHandler = new AsyncCallbackJsonWebHandler(
    "/api/preview",
    [](AsyncWebServerRequest *request, JsonVariant &json) {
      slideshowActive = false;
      if (applyPixelPayload(json)) {
        renderCurrentShape();
        sendOk(request);
      } else {
        sendError(request, "invalid pixel data");
      }
    }
  );
  server.addHandler(previewHandler);

  // POST /api/clear {} -> turns the matrix off
  server.on("/api/clear", HTTP_POST, [](AsyncWebServerRequest *request) {
    slideshowActive = false;
    clearMatrix();
    sendOk(request);
  });

  // POST /api/slideshow { active: bool, interval: ms } -> starts/stops the slideshow animation and sets the interval
  AsyncCallbackJsonWebHandler* slideshowHandler = new AsyncCallbackJsonWebHandler(
    "/api/slideshow",
    [](AsyncWebServerRequest *request, JsonVariant &json) {
      JsonObject doc = json.as<JsonObject>();
      bool active = doc["active"] | false;
      uint32_t interval = doc["interval"] | 2000;
      if (interval < 250) interval = 250;

      refreshSlideshowOrder();

      slideshowInterval = interval;
      slideshowActive = active && (slideshowCount > 0);
      slideshowLastTick = millis();

      JsonDocument resp;
      resp["ok"] = true;
      resp["active"] = slideshowActive;
      resp["count"] = slideshowCount;
      String out;
      serializeJson(resp, out);
      request->send(200, "application/json", out);
    }
  );
  server.addHandler(slideshowHandler);

  server.onNotFound([](AsyncWebServerRequest *request) {
    request->send(404, "text/plain", "Not found");
  });
}

// Updates the slideshow animation if it's active and enough time has passed since the last frame
void updateSlideshow() {
  if (!slideshowActive || slideshowCount == 0) return;

  uint32_t now = millis();
  if (now - slideshowLastTick < slideshowInterval) return;
  slideshowLastTick = now;

  slideshowIndex = (slideshowIndex + 1) % slideshowCount;
  int slot = slideshowOrder[slideshowIndex];

  if (loadSlotToCurrent(slot)) {
    renderCurrentShape();
  }
}

void setup() {
  Serial.begin(115200);

  // Initialize LittleFS and create the shapes directory if it doesn't exist
  if (!LittleFS.begin(true)) {
    Serial.println("LittleFS mount failed even after format attempt.");
  }
  if (!LittleFS.exists(SHAPES_DIR)) {
    LittleFS.mkdir(SHAPES_DIR);
  }
  refreshSlideshowOrder();

  // Initialize FastLED
  FastLED.addLeds<LED_TYPE, DIN_PIN, COLOR_ORDER>(leds, NUM_LEDS);
  FastLED.setBrightness(BRIGHTNESS);
  FastLED.clear();
  FastLED.show();

  // Start the Wi-Fi connection
  WiFi.begin(ssid, password);
  Serial.print("Connecting to Wi-Fi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWi-Fi Connected!");
  // Print the ESP32 IP Address
  Serial.print("Access ESP32 IP Address: http://");
  Serial.println(WiFi.localIP());

  // Set up routes requests and start the web server
  setupWebServerRoutes();
  server.begin();
  Serial.println("ESP32 Web Server started!");
}

void loop() {
  // Handle the slideshow animation when it's active
  updateSlideshow();
}

Осталось вписать данные вашей сети — и всё заработает:

const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

Как работает код#

Библиотеки#

Для управления матрицей из 64 светодиодов используется FastLED, для сети — штатный WiFi, для асинхронного веб-сервера — ESPAsyncWebServer, для файлового хранилища — LittleFS, а ArduinoJson разбирает и собирает данные фигур.

#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <FastLED.h>

Глобальные настройки#

Вывод данных матрицы (DIN) подключён к GPIO 2:

#define DIN_PIN  2

Тип светодиодов — WS2812B, порядок цветов — GRB (у большинства WS2812B именно GRB, а не RGB):

#define LED_TYPE      WS2812B
#define COLOR_ORDER   GRB

Переменная ZIGZAG используется в функции coordToIndex() и определяет, как разведены светодиоды на вашей панели: змейкой или все ряды в одном направлении.

#define ZIGZAG  true

А если картинка выводится зеркально или вверх ногами, помогут два флага:

#define FLIP_H  false  // true, если изображение зеркалится слева направо
#define FLIP_V  false  // true, если изображение перевёрнуто вверх ногами

Преобразование координат#

Функция coordToIndex(x, y) превращает координаты сетки в индекс светодиода. С учётом флагов, описанных выше, она умеет отзеркалить изображение по горизонтали и вертикали и развернуть каждый второй ряд, если панель разведена змейкой. Так визуальная раскладка совпадает с физической.

uint16_t coordToIndex(uint8_t x, uint8_t y) {
  if (FLIP_H) x = (MATRIX_WIDTH - 1) - x;
  if (FLIP_V) y = (MATRIX_HEIGHT - 1) - y;
  if (ZIGZAG && (y & 0x01)) {
    x = (MATRIX_WIDTH - 1) - x;
  }
  return (y * MATRIX_WIDTH) + x;
}

Вывод фигуры#

За вывод текущей фигуры на физическую матрицу отвечает renderCurrentShape(). Она работает с двумерным массивом цветов currentPixelColors, значением яркости (0–255) и флагом «фигура загружена». Функция гасит светодиоды, применяет яркость, пишет цвета через setPixel() и вызывает FastLED.show().

void renderCurrentShape() {
  FastLED.clear();
  if (haveShapeLoaded) {
    for (uint8_t y = 0; y < MATRIX_HEIGHT; y++) {
      for (uint8_t x = 0; x < MATRIX_WIDTH; x++) {
        CRGB c = currentPixelColors[y][x];
        if (c == CRGB::Black) continue;
        c.nscale8_video(currentBrightness);
        setPixel(x, y, c);
      }
    }
  }
  FastLED.show();
}

Хранение фигур#

Фигуры хранятся как отдельные JSON-файлы в LittleFS: /shapes/0.json … /shapes/9.json. В каждом файле лежит имя, яркость и плоский массив из 64 объектов {r,g,b}. Вспомогательные функции собирают путь к файлу, проверяют его существование, загружают фигуру в текущий буфер, удаляют её и так далее.

Веб-страница редактора#

Страница с HTML, CSS и JavaScript хранится в PROGMEM и отдаётся по корневому адресу /. На ней — сетка 8×8 для рисования, выбор цвета, ползунок яркости, поле имени, селектор слота, галерея сохранённых фигур с превью и управление слайд-шоу.

Точки API#

  • GET / — отдаёт страницу редактора;
  • GET /api/shape/list — возвращает JSON-массив с описанием каждого слота (занят ли, имя, яркость, пиксели);
  • POST /api/shape/save — проверяет номер слота и массив пикселей, записывает фигуру в LittleFS и обновляет список для слайд-шоу;
  • POST /api/shape/display — загружает сохранённый слот в память и выводит его (заодно останавливает слайд-шоу);
  • POST /api/shape/delete — удаляет файл, а если эта фигура сейчас на матрице, гасит её;
  • POST /api/preview — применяет временную фигуру и яркость без сохранения;
  • POST /api/clear — гасит матрицу и останавливает слайд-шоу, если оно шло;
  • POST /api/slideshow — запускает и останавливает прокрутку фигур и задаёт интервал (минимум 100 мс).

setup()#

В setup() инициализируется монитор порта, монтируется LittleFS (при необходимости форматируется, создаётся каталог /shapes), настраивается FastLED, устанавливается соединение с Wi-Fi и печатается IP-адрес платы.

void setup() {
  Serial.begin(115200);

  // Монтируем LittleFS и создаём каталог для фигур, если его нет
  if (!LittleFS.begin(true)) {
    Serial.println("LittleFS mount failed even after format attempt.");
  }
  if (!LittleFS.exists(SHAPES_DIR)) {
    LittleFS.mkdir(SHAPES_DIR);
  }
  refreshSlideshowOrder();

  // Инициализация FastLED
  FastLED.addLeds<LED_TYPE, DIN_PIN, COLOR_ORDER>(leds, NUM_LEDS);
  FastLED.setBrightness(BRIGHTNESS);
  FastLED.clear();
  FastLED.show();

  // Подключаемся к Wi-Fi
  WiFi.begin(ssid, password);
  Serial.print("Connecting to Wi-Fi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWi-Fi Connected!");
  Serial.print("Access ESP32 IP Address: http://");
  Serial.println(WiFi.localIP());

  // ...
}

Затем создаются все маршруты и запускается асинхронный веб-сервер:

setupWebServerRoutes();
server.begin();

Слайд-шоу#

Когда слайд-шоу активно, функция updateSlideshow() вызывается на каждой итерации loop() и проверяет, не истёк ли заданный интервал. Если истёк — загружает следующую фигуру из списка занятых слотов и выводит её на матрицу. Список занятых слотов обновляется автоматически при каждом сохранении или удалении фигуры.

void loop() {
  updateSlideshow();
}

Проверяем#

Впишите данные сети, загрузите скетч на плату, откройте монитор порта на 115200 и нажмите кнопку RST.

IP-адрес ESP32 в мониторе порта

IP-адрес платы в мониторе порта

Откройте браузер на любом устройстве в той же сети и введите этот адрес — должна загрузиться такая страница.

Пустая сетка редактора в браузере

Стартовый вид редактора

Интерфейс состоит из трёх частей: редактор сетки, галерея сохранённых фигур и слайд-шоу.

В редакторе выбираете цвет и кликаете по пикселям сетки, рисуя фигуру. Ползунок яркости меняет яркость всей фигуры целиком. Задаёте имя и номер слота — и фигура сохраняется в LittleFS в виде JSON. Кнопки позволяют посмотреть текущий рисунок на матрице, очистить сетку на странице или погасить физическую матрицу.

Рисование фигуры в редакторе

Рисуем фигуру по пикселям

В галерее — превью всех сохранённых фигур. Клик по фигуре выводит её на матрицу и одновременно возвращает в редактор для правки. Красный крестик в правом верхнем углу удаляет фигуру.

Галерея сохранённых фигур

Галерея с превью

Наконец, в блоке слайд-шоу синяя кнопка запускает прокрутку всех сохранённых фигур с интервалом, который задаётся в миллисекундах.

Управление слайд-шоу

Интервал задаётся в миллисекундах

Вот как некоторые фигуры выглядят на реальной матрице.

Фигуры, выведенные на матрицу 8×8

Результат на физической матрице

Итог#

Мы собрали полноценный веб-редактор для матрицы 8×8: рисуем в браузере, храним до десяти фигур в файловой системе платы и крутим их слайд-шоу. Проект легко масштабируется — увеличьте MATRIX_WIDTH, MATRIX_HEIGHT и NUM_SLOTS, и тот же код заработает на панели большего размера.