Grey bin or yellow bin? This week or next week? And that bank holiday on Thursday, does it shift everything or not?
In Rennes Métropole (the Rennes metropolitan area, in Brittany), the answer is in a PDF calendar published every year. It's well made, clear, colourful. But a PDF won't send you a message on Wednesday evening to remind you to put the bin out.
Home Assistant will. I just needed the dates. So I wrote an integration that gets them for the 43 towns of the metropolitan area and the districts of Rennes, and sends a notification the evening before. The code is open source, on GitHub, and the integration has its own project page (installation, features, screenshots).
In this article, I walk you through the whole journey, from reading the PDFs to the notification on the phone. With screenshots, so it's easy to follow.
TL;DR
- The collection dates are not in the PDF's text, but in its coloured boxes: the script reads the drawings with PyMuPDF.
- Bank holidays are already applied in the PDF. The printed rule ("Tuesday - Odd week") is only used to check the result.
- The output is a plain JSON file published on GitHub. Updating it updates every installation.
- In Home Assistant: a calendar, three sensors and a "Put the bin out" sensor that turns on at 6 pm the day before. All that's left is to hook a notification to it.
The starting point: 33 PDFs
The metropolitan area publishes one calendar per town, or per group of towns (and one per district in Rennes). For the 2026-2027 season, that's 33 PDFs. Each one covers twelve months, from October to September, over two pages.
Here is the one for Acigné, the town I used as an example throughout the project:

The calendar as published by Rennes Métropole (first page, October to March, in French).
For a human, it's crystal clear: a grey box is the grey bin (household waste). A yellow box is the yellow bin (recyclables). The small red arrows show the bank holiday shifts.
For a program, it's another story.
Getting the PDFs
First surprise: the links on the "Where and how to dispose of your waste" page don't point to PDFs, but to an online flipbook viewer (Heyzine), which displays the PDF like a magazine. The real file is referenced in the viewer's page. So a small script goes and fetches it:
for url, label in links.items():
viewer = fetch(url).decode("utf-8", "ignore")
m = re.search(r"https://cdn\w*\.heyzine\.com/files/uploaded/[0-9a-f-]+\.pdf", viewer)
target = out / (url.rsplit("/", 1)[1].removesuffix(".html") + ".pdf")
target.write_bytes(fetch(m.group(0)))
One command, and the season's 33 PDFs are in sources/2026-2027/:
python tools/download_sources.py 2026-2027
Reading a PDF... that has nothing to say
The first reflex is to extract the text. With PyMuPDF, it's one line: page.get_text(). And here's what you get for the October column:
OCTOBRE 2026
1
J
2
V
3
S
...
Day numbers and initials (in French). No trace of the collections. That makes sense: the information isn't written, it's drawn. The grey and yellow boxes are coloured rectangles placed behind the table rows.
Good news: PyMuPDF can also read drawings, with their fill colour. We just keep the grey and yellow rectangles (simplified version):
# Fill colours of the boxes (RGB between 0 and 1)
WASTE_COLORS = {
"ordures_menageres": (0.745, 0.735, 0.741), # grey: household waste
"recyclables": (1.0, 0.8, 0.0), # yellow: recyclables
}
for drawing in page.get_drawings():
fill = drawing.get("fill")
waste = next((w for w, c in WASTE_COLORS.items() if _color_match(fill, c)), None)
if waste is not None:
yield waste, pymupdf.Rect(drawing["rect"])
These two colours are easy to find by listing the drawings on a page. A small tolerance (0.06) absorbs rounding differences from one PDF to another.
From rectangle to date
A coloured rectangle is just a position on the page. To turn it into a date, the script relies on three landmarks:
- The month headers ("OCTOBRE 2026"): they give the position of each column. There must be exactly 6 per page.
- The day numbers, aligned on the left of each column: they give the height of each row.
- The coloured boxes: take their centre, find the nearest column, then the nearest day.
Here's what the script "sees" on the same page:

