Preview build — page text is a draft awaiting human review. Code badges come from real compiles. The flasher is real but not yet hardware-tested; the Playground is still a preview.
Clinic Door Status Sign: the finished build
ESP32-C6India utility

E-paper door sign for a clinic or office — IN / WAIT / OUT at a button press (ESP32-C6)

One press tells the waiting room whether the doctor is in — and it stays readable through power cuts.

Difficulty
Easy
Build time
1.5 h
Parts cost
₹2,190 · $26
Verified — core esp32 3.3.12, GxEPD2 1.6.9, 3 Nov 2026Compile-only

Community draft — report a mistake

Parts list

PartQtyApprox.Buy
ESP32-C6-DevKitC-11₹800Buy ESP32-C6-DevKitC-1
2.13" e-paper module 250×122 (Waveshare V4 or WeAct, SSD1680)1₹900Buy 2.13" e-paper module 250×122 (Waveshare V4 or WeAct, SSD1680)
16 mm panel-mount push buttons (green / yellow / red)3₹90Buy 16 mm panel-mount push buttons (green / yellow / red)
5 V USB adapter or small power bank + cable1₹200Buy 5 V USB adapter or small power bank + cable
Acrylic or wooden door plate to mount the panel and buttons1₹200Buy Acrylic or wooden door plate to mount the panel and buttons
Total₹2,190

Affiliate disclosure: “Buy” links may earn FlashReady a small commission at no extra cost to you. Prices are indicative.

Wiring

ESP32-C6-DevKitC-1Only the pins used are shown · 3.3 V logicE-paper display 2.13″ (250×122, SPI)Push button (6 mm tactile switch)Push button (6 mm tactile switch) #2Push button (6 mm tactile switch) #33V36 · 610 · 1011 · 112 · 23 · 3GND23 · 2321 · 2119 · 1918 · 18VCCGNDDINCLKCSDCRSTBUSY121212
Every connection
ESP32-C6-DevKitC-1Part · pinWireNotes
21E-paper display 2.13″ (250×122, SPI) · CLKyellowGPIO21 — the C6’s default SPI SCK
19E-paper display 2.13″ (250×122, SPI) · DINgreenGPIO19 — default SPI MOSI
18E-paper display 2.13″ (250×122, SPI) · CSwhiteGPIO18 — default SS
10E-paper display 2.13″ (250×122, SPI) · DCpurple
11E-paper display 2.13″ (250×122, SPI) · RSTbrown
23E-paper display 2.13″ (250×122, SPI) · BUSYorange
3V3E-paper display 2.13″ (250×122, SPI) · VCCred
GNDE-paper display 2.13″ (250×122, SPI) · GNDblack
2Push button (6 mm tactile switch) · 1greenButton 1 “IN” — GPIO2 (LP GPIO, wakes the C6)
GNDPush button (6 mm tactile switch) · 2black
3Push button (6 mm tactile switch) #2 · 1yellowButton 2 “WAIT” — GPIO3 (LP GPIO)
GNDPush button (6 mm tactile switch) #2 · 2black
6Push button (6 mm tactile switch) #3 · 1orangeButton 3 “OUT” — GPIO6 (LP GPIO)
GNDPush button (6 mm tactile switch) #3 · 2black

Board quirks: Between presses the C6 is in deep sleep. Only its <strong>LP GPIOs (0–7)</strong> can wake it, so the buttons sit on GPIO2, 3 and 6 — GPIO4 and 5 are strapping pins, and 0/1 are kept free for a 32 kHz crystal. The internal pull-ups are switched to the LP domain before sleeping (<code>rtc_gpio_pullup_en</code>), so the buttons need no resistors and the sleep current stays low. Messages are settings: type your own (up to 20 characters, plus a small line for clinic name or timings) in the FlashReady flasher. Long messages wrap onto two lines; the third button’s message is shown white on black. The DevKitC-1’s power LED and USB bridge draw milliamps even when the chip sleeps, so run it from a USB adapter or power bank. The e-paper keeps the message during a power cut either way. GxEPD2 needs the class for your panel: <code>GxEPD2_213_GDEY0213B74</code> for SSD1680 boards; try <code>GxEPD2_213_B74</code> or <code>GxEPD2_213_BN</code> if the picture is garbled.

