Skip to content

ESP32 example ​

A battery-friendly frame with an ESP32 and an e-paper panel, written with Arduino + PlatformIO. It wakes, asks the hub, redraws only when there is a new picture, and deep-sleeps for as long as the hub says (a few µA in between).

The code is in examples/esp32-openframe.

Hardware ​

  • An ESP32 (any classic dev board). Use an ESP32-S3 with PSRAM for colour panels: a 24-bit 800×480 picture is about 1.1 MB.
  • An e-paper panel GxEPD2 supports, plus its driver board. The example is set up for:
    • 7.5″ 800×480 black & white (GDEY075T7 / Waveshare 7.5″ V2), the default;
    • 7.3″ 800×480 Spectra 6 (GDEP073E01 / Waveshare 7.3″ E).
  • Optional: a LiPo with a voltage divider into an ADC pin (battery level), and a button on an RTC pin (check now).

Ready-made boards such as the Waveshare PhotoPainter or Seeed reTerminal E100x can also run TRMNL firmware instead.

Set up ​

  1. In the hub: Add Canvas → Add manually → DIY frame with your panel's size. Copy the endpoint and token from the Connection tab.
  2. Copy include/config.example.h to include/config.h and fill it in:
c
// Copy to config.h and fill in. config.h is git-ignored: it holds your Wi-Fi password and token.
#pragma once

#define WIFI_SSID "my-wifi"
#define WIFI_PASS "my-wifi-password"

// From the frame's Connection tab in E-Ink Hub (no trailing slash).
#define HUB_URL "http://192.168.1.10:8080"
#define FRAME_TOKEN "paste-the-token-here"

#define FW_VERSION "esp32-openframe-1.0"

// Pick one panel. Black & white is the easiest (1-bit BMP, ~48 KB, fits in RAM).
#define PANEL_BW_750      // 7.5" 800×480 black/white (GDEY075T7, Waveshare 7.5" V2)
// #define PANEL_SPECTRA6_730  // 7.3" 800×480 Spectra 6 (GDEP073E01, Waveshare 7.3" E). Needs PSRAM.

// 0 = landscape as the panel is built, 1/3 = portrait, 2 = upside down.
#define ROTATION 0

// SPI pins of the e-paper driver board (Waveshare ESP32 driver board defaults).
#define EPD_CS 15
#define EPD_DC 27
#define EPD_RST 26
#define EPD_BUSY 25
// Only if your board doesn't use the default SPI pins:
// #define EPD_SCK 13
// #define EPD_MOSI 14

// Optional: battery voltage through a divider into an ADC pin (e.g. 2×100k → divider 2.0).
// #define BATTERY_PIN 35
// #define BATTERY_DIVIDER 2.0

// Optional: a button to GND on an RTC-capable pin wakes the frame to check right away.
// #define WAKE_BUTTON_PIN 33

// If the hub can't be reached, try again after this many seconds.
#define RETRY_SECONDS 900
  1. Flash and watch it:
bash
pio run -e esp32 -t upload && pio device monitor

The first poll shows the hub's welcome picture. Give the frame a collection under Schedule and it starts rotating.

How the code works ​

  • The frame asks for format=bmp with palette=bw (or spectra6), so the hub does the dithering. A BMP needs no decoder: each 1-bit pixel is black or white, and each 24-bit pixel is exactly one of the panel's six colours.
  • The id of the picture on screen is kept in RTC memory (RTC_DATA_ATTR) and sent back as current, so a reset never redraws the same picture.
  • Errors (no Wi-Fi, hub down) make it sleep RETRY_SECONDS and try again. A failed download is reported to the hub's log with POST /of/v1/log.
cpp
// Open Frame example: ESP32 + e-paper, deep sleep between polls.
//
// Every wake-up: connect to Wi-Fi, ask the hub what to show (GET /of/v1/display), download the
// BMP if there is something new, draw it, and deep-sleep for as long as the hub says.
// Protocol: https://eink-hub-7ba3a3.gitlab.io/diy/protocol
#include <Arduino.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <SPI.h>
#include <WiFi.h>