Blue: month headers. Green: day numbers. Red: the collection boxes kept by the script.
The core of the calculation fits in a few lines:
for waste, rect in _filled_rects(page):
cx, cy = (rect.x0 + rect.x1) / 2, (rect.y0 + rect.y1) / 2
col = next((c for c in columns if abs(cx - c["cx"]) <= half_width), None)
if col is None:
continue # the legend, outside the calendar
day, y = min(col["days"].items(), key=lambda kv: abs(kv[1] - cy))
cal.collections.setdefault(waste, []).append(date(col["year"], col["month"], day))
A small detail that matters: the PDF's legend also contains a grey and a yellow rectangle. Since they're not in any month column, they're ignored.
Fail rather than lie
This kind of script has a weakness: the day the layout changes, it may keep running... and produce wrong dates. For bins, it's not a disaster, but it's exactly the kind of bug nobody notices.
So the script checks everything it can, and stops at the slightest doubt:
if len(columns) != 6:
raise ParseError(f"page {page.number + 1}: {len(columns)} months found instead of 6")
for col in columns:
expected = _month_length(col["year"], col["month"])
if sorted(col["days"]) != list(range(1, expected + 1)):
raise ParseError(f"{col['month']:02d}/{col['year']}: days read {sorted(col['days'])}")
Six months per page, every day of every month (28, 29, 30 or 31): otherwise, it stops with a clear message.
The bank holiday trap
Each calendar prints its rule in the legend: "Household waste: Tuesday - Odd week". And below: "Week with a bank holiday: all collections are shifted by one day from the holiday until the end of the week."
I could have stopped there: one rule, an ISO week calculation, the list of bank holidays (with Easter, Ascension and Whit Monday moving every year), and done. No need to read the PDFs at all.
Except reality is a bit subtler than the rule:
- Christmas falls on a Friday in 2026: the yellow bin collection moves to Saturday the 26th. The rule covers that.
- Ascension Day, though, shifts nothing: the calendar says "collection maintained".
- Weeks 53 and 1 follow each other and are both odd: two grey bin collections in a row. The PDF flags it in red at the bottom of the page.
- And sometimes, the PDF departs from the rule for no obvious reason. Look at December in the screenshot above: the rule says Tuesday, but the grey box is on Wednesday the 2nd.
Who's right, the rule or the box? For me, it's the box. It's what the metropolitan area published, it's what residents have in front of them, and it's most likely what the truck will follow.
Hence the project's principle: the published dates are the ones from the coloured boxes. The rule is only used as a check. The script replays it, with the bank holiday shifts, and compares:
def expected_dates(rule: Rule, start: date, end: date) -> list[date]:
"""Theoretical dates: weekday + ISO week parity, shifted by one day after a holiday that week."""
dates, day = [], start
while day <= end:
week = day.isocalendar().week
if day.weekday() == rule.weekday and (week % 2 == 0) == (rule.parity == "paire"):
monday = day - timedelta(days=day.weekday())
shift = any(monday + timedelta(days=i) in holidays for i in range(rule.weekday + 1))
dates.append(day + timedelta(days=1) if shift else day)
day += timedelta(days=1)
return dates
Each difference goes into a report, data/rapport.md (in French):
### ⚠️ Acigné (`31d8e5225d.pdf`)
- Ordures ménagères : collecte le mer. 02/12/2026 non prévue par la règle
- Ordures ménagères : pas de collecte le mar. 01/12/2026 alors que la règle la prévoit
In other words: a collection on Wednesday 2 December that the rule doesn't predict, and no collection on Tuesday 1 December when the rule expects one.
Out of this season's 33 PDFs, 29 match the rule perfectly. The other 4 each have a single shift, which I can check by eye in two minutes. The report fixes nothing: it tells me where to look.
One JSON file for everyone
The script produces a single file, data/collectes.json. For each zone (53 in total: the towns, plus the districts of Rennes), the list of dates per bin type:
{
"schema_version": 1,
"valid_until": "2027-09-30",
"collection_start": "05:30",
"zones": {
"acigne": {
"name": "Acigné",
"collections": {
"ordures_menageres": ["2026-10-06", "2026-10-20", "..."],
"recyclables": ["2026-10-02", "2026-10-16", "..."]
}
}
}
}
And this file is simply published on GitHub. Every Home Assistant installation downloads it from raw.githubusercontent.com every 12 hours.
That choice changes everything for maintenance. The calendars change every year in September. If the dates were in the integration's code, I'd have to publish a new version, and every user would have to remember to update. Here, the yearly update is three steps:
python tools/download_sources.py 2027-2028
python tools/build_calendar.py
git add sources data && git commit -m "Season 2027-2028" && git push
And every installation has the new dates within the day.
Two safety nets on the Home Assistant side:
- if GitHub doesn't respond, the integration uses the last downloaded version, kept in a cache;
- if the calendar has no upcoming dates left (after 30 September, if I forgot the update), an alert shows up in Settings → Repairs.
And on the GitHub side, a workflow reruns the conversion whenever the PDFs or the script change, shows the check report in the run summary, and fails if I forgot to regenerate the JSON.
In Home Assistant
Installation
The integration is installed with HACS, Home Assistant's community store. Add the repository URL, install, restart.