Code

clinic-door-status-sign.inoArduino C++Open in PlaygroundOpen in PlaygroundFlash
// Clinic / office door status sign — ESP32-C6-DevKitC-1 + 2.13" e-paper + 3 buttons.
// Press a button and the e-paper shows that message ("DOCTOR IS IN", "PLEASE WAIT",
// "BACK IN 30 MIN"); then the C6 goes into deep sleep. The picture stays with no power,
// and any button wakes the chip again (ext1 wake-up on its LP GPIOs 2, 3 and 6).
// Messages are settings: set them in the FlashReady flasher, or edit the constants.
// E-paper (SPI defaults): CLK 21, DIN 19, CS 18; DC 10, RST 11, BUSY 23, VCC 3V3.
// Buttons: GPIO2 / GPIO3 / GPIO6 to GND (internal pull-ups, kept on in deep sleep).
#include <GxEPD2_BW.h>
#include <Fonts/FreeSansBold18pt7b.h>
#include <Fonts/FreeSansBold9pt7b.h>
#include <esp_sleep.h>
#include <driver/rtc_io.h>
#include "fr_settings.h"

const char* MSG_IN = "DOCTOR IS IN";
const char* MSG_BUSY = "PLEASE WAIT";
const char* MSG_OUT = "BACK IN 30 MIN";
const char* SUB = "OPD 10-1 & 5-8";
FrSetting SETTINGS[] = {{"msg_in", &MSG_IN, false}, {"msg_busy", &MSG_BUSY, false}, {"msg_out", &MSG_OUT, false}, {"sub", &SUB, false}};

constexpr int EPD_CS = 18, EPD_DC = 10, EPD_RST = 11, EPD_BUSY = 23;
const gpio_num_t BTN[3] = {GPIO_NUM_2, GPIO_NUM_3, GPIO_NUM_6};
// Waveshare 2.13" V4 / WeAct (SSD1680). Older panels: GxEPD2_213_B74 or GxEPD2_213_BN.
GxEPD2_BW<GxEPD2_213_GDEY0213B74, GxEPD2_213_GDEY0213B74::HEIGHT> epd(GxEPD2_213_GDEY0213B74(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY));
RTC_DATA_ATTR int shown = -1;  // which message is on the panel (survives deep sleep)

void centre(const char* s, int y) {
  int16_t x1, y1;
  uint16_t w, h;
  epd.getTextBounds(s, 0, y, &x1, &y1, &w, &h);
  epd.setCursor((250 - (int)w) / 2 - x1, y);
  epd.print(s);
}

void draw(int which) {
  const char* msg = which == 0 ? MSG_IN : which == 1 ? MSG_BUSY : MSG_OUT;
  epd.init(115200, true, 2, false);
  epd.setRotation(1);
  epd.setFullWindow();
  epd.firstPage();
  do {
    bool invert = which == 2;  // "out" message in white on black, readable from far away
    epd.fillScreen(invert ? GxEPD_BLACK : GxEPD_WHITE);
    epd.setTextColor(invert ? GxEPD_WHITE : GxEPD_BLACK);
    epd.setFont(&FreeSansBold18pt7b);
    // Long messages wrap at the last space before 12 characters.
    String m = msg;
    int cut = m.length() > 12 ? m.lastIndexOf(' ', 12) : -1;
    if (cut > 0) {
      centre(m.substring(0, cut).c_str(), 48);
      centre(m.substring(cut + 1).c_str(), 86);
    } else centre(msg, 68);
    epd.setFont(&FreeSansBold9pt7b);
    centre(SUB, 114);
  } while (epd.nextPage());
  epd.hibernate();
  shown = which;
  Serial.printf("Showing: %s\n", msg);
}

