Skip to content

Add source: Entsorgung + Recycling Stadt Bern (ERB), Switzerland - #7290

Open
sbaerlocher wants to merge 3 commits into
mampfes:masterfrom
sbaerlocher:source/bern-ch
Open

Add source: Entsorgung + Recycling Stadt Bern (ERB), Switzerland#7290
sbaerlocher wants to merge 3 commits into
mampfes:masterfrom
sbaerlocher:source/bern-ch

Conversation

@sbaerlocher

@sbaerlocher sbaerlocher commented Sep 1, 2026

Copy link
Copy Markdown

Summary

Adds bern_ch.py plus doc/source/bern_ch.md, covering the city of Bern's three collection types: Hauskehricht, Grünabfuhr and Altpapiersammlung. Data comes from the city's public iCalendar feed.

Closes #3727

Note on #3727: that issue carries the data: PDF label, but the city also publishes an iCalendar feed (the reporter linked it themselves). This source uses the feed, not the PDF — the label can be dropped. The issue asks for all three zones; zones need no configuration here, since the address lookup resolves them internally. The reporter's example address (Morgenstrasse, zone B) resolves correctly.

Type of change

  • New source
  • Bug fix / source fix
  • Documentation update
  • Other

Checklist

  • python -m pytest tests/test_source_components.py -q passes
  • ruff check --fix and ruff format run on changed source files
  • No generated files in diff (README.md, info.md, sources.json, translations/*.json — CI regenerates these post-merge)
  • doc/source/<name>.md created for new sources
  • TEST_CASES use real, publicly accessible addresses (not my own)

Data source

ERB publishes a public iCalendar feed:

https://bernentsorgung.glue.ch/erb/web/ical?key=<KEY>

Plain GET, no auth, no cookie, no session. Three recurring VEVENTs with an RRULE bounded by UNTIL=<end of current year>, plus EXDATE lines for public holidays. Parsed with the existing service/ICS.py — no changes to shared code.

Key derivation

The key is the uppercase MD5 of the street name concatenated directly with the house number, UTF-8 encoded, no separator, no ZIP, no salt:

key = MD5(street + number).hexdigest().upper()
MD5("Bundesplatz1") = DC46354136EE5531B312A864FA2C4604

Rather than hashing user input blindly, the source resolves the address through the site's own typeahead endpoint:

GET /erb/web/searchAddress?query=<street>

which returns the canonical street spelling, the house number and the key itself. The returned keys match the MD5 derivation exactly for every address checked, including suffixed numbers and streets with spaces.

The lookup runs first because raw hashing only succeeds on byte-perfect input: bundesplatz1, Bundesplatz 1 and Helvetiaplatz3A all return HTTP 500 and would fall through to the lookup anyway. Hashing first would therefore be slower for mistyped input and identical otherwise, and a wrong-cased number would produce a valid-looking key whose 500 is indistinguishable from an unknown address — losing the suggestion list. MD5 remains the fallback when the lookup returns nothing.

Edge cases verified against the live service

  • House-number suffix: no space, lowercase, e.g. Helvetiaplatz3a. The hash is case-sensitive — MD5(...3a) returns HTTP 200 and matches the server's key, MD5(...3A) returns HTTP 500. The lookup compares case-insensitively, so users may enter either form.
  • Multi-word streets: the space is preserved verbatim. MD5("Alter Aargauerstalden2b") matches the server's key. Bern has no "Alte Bernstrasse"; multi-word names are usually hyphenated (Von-Werdt-Passage).
  • Unknown key: HTTP 500 — not 404, not an empty VCALENDAR. Surfaced as SourceArgumentNotFound.
  • Unknown house number on a known street: raises SourceArgumentNotFoundWithSuggestions listing the known numbers.

Feed quirks handled in the source

Both handled locally so service/ICS.py stays untouched — a fix there would affect ~600 other sources and belongs in its own PR:

  1. Empty EXDATE: lines on two of the three events, which icalendar reports as a broken property. Stripped before parsing. The multi-value EXDATE;VALUE=DATE:...,...,... line parses correctly as-is, so holiday exclusions do come through — verified that 2026-11-23 and 2026-12-25 are absent from the results.

  2. RRULE UNTIL is given in UTC (<Dec 31>T230000Z) while the events are all-day, so that instant is 00:00 local on Jan 1 and the recurrence yields a phantom collection on New Year's Day — confirmed against the published calendar as not a collection day. Entries past the feed's own UNTIL date are dropped. The bound is read from the fetched feed at runtime (no year assumed); this removes a parsing artifact and is not a user-facing time-range filter.

Arguments

  • strasse — street name, e.g. Bundesplatz
  • hnr — house number, e.g. 1 or 3a
  • key — optional; accepts the key from the iKalender link directly and skips address resolution. An escape hatch: if ERB changes the hashing scheme or retires the lookup, users can paste their own link and keep working without waiting for a release.

HOW_TO_GET_ARGUMENTS_DESCRIPTION, PARAM_TRANSLATIONS and PARAM_DESCRIPTIONS are provided in de and en.

Test cases

Public buildings only, no private residences:

Bundesplatz 1        (Bundeshaus)          104 entries
Helvetiaplatz 5      (Historisches Museum)  62 entries
Waisenhausplatz 30   (Polizeiwache)        104 entries
key only             (Bundesplatz 1)       104 entries

The fourth case exercises the key fallback and returns the same result as the street/number path for the same address. Helvetiaplatz 5 legitimately returns fewer entries — its Hauskehricht runs BYDAY=MO,TH rather than MO,TU,TH,FR, and its Altpapiersammlung is INTERVAL=2 (fortnightly). All three collection types are present.

Dates cross-checked against bernentsorgung.glue.ch for the same address, including the holiday exclusions. test_sources.py passes with -d (fetch() does not mutate the Source object). pytest: 39 passed. pre-commit run --all-files: all hooks pass.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are user-facing correctness issues in the new source (waste-type icon mapping and error attribution when configured via key) that should be fixed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a new waste-collection source for Entsorgung + Recycling Stadt Bern (ERB), integrating Bern’s public iCalendar feed into the hacs_waste_collection_schedule source catalogue.

Changes:

  • Added bern_ch Python source that resolves an address (or accepts a key) and parses ERB’s ICS schedule into Collection entries.
  • Added end-user documentation for configuring the new bern_ch source in Home Assistant.
File summaries
File Description
custom_components/waste_collection_schedule/waste_collection_schedule/source/bern_ch.py New source implementation using ERB address lookup / key fallback + ICS parsing.
doc/source/bern_ch.md New documentation describing configuration and how to obtain arguments/key.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +171 to +176
if not entries:
raise SourceArgumentNotFound(
"strasse",
f"{self._strasse or ''} {self._hnr or ''}".strip() or self._key,
"No collections returned for this address.",
)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in c7f5d49. The "no collections" branch now reports key when the source is configured that way, and keeps strasse for the address path:

bad key    : argument 'key' ... The service does not know this key.
bad address: argument 'strasse' with the value 'Gibtsnichtstrasse 1' ...

Comment on lines +30 to +35
ICON_MAP = {
"Hauskehricht": Icons.GENERAL_WASTE,
"Altpapiersammlung": Icons.PAPER,
# Bern collects kitchen and garden organics together in one "Grünabfuhr".
"Gruenabfuhr": Icons.ORGANIC,
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this against the live feed before changing anything — the SUMMARY is ASCII, so the current key is the matching one:

$ grep -c 'Gruenabfuhr' feed.ics   # 1
$ grep -c 'Grünabfuhr'  feed.ics   # 0

All three types resolve to an icon (Hauskehricht mdi:trash-can, Gruenabfuhr mdi:leaf, Altpapiersammlung mdi:package-variant); no entry comes back with icon=None.

The website does display "Grünabfuhr", which is what my comment referred to — that was misleading, so I clarified it in c7f5d49 to state that ICON_MAP keys must match the feed's SUMMARY verbatim. Leaving the key as-is.

Add a source for the city of Bern (ERB), Switzerland, covering
Hauskehricht, Grünabfuhr and Altpapiersammlung.

The provider exposes a public iCalendar feed at
bernentsorgung.glue.ch/erb/web/ical?key=<KEY> without auth or session.
The key is the uppercase MD5 of street name concatenated with the house
number, UTF-8 encoded and without a separator.

Rather than hashing blindly, the source resolves the address through the
site's own typeahead endpoint (searchAddress), which returns the
canonical spelling and the key itself. This makes house-number suffixes
case-insensitive for the user and yields suggestions when a number does
not exist. The MD5 derivation remains as an offline fallback, and an
optional key argument lets users paste the raw link if the scheme ever
changes.

Two feed quirks are handled: empty EXDATE lines are stripped because
icalendar reports them as a broken property, and entries past the RRULE
UNTIL date are dropped since UNTIL is given in UTC while the events are
all-day, which otherwise spills one collection into the next year.

Signed-off-by: Simon Bärlocher <s.baerlocher@sbaerlocher.ch>
When the source is configured with `key` instead of `strasse`/`hnr`, the
"no collections" error reported the argument as `strasse` and passed the
key as its value, so the Home Assistant UI highlighted the wrong field.
Report `key` in that case and keep `strasse` for the address path.

Also clarify the ICON_MAP comment: the keys must match the feed's SUMMARY
values verbatim, and the feed spells "Gruenabfuhr" without the umlaut even
though the website shows "Grünabfuhr".

Signed-off-by: Simon Bärlocher <s.baerlocher@sbaerlocher.ch>
@5ila5

5ila5 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

This looks pretty complicated for a simple source, where the service provider provides a ics file.

Wouldn't it be easier to add a ICS source

@sbaerlocher

Copy link
Copy Markdown
Author

Fair question — and you're right that this could technically be an ICS YAML. I tested that before answering: I dropped a throwaway YAML with the feed URL into doc/ics/yaml and ran it. It works, 101 entries vs 100 from this source.

Two things I want to correct in my own PR description first, because they are not arguments for a Python source:

  • Waste types separate fine via generic ICS (67 Hauskehricht / 17 Gruenabfuhr / 17 Altpapiersammlung) — SUMMARY carries the type.
  • Holiday exclusions come through on both paths; the multi-value EXDATE parses correctly.

What generic ICS does not handle:

  1. A phantom collection. Each RRULE is bounded with UNTIL=<Dec 31>T230000Z, which is 00:00 local on Jan 1. Since the events are all-day, the recurrence emits a collection on New Year's Day, a date the published calendar does not contain. Generic ICS shows 2027-01-01; this source drops everything past the feed's own UNTIL.
  2. Icons. Generic ICS gives every entry [None].

The bigger issue is the URL itself. The feed key is MD5(street + housenumber) uppercased — MD5("Bundesplatz1") = DC46354136EE5531B312A864FA2C4604 — so it is per address, not per collection zone. I sampled the address search across the city: there are 12 distinct zones (A2_1, A2_2, A3_1, A5_1, A5_2, B1_1, B1_2, B3_2, B4_1, B4_2, B4_3, C3) and exactly one calendar per zone — addresses within a zone return byte-identical RRULEs, and no two zones share one. But there is no zone-level URL: roughly 2100 address URLs map onto those 12 calendars.

So an ICS YAML could not offer the 12 zones as extra_info entries. Every user would have to look up their own hashed URL on https://www.bern.ch/themen/abfall/abfuhrdaten/kostenloser-kalender-import (which just iframes the same bernentsorgung.glue.ch widget), and they cannot tell which zone they are in — the widget never shows it. On moving within Bern, they repeat the whole procedure.

This source asks for "Bundesplatz" and "1" instead, derives the key via the site's own address lookup, and turns a wrong house number into a suggestion list rather than a silent HTTP 500.

That said — if you'd still rather keep the source catalogue lean, say so and I'll ship it as an ICS YAML instead and drop the Python source. The cost is the 2027-01-01 artifact, no icons, and users pasting a hashed URL they have to look up per address. Your call.

@bbr111
bbr111 requested a lite review from Copilot September 6, 2026 20:24
@bbr111 bbr111 self-assigned this Sep 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new source raises SourceArgumentNotFound for missing required arguments in __init__, which yields misleading UI/error semantics and should use SourceArgumentRequired for the missing field.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +97 to +102
if not self._key and not (self._strasse and self._hnr):
raise SourceArgumentNotFound(
"strasse",
self._strasse,
"Either 'key' or both 'strasse' and 'hnr' must be provided.",
)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — contributing_source.md says as much explicitly ("It should NOT be thrown when a required argument is not provided"). Fixed in 8f5b233: the constructor now raises SourceArgumentRequired and names the field that is actually missing instead of always pointing at strasse.

nothing given : Argument 'strasse' must be provided, Provide 'strasse' and 'hnr', or 'key' instead.
strasse only  : Argument 'hnr' must be provided, ...
hnr only      : Argument 'strasse' must be provided, ...

The constructor raised SourceArgumentNotFound when neither `key` nor the
`strasse`/`hnr` pair was supplied. That exception is meant for values the
API could not resolve, so the user saw "We could not find values for the
argument ..." for an argument they had simply not filled in.

Raise SourceArgumentRequired instead, and name the field that is actually
missing rather than always pointing at `strasse`.

Signed-off-by: Simon Bärlocher <s.baerlocher@sbaerlocher.ch>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Source Request]: [Source Request]: Switzerland Bern, the capitol is missing.

4 participants