The screenshots in this section come from the repository's demo video (Home Assistant in French).
Then, in Settings → Devices & services, add the integration and pick your town (or your district in Rennes). The list is read straight from the JSON file: when a new season adds or regroups towns, the list follows on its own.

One detail I paid attention to: a zone's identifier (acigne, or rennes-q5 for a district) must never change from one year to the next. It's what identifies the configuration in Home Assistant. If the metropolitan area groups two towns into one PDF next year, each one keeps its identifier.
Five entities
Once the zone is chosen, the integration creates five entities:

- a calendar, with one event per collection day;
- a sensor per bin (grey and yellow), with the date of the next pickup and the number of days left;
- a Next collection sensor, all bins combined;
- and the most useful one: Put the bin out.
"Put the bin out": the only question that matters
Deep down, you don't want to know when the truck comes. You want to know whether to put the bin out tonight.
That's the job of this binary sensor. It turns "on" the day before the collection at 6 pm (the time can be changed in the options), and back "off" at the start of the collection, 5:30 am. All the logic is in one very simple function:
def take_out_window(self, day: date, reminder: time, tz) -> tuple[datetime, datetime]:
"""Time window during which the bin must be out for the collection on ``day``."""
start = datetime.combine(day - timedelta(days=1), reminder, tzinfo=tz)
end = datetime.combine(day, self.collection_start, tzinfo=tz)
return start, end
The sensor doesn't check the time every minute. It works out the next moment its state must change, and asks Home Assistant to wake it up at that exact moment:
self._collection, next_change = self.coordinator.data.take_out_status(dt_util.now(), self._reminder)
if next_change is not None:
self._unsub_timer = async_track_point_in_time(self.hass, self._refresh, next_change)
No wasted work, and a state change right on time.
These calendar functions live in a separate file, schedule.py, which doesn't depend on Home Assistant at all. So I can test them on Windows, in a few seconds, without installing Home Assistant. The full integration tests run on Linux in GitHub Actions.
The notification
All that's left is to hook a notification to "Put the bin out". A few lines of automation are enough:
automation:
- alias: "Bin reminder"
triggers:
- trigger: state
entity_id: binary_sensor.acigne_put_the_bin_out
to: "on"
actions:
- action: notify.mobile_app_my_phone
data:
title: "Bins"
message: >-
Tonight, put out: {{ state_attr('binary_sensor.acigne_put_the_bin_out', 'description') }}
The sensor provides everything you need in its attributes: bins, the bin colours, and description, the waste types ("Recyclables"), in Home Assistant's language. When both bins are collected on the same day, both are in the message.
And on Thursday evening at 6 pm:

The calendar fits nicely on a dashboard, with the upcoming pickups:

What I take away
- Look at how the information is drawn, not just written. In a PDF, the text is only part of the story. PyMuPDF gives access to shapes and colours: that's often where the data hides.
- Copy rather than recompute. The PDF's dates are the reference; the rule is a check. A "smart" calculation would have been wrong four times this season, without anyone noticing.
- A script that stops beats a script that's wrong. The day the layout changes, I'll get a clear message, not forgotten bins.
- Keep the data separate from the code. A JSON file on GitHub, and the yearly update becomes a simple commit.
- Keep the logic separate from the framework. Everything that matters (the dates, the "put the bin out" window) can be tested without Home Assistant.
If you live in the Rennes area and run Home Assistant, everything is on the project page: the four-step installation, the features, and the link to GitHub with the demo video. And if your town also publishes its calendars as PDFs, the idea is the same: you just need to find the right colours.

Comments (0)
No comments yet. Be the first to comment!
Leave a comment