void setup() {
  Serial.begin(115200);
  for (auto b : BTN) pinMode(b, INPUT_PULLUP);
  esp_sleep_wakeup_cause_t why = esp_sleep_get_wakeup_cause();
  int pressed = -1;
  if (why == ESP_SLEEP_WAKEUP_EXT1) {
    uint64_t mask = esp_sleep_get_ext1_wakeup_status();
    for (int i = 0; i < 3; i++)
      if (mask & (1ULL << BTN[i])) pressed = i;
    frLoad();  // woke from sleep: stored messages, no 2 s wait
    for (auto& s : SETTINGS) *s.value = frSettingsGet(s.key, *s.value);
  } else {
    frSettingsBegin(SETTINGS);
    pressed = 0;  // power-up: show the first message
  }
  // Empty settings fall back to the defaults.
  if (!*MSG_IN) MSG_IN = "DOCTOR IS IN";
  if (!*MSG_BUSY) MSG_BUSY = "PLEASE WAIT";
  if (!*MSG_OUT) MSG_OUT = "BACK IN 30 MIN";
  if (pressed >= 0 && pressed != shown) draw(pressed);
  for (auto b : BTN)  // wait for release, or it wakes straight back up
    while (digitalRead(b) == LOW) delay(10);
  uint64_t mask = 0;
  for (auto b : BTN) {
    rtc_gpio_pullup_en(b);
    rtc_gpio_pulldown_dis(b);
    mask |= 1ULL << b;
  }
  esp_sleep_enable_ext1_wakeup(mask, ESP_EXT1_WAKEUP_ANY_LOW);
  Serial.println("Sleeping until a button is pressed");
  Serial.flush();
  esp_deep_sleep_start();
}

void loop() {}

Arduino IDE: keep fr_settings.h in the same folder as the .ino (tab menu → New Tab, same name). Flashing from this page doesn’t need it.

fr_settings.hArduino C++
// fr_settings.h — FlashReady on-board settings (Wi-Fi name, phone number, ...) over USB.
//
// Why: the pre-built file on flashready.in is the same for everyone, so your own settings
// can't be inside it. Instead the board asks for them over USB serial and keeps them in
// its EEPROM. If you use Arduino IDE you can still type them into the constants in the
// sketch — this file then changes nothing except a 2-second pause at start-up.
//
// The protocol, in plain words (one text line each way, ending in \n, at 115200 baud):
//  1. Right after Serial.begin(115200) the board prints
//       FR?SETTINGS v1 keys=wifi_ssid,wifi_pass missing=wifi_pass
//     "missing" lists the required settings that still have no real value.
//  2. If nothing is missing it listens for 2 seconds and then carries on. If something is
//     missing it keeps listening, printing the line again every 2 seconds, for up to
//     30 seconds; then it carries on anyway and the sketch skips whatever needs the
//     missing value (an alarm still sounds, it just can't send the SMS).
//  3. The flasher answers   FR!SET wifi_ssid=My%20Home&wifi_pass=secret
//     (values URL-encoded). The board stores them and answers FR!OK, then carries on with
//     the new values. Errors: FR!ERR unknown <key>, FR!ERR long <key> (nothing is stored).
//     A key that is left out keeps its stored value; "key=" (empty) removes it.
//     "FR!SET" with nothing after it means "carry on now": FR!OK, nothing is stored.
//  4. FR!CLEAR wipes everything that was stored, answers FR!OK and keeps listening.
//  Other lines are ignored.
//
// A stored value wins over the constant in the sketch. A constant that is still a
// placeholder ("YOUR_SSID", "your-wifi", "+91XXXXXXXXXX") or empty counts as not set.
//
// EEPROM block at FR_SETTINGS_ADDR: 'F' 'R' 0x01, length (uint16, low byte first), then
// "key=value\n" lines. Uses FR_SETTINGS_SIZE bytes; move FR_SETTINGS_ADDR with a #define
// before the #include if your sketch uses EEPROM for something else.
//
// Use in a sketch:
//   const char* WIFI_SSID = "YOUR_SSID";
//   FrSetting SETTINGS[] = { {"wifi_ssid", &WIFI_SSID, true} };   // key, constant, required
//   void setup() { Serial.begin(115200); frSettingsBegin(SETTINGS); ... WiFi.begin(WIFI_SSID, ...); }
// After frSettingsBegin() each constant points at the value to use ("" if none is set).
#pragma once
#include <Arduino.h>
#include <EEPROM.h>
#include <string.h>

