Retour aux articles

Mes poubelles dans Home Assistant : du calendrier PDF à la notification

Mes poubelles dans Home Assistant : du calendrier PDF à la notification

Bac gris ou bac jaune ? Cette semaine ou la semaine prochaine ? Et ce jeudi férié, il décale tout, ou pas ?

À Rennes Métropole, la réponse est dans un calendrier PDF publié chaque année. Il est bien fait, clair, en couleurs. Mais un PDF, ça ne vous envoie pas de message le mercredi soir pour vous dire de sortir la poubelle.

Home Assistant, si. Il me manquait juste les dates. Alors j'ai écrit une intégration qui les récupère pour les 43 communes de la métropole et les quartiers de Rennes, et qui envoie une notification la veille au soir. Le code est open source, sur GitHub, et l'intégration a sa page dans mes projets (installation, fonctionnalités, captures).

Dans cet article, je vous raconte le chemin complet, de la lecture des PDF jusqu'à la notification sur le téléphone. Avec les captures, pour que ce soit simple à suivre.

TL;DR

  • Les dates de collecte ne sont pas dans le texte du PDF, mais dans ses cases colorées : le script lit les dessins avec PyMuPDF.
  • Les jours fériés sont déjà reportés sur le PDF. La règle imprimée (« Mardi - Semaine impaire ») ne sert qu'à contrôler le résultat.
  • Le résultat est un simple fichier JSON publié sur GitHub. Le mettre à jour suffit à mettre à jour toutes les installations.
  • Dans Home Assistant : un calendrier, trois capteurs et un capteur « Sortir le bac » qui s'allume la veille à 18 h. Il ne reste qu'à brancher une notification dessus.

Le point de départ : 33 PDF

La métropole publie un calendrier par commune, ou par groupe de communes (et un par quartier à Rennes). Pour la saison 2026-2027, ça fait 33 PDF. Chacun couvre douze mois, d'octobre à septembre, sur deux pages.

Voici celui d'Acigné, la commune que j'ai prise comme exemple tout au long du projet :

Calendrier de collecte 2026-2027 d'Acigné publié par Rennes Métropole : six colonnes de mois, cases grises et jaunes

Le calendrier tel que publié par Rennes Métropole (première page, octobre à mars).

Pour un humain, c'est limpide : une case grise, c'est le bac gris (ordures ménagères). Une case jaune, c'est le bac jaune (recyclables). Les petites flèches rouges montrent les reports de jours fériés.

Pour un programme, c'est une autre histoire.

Récupérer les PDF

Première surprise : les liens de la page « Où et comment jeter vos déchets » ne pointent pas vers des PDF, mais vers une liseuse en ligne (Heyzine), qui affiche le PDF façon magazine. Le vrai fichier est référencé dans la page de la liseuse. Un petit script va donc le chercher :

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)))

Une commande, et les 33 PDF de la saison sont dans sources/2026-2027/ :

python tools/download_sources.py 2026-2027

Lire un PDF... qui n'a rien à dire

Le réflexe, c'est d'extraire le texte. Avec PyMuPDF, c'est une ligne : page.get_text(). Et voilà ce qu'on obtient pour la colonne d'octobre :

OCTOBRE 2026
1
J
2
V
3
S
...

Des numéros de jour et des initiales. Aucune trace des collectes. Normal : l'information n'est pas écrite, elle est dessinée. Les cases grises et jaunes sont des rectangles colorés, posés derrière les lignes du tableau.

Bonne nouvelle : PyMuPDF sait aussi lire les dessins, avec leur couleur de remplissage. Il suffit de garder les rectangles gris et jaunes (version simplifiée) :

# Couleurs de remplissage des cases (RVB entre 0 et 1)
WASTE_COLORS = {
    "ordures_menageres": (0.745, 0.735, 0.741),  # gris
    "recyclables": (1.0, 0.8, 0.0),              # jaune
}

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"])

Ces deux couleurs se retrouvent facilement en listant les dessins d'une page. Une petite tolérance (0.06) absorbe les écarts d'arrondi d'un PDF à l'autre.

Du rectangle à la date

Un rectangle coloré, c'est une position sur la page. Pour en faire une date, le script s'appuie sur trois repères :

  1. Les en-têtes de mois (« OCTOBRE 2026 ») : ils donnent la position de chaque colonne. Il en faut exactement 6 par page.
  2. Les numéros de jour, alignés à gauche de chaque colonne : ils donnent la hauteur de chaque ligne.
  3. Les cases colorées : on prend leur centre, on cherche la colonne la plus proche, puis le jour le plus proche.

Voici ce que « voit » le script, sur la même page :

Le calendrier d'Acigné avec les repères du script : mois encadrés en bleu, numéros de jour en vert, cases de collecte en rouge

En bleu les en-têtes de mois, en vert les numéros de jour, en rouge les cases de collecte retenues.

