A 7.5-inch E-ink Weather & Calendar Dashboard Powered by ESP32

Contents

A dedicated weather and calendar display for the wall: a Waveshare 7.5" three-colour e-ink panel driven by an ESP32. It holds its image with no power, has no backlight, and reads the same PHP proxies as the Glance dashboard. It refreshes every two hours.

The rendered dashboard: weather and air quality on the left, upcoming events on the rightRenderer output before it is packed into a bitmap. The real panel shows Hungarian; labels here are translated and the calendar entries are placeholders.

Reading the screen#

The 800 by 480 panel is split in two.

Region Width Contents
Weather 480 px Current conditions, UV and rain, 12 hourly cards, 3-day forecast, air quality, update timestamp
Calendar 318 px Up to eight upcoming events

Red marks a value that needs attention. Everything else is black on white:

Value Turns red when
UV index above 5
Rainfall above 5 mm
Temperature above 25 °C
Air quality the category is poor or worse

The panel#

The screen is a Waveshare 7.5" (B) V2.

Property Value
Resolution 800 x 480 pixels
Colour planes Black / white / red
Interface SPI
Refresh time around 15 seconds, full refresh
Power when idle effectively zero, image held without power
Driver IC UC8179

The driver is the GxEPD2 Arduino library, specifically the GxEPD2_750c_Z08 class. The page buffer size is the interesting parameter:

C++
GxEPD2_3C<GxEPD2_750c_Z08, GxEPD2_750c_Z08::HEIGHT / 8> display(
  GxEPD2_750c_Z08(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY));

HEIGHT / 8 keeps one eighth of the frame in RAM at a time and sends it in pages: about 12 KB instead of 96 KB. Both bitmap planes are already held in RAM, and paging costs nothing since the image arrives as a finished byte stream.

Wiring#

A plain ESP32 dev board talking SPI. The Waveshare ESP32 driver board fixes the pinout:

Signal ESP32 GPIO
CS (chip select) 15
DC (data/command) 27
RST (reset) 26
BUSY 25
CLK (SPI clock) 13
MOSI (SPI data) 14

Start SPI before the display driver. MISO is -1 because the panel never talks back:

C++
SPI.begin(13, -1, 14, 15);  // CLK, MISO (unused), MOSI, CS
display.init(115200, true, 10, false);
display.setRotation(0);

Per-deployment settings:

Per-deployment settings
const char* WIFI_SSID  = "YOUR_WIFI_SSID";
const char* WIFI_PASS  = "YOUR_WIFI_PASSWORD";
const char* BITMAP_URL = "http://YOUR_SERVER_IP:8081/bitmap.bin";

#define SLEEP_SECONDS    7200    // 2 hours between refreshes
#define WIFI_TIMEOUT_MS  20000   // give up on WiFi after 20 s
#define HTTP_TIMEOUT_MS  30000   // give up on the download after 30 s

Where the data comes from#

Three sources, all through PHP proxies shared with the browser dashboard:

  • Időkép via idokep.php for current conditions, UV, rainfall, the hourly strip and the forecast.
  • AQICN via aqicn.php for the air quality composite and the individual pollutants.
  • Google Calendar via icsproxy.php, which turns an ICS feed into a sorted list of upcoming events.
Data flow
Glance PHP proxies (localhost:8081)
  ├── idokep.php    → weather JSON
  ├── aqicn.php     → air quality JSON
  └── icsproxy.php  → calendar JSON
         ↓
generate_bitmap.py  (runs on the NAS)
  → fetches all three sources
  → renders an 800x480 RGB image with Pillow
  → converts it to a 2-plane BWR bitmap
  → writes bitmap.bin (96 000 bytes)
         ↓
HTTP server (LAN, :8081)
  → serves bitmap.bin on GET
         ↓
ESP32 (wakes from deep sleep every 2 hours)
  → connects to WiFi
  → downloads bitmap.bin (96 KB)
  → renders to e-paper over SPI
  → radio off, panel off
  → back to deep sleep

The bitmap format#

The panel wants two separate one-bit planes, and bitmap.bin is exactly 96 000 bytes: the black and white plane followed by the red plane.

Bytes Plane Bit = 1 Bit = 0
0 to 47 999 Black and white white black
48 000 to 95 999 Red no red red

The firmware reads the first half into bwBuf, the second into redBuf, and hands both to display.writeImage().

A pixel counts as red when R is above 200 and G and B are below 100. Red pixels become white in the BW plane and are marked in the red plane; everything else is thresholded to black or white:

generate_bitmap.py: plane conversion
def to_2plane(img: Image.Image) -> bytes:
    """
    Returns 96 000 raw bytes: BW plane (48 000) followed by red plane (48 000).
    Convention (GxEPD2_3C):
      BW plane  : bit 1 = white, bit 0 = black
      Red plane : bit 1 = no red, bit 0 = red pixel
    """
    r_ch, g_ch, b_ch = img.split()

    # pixels where R > 200 AND G < 100 AND B < 100 count as red
    r_high = r_ch.point(lambda v: 255 if v > 200 else 0)
    g_low  = g_ch.point(lambda v: 255 if v < 100 else 0)
    b_low  = b_ch.point(lambda v: 255 if v < 100 else 0)
    red_mask = ImageChops.multiply(ImageChops.multiply(r_high, g_low), b_low)

    # BW plane: greyscale content, with red pixels replaced by white
    grey     = img.convert("L")
    bw_plane = Image.composite(Image.new("L", img.size, 255), grey, red_mask)

    # red plane: 255 where NOT red, 0 where red
    red_plane = ImageOps.invert(red_mask)

    bw_data  = bw_plane.convert("1", dither=Image.NONE).tobytes()
    red_data = red_plane.convert("1", dither=Image.NONE).tobytes()
    return bw_data + red_data

The renderer#

generate_bitmap.py fetches the three feeds, draws an 800 by 480 Pillow image and converts it:

generate_bitmap.py: settings
GLANCE_BASE  = "http://YOUR_SERVER_IP:8081/glance"
OUT_BIN      = os.path.join(os.path.dirname(__file__), "bitmap.bin")

WEATHER_CITY = "Szeged"
WEATHER_LAT  = "46.253"
WEATHER_LON  = "20.149"
AQI_URL      = "https://aqicn.org/city/.../"
CALENDAR_ICS = os.environ["CALENDAR_ICS_URL"]   # private feed, never hard-coded

W, H        = 800, 480
WEATHER_W   = 480                 # 60 %
CALENDAR_W  = W - WEATHER_W - 2   # 318 px

Each fetch is a thin GET against a proxy. One dead source must not stop the render:

Python
def fetch_calendar(limit: int = 8) -> dict:
    r = _sess.get(f"{GLANCE_BASE}/icsproxy.php",
                  params={"url": CALENDAR_ICS, "tz": "Europe/Budapest",
                          "days": 60, "limit": limit},
                  timeout=15)
    r.raise_for_status()
    return r.json()

def safe_fetch(name: str, fn) -> dict:
    try:
        return fn()
    except Exception as e:
        print(f"  {name} fetch failed: {e}", file=sys.stderr)
        return {}   # render continues with an empty panel

A missing air quality box is obvious and harmless. A crashed renderer would leave yesterday’s screen looking current.

Fonts and DPI#

Text uses Segoe UI on Windows or DejaVu Sans on Linux through ImageFont.truetype. Sizes are larger than on the browser dashboard: the panel is about 125 DPI and read from across a room. The loader tries a list of platform paths and caches each size and weight:

Python
_FC: dict = {}

def _font(size: int, bold: bool = False) -> ImageFont.ImageFont:
    k = (size, bold)
    if k in _FC:
        return _FC[k]
    REG  = ["C:/Windows/Fonts/segoeui.ttf", "C:/Windows/Fonts/arial.ttf",
            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
            "/System/Library/Fonts/Helvetica.ttc"]
    BOLD = ["C:/Windows/Fonts/segoeuib.ttf", "C:/Windows/Fonts/arialbd.ttf",
            "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"]
    for p in (BOLD + REG if bold else REG):
        try:
            _FC[k] = ImageFont.truetype(p, size)
            return _FC[k]
        except (IOError, OSError):
            pass
    _FC[k] = ImageFont.load_default()
    return _FC[k]

Hungarian text needs Latin Extended-A. The fallback ends in load_default() rather than an exception, because a wrong-looking screen beats no screen.

Weather icons come from the Időkép CDN (PNG first, otherwise SVG via cairosvg or resvg-py). The sun is the most common icon, so it is drawn in code. Rain and snow icons are composited in red:

Python
def _draw_sun(size: int) -> Image.Image:
    import math
    img = Image.new("L", (size, size), 255)
    d   = ImageDraw.Draw(img)
    cx = cy = size / 2
    r_body = size * 0.28
    d.ellipse([(cx - r_body, cy - r_body), (cx + r_body, cy + r_body)], fill=0)
    for i in range(8):                      # 8 rays at 45 degree steps
        a  = math.radians(i * 45)
        x0 = cx + size * 0.36 * math.cos(a); y0 = cy + size * 0.36 * math.sin(a)
        x1 = cx + size * 0.48 * math.cos(a); y1 = cy + size * 0.48 * math.sin(a)
        d.line([(x0, y0), (x1, y1)], fill=0, width=max(2, int(size * 0.07)))
    return img

The calendar problem#