#ifndef FR_SETTINGS_ADDR
#define FR_SETTINGS_ADDR 0
#endif
#ifndef FR_SETTINGS_SIZE
#if defined(__AVR__)
#define FR_SETTINGS_SIZE 96  // 2 KB of RAM on an Uno: a phone number fits easily
#else
#define FR_SETTINGS_SIZE 512
#endif
#endif
#ifndef FR_LINE_MAX
#if defined(__AVR__)
#define FR_LINE_MAX 128
#else
#define FR_LINE_MAX 1024
#endif
#endif
#define FR_MAX_KEYS 8
#define FR_WAIT_MAX_MS 30000UL  // never wait longer than this for missing settings
#define FR_DATA_MAX (FR_SETTINGS_SIZE - 5)

struct FrSetting {
  const char* key;     // [a-z0-9_], as in settings.json
  const char** value;  // the sketch's constant; replaced by the value to use
  bool required;
};

// Stored settings in RAM as "key=value\0key=value\0" (only non-empty values).
static char frData[FR_DATA_MAX + 1];
static uint16_t frLen = 0;

static inline void frEepromBegin() {
#if defined(ESP32) || defined(ESP8266)
  static bool started = false;
  if (!started) EEPROM.begin(FR_SETTINGS_ADDR + FR_SETTINGS_SIZE);
  started = true;
#endif
}

static inline void frLoad() {
  frEepromBegin();
  frLen = 0;
  frData[0] = 0;
  if (EEPROM.read(FR_SETTINGS_ADDR) != 'F' || EEPROM.read(FR_SETTINGS_ADDR + 1) != 'R' || EEPROM.read(FR_SETTINGS_ADDR + 2) != 1) return;
  uint16_t n = EEPROM.read(FR_SETTINGS_ADDR + 3) | (EEPROM.read(FR_SETTINGS_ADDR + 4) << 8);
  if (n > FR_DATA_MAX) return;
  for (uint16_t i = 0; i < n; i++) {
    char c = EEPROM.read(FR_SETTINGS_ADDR + 5 + i);
    frData[i] = c == '\n' ? '\0' : c;
  }
  frData[n] = 0;
  frLen = n;
}

static inline void frPut(int addr, uint8_t b) {
#if defined(__AVR__)
  EEPROM.update(addr, b);
#else
  EEPROM.write(addr, b);
#endif
}

static inline void frSave() {
  frEepromBegin();
  frPut(FR_SETTINGS_ADDR, 'F');
  frPut(FR_SETTINGS_ADDR + 1, 'R');
  frPut(FR_SETTINGS_ADDR + 2, 1);
  frPut(FR_SETTINGS_ADDR + 3, frLen & 0xFF);
  frPut(FR_SETTINGS_ADDR + 4, frLen >> 8);
  for (uint16_t i = 0; i < frLen; i++) frPut(FR_SETTINGS_ADDR + 5 + i, frData[i] ? frData[i] : '\n');
#if defined(ESP32) || defined(ESP8266)
  EEPROM.commit();
#endif
}

// The stored value for key, or nullptr.
static inline const char* frStored(const char* key) {
  size_t kl = strlen(key);
  for (uint16_t i = 0; i < frLen; i += strlen(frData + i) + 1) {
    if (strncmp(frData + i, key, kl) == 0 && frData[i + kl] == '=') return frData + i + kl + 1;
  }
  return nullptr;
}

// Empty, "your_…" / "YOUR-…" / "your …", or a "+91XXXX…" phone number.
static inline bool frIsPlaceholder(const char* v) {
  if (!v || !*v) return true;
  if (strncasecmp(v, "your", 4) == 0 && (v[4] == '_' || v[4] == '-' || v[4] == ' ')) return true;
  return v[0] == '+' && strstr(v, "XXXX") != nullptr;
}