Le cœur du calcul tient en quelques lignes :

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  # la légende, hors calendrier
    day, y = min(col["days"].items(), key=lambda kv: abs(kv[1] - cy))
    cal.collections.setdefault(waste, []).append(date(col["year"], col["month"], day))

Petit détail qui compte : la légende du PDF contient elle aussi un rectangle gris et un jaune. Comme ils ne sont dans aucune colonne de mois, ils sont ignorés.

Planter plutôt que mentir

Ce genre de script a un défaut : le jour où la mise en page change, il peut continuer à tourner... et produire des dates fausses. Pour des poubelles, ce n'est pas dramatique, mais c'est exactement le genre de bug qu'on ne remarque pas.

Alors le script vérifie tout ce qu'il peut, et s'arrête au moindre doute :

if len(columns) != 6:
    raise ParseError(f"page {page.number + 1} : {len(columns)} mois trouvés au lieu de 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']} : jours lus {sorted(col['days'])}")

Six mois par page, tous les jours de chaque mois (28, 29, 30 ou 31) : sinon, on s'arrête avec un message clair.

Le piège des jours fériés

Chaque calendrier rappelle sa règle en légende : « Ordures ménagères : Mardi - Semaine impaire ». Et en dessous : « Semaine à jour férié : décalage d'une journée de toutes les collectes à partir du jour férié jusqu'à la fin de la semaine. »

J'aurais pu m'arrêter là : une règle, un calcul de semaines ISO, la liste des jours fériés (avec Pâques, l'Ascension, la Pentecôte qui bougent chaque année) et le tour était joué. Pas besoin de lire les PDF.

Sauf que la réalité est un peu plus subtile que la règle :

  • Noël tombe un vendredi en 2026 : la collecte du bac jaune passe au samedi 26. Ça, la règle le prévoit.
  • L'Ascension, elle, ne décale rien : le calendrier indique « collecte maintenue ».
  • Les semaines 53 et 1 se suivent et sont toutes les deux impaires : deux collectes de bac gris d'affilée. Le PDF le signale en rouge en bas de page.
  • Et parfois, le PDF s'écarte de la règle sans raison apparente. Regardez décembre sur la capture ci-dessus : la règle dit mardi, mais la case grise est le mercredi 2.

Qui a raison, la règle ou la case ? Pour moi, c'est la case. C'est ce que la métropole a publié, c'est ce que les habitants ont sous les yeux, et c'est sûrement ce que le camion suivra.

D'où le principe du projet : les dates publiées sont celles des cases colorées. La règle sert uniquement de contrôle. Le script la rejoue, avec les reports de jours fériés, et compare :

def expected_dates(rule: Rule, start: date, end: date) -> list[date]:
    """Dates théoriques : jour + parité ISO, décalées d'un jour après un férié de la semaine."""
    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

Chaque différence part dans un rapport, data/rapport.md :

### ⚠️ 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

Sur les 33 PDF de cette saison, 29 collent parfaitement à la règle. Les 4 autres ont chacun un seul décalage, que je peux vérifier à l'œil en deux minutes. Le rapport ne corrige rien : il me dit où regarder.

Un fichier JSON pour tout le monde

Le script produit un seul fichier, data/collectes.json. Pour chaque zone (53 en tout : les communes, plus les quartiers de Rennes), la liste des dates par type de bac :

{
  "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", "..."]
      }
    }
  }
}

Et ce fichier est publié sur GitHub, tout simplement. Chaque installation de Home Assistant le télécharge depuis raw.githubusercontent.com toutes les 12 heures.

Ce choix change tout pour la maintenance. Les calendriers changent chaque année en septembre. Si les dates étaient dans le code de l'intégration, il faudrait publier une nouvelle version, et que chaque utilisateur pense à la mettre à jour. Là, la mise à jour annuelle tient en trois étapes :

python tools/download_sources.py 2027-2028
python tools/build_calendar.py
git add sources data && git commit -m "Saison 2027-2028" && git push

Et toutes les installations ont les nouvelles dates dans la journée.

Deux filets de sécurité côté Home Assistant :

  • si GitHub ne répond pas, l'intégration utilise la dernière version téléchargée, gardée en cache ;
  • si le calendrier n'a plus de dates à venir (après le 30 septembre, si j'ai oublié la mise à jour), une alerte apparaît dans Paramètres → Réparations.

Et côté GitHub, un workflow relance la conversion à chaque modification des PDF ou du script, affiche le rapport de contrôle dans le résumé, et échoue si j'ai oublié de régénérer le JSON.

Dans Home Assistant

L'installation

L'intégration s'installe avec HACS, le magasin d'intégrations communautaires de Home Assistant. On ajoute l'adresse du dépôt, on installe, on redémarre.

HACS : ajout du dépôt collecte-dechets-rennes-metropole-ha dans les dépôts personnalisés

Les captures de cette partie sont tirées de la vidéo de démonstration du dépôt.

Puis, dans Paramètres → Appareils et services, on ajoute l'intégration et on choisit sa commune (ou son quartier à Rennes). La liste est lue directement dans le fichier JSON : quand une saison ajoute ou regroupe des communes, la liste suit toute seule.

