An e-paper weather station

RECIPE

Weather is the classic first e-paper project, and it is a good one — the data changes slowly, the display suits glanceable reading, and the whole thing can run for months on a battery if the refresh schedule is honest about how often forecasts actually update.

Match the refresh rate to the data

Most weather APIs update their forecast every 10 to 60 minutes, and observations every 5 to 15. Refreshing a panel every minute against a source that changes hourly is pure waste — it burns battery and accumulates ghosting to display the same numbers.

Pick the interval from the upstream cadence, not from a sense of liveness. Fifteen minutes is generous for observations; thirty is plenty for a forecast.

Layout

One large current temperature, a compact strip of upcoming hours, and trend lines underneath. The temperature is the only thing read at a glance, so it gets the same dominance the price gets on a shelf label.

<div class="ink-page weather">
  <e-flex justify="space-between" align="baseline">
    <e-title level="1">Hamburg</e-title>
    <e-last-updated id="stamp" datetime="2026-08-18T09:00:00Z"
                    stale-after="2700"></e-last-updated>
  </e-flex>

  <e-grid cols="2" gap="24">
    <e-statistic id="temp" label="Now" value="18.4" suffix="°C"
                 precision="1" trend="up" delta="1.2°"></e-statistic>
    <e-statistic id="feels" label="Feels like" value="17.1" suffix="°C"
                 precision="1"></e-statistic>
  </e-grid>

  <e-divider label="Next 12 hours"></e-divider>

  <e-table id="forecast"
    columns='[
      {"key":"hour","title":"Hour"},
      {"key":"temp","title":"°C","align":"right"},
      {"key":"rain","title":"Rain","align":"right"},
      {"key":"wind","title":"Wind","align":"right"}
    ]'
    data='[
      {"hour":"10:00","temp":"19","rain":"0 %","wind":"12 km/h"},
      {"hour":"13:00","temp":"22","rain":"10 %","wind":"14 km/h"},
      {"hour":"16:00","temp":"21","rain":"40 %","wind":"18 km/h"},
      {"hour":"19:00","temp":"17","rain":"60 %","wind":"15 km/h"}
    ]'></e-table>

  <e-grid cols="2" gap="24">
    <e-sparkline id="trend-temp" label="Temperature, 24 h"
                 values="[14,13,13,15,18,21,22,21,19,17,16,15]"></e-sparkline>
    <e-meter id="humidity" label="Humidity" value="72" unit="%"
             low="30" high="80"></e-meter>
  </e-grid>
</div>

Rendering the trend in one bit

<e-sparkline> draws a static line with an explicit caption rather than a shaded area chart. That is deliberate: gradient fills and anti-aliased curves are grey, and grey in a region forces the panel into its slow grey-capable waveform. A hard one-pixel line stays in the fast mode.

async function refresh() {
  const w = await fetch('/api/weather').then((r) => r.json());

  const temp = document.getElementById('temp');
  temp.setAttribute('value', w.now.temp.toFixed(1));
  temp.setAttribute('trend', w.now.temp >= w.previous.temp ? 'up' : 'down');
  temp.setAttribute('delta',
    `${Math.abs(w.now.temp - w.previous.temp).toFixed(1)}°`);

  document.getElementById('feels')
    .setAttribute('value', w.now.feelsLike.toFixed(1));

  // Attribute writes are diffed by the components; identical values
  // produce no DOM mutation and therefore no panel update at all.
  document.getElementById('forecast')
    .setAttribute('data', JSON.stringify(w.hourly));
  document.getElementById('trend-temp')
    .setAttribute('values', JSON.stringify(w.history24h));
  document.getElementById('humidity')
    .setAttribute('value', String(w.now.humidity));

  document.getElementById('stamp')
    .setAttribute('datetime', new Date().toISOString());
}

refresh();
setInterval(refresh, 15 * 60_000);   // matched to the upstream cadence

Running it on a battery

For a mains-powered display a browser left open is fine. For a battery build the browser is the wrong shape: keeping a rendering engine resident to change four numbers every quarter hour dominates the power budget.

The usual arrangement is to render off-device and treat the panel as a dumb sink — a server renders the page headlessly to a PNG, the device wakes on a timer, fetches the image, drives the panel and goes back to deep sleep. The layout work above is unchanged; only who runs the browser moves.

// Server side: render the same page to a 1-bit PNG on a schedule.
import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage({
  viewport: { width: 800, height: 480 },     // the panel's native size
  deviceScaleFactor: 1,
});
await page.goto('http://localhost:8080/weather', { waitUntil: 'networkidle' });
await page.screenshot({ path: 'panel.png' });
await browser.close();

Because the components render without animation and without hover states, the screenshot is deterministic — the same data produces byte-identical output, so the device can skip the panel write entirely when the image has not changed.

Pitfalls

  • Do not show more precision than the forecast has. A temperature to one decimal is defensible for an observation, absurd for a forecast three days out.
  • Give the temperature a fixed-width slot. Going from 9.8 to 10.1 must not reflow the row.
  • Handle the failed fetch by leaving the numbers alone and letting the timestamp go stale. Wrong weather is worse than old weather.
  • Render at the panel's native resolution. Scaling a screenshot reintroduces the grey anti-aliased edges the design was avoiding.
  • Watch the timezone. Forecast hours arriving in UTC and rendered as local time is the single most common bug in these builds.