// The stored value for key, else the fallback constant; "" if that is a placeholder.
static inline const char* frSettingsGet(const char* key, const char* fallback) {
  const char* v = frStored(key);
  if (!v) v = fallback;
  return frIsPlaceholder(v) ? "" : v;
}

static inline bool frIsMissing(const FrSetting& s) {
  return s.required && !*frSettingsGet(s.key, *s.value);
}

static inline void frRequest(FrSetting* s, uint8_t n) {
  Serial.print(F("FR?SETTINGS v1 keys="));
  for (uint8_t i = 0; i < n; i++) {
    if (i) Serial.print(',');
    Serial.print(s[i].key);
  }
  Serial.print(F(" missing="));
  bool first = true;
  for (uint8_t i = 0; i < n; i++) {
    if (!frIsMissing(s[i])) continue;
    if (!first) Serial.print(',');
    Serial.print(s[i].key);
    first = false;
  }
  Serial.print('\n');
}

static inline void frReply(const __FlashStringHelper* what, const char* key) {
  Serial.print(what);
  Serial.print(key);
  Serial.print('\n');
}

static inline int8_t frHex(char c) {
  if (c >= '0' && c <= '9') return c - '0';
  c |= 0x20;
  return (c >= 'a' && c <= 'f') ? c - 'a' + 10 : -1;
}

// Percent-decodes in place. Drops NUL, CR and LF: they can't be stored.
static inline void frDecode(char* s) {
  char* o = s;
  for (; *s; s++) {
    char c = *s;
    if (c == '%' && frHex(s[1]) >= 0 && frHex(s[2]) >= 0) {
      c = (char)(frHex(s[1]) << 4 | frHex(s[2]));
      s += 2;
    }
    if (c != '\0' && c != '\n' && c != '\r') *o++ = c;
  }
  *o = 0;
}

// "k1=v1&k2=v2" (cut short if the line overflowed). Saves and returns true, or replies with an error.
static inline bool frSet(FrSetting* s, uint8_t n, char* p, bool overflow) {
  const char* upd[FR_MAX_KEYS] = {};
  while (*p) {
    char* amp = strchr(p, '&');
    bool last = !amp;
    if (amp) *amp = 0;
    char* eq = strchr(p, '=');
    if (eq) *eq = 0;
    if (*p) {
      if (last && overflow) {
        frReply(F("FR!ERR long "), p);
        return false;
      }
      uint8_t i = 0;
      while (i < n && strcmp(s[i].key, p) != 0) i++;
      if (i == n) {
        frReply(F("FR!ERR unknown "), p);
        return false;
      }
      char* v = eq ? eq + 1 : p + strlen(p);  // "key" alone = "key="
      frDecode(v);
      upd[i] = v;
    }
    if (last) break;
    p = amp + 1;
  }
  // Build the new block: for each key, the new value or the one already stored.
  char next[FR_DATA_MAX + 1];
  uint16_t len = 0;
  for (uint8_t i = 0; i < n; i++) {
    const char* v = upd[i] ? upd[i] : frStored(s[i].key);
    if (!v || !*v) continue;
    size_t kl = strlen(s[i].key), vl = strlen(v);
    if (len + kl + vl + 2 > FR_DATA_MAX) {
      frReply(F("FR!ERR long "), s[i].key);
      return false;
    }
    memcpy(next + len, s[i].key, kl);
    next[len + kl] = '=';
    memcpy(next + len + kl + 1, v, vl + 1);
    len += kl + vl + 2;
  }
  memcpy(frData, next, len);
  frData[len] = 0;
  frLen = len;
  frSave();
  return true;
}

// Handles one received line. Returns true once settings were saved (boot carries on).
static inline bool frCommand(FrSetting* s, uint8_t n, char* line, bool overflow) {
  if (strcmp(line, "FR!CLEAR") == 0) {
    frLen = 0;
    frData[0] = 0;
    frSave();
    Serial.print(F("FR!OK\n"));
    return false;
  }
  if (strncmp(line, "FR!SET", 6) != 0 || (line[6] != ' ' && line[6] != 0)) return false;  // not for us
  char* p = line + 6;
  while (*p == ' ') p++;
  if (*p && !frSet(s, n, p, overflow)) return false;  // empty FR!SET = carry on, store nothing
  Serial.print(F("FR!OK\n"));
  return true;
}