Choix de la zone de collecte : la commune d'Acigné sélectionnée dans la liste

Un détail auquel j'ai fait attention : l'identifiant d'une zone (acigne, ou rennes-q5 pour un quartier) ne doit jamais changer d'une année à l'autre. C'est lui qui identifie la configuration dans Home Assistant. Si la métropole regroupe deux communes dans un même PDF l'an prochain, chacune garde son identifiant.

Cinq entités

Une fois la zone choisie, l'intégration crée cinq entités :

Les cinq entités créées pour Acigné : Collectes, Déchets recyclables, Ordures ménagères, Prochaine collecte, Sortir le bac

  • un calendrier, avec un événement par jour de collecte ;
  • un capteur par bac (gris et jaune), avec la date du prochain passage et le nombre de jours restants ;
  • un capteur Prochaine collecte, tous bacs confondus ;
  • et la plus utile : Sortir le bac.

« Sortir le bac » : la seule question qui compte

Au fond, on ne veut pas savoir quand passe le camion. On veut savoir s'il faut sortir la poubelle ce soir.

C'est le rôle de ce capteur binaire. Il passe à « activé » la veille de la collecte à 18 h (l'heure est réglable dans les options), et repasse à « désactivé » au début de la collecte, à 5 h 30. Toute la logique est dans une fonction toute simple :

def take_out_window(self, day: date, reminder: time, tz) -> tuple[datetime, datetime]:
    """Période où il faut sortir le bac pour la collecte du jour ``day``."""
    start = datetime.combine(day - timedelta(days=1), reminder, tzinfo=tz)
    end = datetime.combine(day, self.collection_start, tzinfo=tz)
    return start, end

Le capteur ne vérifie pas l'heure toutes les minutes. Il calcule le prochain moment où son état doit changer, et demande à Home Assistant de le réveiller à cet instant précis :

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)

Zéro calcul inutile, et un changement d'état à la seconde près.

Ces fonctions de calendrier sont dans un fichier à part, schedule.py, qui ne dépend pas du tout de Home Assistant. Je peux donc les tester sur Windows, en quelques secondes, sans installer Home Assistant. Les tests de l'intégration complète, eux, tournent sous Linux dans GitHub Actions.

La notification

Il ne reste plus qu'à brancher une notification sur « Sortir le bac ». Une automatisation de quelques lignes suffit :

automation:
  - alias: "Rappel poubelles"
    triggers:
      - trigger: state
        entity_id: binary_sensor.acigne_sortir_le_bac
        to: "on"
    actions:
      - action: notify.mobile_app_mon_telephone
        data:
          title: "Poubelles"
          message: >-
            Sortir ce soir : bac {{ state_attr('binary_sensor.acigne_sortir_le_bac', 'bins') | join(' + ') }}
            ({{ state_attr('binary_sensor.acigne_sortir_le_bac', 'description') }})

Le capteur fournit tout ce qu'il faut dans ses attributs : bins, la couleur des bacs (« jaune »), et description, le type de déchets (« Déchets recyclables »), dans la langue de Home Assistant. Quand les deux bacs passent le même jour, ils sont tous les deux dans le message.

Et le jeudi soir à 18 h :

Notification Home Assistant à 18 h : « Sortir ce soir : bac jaune (Déchets recyclables) »

Le calendrier, lui, se pose sur un tableau de bord, avec les prochains passages :

Tableau de bord Home Assistant : prochain bac gris, prochain bac jaune et calendrier d'octobre 2026

Ce que je retiens

  • Regarder comment l'information est dessinée, pas seulement écrite. Dans un PDF, le texte n'est qu'une partie de l'histoire. PyMuPDF donne accès aux formes et aux couleurs : c'est souvent là que se cache la donnée.
  • Recopier plutôt que recalculer. Les dates du PDF sont la référence ; la règle sert de contrôle. Un calcul « intelligent » aurait été faux quatre fois cette saison, sans que personne ne le voie.
  • Un script qui s'arrête vaut mieux qu'un script qui se trompe. Le jour où la mise en page changera, j'aurai un message clair, pas des poubelles oubliées.
  • Séparer les données du code. Un JSON sur GitHub, et la mise à jour annuelle devient un simple commit.
  • Séparer la logique du framework. Tout ce qui compte (les dates, la fenêtre « sortir le bac ») se teste sans Home Assistant.

Si vous habitez la métropole de Rennes et que vous avez Home Assistant, tout est sur la page du projet : l'installation en quatre étapes, les fonctionnalités, et le lien vers GitHub avec la vidéo de démo. Et si votre commune publie aussi ses calendriers en PDF, le principe est le même : il suffit de trouver les bonnes couleurs.

Commentaires (0)

Aucun commentaire pour le moment. Soyez le premier à commenter !

Laisser un commentaire

Votre e-mail ne sera pas publié.
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.