#include "config.h"

#if defined(PANEL_SPECTRA6_730)
#include <GxEPD2_7C.h>
// A quarter of the panel per page keeps the frame buffer small; the picture itself sits in PSRAM.
GxEPD2_7C<GxEPD2_730c_GDEP073E01, GxEPD2_730c_GDEP073E01::HEIGHT / 4> display(GxEPD2_730c_GDEP073E01(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY));
#define PALETTE "spectra6"
#else
#include <GxEPD2_BW.h>
GxEPD2_BW<GxEPD2_750_GDEY075T7, GxEPD2_750_GDEY075T7::HEIGHT> display(GxEPD2_750_GDEY075T7(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY));
#define PALETTE "bw"
#endif

// Survives deep sleep: the id of the picture on screen, so the hub doesn't resend it.
RTC_DATA_ATTR char currentId[48] = "";

static void sleepFor(uint32_t seconds) {
  Serial.printf("Sleeping %u s\n", seconds);
  WiFi.disconnect(true);
  WiFi.mode(WIFI_OFF);
#ifdef WAKE_BUTTON_PIN
  esp_sleep_enable_ext0_wakeup((gpio_num_t)WAKE_BUTTON_PIN, 0);
#endif
  esp_sleep_enable_timer_wakeup((uint64_t)seconds * 1000000ULL);
  esp_deep_sleep_start();
}

static int batteryPercent() {
#ifdef BATTERY_PIN
  float volts = analogReadMilliVolts(BATTERY_PIN) * BATTERY_DIVIDER / 1000.0f;
  // LiPo: ~3.0 V empty, ~4.2 V full. Good enough for a "replace soon" warning.
  return constrain((int)((volts - 3.0f) / 1.2f * 100), 0, 100);
#else
  return -1;
#endif
}

static bool connectWifi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  for (int i = 0; i < 100 && WiFi.status() != WL_CONNECTED; i++) delay(100);
  return WiFi.status() == WL_CONNECTED;
}

static void hubLog(const char* level, const String& message) {
  HTTPClient http;
  http.begin(String(HUB_URL) + "/of/v1/log");
  http.addHeader("Authorization", "Bearer " FRAME_TOKEN);
  http.addHeader("Content-Type", "application/json");
  JsonDocument doc;
  doc["level"] = level;
  doc["message"] = message;
  String body;
  serializeJson(doc, body);
  http.POST(body);
  http.end();
}

// Whole file into memory (PSRAM when there is some). The hub always sends Content-Length.
static uint8_t* download(const String& url, size_t& len) {
  HTTPClient http;
  http.begin(url);
  http.setTimeout(20000);
  int code = http.GET();
  int size = http.getSize();
  if (code != 200 || size <= 0) {
    http.end();
    return nullptr;
  }
  uint8_t* buf = (uint8_t*)(psramFound() ? ps_malloc(size) : malloc(size));
  if (!buf) {
    http.end();
    return nullptr;
  }
  WiFiClient* stream = http.getStreamPtr();
  size_t got = 0;
  uint32_t start = millis();
  while (got < (size_t)size && millis() - start < 60000) {
    size_t n = stream->readBytes(buf + got, size - got);
    if (n == 0) delay(5);
    got += n;
  }
  http.end();
  if (got != (size_t)size) {
    free(buf);
    return nullptr;
  }
  len = got;
  return buf;
}

// --- BMP: uncompressed, 1-bit (index 0 black, 1 white) or 24-bit BGR, rows bottom-up ---
struct Bmp {
  int32_t width, height;
  uint16_t bpp;
  uint32_t offset, rowBytes;
  bool bottomUp;
};

static uint32_t rd32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | ((uint32_t)p[3] << 24); }
static uint16_t rd16(const uint8_t* p) { return p[0] | (p[1] << 8); }