// Call right after Serial.begin(115200). Prints the request, waits as described at the top
// (30 s at most), then points every constant in the list at the value to use ("" = not set).
static inline void frSettingsBegin(FrSetting* s, uint8_t n) {
  frLoad();
  char line[FR_LINE_MAX];
  uint16_t len = 0;
  bool overflow = false;
  frRequest(s, n);
  unsigned long start = millis(), quietSince = start;
  while (millis() - start < FR_WAIT_MAX_MS) {
    int c = Serial.read();
    if (c >= 0) {
      quietSince = millis();
      if (c == '\r') continue;
      if (c != '\n') {
        if (len < FR_LINE_MAX - 1) line[len++] = (char)c;
        else overflow = true;
        continue;
      }
      line[len] = 0;
      bool saved = frCommand(s, n, line, overflow);
      len = 0;
      overflow = false;
      if (saved) break;
      continue;
    }
    if (millis() - quietSince < 2000) {
      delay(1);  // also keeps the ESP8266 watchdog fed
      continue;
    }
    len = 0;  // drop half a line
    overflow = false;
    bool missing = false;
    for (uint8_t i = 0; i < n; i++) missing |= frIsMissing(s[i]);
    if (!missing) break;
    frRequest(s, n);
    quietSince = millis();
  }
  for (uint8_t i = 0; i < n; i++) *s[i].value = frSettingsGet(s[i].key, *s[i].value);
}

template <size_t N>
static inline void frSettingsBegin(FrSetting (&s)[N]) {
  static_assert(N <= FR_MAX_KEYS, "fr_settings.h: at most FR_MAX_KEYS settings");
  frSettingsBegin(s, (uint8_t)N);
}

Compiled for esp32c6 on esp32 3.3.12 with GxEPD2 1.6.9 · 3 Nov 2026

How it works

Small clinics, diagnostic labs, government offices and shops all have the same door problem: people keep knocking to ask if the doctor or officer is in. A handwritten “Back in 10 minutes” card stays up all afternoon. This sign changes with a button press and looks tidy.

There are three buttons on the door plate. Press IN and the e-paper shows “DOCTOR IS IN”; WAIT shows “PLEASE WAIT”; OUT shows “BACK IN 30 MIN” in white on black. A small line under every message carries the clinic name or OPD timings. All four texts are settings you can change from the FlashReady flasher, so a pharmacy, bank branch or principal’s office can use the same build.

The ESP32-C6 spends almost all its time in deep sleep. A press on any of the three buttons pulls an LP GPIO low and wakes it through the ext1 wake-up source; the C6 reads which pin woke it, redraws the panel if the message changed, waits for the button to be released and goes back to sleep. The e-paper keeps the message with no power, so it stays readable through power cuts and costs nothing to display.

The e-paper uses the C6’s default SPI pins — GPIO21 (SCK), GPIO19 (MOSI), GPIO18 (CS) — and the rest of the wiring avoids the strapping pins 8, 9 and 15 and the USB pins 12 and 13.

Troubleshooting

Pressing a button does nothing

The first press after a message is shown wakes the chip, redraws (about 2 s, with flashing) and sleeps. Pressing the button for the message already shown does nothing on purpose. Check the button goes between its GPIO and GND.

The panel stays blank

Check BUSY on GPIO23 and RST on GPIO11. Open the Serial Monitor after pressing RESET: it prints “Showing: …” after each refresh.

It keeps waking up by itself

A long, unshielded button cable can pick up noise. Keep button wires under 1 m, or add 10 kΩ pull-ups to 3V3 at the board.

Upgrades

  • Add a fourth message on a long press of button 3 (e.g. CLOSED TODAY).
  • Show a queue token number that a fourth button advances.
  • Build it on a bare ESP32-C6 module with a Li-ion cell for a sign that runs months without a cable.

↑↓ to move · Enter to open