Back to articles

My bins in Home Assistant: from a PDF calendar to a notification

My bins in Home Assistant: from a PDF calendar to a notification

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:

2026-2027 collection calendar for Acigné published by Rennes Métropole: six month columns, grey and yellow boxes

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:

  1. The month headers ("OCTOBRE 2026"): they give the position of each column. There must be exactly 6 per page.
  2. The day numbers, aligned on the left of each column: they give the height of each row.
  3. The coloured boxes: take their centre, find the nearest column, then the nearest day.

Here's what the script "sees" on the same page:

The Acigné calendar with the script's landmarks: months framed in blue, day numbers in green, collection boxes in red

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.

HACS: adding the collecte-dechets-rennes-metropole-ha repository to the custom repositories

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.

Choosing the collection zone: the town of Acigné selected in the list

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:

The five entities created for Acigné: Collections, Recyclables, Household waste, Next collection, Put the bin out

  • 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:

Home Assistant notification at 6 pm (in French): "Put out tonight: yellow bin (Recyclables)"

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

Home Assistant dashboard (in French): next grey bin, next yellow bin and the October 2026 calendar

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

Your email will not be published.
An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.