static bool parseBmp(const uint8_t* b, size_t len, Bmp& bmp) {
  if (len < 54 || b[0] != 'B' || b[1] != 'M' || rd32(b + 30) != 0) return false;
  bmp.offset = rd32(b + 10);
  bmp.width = (int32_t)rd32(b + 18);
  int32_t h = (int32_t)rd32(b + 22);
  bmp.bottomUp = h > 0;
  bmp.height = abs(h);
  bmp.bpp = rd16(b + 28);
  bmp.rowBytes = ((bmp.width * bmp.bpp + 31) / 32) * 4;
  return (bmp.bpp == 1 || bmp.bpp == 24) && bmp.offset + bmp.rowBytes * bmp.height <= len;
}

// The hub already dithered to the panel's palette, so every pixel is exactly one of its colours.
static uint16_t panelColour(uint8_t r, uint8_t g, uint8_t b) {
#if defined(PANEL_SPECTRA6_730)
  if (r > 128 && g > 128 && b > 128) return GxEPD_WHITE;
  if (r > 128 && g > 128) return GxEPD_YELLOW;
  if (r > 128) return GxEPD_RED;
  if (g > 128) return GxEPD_GREEN;
  if (b > 128) return GxEPD_BLUE;
  return GxEPD_BLACK;
#else
  return (r + g + b) > 382 ? GxEPD_WHITE : GxEPD_BLACK;
#endif
}

static uint16_t pixelAt(const uint8_t* buf, const Bmp& bmp, int x, int y) {
  const uint8_t* row = buf + bmp.offset + (bmp.bottomUp ? bmp.height - 1 - y : y) * bmp.rowBytes;
  if (bmp.bpp == 1) return (row[x >> 3] & (0x80 >> (x & 7))) ? GxEPD_WHITE : GxEPD_BLACK;
  return panelColour(row[x * 3 + 2], row[x * 3 + 1], row[x * 3]);
}

static void draw(const uint8_t* buf, const Bmp& bmp) {
  int w = min<int>(bmp.width, display.width());
  int h = min<int>(bmp.height, display.height());
  display.setFullWindow();
  display.firstPage();
  do {
    display.fillScreen(GxEPD_WHITE);
    for (int y = 0; y < h; y++)
      for (int x = 0; x < w; x++) display.drawPixel(x, y, pixelAt(buf, bmp, x, y));
  } while (display.nextPage());
  display.hibernate();
}

void setup() {
  Serial.begin(115200);
#ifdef EPD_SCK
  SPI.begin(EPD_SCK, -1, EPD_MOSI, EPD_CS);
#endif
  display.init(115200, true, 2, false);
  display.setRotation(ROTATION);

  if (!connectWifi()) sleepFor(RETRY_SECONDS);

  String url = String(HUB_URL) + "/of/v1/display?format=bmp&palette=" PALETTE "&fw=" FW_VERSION;
  url += "&w=" + String(display.width()) + "&h=" + String(display.height());
  url += "&rssi=" + String(WiFi.RSSI());
  int battery = batteryPercent();
  if (battery >= 0) url += "&battery=" + String(battery);
  if (currentId[0]) url += "&current=" + String(currentId);

  HTTPClient http;
  http.begin(url);
  http.addHeader("Authorization", "Bearer " FRAME_TOKEN);
  int code = http.GET();
  if (code != 200) {
    Serial.printf("Hub answered %d\n", code);
    http.end();
    sleepFor(RETRY_SECONDS);
  }
  JsonDocument reply;
  DeserializationError err = deserializeJson(reply, http.getString());
  http.end();
  if (err) sleepFor(RETRY_SECONDS);

  uint32_t nextPoll = reply["next_poll_s"] | RETRY_SECONDS;
  if (reply["action"] == "show") {
    size_t len = 0;
    uint8_t* buf = download(reply["image_url"].as<String>(), len);
    Bmp bmp;
    if (buf && parseBmp(buf, len, bmp)) {
      draw(buf, bmp);
      strlcpy(currentId, reply["id"] | "", sizeof(currentId));
    } else {
      hubLog("error", "image download or decode failed");
    }
    free(buf);
  }
  sleepFor(nextPoll);
}

void loop() {}