We put the headlines on an e-ink screen so we'd stop opening X
We wanted the top of the news on a shelf, not in a feed. An e-ink news display is an e-paper panel driven by a small computer that redraws a short list of headlines on a schedule and holds the image with no power in between. Unlike a Raspberry Pi e-ink dashboard that mixes weather, calendar and stock tickers, this one shows headlines and nothing else. Ours is a Waveshare 7.5" e-Paper HAT (800×480, black and white) on a Raspberry Pi, fed by a news API, redrawn by cron once an hour. The board shows nine headlines: three top stories, two business, two science & tech, two sport.
Three things we measured before calling it done. At 18 px, nine headlines fit on 800×480 with two-line wrapping; a source column would have cost three of them. The API's /top-headlines endpoint returns HTTP 500 or 502 the moment you add category.id, so the category tabs come from /everything with a source-rank floor. And across 24 hourly snapshots, the front page's top three changed by 0.83 headlines per hour — in 8 of 23 hours it didn't change at all — and the category tabs by 0.61 to 0.96 of their two lines. The whole nine-line board averaged 3.13 new lines per hour.
This post is for anyone with a Pi, a Waveshare panel and 40 minutes. The script is complete and below. No hardware photos here: the image you'll see is the exact 1-bit frame the script pushes to the panel.
Takeaways
- 9 headlines on a 7.5" panel at 18 px; 75% of 256 real headlines fit on one line at that size, none needs more than two. In 2 of 24 reconstructed hours one headline had to be dropped to fit.
-
/top-headlines+category.id= HTTP 500/502 on 5 of 5 tries. Category tabs use/everything+source.rank.opr.min=8instead. - Science & tech at rank ≥ 8 is only 8 articles a day. One notch lower plus
ignore.source.domain=dev.togives 83. - Front page top 3: 0.83 changes per hour, 8 of 23 hours with no change. Business 0.96 of 2, science & tech 0.74, sport 0.61. Refreshing every 3 hours instead would show 6.71 new lines of 9 per redraw.
- An API call takes 3.53 s median from a laptop; the panel's full refresh is the slow part, so hourly is plenty.
What's on the shelf
- Raspberry Pi 4 (we ran it here; a Pi Zero 2 W has the same 40-pin header and the script only needs
requestsand Pillow, so it should be fine — we didn't test it). - Waveshare 7.5" e-Paper HAT, V2. 800×480, 1-bit. The HAT sits straight on the GPIO header, no wiring.
- A picture frame from the drawer, with the mat cut to the panel's active area.
Software: Raspberry Pi OS, Python 3, Pillow, requests, the Waveshare driver from github.com/waveshare/e-Paper, and a news API key. We used APITube, where we work — more on that at the end.
"Top headlines by category" doesn't exist in the API, so we built it
The obvious call works. /v1/news/top-headlines with a language returns a ranked list from high-authority sources and even a bare headlines array:
curl -s "https://api.apitube.io/v1/news/top-headlines?language.code=en&per_page=3&fl=title,source.domain,published_at" \
-H "X-API-Key: $APITUBE_API_KEY"
{
"status": "ok",
"headlines": [
"NBC helicopter crash in LA kills at least three people with others injured",
"Federal Reserve raises interest rates for the first time since 2023",
"Trump administration will comply with mail ballot ruling by Supreme Court, Blanche says"
],
"results": [
{"title": "NBC helicopter crash in LA kills at least three people with others injured",
"source": {"domain": "theguardian.com"}, "published_at": "2026-09-16T04:32:48.000Z"},
{"title": "Federal Reserve raises interest rates for the first time since 2023",
"source": {"domain": "cbsnews.com"}, "published_at": "2026-09-16T18:31:07.000Z"},
{"title": "Trump administration will comply with mail ballot ruling by Supreme Court, Blanche says",
"source": {"domain": "cbsnews.com"}, "published_at": "2026-09-15T22:20:23.000Z"}
]
}
Add category.id=medtop:04000000 (business, IPTC media topic) and it stops working:
{"status":"not_ok","errors":[{"status":500,"code":"ER0183","message":"Something went wrong."}]}
Five attempts, five failures: three HTTP 500 within 0.3 s, two HTTP 502 after a 30-second wait. source.country.code on the same endpoint also gave 500. language.code, per_page, fl, title and published_at.end were fine. So the front page comes from /top-headlines, and the category tabs are assembled from /everything. Unlike /top-headlines, /everything returns whatever matched, newest first, with no editorial ranking, which means the tabs need a source-rank floor and a topic rule before they look like a front page:
/v1/news/everything
?language.code=en
&category.id=medtop:04000000
&source.rank.opr.min=8 # big outlets only
&is_duplicate=0 # one copy of each story
&sort.by=published_at&sort.order=desc
&per_page=16 # 8 candidates per slot shown
&fl=title,source.domain,published_at,categories
source.rank.opr.min is an Open PageRank floor. At 8 the business tab over the last 24 hours (16 Sep 07:00 to 17 Sep 07:00 UTC) had 86 articles from the Guardian, CBS, TechCrunch and CNET. Sport had 29. Science & tech had 8 — the panel would have redrawn the same two lines all day. Dropping that tab to source.rank.opr.min=7 gave 110 articles, but 27 of them (25%) were dev.to posts, because dev.to ranks high and gets tagged as technology. ignore.source.domain=dev.to brought it to 83 from independent.co.uk, sciencedaily.com, gizmodo.com and space.com. That tab is science-heavy (medtop:13000000 is "science and technology"), so the label on the board says SCIENCE & TECH.
One more filter, and it is the weak spot. Each article carries categories[] with a score per category, and the tagger is generous: over the same 24 hours the Guardian's "Thursday news quiz" was tagged Business at 0.3 and Sport at 0.46, a Snap Specs review was Sport at 1.03, and six Guardian film reviews sat under Business at exactly 2.0. The score alone doesn't separate them — a $18M funding round scored 1.31 for Business, below the film reviews.
What worked best of the rules we tried: keep an article only if the tab's category out-scores every other category on it and reaches 1.0. On the 86 business articles that keeps 32, and 26 of those are business by our reading (81%); the unfiltered list was 50/50. It keeps 19 of 29 sport and 20 of 83 science & tech, so the script fetches eight candidates per slot to have enough left. Expect roughly one tab line in five to be off-topic anyway. In the frame below it's two of six: a Nicolas Winding Refn interview under Business and a partisan book review under Science & tech.
Nine headlines fit at 18 px, not ten
E-paper is 1-bit: no grey, no anti-aliasing to hide behind. Before choosing a font size we rendered the 256 unique headlines from the 24-hour sample (median 73 characters) with Pillow in DejaVu Sans, at 768 px of text width, and counted lines.
| Font size | Fits on one line | Needs two lines | Needs three |
|---|---|---|---|
| 17 px | 85% | 15% | 0% |
| 18 px | 75% | 25% | 0% |
| 19 px | 71% | 29% | 0% |
| 20 px | 54% | 46% | 0% |
| 22 px | 35% | 65% | 0.4% |
Then the same test with a 150 px source column on the right (618 px for text): at 18 px only 34% fit on one line. The column costs about three headlines per screen. So sources went into a footer line and headlines got the full width.
The pixel budget at 18 px: header 32 px, four section labels at 20 px, a footer line, 21 px per text line (DejaVu 18 px is 17 px ascent plus 5 descent) and 4 px between headlines. Nine one-line headlines use 416 px of the 480; every wrapped one adds 21 px, so the board fits up to four two-liners. We replayed the 24 reconstructed screens through the renderer: in 22 hours all nine fit, in 2 the last sport headline was dropped rather than drawn over the footer. Ten headlines did not fit in any layout we tried.
This is the 1-bit PNG the script produces with --preview; the panel shows exactly this.
The script
One file, 142 lines. Run it with --preview out.png on a laptop and it writes the frame instead of touching the panel; on the Pi it imports the Waveshare driver and draws.
#!/usr/bin/env python3
"""Headlines on a Waveshare 7.5" e-paper (800x480). Runs once and exits;
cron calls it every hour. `--preview out.png` renders without the panel.
"""
import os
import sys
import time
import datetime as dt
import requests
from PIL import Image, ImageDraw, ImageFont
API = "https://api.apitube.io/v1/news"
KEY = os.environ["APITUBE_API_KEY"]
W, H = 800, 480
FONT_DIR = os.environ.get("FONT_DIR", "/usr/share/fonts/truetype/dejavu")
COMMON = {"language.code": "en", "fl": "title,source.domain,published_at,categories"}
# top-headlines is already limited to high-authority sources; the category
# tabs get the same treatment by hand: big outlets only, no duplicates, newest first.
BIG = {"source.rank.opr.min": 8, "is_duplicate": 0,
"sort.by": "published_at", "sort.order": "desc"}
# Science & tech at OPR 8+ is only ~8 articles a day, so that tab goes one
# notch lower and drops dev.to, which otherwise takes a quarter of the slots.
TECH = dict(BIG, **{"source.rank.opr.min": 7, "ignore.source.domain": "dev.to"})
# (label, endpoint, params, headlines to show)
TABS = [
("TOP STORIES", "top-headlines", {}, 3),
("BUSINESS", "everything", dict(BIG, **{"category.id": "medtop:04000000"}), 2),
("SCIENCE & TECH", "everything", dict(TECH, **{"category.id": "medtop:13000000"}), 2),
("SPORT", "everything", dict(BIG, **{"category.id": "medtop:15000000"}), 2),
]
def on_topic(article, cat):
"""Keep an article only if the tab's category out-scores every other
category on it and reaches 1.0. The tagger alone is not enough: it puts
a Guardian film review under Business at 2.0 and a news quiz under Sport."""
cats = article.get("categories") or []
mine = [c["score"] for c in cats if c["links"]["self"].endswith("/" + cat)]
others = [c["score"] for c in cats if not c["links"]["self"].endswith("/" + cat)]
return bool(mine) and mine[0] >= 1 and all(o < mine[0] for o in others)
def fetch(endpoint, params, n, seen):
r = requests.get(f"{API}/{endpoint}", params=dict(COMMON, per_page=8 * n, **params),
headers={"X-API-Key": KEY}, timeout=30)
r.raise_for_status()
out = []
for a in r.json()["results"]:
if a["title"] in seen:
continue # same story already on screen
if "category.id" in params and not on_topic(a, params["category.id"]):
continue
seen.add(a["title"])
out.append(a)
if len(out) == n:
break
return out
def font(size, bold=False):
name = "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf"
return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
def wrap(draw, text, fnt, max_w, max_lines=2):
"""Greedy word wrap into at most max_lines; ellipsis if the text ran over."""
lines, line = [], ""
for word in text.split():
trial = f"{line} {word}".strip()
if draw.textlength(trial, font=fnt) <= max_w:
line = trial
continue
lines.append(line)
line = word
if len(lines) == max_lines:
break
else:
lines.append(line)
return lines
while draw.textlength(lines[-1] + "…", font=fnt) > max_w:
lines[-1] = lines[-1].rsplit(" ", 1)[0]
lines[-1] += "…"
return lines
def render(sections, now):
img = Image.new("1", (W, H), 255) # 1-bit: 0 = black, 255 = white
d = ImageDraw.Draw(img)
f_head, f_label, f_item, f_foot = font(20, True), font(14, True), font(18), font(13)
LINE, GAP = 21, 4 # 18 px DejaVu is 17 px ascent + 5 descent
d.rectangle((0, 0, W, 32), fill=0)
d.text((16, 5), now.strftime("%a %d %b %H:%M"), font=f_head, fill=255)
d.text((W - 16, 5), "headlines", font=f_head, fill=255, anchor="ra")
y, floor, sources, dropped = 38, H - 22, [], 0 # floor: keep clear of the footer
for label, items in sections:
d.text((16, y), label, font=f_label, fill=0)
d.line((16, y + 17, W - 16, y + 17), fill=0)
y += 20
for a in items:
lines = wrap(d, a["title"], f_item, W - 32)
if y + LINE * len(lines) > floor:
dropped += 1 # too many two-liners this hour
continue
for ln in lines:
d.text((16, y), ln, font=f_item, fill=0)
y += LINE
y += GAP
sources.append(a["source"]["domain"])
y += 6
seen = list(dict.fromkeys(sources)) # unique, in order
d.text((16, H - 18), "Sources: " + ", ".join(seen), font=f_foot, fill=0)
return img, dropped
def main():
now = dt.datetime.now()
seen = set()
sections = [(label, fetch(ep, params, n, seen)) for label, ep, params, n in TABS]
img, dropped = render(sections, now)
if dropped:
print(f"{now:%H:%M} dropped {dropped} headline(s) that did not fit")
if len(sys.argv) > 2 and sys.argv[1] == "--preview":
img.save(sys.argv[2])
return
from waveshare_epd import epd7in5_V2 # only importable on the Pi
epd = epd7in5_V2.EPD()
epd.init() # full refresh: no ghosting
epd.display(epd.getbuffer(img))
time.sleep(2)
epd.sleep() # panel holds the image unpowered
if __name__ == "__main__":
main()
Notes on the parts that aren't obvious:
-
Image.new("1", ...)makes a 1-bit image.epd.getbuffer()packs it for the panel; you never convert. -
wrap()is a greedy word wrap capped at two lines with an ellipsis. Pillow'stextlength()is what makes the layout table above true on the device. -
fetch()asks for8 * nresults, drops anything already on screen (the news quiz showed up under two tabs at once) and anything that failson_topic(), then keepsn. -
render()refuses to draw a headline that would run into the footer and reports it on stdout, which cron writes to the log. - The API key comes from the environment. Don't put it in the file; cron can pass it.
Cron and being kind to the panel
Setup on the Pi, once:
-
sudo raspi-config→ Interface Options → SPI → enable. sudo apt install python3-pil python3-numpy python3-requests fonts-dejavu-
git clone https://github.com/waveshare/e-Paperand pointPYTHONPATHate-Paper/RaspberryPi_JetsonNano/python/lib, or copy thewaveshare_epdfolder next to the script. -
python3 headlines_eink.py --preview test.pngfirst. If the PNG looks right, run it without the flag.
Then the cron line (crontab -e):
0 * * * * APITUBE_API_KEY=your_key PYTHONPATH=/home/pi/e-Paper/RaspberryPi_JetsonNano/python/lib /usr/bin/python3 /home/pi/headlines_eink.py >> /home/pi/headlines.log 2>&1
Two habits keep the panel healthy. The script calls epd.init() before every draw, which is a full refresh — the whole screen flashes black and white for a few seconds and ghosting from the previous frame is gone. The driver also has init_part() and display_Partial() for updating a region without the flash; we don't use them, because at one redraw an hour the flash doesn't matter and full refreshes are what clear ghosting. After drawing, epd.sleep() powers the panel down; e-paper holds the image with no power, so between refreshes the HAT draws nothing. Waveshare's wiki is explicit that e-paper isn't meant for continuous refreshing, which is the other reason hourly is the right cadence.
How often does the board actually change?
Hourly was a guess. To check it we reconstructed what the board would have shown at each full hour over 24 hours (16 Sep 08:00 to 17 Sep 07:00 UTC) by calling each endpoint with published_at.end set to that hour, then counted how many of the on-screen headlines were new compared with the hour before. One caveat: /top-headlines ranks as the API sees it today, restricted to articles that existed at that hour — a reconstruction, not a log we kept live.
| Section (lines) | New lines per hour | Hours with no change (of 23) |
|---|---|---|
| Top stories (3) | 0.83 | 8 |
| Business (2) | 0.96 | 8 |
| Science & tech (2) | 0.74 | 10 |
| Sport (2) | 0.61 | 11 |
| Whole board (9) | 3.13 | 2 |
So an hourly redraw replaces about a third of the board. Science & tech was thin even after the OPR change: in 9 of the 24 hours fewer than two of the twelve candidates we fetched passed the topic rule, which is why the script fetches sixteen.
The refresh cadence follows from that. Redrawing every 2 hours would show 5.09 new lines per refresh, every 3 hours 6.71, every 6 hours 8 of 9 — at which point the board is a different board each time and you've lost the point of glancing at it. We kept hourly: 3 new lines per glance is enough to be worth looking, and in only 2 of 23 hours would the flash have been for nothing. If the full-refresh flash bothers you at your desk, every 3 hours is the next sensible setting, not every 15 minutes.
For latency, the 24 front-page calls took a median of 3.53 s from a laptop (min 2.14 s, p90 5.04 s, max 6.36 s); the category calls took 8–10 s each. None of it matters at one run an hour.
FAQ
How do you display news headlines on a Raspberry Pi e-ink screen?
The simplest way to display news headlines on a Raspberry Pi e-ink screen is a Python script that fetches titles from a news API, draws them into a 1-bit Pillow image the size of the panel (800×480 for the Waveshare 7.5" V2) and pushes it with the vendor driver — epd.init(), epd.display(epd.getbuffer(img)), epd.sleep() — because the panel accepts nothing but a packed bitmap. The script above is the whole thing.
How often can you refresh an e-paper display?
The right refresh rate for an e-paper news display is once an hour, because the vendor advises against continuous refreshing and the news doesn't move faster than that: in our 24-hour sample the top three headlines changed 0.83 times per hour and the nine-line board 3.13 lines per hour. A full refresh on the Waveshare 7.5" V2 takes a few seconds; partial refresh is faster but leaves ghosting.
Can a Raspberry Pi Zero 2 W drive a 7.5-inch e-paper?
A Raspberry Pi Zero 2 W should drive the 7.5-inch Waveshare HAT, because the HAT uses SPI on the standard 40-pin header and the script needs only requests and Pillow. We ran ours on a Pi 4 and did not test a Zero 2 W.
How do you run a Python script every hour on a Raspberry Pi?
The standard way to run a Python script every hour on a Raspberry Pi is a cron line — 0 * * * * /usr/bin/python3 /home/pi/headlines_eink.py — because cron is already installed and survives reboots. Put environment variables (the API key, PYTHONPATH) on the same line and redirect output to a log file so failures are visible.
Which Waveshare e-paper is best for a headlines board?
The best Waveshare e-paper for a headlines board is the 7.5" black-and-white V2 (800×480), because nine headlines fit at 18 px and a black-and-white panel does a full refresh in seconds. We didn't test the three-colour variants or the smaller panels; scale the layout table above by their pixel height before buying.
Disclosure
We work on APITube, and it is the API in this post — free tier at apitube.io. Any API that returns titles as JSON works with the script; only fetch() changes.
Resources
- Waveshare e-Paper driver and examples: https://github.com/waveshare/e-Paper
- APITube API reference: https://docs.apitube.io
- IPTC Media Topics (the
medtop:ids): https://iptc.org/standards/media-topics/ - Pillow ImageDraw: https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html



Top comments (0)