icsproxy.php expands RRULE recurrences (daily, weekly, monthly, yearly, with INTERVAL, COUNT, UNTIL, BYDAY and EXDATE) into concrete dates inside the lookahead window.

The subtle part is single-instance edits. Moving or deleting one occurrence of a recurring series does not rewrite the master event. Google Calendar emits a second VEVENT with the same UID and a RECURRENCE-ID pointing at the original slot. Ignore those and moved events show at their old time while cancelled ones stay. Three passes fix it:

icsproxy.php: overrides
// Pass 1: split master events from per-instance overrides (RECURRENCE-ID).
$masters   = [];
$overrides = [];   // uid => [ recurrence-id timestamp => override event ]
foreach ($matches[1] as $block) {
    $event = parse_vevent($block, $tz);
    if (!$event) continue;
    if (!empty($event['recurrence_id'])) {
        $overrides[$event['uid']][$event['recurrence_id']->getTimestamp()] = $event;
    } else {
        $masters[] = $event;
    }
}

// Pass 2: expand masters, skipping occurrences an override replaces.
foreach ($masters as $event) {
    if (($event['status'] ?? '') === 'CANCELLED') continue;   // whole series deleted
    $evOverrides = $overrides[$event['uid']] ?? [];
    foreach (expand_occurrences($event, $now, $horizon) as $occ) {
        if (isset($evOverrides[$occ->getTimestamp()])) continue;   // superseded
        $events[] = format_event($occ, $event, $now, $tz);
    }
}

// Pass 3: emit modified instances at their new time, drop cancelled ones.
foreach ($overrides as $perUid) {
    foreach ($perUid as $ovr) {
        if (($ovr['status'] ?? '') === 'CANCELLED') continue;   // single instance deleted
        $start = $ovr['dtstart'];
        if ($start <= $horizon && $start >= $now) {
            $events[] = format_event($start, $ovr, $now, $tz);
        }
    }
}

The renderer knows none of this. It receives a flat, sorted list and draws it.

Power#

State Draw Time
Deep sleep around 10 to 20 µA the rest of the two hours
Awake: WiFi, download, refresh roughly 120 mA 25 to 35 seconds

At a two-hour interval the chip is awake about half a percent of the time. On USB the draw is irrelevant.

Deep sleep restarts the chip from setup() on every wake, so the whole program is one linear sequence and loop() is empty:

The whole program
void setup() {
  Serial.begin(115200);

  bwBuf  = new uint8_t[PLANE_BYTES];   // 48 000 bytes each
  redBuf = new uint8_t[PLANE_BYTES];

  SPI.begin(13, -1, 14, 15);           // CLK, MISO (unused), MOSI, CS
  display.init(115200, true, 10, false);
  display.setRotation(0);

  if (!connectWiFi())   { goToSleep(); return; }
  if (!downloadBitmap()){ goToSleep(); return; }

  WiFi.disconnect(true);
  WiFi.mode(WIFI_OFF);                 // radio off before the slow SPI refresh
  renderBitmap();
  goToSleep();
}

void loop() { /* never reached, deep sleep restarts from setup() */ }

The radio goes off before the refresh: an idle WiFi connection held through 15 seconds of panel refresh is the biggest waste in the wake cycle.

C++
static void goToSleep() {
  WiFi.disconnect(true);
  WiFi.mode(WIFI_OFF);
  display.powerOff();
  esp_sleep_enable_timer_wakeup((uint64_t)SLEEP_SECONDS * 1000000ULL);
  esp_deep_sleep_start();
}

Any failure (no WiFi in 20 seconds, a failed or incomplete download) goes straight back to sleep without touching the display, so the previous image stays up. The download streams into the two plane buffers in 512 byte chunks, with a deadline against a stalled connection and a byte count check that rejects a truncated file:

C++
WiFiClient* stream = http.getStreamPtr();
size_t pos = 0;
uint8_t chunk[512];
unsigned long deadline = millis() + HTTP_TIMEOUT_MS;

while (pos < (size_t)BITMAP_BYTES && millis() < deadline) {
  int avail = stream->available();
  if (avail <= 0) { if (!stream->connected()) break; delay(1); continue; }
  size_t want = min((size_t)avail, min(sizeof(chunk), (size_t)BITMAP_BYTES - pos));
  size_t got  = stream->readBytes(chunk, want);
  for (size_t i = 0; i < got; i++) {
    size_t p = pos + i;
    if (p < (size_t)PLANE_BYTES) bwBuf[p] = chunk[i];      // first 48 000 to BW
    else                         redBuf[p - PLANE_BYTES] = chunk[i];  // rest to red
  }
  pos += got;
}
if (pos != (size_t)BITMAP_BYTES) return false;   // truncated, keep the old image

Without the length check, a partial download renders as part of the old screen above a band of noise.