mampfes__hacs_waste_collection_schedule METASEP

doc/source/york_gov_uk.md METASEP
# York Council

Support for schedules provided by [York Council](https://myaccount.york.gov.uk/bin-collections), serving the city of York, UK.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
    sources:
    - name: york_gov_uk
      args:
        uprn: UPRN_CODE
```

### Configuration Variables

**uprn**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
    sources:
    - name: york_gov_uk
      args:
        uprn: "100050580641"
```

## How to get the source argument

The UPRN code can be found in the network request when entering your postcode and selecting your address on the [York Waste Collection Calendar page](https://myaccount.york.gov.uk/bin-collections). You should look for a request like `https://waste-api.york.gov.uk/api/GetBinCollectionLocationForUprn/010093236548` the last segment is your UPRN code.

doc/source/ximmio_nl.md METASEP
# Ximmio

Support for schedules provided by [Ximmio.nl](https://www.ximmio.nl/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: ximmio_nl
      args:
        company: COMPANY
        post_code: POST_CODE
        house_number: HOUSE_NUMBER
```

### Configuration Variables

**company**<br>
*(string) (required)*

Use one of the following codes as company code:

- acv
- almere
- areareiniging
- avri
- bar
- hellendoorn
- meerlanden
- meppel
- rad
- reinis
- twentemilieu
- waardlanden
- ximmio

**post_code**<br>
*(string) (required)*

**house_number**<br>
*(integer) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: ximmio_nl
      args:
        company: acv
        post_code: 6721MH
        house_number: 1
```

doc/source/wuerzburg_de.md METASEP
# Abfallkalender Würzburg

Support for schedules provided by [Abfallkalender Würzburg](https://www.wuerzburg.de/themen/umwelt-verkehr/vorsorge-entsorgung/abfallkalender/32208.Abfallkalender.html), serving the City of Würzburg.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: wuerzburg_de
      args:
        district: ADDRESS
        street: STREET
```

### Configuration Variables

**district** and **street** can be used independently, only **one** is required. If set, priority will be given to **street**.

**district**<br>
*(string) (required)* - if *street* is empty

**street**<br>
*(string) (required)* - if *district* is empty

## Example

Both examples will yield the same result.

```yaml
waste_collection_schedule:
  sources:
    - name: wuerzburg_de
      args:
        district: "Altstadt"
```

```yaml
waste_collection_schedule:
  sources:
    - name: wuerzburg_de
      args:
        street: "Juliuspromenade"
```

## How to get the source argument

The source argument is either district or street as displayed in the web form at [Abfallkalender Würzburg](https://www.wuerzburg.de/themen/umwelt-verkehr/vorsorge-entsorgung/abfallkalender/32208.Abfallkalender.html). Exact match is required.

doc/source/wellington_govt_nz.md METASEP
# Wellington City Council

Support for schedules provided by [Wellington City Council](https://wellington.govt.nz/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: wellington_govt_nz
      args:
        streetName: Street Name, Suburb # see 'How to get the source argument below'
        streetId: streetID Number 
```

### Configuration Variables

**streetName**<br>
*(string)*<br>

**streetId**<br>
*(string)*<br>

*One of the above is required*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: wellington_govt_nz
      args:
        streetName: Chelsea Street
```

```yaml
waste_collection_schedule:
  sources:
    - name: wellington_govt_nz
      args:
        streetId: 6515
```

## How to get the source argument

The source argument is the either the street name or street id number from Wellington City Council site:

- Open your *When to put out your rubbish and recycling* page, and enter your address in the search box [on the Wellington City Council collection day finder](https://wellington.govt.nz/rubbish-recycling-and-waste/when-to-put-out-your-rubbish-and-recycling)
- The "streetName" variable uses the same code that the search box uses to find the streetId from your street name, suburb. But like the search box you do not need to enter the full name if your text matches only one result. For example "Miramar" would get you two results for Miramar Ave, Miramar and Miramar North Road, Miramar and the script would error as it can only deal with one street per call, so you would need to write "Miramar Ave" or "Miramar Avenue" etc to get the right result.
- If you are having issues with the script finding your street, or you can't get it to narrow the result without selection options on the Wellington Council site, you can use the streetId parameter directly.
- Once you have found your street on the website, look at the blue button which is meant to show you the next weeks collection info, if you hover over the button, right click and copy link, you should get back a URL e.g. https://wellington.govt.nz/rubbish-recycling-and-waste/when-to-put-out-your-rubbish-and-recycling/components/collection-search-results?streetId=8187&addWeeks=1
- Look for the streetId parameter in the URL in the example link above, it is streetId=`8187`

doc/source/wastenet_org_nz.md METASEP
# WasteNet Southland

Support for schedules provided by [WasteNet Southland](http://wastenet.org.nz/), serving the Gore District Council, Invercargill City Council and Southland District Council.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: wastenet_org_nz
      args:
        address: ADDRESS
```

### Configuration Variables

**address**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: wastenet_org_nz
      args:
        address: "31 Conyers Street INVERCARGILL"
```

## How to get the source argument

The source argument is simply the full house mailing address as displayed in the web form, but without the comma.

doc/source/was_wolfsburg_de.md METASEP
# Wolfsburger Abfallwirtschaft und Straßenreinigung

Support for schedules provided by [WAS-Wolfsburg.de](https://was-wolfsburg.de).

Please note that WAS Wolfsburg provides 2 different schedules: One for "Restabfall - Bioabfall - Altpapier" and one for "Gelber Sack". This source fetches both schedules and merges it into one.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: was_wolfsburg_de
      args:
        city: CITY
        street: STREET
```

### Configuration Variables

**city**<br>
*(string) (required)*

**street**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: was_wolfsburg_de
      args:
        city: Barnstorf
        street: Bahnhofspassage
```

## How to get the source arguments

| Argument | Description |
| ----------- | ----------- |
| city | Full district name as shown in the `Gelber Sack` web page. |
| street | Full street name as shown in the `Restabfall/Bioabfall/Altpapier` web page. |

doc/source/thehills_nsw_gov_au.md METASEP
# The Hills, Sydney's Garden Shire

Support for schedules provided by [The Hills Council](https://www.thehills.nsw.gov.au/Home), serving the city of Sydney, Australia.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: thehills_nsw_gov_au
      args:
        suburb: SUBURB
        street: STREET
        houseNo: HOUSENO
```

### Configuration Variables

**suburb**<br>
*(string) (required)*

**street**<br>
*(string) (required)*

**houseNo**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: thehills_nrw_gov_au
      args:
        suburb: ANNANGROVE
        street: Amanda Place
        houseNo: 10
```

## How to get the source arguments

The source arguments are simply the values of the 3 selections from the web form.

doc/source/sysav_se.md METASEP
# Sysav Sophämtning

Support for schedules provided by [Sysav](https://www.sysav.se/Privat/min-sophamtning/), serving the municipality of Svedala, Kävlinge and Lomma, Sweden.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: sysav_se
      args:
        street_address: STREET_ADDRESS
```

### Configuration Variables

**street_address**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: sysav_se
      args:
        street_address: Sommargatan 1, Svedala
```

## How to get the source argument

The source argument is the address to the house with waste collection. The address can be tested [here](https://www.sysav.se/Privat/min-sophamtning/).

doc/source/stuttgart_de.md METASEP
# Abfallwirtschaft Stuttgart

Support for schedules provided by [stuttgart.de](https://service.stuttgart.de/lhs-services/aws/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: stuttgart_de
      args:
        street: ORT_SEL
        streetnr: STR_SEL
```

### Configuration Variables

**street**<br>
*(string) (required)*

**streetnr**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: stuttgart_de
      args:
        street: Im Steinengarten
        streetnr: 7
```

## How to get the source arguments

There is a script with an interactive command line interface which generates the required source configuration:

[https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/stuttgart_de.py](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/stuttgart_de.py).

First, install the Python module `inquirer`. Then run this script from a shell and answer the questions.

doc/source/stonnington_vic_gov_au.md METASEP
# Stonnington City Council

Support for schedules provided by [Stonnington City Council](https://www.stonnington.vic.gov.au/Services/Waste-and-recycling).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: stonnington_vic_gov_au
      args:
        street_address: STREET_ADDRESS
```

### Configuration Variables

**street_address**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: stonnington_vic_gov_au
      args:
        street_address: 500 Chapel Street, South Yarra
```

## How to get the source arguments

Visit the [Stonnington City Council waste and recycling](https://www.stonnington.vic.gov.au/Services/Waste-and-recycling) page and search for your address. The arguments should exactly match the street address shown in the autocomplete result.

doc/source/stadtreinigung_leipzig_de.md METASEP
# HVCGroep

Support for schedules provided by [stadtreinigung-leipzig.de](https://stadtreinigung-leipzig.de/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: stadtreinigung_leipzig_de
      args:
        street: STREET
        house_number: HOUSE_NUMBER
```

### Configuration Variables

**street**<br>
*(string) (required)*

**house_number**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: stadtreinigung_leipzig_de
      args:
        street: Bahnhofsallee
        house_number: 7
```

doc/source/stadtreinigung_hamburg.md METASEP
# Stadtreinigung Hamburg

Add support for schedules provided by [stadtreinigung.hamburg](https://www.stadtreinigung.hamburg/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: stadtreinigung_hamburg
      args:
        hnId: HNID
```

### Configuration Variables

**hnId**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: stadtreinigung_hamburg
      args:
        hnId: 113084
```

## How to get the source arguments

Open [stadtreinigung.hamburg](https://www.stadtreinigung.hamburg/abfuhrkalender/) and search for schedules for your location.

Check the URL and extract the number after field `housenumber%5D`.

Example:

`https://www.stadtreinigung.hamburg/abfuhrkalender/?tx_srh_pickups%5Bstreet%5D=2586&tx_srh_pickups%5Bhousenumber%5D=53814`

The resulting `hnId` is `53814`.

doc/source/sector27_de.md METASEP
# Sector27.de

Support for schedules provided by [Sector27.de](https://muellkalender.sector27.de). This service is used by the following cities:

- Datteln
- Marl
- Oer-Erkenschwick

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: sector27_de
      args:
        city: CITY
        street: STREET
```

### Configuration Variables

**city**<br>
*(string) (required)*

**street**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: sector27_de
      args:
        city: Marl
        street: Ahornweg
```

doc/source/seattle_gov.md METASEP
# Seattle Public Utilities

Support for schedules provided by [Seattle Public Utilities](https://myutilities.seattle.gov/eportal/#/accountlookup/calendar), serving the city of Seattle, WA, USA.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: seattle_gov
      args:
        street_address: STREET_ADDRESS
        prem_code: PREM_CODE
```

### Configuration Variables

**street_address**<br>
*(string) (required)*

**prem_code**<br>
*(string) (optional)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: seattle_gov
      args:
        street_address: 600 4th Ave
```

## How to get the source argument

The street_address argument is simply the house mailing address. Road type (eg. St, Ave) and cardinal direction if applicable (eg. N/S/NW) are required, so "501 23rd Ave" and "501 23rd Ave E" will give different results.

If the service cannot be identified based on street address alone (in some multi-family houses, etc), a `prem_code` can be extracted by inspecting the "findAccount" call when looking up your service on the Collection Calendar.

doc/source/rh_entsorgung_de.md METASEP
# Rhein-Hunsrück Entsorgung (RHE)

Support for schedules provided by [RHE](https://www.rh-entsorgung.de) (Rhein-Hunsrück Entsorgung) located in Rhineland-Palatinate, Germany.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: rh_entsorgung_de
      args:
        city: CITY
        street: STREET
        house_number: HNR
        address_suffix: HNR_SUFFIX
        garbage_types:
          - 1
          - 2
          - 3
          - 4
          - 5
```

### Configuration Variables

**city**<br>
*(string) (required)*

**street**<br>
*(string) (required)*

**house_number**<br>
*(integer) (required)*

**address_suffix**<br>
*(string) (optional) (default: "")*

**garbage_types**<br>
*(list of integers) (optional) (default: [1,2,3,4,5])*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: rhe_de
      args:
        city: "Alterkülz"
        street: "Brühlweg"
        house_number: 1
```

## How to get the source arguments

### city, street and house_number

These values are the location you want to query for. Make sure, the writing is exactly as it is on [https://www.rh-entsorgung.de/de/Service/Abfallkalender/](https://www.rh-entsorgung.de/de/Service/Abfallkalender/). Typos will result in the collection schedule for the default location *(Alterkülz, Brühlweg)*, so make sure to validate the returned schedule after setting up the integration. As `house_number` expects a numeric input, address suffixes have to be provided via the `address_suffix` argument.

### garbage_types

Garbage types are mapped as follows:

```text
1: Restmülltonne
2: Biotonne
3: Papiertonne
4: Gelber Sack
5: Problemmüll
```

doc/source/recycleapp_be.md METASEP
# Recycle!

Support for schedules provided by [Recycle / recycleapp.be](https://www.recycleapp.be/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: recycleapp_be
      args:
        postcode: POST_CODE
        street: STREET
        house_number: HOUSE_NUMBER
        add_events: ADD_EVENTS
```

The source arguments are simply the values of the form elements on the homepage.

### Configuration Variables

**postcode**<br>
*(int)*
Postal Code.

**street**<br>
*(string)*
Street name.

**house_number**<br>
*(int)*
House number

**add_events**<br>
*(boolean)*
Add events (e.g. Repair Cafe) in addition to waste collections.

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: recycleapp_be
      args:
        postcode: 1140
        street: Bazellaan
        house_number: 1
```

doc/source/pgh_st.md METASEP
# PGH.ST

Support for schedules provided by [PGH.ST](https://www.pgh.st/), serving the city of Pittsburgh, PA, USA.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: pgh_st
      args:
        house_number: HOUSE_NUMBER
        street_name: STREET_NAME
        zipcode: ZIPCODE
```

### Configuration Variables

**house_number**<br>
*(integer) (required)*

**street_name**<br>
*(string) (required)*

**zipcode**<br>
*(integer) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: pgh_st
      args:
        house_number: 800
        street_name: Negley
        zipcode: 15232
```

## How to get the source arguments

The source arguments are simply the house mailing address. The street_name field doesn't require cardinal direction (N/S/E/W) or road type (e.g. st) designations, so `S Negley Ave` can just be `Negley`. The zipcode field is just the 5-digit zipcode, not the 9-digit extended zipcode.

doc/source/nawma_sa_gov_au.md METASEP
# North Adelaide Waste Management Authority (South Australia)

Support for schedules provided by [North Adelaide Waste Management Authority](https://www.nawma.sa.gov.au/kerbside-collections/bin-collection-days/).
This covers Salisbury, Playford, and Gawler councils.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: nawma_sa_gov_au
      args:
        suburb: SUBURB
        street_name: STREET_NAME
        street_number: STREET_NUMBER
```

### Configuration Variables

**suburb**<br>
*(string) (required)*

**street_name**<br>
*(string) (required)*

**street_number**<br>
*(string) (optional)*

Only required if the street crosses multiple collection areas with different days.

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: nawma_sa_gov_au
      args:
        suburb: Whites Road
        street_name: Paralowie
```

## How to get the source arguments

Visit the [North Adelaide Waste Management Authority collection days](https://www.nawma.sa.gov.au/kerbside-collections/bin-collection-days/) page and search for your address.  The arguments should exactly match the results shown for Suburb and Street and (if required) the number portion of the auto-completed address.

Note: Some addresses can be quite obscure, for example for the Gawler main street set `street_name` to `'Murray Street (sec between Ayers and the railway line'`.

doc/source/muellmax_de.md METASEP
# Müllmax

Support for schedules provided by [muellmax.de](https://www.muellmax.de).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: muellmax_de
      args:
        service: SERVICE
        mm_frm_ort_sel: ORT_SEL
        mm_frm_str_sel: STR_SEL
        mm_frm_hnr_sel: HNR_SEL
```

### Configuration Variables

**service**<br>
*(string) (required)*

**mm_frm_ort_sel**<br>
*(string) (optional)*

**mm_frm_str_sel**<br>
*(string) (optional)*

**mm_frm_hnr_sel**<br>
*(string) (optional)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: muellmax_de
      args:
        service: Fes
        mm_frm_str_sel: Achenbachstraße
        mm_frm_hnr_sel: 60596;Sachsenhausen;2;
```

## How to get the source arguments

There is a script with an interactive command line interface which generates the required source configuration:

[https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/muellmax_de.py](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/muellmax_de.py).

First, install the Python module `inquirer`. Then run this script from a shell and answer the questions.

doc/source/mrsc_vic_gov_au.md METASEP
# Macedon Ranges Shire Council

Support for schedules provided by [Macedon Ranges Shire Council](https://www.mrsc.vic.gov.au/Live-Work/Bins-Rubbish-Recycling/Bins-and-collection-days/Bin-collection-days).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: mrsc_vic_gov_au
      args:
        street_address: STREET_ADDRESS
```

### Configuration Variables

**street_address**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: mrsc_vic_gov_au
      args:
        street_address: 45 Aitken Street, Gisborne
```

## How to get the source arguments

Visit the [Macedon Ranges Shire Council waste and recycling](https://www.mrsc.vic.gov.au/Live-Work/Bins-Rubbish-Recycling/Bins-and-collection-days/Bin-collection-days) page and search for your address. The arguments should exactly match the street address shown in the autocomplete result.

doc/source/lindau_ch.md METASEP
# Abfallwirtschaft Zollernalbkreis

Support for schedules provided by [https://www.lindau.ch/abfalldaten](https://www.lindau.ch/abfalldaten).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: lindau_ch
      args:
        city: Tagelswangen

```

### Configuration Variables

**city**<br>
*(string) (required)*

Choose one of the following list:

- Grafstal
- Lindau
- Tagelswangen
- Winterberg

or use one the following IDs: 190, 191, 192, 193

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: lindau_ch
      args:
        city: Tagelswangen

```

doc/source/lerum_se.md METASEP
# Lerum Vatten och Avlopp

Support for schedules provided by [Lerum Vatten och Avlopp](https://vatjanst.lerum.se/FutureWeb/SimpleWastePickup/SimpleWastePickup), serving the municipality of Lerum, Sweden.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: lerum_se
      args:
        street_address: STREET_ADDRESS
```

### Configuration Variables

**street_address**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: lerum_se
      args:
        street_address: Götebordsvägen 16, Lerum
```

## How to get the source argument

The source argument is the address to the house with waste collection. The address can be tested [here](https://vatjanst.lerum.se/FutureWeb/SimpleWastePickup/SimpleWastePickup).

doc/source/landkreis_wittmund_de.md METASEP
# Landkreis-Wittmund.de

Support for schedules provided by [Landkreis-Wittmund.de](https://www.landkreis-wittmund.de) located in Lower Saxony, Germany.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: landkreis_wittmund_de
      args:
        city: Werdum
        # optional
        # street: street name
```

### Configuration Variables

**city**<br>
*(string) (required)*

**street**<br>
*(string) (optional)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: landkreis_wittmund_de
      args:
        city: Werdum
```

Use `sources.customize` to filter or rename the waste types:

```yaml
waste_collection_schedule:
  sources:
    - name: landkreis_wittmund_de
      args:
        city: Werdum
      calendar_title: Abfallkalender
      customize:
        # rename types to shorter name
        - type: Restmülltonne
          alias: Restmüll
        
        # hide unwanted types
        - type: Baum- und Strauchschnitt
          show: false
```

doc/source/kwb_goslar_de.md METASEP
# KWB-Goslar.de

Support for schedules provided by [KWB-Goslar.de](https://www.kwb-goslar.de/Abfallwirtschaft/Abfuhr/) located in Lower Saxony, Germany.  

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: kwb_goslar_de
      args:
        pois: 2523.602 # Berliner Straße (Clausthal-Zellerfeld)
```

### Configuration Variables

**pois**
*(string) (required)*

#### How to find the `pois` value

1. Open [Online-Abfuhrkalender 2022](https://www.kwb-goslar.de/Abfallwirtschaft/Abfuhr/Online-Abfuhrkalender-2022/) (`Abfallwirtschaft -> Abfuhr -> Online-Abfuhrkalender 2022`)
2. Select your city (`Ort auswählen`)
3. Select your district/street (`Ortsteil, Straße auswählen`)
4. Copy the `pois` value out of the url (e.g. `https://www.kwb-goslar.de/Abfallwirtschaft/Abfuhr/Online-Abfuhrkalender-2022/index.php?ModID=48&...&pois=2523.602`)

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: kwb_goslar_de
      args:
        pois: 2523.409 # Braunschweiger Straße (Seesen)
```

Use `sources.customize` to filter or rename the waste types:

```yaml
waste_collection_schedule:
  sources:
    - name: kwb_goslar_de
      args:
        pois: 2523.409 # Braunschweiger Straße (Seesen)
      calendar_title: Abfuhrtermine - Braunschweiger Straße (Seesen)
      customize:
        # rename types to shorter name
        - type: Restmülltonne
          alias: Restmüll
        
        # hide unwanted types
        - type: Baum- und Strauchschnitt
          show: false
```

doc/source/jumomind_de.md METASEP
# Jumomind.de / MyMuell.de

Support for schedules provided by [jumomind.de](https://jumomind.de/) and [MyMüll App](https://www.mymuell.de). Jumomind and MyMüll are services provided by [junker.digital](https://junker.digital/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: jumomind_de
      args:
        service_id: SERVICE_ID
        city_id: CITY_ID
        area_id: AREA_ID
```

### Configuration Variables

**service_id**<br>
*(string) (required)*

**city_id**<br>
*(string) (required)*

**area_id**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: jumomind_de
      args:
        service_id: zaw
        city_id: 106
        area_id: 94
```

## How to get the source arguments

There is a script with an interactive command line interface which generates the required source configuration:

[https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/jumomind_de.py](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/jumomind_de.py).

First, install the Python module `inquirer`. Then run this script from a shell and answer the questions.

doc/source/ics.md METASEP
# ICS / iCal

Add support for schedules from ICS / iCal files. Files can be either stored in a  local folder or fetched from a static URL. The waste type will be taken from the `summary` attribute.

This source has been successfully tested with the following service providers:

### Belgium

- [Limburg.net](https://www.limburg.net/afvalkalender) ([Example](#limburg-net))

### Germany

- [Müllabfuhr-Deutschland](https://www.muellabfuhr-deutschland.de/) ([Notes](#müllabfuhr-deutschland))

#### Baden-Württemberg

- [Abfallwirtschaftsamt Bodenseekreis](https://www.bodenseekreis.de/umwelt-landnutzung/abfallentsorgung-privat/termine/abfuhrkalender/) ([Notes](#abfallwirtschaftsamt-bodenseekreis))
- [Abfallwirtschaft Kreis Böblingen](https://www.lrabb.de/start/Service+_+Verwaltung/Abfuhrtermine.html)
- [Abfall Landkreis Stade](https://abfall.landkreis-stade.de/)
- [AVL Ludwigsburg](https://www.avl-ludwigsburg.de/) ([Example](#avl-ludwigsburg))
- [AWB Esslingen](https://www.awb-es.de/)

#### Bayern

- [AWM München](https://www.awm-muenchen.de) ([Notes](#awm-münchen))
- [Awista Starnberg](https://www.awista-starnberg.de/)

#### Hessen

- [Erlensee](https://sperrmuell.erlensee.de/?type=reminder) ([Example](#erlensee))
- [EAW Rheingau Taunus](https://www.eaw-rheingau-taunus.de/service/abfallkalender.html) ([Example](#eaw-rheingau-taunus))

#### Niedersachsen

- [Abfallkalender Zollernalbkreis](https://www.zollernalbkreis.de/landratsamt/aemter++und+organisation/Elektronischer+Abfallkalender) ([Example](#abfallkalender-zollernalbkreis))

#### Nordrhein-Westfalen

- [EDG Entsorgung Dortmund](https://www.edg.de/)

#### Sachsen

- [ASR Chemnitz](https://www.asr-chemnitz.de/kundenportal/entsorgungskalender/)
- [Stadtreinigung Leipzig](https://www.stadtreinigung-leipzig.de/)
- [Entsorgungsgesellschaft Görlitz-Löbau-Zittau](https://www.abfall-eglz.de/abfallkalender.0.html) ([Notes](#entsorgungsgesellschaft-görlitz-löbau-zittau))

### Sweden

- [NSR Nordvästra Skåne](https://nsr.se/privat/allt-om-din-sophamtning/nar-toms-mitt-karl/tomningskalender/)

### United States of America

- [ReCollect.net](https://recollect.net) ([Notes](#recollect))
- [Western Disposal Residential (Colorado)](https://www.westerndisposal.com/residential/) (Unofficial, [Notes](#western-disposal-colorado))

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: URL
        file: FILE
        offset: OFFSET
        method: METHOD
        params: PARAMS
        year_field: YEAR_FIELD
        split_at: SPLIT_AT
        version: 2
```

### Configuration Variables

**url**<br>
*(string) (optional)*

URL to ICS / iCal file. File will be downloaded using a HTTP GET request.

If the original url contains the current year (4 digits including century), this can be replaced by the wildcard `{%Y}` (see example below).

You have to specify either `url` or `file`!

**file**<br>
*(string) (optional)*

Local ICS / iCal file name. Can be used instead of `url` for local files.

You have to specify either `url` or `file`!

**offset**<br>
*(int) (optional, default: `0`)*

Offset in days which will be added to every start time. Can be used if the start time of the events in the ICS file are ahead of the actual date.

**method**<br>
*(string) (optional, default: `GET`)*

Method to send the URL `params`.

Need to be `GET` or `POST`.

**params**<br>
*(dict) (optional, default: None)*

Dictionary, list of tuples or bytes to send in the query string for the HTTP request.

This gets

- urlencoded and either attached to the raw URL when GET method is used.
- send with `Content-Type: application/x-www-form-urlencoded` and an automatically generated `Content-Length` header as POST method HTTP body.

Only used if `url` is specified, not used for `file`.

**year_field**<br>
*(string) (optional, default: None)*

Field in params dictionary to be replaced with current year (4 digits including century).

**split_at**<br>
*(string) (optional, default: None)*

Delimiter to split event summary into individual collection types. If your service puts multiple collections types which occur at the same day into a single event, this option can be used to separate the collection types again.

**version**<br>
*(integer) (optional, default: 2)*

Selects the underlying ICS file parser:

- version: 1 uses `recurring_ical_events`
- version: 2 uses `icalevents`

## Examples and Notes

***

### Local file

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        file: "test.ics"
```

***

### AVL Ludwigsburg

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: "https://www.avl-ludwigsburg.de/fileadmin/Files/Abfallkalender/ICS/Privat/Privat_{%Y}_Ossweil.ics"
        offset: 1
```

***

### Abfallwirtschaftsamt Bodenseekreis

Go to the [service provider website](https://www.bodenseekreis.de/umwelt-landnutzung/abfallentsorgung-privat/termine/abfuhrkalender/) and select location and desired waste types. Afterwards an iCal calender export is provided. Simply take this URL and replace the year with "{%Y}" and use this URL within the configuration.

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: https://www.bodenseekreis.de/umwelt-landnutzung/abfallentsorgung-privat/termine/abfuhrkalender/export/{%Y}/salem/salem-i/1,4,5,16,7,8,10,6/ics/
      customize:
        - type: Restmüll 2-wöchentlich
          alias: Restmüll
          icon: mdi:trash-can
        - type: Bioabfall 2-wöchentlich
          alias: Bioabfall
          icon: mdi:flower-outline
        - type: Papier 4-wöchentlich
          alias: Papierabfall
          icon: mdi:trash-can-outline
        - type: Gelber Sack
          icon: mdi:recycle
```

### Abfallkalender Zollernalbkreis

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: "https://www.abfallkalender-zak.de",
        params:
            city: 2,3,4
            street: 3
            types[]:
              - restmuell
                gelbersack
                papiertonne
                biomuell
                gruenabfall
                schadstoffsammlung
                altpapiersammlung
                schrottsammlung
                weihnachtsbaeume
                elektrosammlung
            go_ics: Download
        },
        year_field: year
```

***

### EAW Rheingau Taunus

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: "https://www.eaw-rheingau-taunus.de/abfallkalender/calendar.ics?streetid=1429"
        split_at: ","
```

***

### ReCollect

To get the URL, search your address in the recollect form of your home town, click "Get a calendar", then "Add to iCal". Finally, the URL under "Subscribe to calendar" is your ICS calendar link:

```url
webcal://recollect.a.ssl.fastly.net/api/places/BCCDF30E-578B-11E4-AD38-5839C200407A/services/208/events.en.ics?client_id=6FBD18FE-167B-11EC-992A-C843A7F05606
```

Strip the client ID and change the protocol to https, and you have a valid ICS URL.

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: "https://recollect.a.ssl.fastly.net/api/places/BCCDF30E-578B-11E4-AD38-5839C200407A/services/208/events.en.ics",
        split_at: "\\, [and ]*",
```

***

### Entsorgungsgesellschaft Görlitz-Löbau-Zittau

Remove the year from the generated URL to always get the current year.

***

### Müllabfuhr-Deutschland

You need to find the direct ics export link for your region, e.g. [Weimarer Land, Bad Berka](https://www.muellabfuhr-deutschland.de/weimarer-land/location/0c595d1c-2cbc-4d19-ae81-df5318fceb7c/pickups).

Known districts:

- [Burgenlandkreis](https://www.muellabfuhr-deutschland.de/burgenlandkreis)
- [Saalkreis](https://www.muellabfuhr-deutschland.de/saalekreis)
- [Sömmerda](https://www.muellabfuhr-deutschland.de/soemmerda)
- [Weimarer Land](https://www.muellabfuhr-deutschland.de/weimarer-land)

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: https://www.muellabfuhr-deutschland.de/weimarer-land/location/0c595d1c-2cbc-4d19-ae81-df5318fceb7c/pickups/ical.ics
```

***

### AWM München

1. Find your ICS export link via the AWM web page
2. Remove the cHash attribute
3. Replace current year with {%Y}

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: "https://www.awm-muenchen.de/entsorgen/abfuhrkalender?tx_awmabfuhrkalender_abfuhrkalender%5Bhausnummer%5D=2&tx_awmabfuhrkalender_abfuhrkalender%5Bleerungszyklus%5D%5BB%5D=1%2F2%3BG&tx_awmabfuhrkalender_abfuhrkalender%5Bleerungszyklus%5D%5BP%5D=1%2F2%3BU&tx_awmabfuhrkalender_abfuhrkalender%5Bleerungszyklus%5D%5BR%5D=001%3BG&tx_awmabfuhrkalender_abfuhrkalender%5Bsection%5D=ics&tx_awmabfuhrkalender_abfuhrkalender%5Bsinglestandplatz%5D=false&tx_awmabfuhrkalender_abfuhrkalender%5Bstandplatzwahl%5D=true&tx_awmabfuhrkalender_abfuhrkalender%5Bstellplatz%5D%5Bbio%5D=70114566&tx_awmabfuhrkalender_abfuhrkalender%5Bstellplatz%5D%5Bpapier%5D=70114566&tx_awmabfuhrkalender_abfuhrkalender%5Bstellplatz%5D%5Brestmuell%5D=70114566&tx_awmabfuhrkalender_abfuhrkalender%5Bstrasse%5D=Freimanner%20Bahnhofstr.&tx_awmabfuhrkalender_abfuhrkalender%5Byear%5D={%Y}}
```

***

### Erlensee

Just replace the street number (8 in the example below) with the number of your street. You can find the right number if you inspect the street drop-down list [here](https://sperrmuell.erlensee.de/?type=reminder).

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: "https://sperrmuell.erlensee.de/?type=reminder"
        method: POST
        params:
          street: 8
          eventType[]:
            - 27
            - 23
            - 19
            - 20
            - 21
            - 24
            - 22
            - 25
            - 26
          timeframe: 23
          download: ical
```

***

### Limburg.net

This tool works for all municipalities of the province of Limburg and the municipality of Diest.

Find your ICS export link via the calendar page - enter your address so that the system can look up the necessary data for your city and street to construct the URL.

Generating the URL on the site of Limburg.net is the simplest.

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: https://limburg.net/api-proxy/public/kalender-download/ical/72030?straatNummer=66536&huisNummer=1&toevoeging=&includeAllEventTypes=1&eventTypes[]=14&eventTypes[]=22&eventTypes[]=23&eventTypes[]=26&eventTypes[]=27&eventTypes[]=29
```

You can also compose the URL yourself. You need the following elements for this:
1. the nis-code of your municipality: query the api with the name of your municipality;<br>example: https://limburg.net/api-proxy/public/afval-kalender/gemeenten/search?query=Peer
```json
[{"nisCode":"72030","naam":"Peer"}]
```
2. the number of your street: query the api with the nis-code of your municipality and the name of your street;<br>example: https://limburg.net/api-proxy/public/afval-kalender/gemeente/72030/straten/search?query=Zuidervest
```json
[{"nummer":"66536","naam":"Zuidervest"}]
```
3. your housenumber

```yaml
waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: "https://limburg.net/api-proxy/public/kalender-download/ical/72030"
        method: GET
        params:
          straatNummer: 66536
          huisNummer: 1
          includeAllEventTypes: 1
          eventTypes[]:
            - 14
            - 22
            - 23
            - 26
            - 27
            - 29
```

***

### Western Disposal Colorado

*Unofficial calendar* maintained by burkemw3@gmail.com

[online calendar view](https://calendar.google.com/calendar/embed?src=gn2i5lqgobo5deb6p7j69l9aq8%40group.calendar.google.com&ctz=America%2FDenver)

```yaml
sensor:
  - platform: waste_collection_schedule
    name: Trash Recycling # whatever you want the UI to show. Consider adding a similar prefix for both sensors so they get sorted together, "Trash" in this case
    types:
      - Recycling # matches alias in waste_collection_schedule below
  - platform: waste_collection_schedule
    name: Trash Compost
    types:
      - Compost

waste_collection_schedule:
  sources:
    - name: ics
      args:
        url: https://calendar.google.com/calendar/ical/gn2i5lqgobo5deb6p7j69l9aq8%40group.calendar.google.com/public/basic.ics
      customize:
        - type: Wednesday E Compost # from calendar event name
          alias: Compost # matches type in sensor configuration above
        - type: Wednesday E Recycling
          alias: Recycling
```

doc/source/hygea_be.md METASEP
# Hygea

Support for schedules provided by [hygea.be](https://www.hygea.be/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: hygea_be
      args:
        streetIndex: STREET_INDEX
        cp: POSTAL_CODE
```

The arguments can be found in the URL after visiting the [the calendar](https://www.hygea.be/votre-calendrier-de-collecte.html). Select your city and optionally street to show the schedule for your town. Now check the URL and extract either streetIndex (if available) or postal code from the URL. See also examples below.

### Configuration Variables

**streetIndex**<br>
*(int)*
Street index, extracted from URL.

**cp**<br>
*(int)*
Postal code, extracted from URL.

## Example

```yaml
# URL: https://www.hygea.be/votre-calendrier-de-collecte.html?cp=7021&street=&streetIndex=13

waste_collection_schedule:
  sources:
    - name: hygea_be
      args:
        streetIndex: 13
```

```yaml
# URL: https://www.hygea.be/votre-calendrier-de-collecte.html?cp=6560&streetIndex=

waste_collection_schedule:
  sources:
    - name: hygea_be
      args:
        cp: 6560
```

doc/source/hvcgroep_nl.md METASEP
# HVCGroep

Support for schedules provided by [hvcgroep.nl](https://www.hvcgroep.nl/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: hvcgroep_nl
      args:
        postal_code: POSTAL_CODE
        house_number: HOUSE_NUMBER
```

### Configuration Variables

**postal_code**<br>
*(string) (required)*

**house_number**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: hvcgroep_nl
      args:
        postal_code: 1713VM
        house_number: 1
```

doc/source/egn_abfallkalender_de.md METASEP
# EGN - Abfallkalender

Support for schedules provided by [egn-abfallkalender.de](https://www.egn-abfallkalender.de/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: egn_abfallkalender_de
      args:
        city: CITY_NAME
        district: DISTRICT_NAME
        street: STREET_NAME
        housenumber: HOUSE_NUMBER
```

The arguments can be found above the calendar after generating one [here](https://www.egn-abfallkalender.de/kalender#skill-setup-form). Select your city, street and enter your housenumber to show the schedule for your address. The complete information you need are displayed above the calendar view in the format `für <street> <housenumber>, <city> (<district>)`. See also examples below.

### Configuration Variables

**city**<br>
*(string) (required)*
City, extracted from the displayed address.

**district**<br>
*(string) (required)*
District, extracted from the displayed address.

**street**<br>
*(string) (required)*
Street, extracted from the displayed address.

**housenumber**<br>
*(string) (required)*
Housenumber, extracted from the displayed address.

## Examples

```yaml
# Displayed address: 
# für Albert-Schweitzer-Weg 27, Grevenbroich (Stadtmitte)

waste_collection_schedule:
  sources:
    - name: egn_abfallkalender_de
      args:
        city: Grevenbroich
        district: Stadtmitte
        street: Albert-Schweitzer-Weg
        housenumber: 27
```

```yaml
# Displayed address:
# für Am Damschenpfad 81, Dormagen (Nievenheim)

waste_collection_schedule:
  sources:
    - name: egn_abfallkalender_de
      args:
        city: Dormagen
        district: Nievenheim
        street: "Am Damschenpfad"
        housenumber: 81
```

doc/source/data_umweltprofis_at.md METASEP
# Umweltprofis.at

Support for schedules provided by [Umweltprofis.at](https://www.umweltprofis.at).

## Prerequisites

You need to generate your personal iCal Link before you can start using this source. Go to [https://data.umweltprofis.at/opendata/AppointmentService/index.aspx](https://data.umweltprofis.at/opendata/AppointmentService/index.aspx) and fill out the form. At the end, you can generate an iCal link. Copy this link and paste it to configuration.yaml as seen below.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: data_umweltprofis_at
      args:
        url: https://data.umweltprofis.at/OpenData/AppointmentService/AppointmentService.asmx/GetIcalWastePickupCalendar?key=xxx
      customize:
        - type: "Abholung: Hausabfall"
          alias: Restmüll
        - type: "Abholung: Gelbe Tonne"
          alias: Gelbe Tonne
        - type: "Abholung: Papiertonne"
          alias: Papier
        - type: "Abholung: Biotonne"
          alias: Biotonne
          show: false
```

### Configuration Variables

**url**<br>
*(url) (required)*
doc/source/colchester_gov_uk.md METASEP
# Colchester Council

Support for schedules provided by [Colchester Council](https://www.colchester.gov.uk/your-recycling-calendar/), serving the borough of Colchester, UK.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
    sources:
    - name: colchester_gov_uk
      args:
        llpgid: LLPDID_CODE
```

### Configuration Variables

**llpgid**<br>
*(string) (required)*

#### How to find your `llpgid` code
The LLPDID code can be found in the URL after entering your postcode and selecting your address on the [Colchester Your recycling calendar page](https://www.colchester.gov.uk/your-recycling-calendar/). The URL in your browser URL bar should look like `https://www.colchester.gov.uk/your-recycling-calendar/?start=true&step=1&llpgid=1197e725-3c27-e711-80fa-5065f38b5681`.

## Example

```yaml
waste_collection_schedule:
    sources:
    - name: colchester_gov_uk
      args:
        llpgid: "1197e725-3c27-e711-80fa-5065f38b5681"
```
doc/source/ccc_govt_nz.md METASEP
# Christchurch City Council

Support for schedules provided by [Christchurch City Council](https://ccc.govt.nz/services/rubbish-and-recycling/collections).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: ccc_govt_nz
      args:
        address: STREET_NUMBER_AND_STREET_NAME
```

### Configuration Variables

**address**<br>
*(string) (required)*

## Bin Names example - Garbage, Recycle & Organic

```yaml
waste_collection_schedule:
  sources:
    - name: ccc_govt_nz
      args:
        address: "53 Hereford Street"
```

## Bin Colours example - Red, Yellow & Green

```yaml
waste_collection_schedule:
  sources:
    - name: ccc_govt_nz
      args:
        address: "53 Hereford Street"
      customize:
          - type: Garbage
            alias: Red
          - type: Recycle
            alias: Yellow
          - type: Organic
            alias: Green
      calendar_title: "CCC Bins"
  separator: " & "
```

doc/source/cambridge_gov_uk.md METASEP
# Cambridge City Council

Support for schedules provided by [Cambridge City Council](https://www.cambridge.gov.uk/check-when-your-bin-will-be-emptied), serving Cambridge (UK) and part of Cambridgeshire.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
    sources:
    - name: cambridge_gov_uk
      args:
        post_code: POST_CODE
        number: NUMBER

```

### Configuration Variables

**POST_CODE**<br>
*(string) (required)*

**NUMBER**<br>
*(string) (required)*


## Example

```yaml
waste_collection_schedule:
    sources:
    - name: cambridge_gov_uk
      args:
        post_code: "CB13JD"
        number: "37"
```

doc/source/bsr_de.md METASEP
# Berliner Stadtreinigungsbetriebe

Support for schedules provided by [bsr.de](https://www.bsr.de/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: bsr_de
      args:
        abf_strasse: STRASSE
        abf_hausnr: HAUSNR
```

### Configuration Variables

**abf_strasse**<br>
*(string) (required)*

**abf_hausnr**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: bsr_de
      args:
        abf_strasse: "Bahnhofstr., 12159 Berlin (Tempelhof-Schöneberg)"
        abf_hausnr: 1
```

## How to get the source arguments

There is a script with an interactive command line interface which generates the required source configuration:

[https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/bsr_de.py](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/bsr_de.py).

First, install the Python module `inquirer`. Then run this script from a shell and answer the questions.

doc/source/brisbane_qld_gov_au.md METASEP
# Brisbane City Council

Support for schedules provided by [Brisbane City Council](https://www.brisbane.qld.gov.au/clean-and-green/rubbish-tips-and-bins/rubbish-collections/bin-collection-calendar).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: brisbane_qld_gov_au
      args:
        suburb: SUBURB
        street_name: STREET_NAME
        street_number: STREET_NUMBER
```

### Configuration Variables

**suburb**<br>
*(string) (required)*

**street_name**<br>
*(string) (required)*

**street_number**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: brisbane_qld_gov_au
      args:
        suburb: Milton
        street_name: Park Rd
        street_number: 8/1
```

## How to get the source arguments

Visit the [Brisbane City Council bin collection calendar](https://www.brisbane.qld.gov.au/clean-and-green/rubbish-tips-and-bins/rubbish-collections/bin-collection-calendar) page and search for your address.  The arguments should exactly match the results shown for Suburb and Street and the number portion of the Property.

doc/source/berlin_recycling_de.md METASEP
# Berlin Recycling

Support for schedules provided by [berlin-recycling.de](https://www.berlin-recycling.de/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: berlin_recycling_de
      args:
        username: USERNAME
        password: PASSWORD
```

### Configuration Variables

**username**<br>
*(string) (required)*

**password**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: bsr_de
      args:
        username: My User Name
        password: My Password
```

doc/source/banyule_vic_gov_au.md METASEP
# Banyule City Council

Support for schedules provided by [Banyule City Council](https://www.banyule.vic.gov.au/binday). This implementation is heavily based upon the Stonnington City Council parser, as both interfaces appear to use the same back-end.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: banyule_vic_gov_au
      args:
        street_address: STREET_ADDRESS
```

### Configuration Variables

**street_address**<br>
*(string) (optional)*

**geolocation_id**<br>
*(string) (optional)*

At least one argument must be provided.

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: banyule_vic_gov_au
      args:
        street_address: 6 Mandall Avenue, IVANHOE
```

```yaml
waste_collection_schedule:
  sources:
    - name: banyule_vic_gov_au
      args:
        geolocation_id: 4f7ebfca-1526-4363-8b87-df3103a10a87
```

## How to get the source arguments

Visit the [Banyule City Council bin collection services](https://www.banyule.vic.gov.au/Waste-environment/Waste-recycling/Bin-collection-services) page and search for your address. The ```street_address``` argument should exactly match the street address shown in the autocomplete result. For unlisted addresses use an adjacent listed address.

The ```geolocation_id``` argument can be used to bypass the initial address lookup on first use. This value can be discovered using the developer console in any modern browser and inspecting the request sent once an address is selected and the search button is clicked. The request URL takes the format: ```https://www.banyule.vic.gov.au/ocapi/Public/myarea/wasteservices?geolocationid=<GEOLOCATION ID>&ocsvclang=en-AU```.

doc/source/awsh_de.md METASEP
# AWSH

Support for schedules provided by [AWSH](https://www.awsh.de)

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: awsh_de
      args:
        city: CITY
        street: STREET
```

### Configuration Variables

**city**<br>
*(string) (required)*

**street**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: awsh_de
      args:
        city: Reinbek
        street: Ahornweg
```

doc/source/awr_de.md METASEP
# AWR

Support for schedules provided by [AWR](https://www.awr.de)

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: awr_de
      args:
        city: CITY
        street: STREET
```

### Configuration Variables

**city**<br>
*(string) (required)*

**street**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: awr_de
      args:
        city: Rendsburg
        street: Hindenburgstraße
```

doc/source/awido_de.md METASEP
# AWIDO based services

Cubefour AWIDO is a platform for waste schedules, which has several German cities and districts as customers. The homepage of the company is https://www.awido-online.de/.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: awido_de
      args:
        customer: customer
        city: city
        street: street
        housenumber: 2
```

### Configuration Variables

**customer**<br>
*(string) (required)*

**city**<br>
*(string) (required)*

**street**<br>
*(integer) (optional, depends on customer)*

**housenumber**<br>
*(integer) (optional, depends on customer)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: awido_de
      args:
        customer: rmk
        city: Waiblingen
        street: Benzstr.
        housenumber: 14
```

## How to get the source arguments

### customer

List of customers (2021-07-09):

- `rmk`: AWG des Rems-Murr-Kreises mbH
- `lra-schweinfurt`: Landkreis Schweinfurt
- `gotha`: Landkreis Gotha
- `zaso`: Zweckverband Abfallwirtschaft Saale-Orla
- `unterhaching`: Gemeinde Unterhaching
- `kaufbeuren`: Stadt Kaufbeuren
- `bgl`: Landkreis Berchtesgadener Land
- `pullach`: Pullach im Isartal
- `ffb`: Landkreis Fürstenfeldbruck
- `unterschleissheim`: Stadt Unterschleißheim
- `kreis-tir`: Landkreis Tirschenreuth
- `rosenheim`: Landkreis Rosenheim
- `tuebingen`: Landkreis Tübingen
- `kronach`: Landkreis Kronach
- `erding`: Landkreis Erding
- `zv-muc-so`: Zweckverband München-Südost
- `coburg`: Landkreis Coburg
- `ansbach`: Landkreis Ansbach
- `awb-duerkheim`: AWB Landkreis Bad Dürkheim
- `aic-fdb`: Landratsamt Aichach-Friedberg
- `wgv`: WGV Recycling GmbH
- `neustadt`: Neustadt a.d. Waldnaab
- `kelheim`: Landkreis Kelheim
- `kaw-guenzburg`: Landkreis Günzburg
- `memmingen`: Stadt Memmingen
- `eww-suew`: Landkreis Südliche Weinstraße
- `lra-dah`: Landratsamt Dachau
- `landkreisbetriebe`: Landkreisbetriebe Neuburg-Schrobenhausen
- `awb-ak`: Abfallwirtschaftsbetrieb Landkreis Altenkirchen
- `awld`: Abfallwirtschaft des Lahn-Dill-Kreises
- `azv-hef-rof`: Abfallwirtschafts-Zweckverband des Landkreises Hersfeld-Rotenburg
- `awv-nordschwaben`: Abfall-Wirtschafts-Verband Nordschwaben
- `zv-muc-so`: Zweckverband München-Südost

### city, street, house number

- Go to your calendar at `https://awido.cubefour.de/Customer/<customer>/v2/Calendar2.aspx`. Replace `<customer>` with the one of the keys listed above.
- Enter your city name from the first page into the `city` field.
- If you have to enter a street or district, enter the name into the `street` field.
- If you have to enter a house number, enter the house number into the `housenumber` field.

doc/source/awbkoeln_de.md METASEP
# Abfallwirtschaftsbetriebe Köln

Support for schedules provided by [awbkoeln.de](https://www.awbkoeln.de/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: awbkoeln_de
      args:
        street_code: STREET_CODE
        building_number: BUILDING_NUMBER
```

### Configuration Variables

**street_code**<br>
*(string) (required)*

**building_number**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: awbkoeln_de
      args:
        street_code: 4272
        building_number: 10
```

## How to get the source arguments

There is a script with an interactive command line interface which generates the required source configuration:

[https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/awbkoeln_de.py](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/awbkoeln_de.py).

First, install the Python module `inquirer`. Then run this script from a shell and answer the questions.

doc/source/awb_bad_kreuznach_de.md METASEP
# AWB Bad Kreuznach

Support for schedules provided by [AWB Bad Kreuznach](https://app.awb-bad-kreuznach.de/), Germany.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: awb_bad_kreuznach_de
      args:
        ort: ORT
        strasse: STRASSE
        nummer: NUMMER
```

### Configuration Variables

**ort**<br>
*(string) (required)*

**strasse**<br>
*(string) (required)*

**nummer**<br>
*(integer) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: awb_bad_kreuznach_de
      args:
        ort: "Hargesheim"
        strasse: "Winzenheimer Straße"
        nummer: 16
```

## How to get the source arguments

1. Go to your calendar at `https://app.awb-bad-kreuznach.de/`.
2. Enter your location.
3. Copy the exact values from the 3 textboxes as `ort`, `strasse` and `nummer` in the source configuration.

doc/source/aucklandcouncil_govt_nz.md METASEP
# Auckland Council

Support for schedules provided by [Auckland Council](https://aucklandcouncil.govt.nz/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: aucklandcouncil_govt_nz
      args:
        area_number: AREA_NUMBER_FROM_COLLECTION_PAGE # see 'How to get the source argument below'
```

### Configuration Variables

**area_number**<br>
*(string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: aucklandcouncil_govt_nz
      args:
        area_number: 12342306525
```

## How to get the source argument

The source argument is the area number from Auckland Council site:

-  Open your collection days page by  entering your address [on the Auckland Council collection day finder](https://www.aucklandcouncil.govt.nz/rubbish-recycling/rubbish-recycling-collections/Pages/rubbish-recycling-collection-days.aspx)
- Look for 'an' parameter in your browser URL, e.g. https://www.aucklandcouncil.govt.nz/rubbish-recycling/rubbish-recycling-collections/Pages/collection-day-detail.aspx?an=12342306525
- In this example the area number is `12342306525`.

doc/source/abfallnavi_de.md METASEP
# AbfallNavi.de

Support for schedules provided by [Abfallnavi.de](https://www.abfallnavi.de). The service is hosted under [regioit.de](https://regioit.de).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: abfallnavi_de
      args:
        service: SERVICE
        ort: SERVICE
        strasse: STRASSE
        hausnummer: hausnummer
```

### Configuration Variables

**service**<br>
*(string) (required)*

**ort**<br>
*(string) (required)*

**strasse**<br>
*(string) (required)*

**hausnummer**<br>
*(string) (optional)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: abfallnavi_de
      args:
        service: coe
        ort: Coesfeld
        strasse: Ahornweg
```

## How to get the source arguments

There is a script with an interactive command line interface which generates the required source configuration:

[https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/abfallnavi_de.py](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/abfallnavi_de.py).

First, install the Python module `inquirer`. Then run this script from a shell and answer the questions.

doc/source/abfall_zollernalbkreis_de.md METASEP
# Abfallwirtschaft Zollernalbkreis

Support for schedules provided by [https://www.abfallkalender-zak.de/](https://www.abfallkalender-zak.de/).

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: abfall_zollernalbkreis_de
      args:
        city: CITY
        street: STREET
        types:
          - "restmuell"
          - "gelbersack"
          - "papiertonne"
          - "biomuell"
          - "gruenabfall"
          - "schadstoffsammlung"
          - "altpapiersammlung"
          - "schrottsammlung"
          - "weihnachtsbaeume"
          - "elektrosammlung"
```

### Configuration Variables

**city**<br>
*(string) (required)*

**street**<br>
*(integer) (optional)*

**types**<br>
*(list of string) (required)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: abfall_zollernalbkreis_de
      args:
        city: "2,3,4"
        street: 3
        types:
          - "restmuell"
          - "gelbersack"
          - "papiertonne"
          - "biomuell"
          - "gruenabfall"
          - "schadstoffsammlung"
          - "altpapiersammlung"
          - "schrottsammlung"
          - "weihnachtsbaeume"
          - "elektrosammlung"
```

## How to get the source arguments

### Hardcore Variant: Extract arguments from website

Another way get the source arguments is to extract the arguments from the website using a (desktop) browser with developer tools, e.g. Google Chrome:

1. Open [https://www.abfallkalender-zak.de/](https://www.abfallkalender-zak.de/).
2. Enter your data, but don't click on "ICS Download" so far!
3. Open the Developer Tools (Ctrl + Shift + I) and open the `Network` tab.
4. Now click the "ICS Download" button.
5. You should see (amongst other's) one POST entry to Host https://www.abfallkalender-zak.de/ labeled `/` in the network recording.
6. Select `/` on the left hand side and click on Request on the right hand side.
7. At the `Form Data` you can find the values for `city` and `street` etc..

doc/source/abfall_io.md METASEP
# Abfall.IO / AbfallPlus.de

Support for schedules provided by [Abfall.IO](https://abfall.io). The official homepage is using the URL [AbfallPlus.de](https://www.abfallplus.de/) instead.

## Configuration via configuration.yaml

```yaml
waste_collection_schedule:
  sources:
    - name: abfall_io
      args:
        key: KEY
        f_id_kommune: KOMMUNE
        f_id_bezirk: BEZIRK
        f_id_strasse: strasse
        f_id_strasse_hnr: HNR
        f_abfallarten:
          - 1
          - 2
          - 3
```

### Configuration Variables

**key**<br>
*(hash) (required)*

**f_id_kommune**<br>
*(integer) (required)*

**f_id_bezirk**<br>
*(integer) (optional)*

**f_id_strasse**<br>
*(integer) (required)*

**f_id_strasse_hnr**<br>
*(string) (optional)*

**f_abfallarten**<br>
*(list of integer) (optional)*

## Example

```yaml
waste_collection_schedule:
  sources:
    - name: abfall_io
      args:
        key: "8215c62763967916979e0e8566b6172e"
        f_id_kommune: 2999
        f_id_strasse: 1087
```

## How to get the source arguments

## Simple Variant: Use wizard script

There is a script with an interactive command line interface which generates the required source configuration:

[https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/abfall_io.py](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/custom_components/waste_collection_schedule/waste_collection_schedule/wizard/abfall_io.py).

First, install the Python module `inquirer`. Then run this script from a shell and answer the questions.

### Hardcore Variant: Extract arguments from website

Another way get the source arguments is to us a (desktop) browser with developer tools, e.g. Google Chrome:

1. Open your county's `Abfuhrtermine` homepage, e.g. [https://www.lrabb.de/start/Service+_+Verwaltung/Abfuhrtermine.html](https://www.lrabb.de/start/Service+_+Verwaltung/Abfuhrtermine.html).
2. Enter your data, but don't click on `Datei exportieren` so far!
3. Select `Exportieren als`: `ICS`
4. Open the Developer Tools (Ctrl + Shift + I) and open the `Network` tab.
5. Now click the `Datei exportieren` button.
6. You should see one entry in the network recording.
7. Select the entry on the left hand side and scroll down to `Query String Parameters` on the right hand side.
8. Here you can find the value for `key`.
9. Now go down to the next section `Form Data`.
10. Here you can find the values for `f_id_kommune`, `f_id_bezirk`, `f_id_strasse`, `f_id_strasse_hnr` and `f_abfallarten`. All other entries don't care.

tests/bandit.yaml METASEP
# https://bandit.readthedocs.io/en/latest/config.html

tests:
  - B108
  - B306
  - B307
  - B313
  - B314
  - B315
  - B316
  - B317
  - B318
  - B319
  - B320
  - B325
  - B602
  - B604

images/wcs.svg METASEP
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
   xmlns:dc="http://purl.org/dc/elements/1.1/"
   xmlns:cc="http://creativecommons.org/ns#"
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
   xmlns:svg="http://www.w3.org/2000/svg"
   xmlns="http://www.w3.org/2000/svg"
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
   sodipodi:docname="wcs.svg"
   inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
   id="svg8"
   version="1.1"
   viewBox="0 0 210 297"
   height="297mm"
   width="210mm">
  <defs
     id="defs2">
    <inkscape:path-effect
       effect="fillet_chamfer"
       id="path-effect1487"
       is_visible="true"
       lpeversion="1"
       satellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1"
       unit="px"
       method="auto"
       mode="F"
       radius="4"
       chamfer_steps="1"
       flexible="false"
       use_knot_distance="true"
       apply_no_radius="true"
       apply_with_radius="true"
       only_selected="false"
       hide_knots="false" />
    <inkscape:path-effect
       effect="fillet_chamfer"
       id="path-effect1462"
       is_visible="true"
       lpeversion="1"
       satellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1"
       unit="px"
       method="auto"
       mode="F"
       radius="4"
       chamfer_steps="1"
       flexible="false"
       use_knot_distance="true"
       apply_no_radius="true"
       apply_with_radius="true"
       only_selected="false"
       hide_knots="false" />
    <marker
       inkscape:isstock="true"
       style="overflow:visible"
       id="DotL"
       refX="0.0"
       refY="0.0"
       orient="auto"
       inkscape:stockid="DotL">
      <path
         transform="scale(0.8) translate(7.4, 1)"
         style="fill-rule:evenodd;stroke:#ff0000;stroke-width:1pt;stroke-opacity:1;fill:#ff0000;fill-opacity:1"
         d="M -2.5,-1.0 C -2.5,1.7600000 -4.7400000,4.0 -7.5,4.0 C -10.260000,4.0 -12.5,1.7600000 -12.5,-1.0 C -12.5,-3.7600000 -10.260000,-6.0 -7.5,-6.0 C -4.7400000,-6.0 -2.5,-3.7600000 -2.5,-1.0 z "
         id="path1088" />
    </marker>
    <marker
       inkscape:isstock="true"
       style="overflow:visible;"
       id="Arrow2Lend"
       refX="0.0"
       refY="0.0"
       orient="auto"
       inkscape:stockid="Arrow2Lend">
      <path
         transform="scale(1.1) rotate(180) translate(1,0)"
         d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z "
         style="fill-rule:evenodd;stroke-width:0.625;stroke-linejoin:round;stroke:#ff0000;stroke-opacity:1;fill:#ff0000;fill-opacity:1"
         id="path1048" />
    </marker>
    <inkscape:path-effect
       hide_knots="false"
       only_selected="false"
       apply_with_radius="true"
       apply_no_radius="true"
       use_knot_distance="true"
       flexible="false"
       chamfer_steps="1"
       radius="4"
       mode="F"
       method="auto"
       unit="px"
       satellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1"
       lpeversion="1"
       is_visible="true"
       id="path-effect1025"
       effect="fillet_chamfer" />
    <inkscape:path-effect
       hide_knots="false"
       only_selected="false"
       apply_with_radius="true"
       apply_no_radius="true"
       use_knot_distance="true"
       flexible="false"
       chamfer_steps="1"
       radius="4"
       mode="F"
       method="auto"
       unit="px"
       satellites_param="F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1 @ F,0,0,1,0,1.0583333,0,1"
       lpeversion="1"
       is_visible="true"
       id="path-effect1021"
       effect="fillet_chamfer" />
    <inkscape:path-effect
       hide_knots="false"
       only_selected="false"
       apply_with_radius="true"
       apply_no_radius="true"
       use_knot_distance="true"
       flexible="false"
       chamfer_steps="1"
       radius="3"
       mode="F"
       method="auto"
       unit="px"
       satellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1 @ F,0,0,1,0,0.79375,0,1"
       lpeversion="1"
       is_visible="true"
       id="path-effect1015"
       effect="fillet_chamfer" />
    <style
       id="style883">.cls-1,.cls-2,.cls-3,.cls-4{fill:none;stroke:#41b0e0;stroke-miterlimit:10;}.cls-1,.cls-2{stroke-width:3px;}.cls-2,.cls-4{stroke-linecap:round;}.cls-3,.cls-4{stroke-width:2.94px;}.cls-5{fill:#41b0e0;}</style>
  </defs>
  <sodipodi:namedview
     inkscape:window-maximized="1"
     inkscape:window-y="-8"
     inkscape:window-x="-8"
     inkscape:window-height="1017"
     inkscape:window-width="1920"
     showgrid="false"
     inkscape:document-rotation="0"
     inkscape:current-layer="layer1"
     inkscape:document-units="mm"
     inkscape:cy="330.10623"
     inkscape:cx="400.84327"
     inkscape:zoom="1.7536779"
     inkscape:pageshadow="2"
     inkscape:pageopacity="0.0"
     borderopacity="1.0"
     bordercolor="#666666"
     pagecolor="#ffffff"
     id="base" />
  <metadata
     id="metadata5">
    <rdf:RDF>
      <cc:Work
         rdf:about="">
        <dc:format>image/svg+xml</dc:format>
        <dc:type
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
        <dc:title></dc:title>
      </cc:Work>
    </rdf:RDF>
  </metadata>
  <g
     id="layer1"
     inkscape:groupmode="layer"
     inkscape:label="Layer 1">
    <rect
       y="27.850878"
       x="30.9841"
       height="100"
       width="100"
       id="rect833"
       style="fill:none;fill-rule:evenodd;stroke-width:0.264583;stroke:#000000" />
    <g
       inkscape:export-filename="C:\Users\Steffen\Downloads\icon.png"
       inkscape:export-ydpi="76.470062"
       inkscape:export-xdpi="76.470062"
       transform="translate(0.28038671,-8.7785167)"
       id="g1587">
      <path
         style="fill:#ff7f2a;stroke:#000000;stroke-width:1.265;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
         d="m 51.187897,115.03922 h -9.645126 a 1.0583333,1.0583333 45 0 1 -1.058333,-1.05833 V 92.015494 a 2.8371012,2.8371012 110.45719 0 1 0.693135,-1.858104 l 9.6646,-11.151462 a 2.3177099,2.3177099 155.45719 0 1 1.751468,-0.799771 h 3.515027 a 1.0583333,1.0583333 135 0 0 1.058333,-1.058333 v -0.646052 a 1.0583333,1.0583333 135 0 1 1.058333,-1.058333 h 3.196252 a 1.0583333,1.0583333 45 0 1 1.058333,1.058333 v 0.646052 a 1.0583333,1.0583333 45 0 0 1.058333,1.058333 h 24.022892 a 1.0583333,1.0583333 135 0 0 1.058333,-1.058333 v -0.433533 a 1.0583333,1.0583333 135 0 1 1.058333,-1.058333 h 2.558701 a 1.0583333,1.0583333 45 0 1 1.058333,1.058333 v 27.210649 a 1.0583333,1.0583333 45 0 0 1.058333,1.05833 h 2.346186 a 1.0583333,1.0583333 135 0 0 1.058333,-1.05833 V 84.152373 A 1.0583333,1.0583333 135 0 1 98.816029,83.09404 H 109.8754 a 2.6276032,2.6276032 21.938351 0 1 1.82121,0.73354 l 1.23696,1.189382 a 8.0277569,8.0277569 51.386938 0 1 1.30959,1.639729 l 6.66344,11.044882 a 7.6857943,7.6857943 66.737493 0 1 0.82815,1.926414 l 0.28719,1.041043 a 8.0584969,8.0584969 82.059724 0 1 0.28991,2.07853 l 0.0893,11.16569 a 1.0509135,1.0509135 134.74319 0 1 -1.04986,1.05932 l -68.046747,0.0656 a 2194.6591,2194.6591 179.97237 0 1 -2.116666,0.001 z"
         id="path1019"
         sodipodi:nodetypes="cccccccccccccccccccc"
         inkscape:path-effect="#path-effect1021"
         inkscape:original-d="M 52.24623,115.03922 H 40.484438 V 90.957161 l 11.05087,-12.751004 h 5.631693 v -2.762718 h 5.312918 v 2.762718 h 26.139558 v -2.550199 h 4.675367 v 29.327312 h 4.462852 V 83.09404 h 13.176034 l 2.76272,2.656462 7.75686,12.85726 0.85007,3.081498 0.10626,13.28229 z" />
      <circle
         style="fill:#ffffff;stroke:#000000;stroke-width:1.26062;stroke-miterlimit:4;stroke-dasharray:none"
         id="path970"
         cx="59.292168"
         cy="113.90897"
         r="6.4276934" />
      <circle
         r="6.4276934"
         cy="113.90897"
         cx="75.549698"
         id="path970-3"
         style="fill:#ffffff;stroke:#000000;stroke-width:1.26062;stroke-miterlimit:4;stroke-dasharray:none" />
      <circle
         r="6.4276934"
         cy="113.90897"
         cx="104.34572"
         id="path970-8"
         style="fill:#ffffff;stroke:#000000;stroke-width:1.26062;stroke-miterlimit:4;stroke-dasharray:none" />
      <g
         id="g1498"
         transform="matrix(0.73289811,0,0,0.73289811,49.470895,34.705428)"
         style="stroke:#000000;stroke-width:1.75877;stroke-miterlimit:4;stroke-dasharray:none">
        <g
           id="g1437"
           style="stroke:#000000;stroke-width:1.75877;stroke-miterlimit:4;stroke-dasharray:none">
          <path
             style="fill:none;stroke:#000000;stroke-width:1.75877;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
             d="M 7.4380857,87.344376 H 1.9084002 a 0.61209288,0.61209288 59.956822 0 1 -0.5305486,-0.91734 l 3.9350666,-6.839521"
             id="path1023"
             inkscape:path-effect="#path-effect1025"
             inkscape:original-d="M 7.4380857,87.344376 H 0.85006692 L 5.3129182,79.587515"
             sodipodi:nodetypes="ccc" />
          <path
             style="fill:none;stroke:#000000;stroke-width:1.75877;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
             d="m 2.4439424,80.225066 2.8689758,-0.637551 0.8500671,2.868978"
             id="path1433" />
        </g>
        <g
           transform="rotate(119.35791,10.50695,81.623512)"
           id="g1437-1"
           style="stroke:#000000;stroke-width:1.75877;stroke-miterlimit:4;stroke-dasharray:none">
          <path
             sodipodi:nodetypes="ccc"
             inkscape:original-d="M 7.4380857,87.344376 H 0.85006692 L 5.3129182,79.587515"
             inkscape:path-effect="#path-effect1462"
             id="path1023-1"
             d="M 7.4380857,87.344376 H 1.9084002 a 0.61209288,0.61209288 59.956822 0 1 -0.5305486,-0.91734 l 3.9350666,-6.839521"
             style="fill:none;stroke:#000000;stroke-width:1.75877;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
          <path
             id="path1433-9"
             d="m 2.4439424,80.225066 2.8689758,-0.637551 0.8500671,2.868978"
             style="fill:none;stroke:#000000;stroke-width:1.75877;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
        </g>
        <g
           transform="rotate(-121.676,10.13113,81.832664)"
           id="g1437-0"
           style="stroke:#000000;stroke-width:1.75877;stroke-miterlimit:4;stroke-dasharray:none">
          <path
             sodipodi:nodetypes="ccc"
             inkscape:original-d="M 7.4380857,87.344376 H 0.85006692 L 5.3129182,79.587515"
             inkscape:path-effect="#path-effect1487"
             id="path1023-2"
             d="M 7.4380857,87.344376 H 1.9084002 a 0.61209288,0.61209288 59.956822 0 1 -0.5305486,-0.91734 l 3.9350666,-6.839521"
             style="fill:none;stroke:#000000;stroke-width:1.75877;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
          <path
             id="path1433-5"
             d="m 2.4439424,80.225066 2.8689758,-0.637551 0.8500671,2.868978"
             style="fill:none;stroke:#000000;stroke-width:1.75877;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
        </g>
      </g>
      <g
         id="g1552"
         transform="matrix(1.4800931,0,0,1.4800931,47.873458,-47.901715)"
         style="stroke-width:0.86481;stroke-miterlimit:4;stroke-dasharray:none">
        <g
           style="stroke-width:1.15062;stroke-miterlimit:4;stroke-dasharray:none"
           transform="matrix(0.75160641,0,0,0.75160641,32.706105,9.6100167)"
           id="g1546">
          <path
             style="fill:none;stroke:#000000;stroke-width:1.15062;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
             d="m -16.624721,109.12637 c -1.601898,-0.0814 -2.913547,-1.00132 -5.386564,1.43383 2.190262,1.9655 3.470162,4.14618 0.03875,7.44044 1.756769,2.0041 3.818483,1.90281 5.575252,1.35145"
             id="path1514"
             sodipodi:nodetypes="cccc" />
          <path
             sodipodi:nodetypes="cccc"
             id="path1514-7"
             d="m -16.779733,109.12637 c 1.601898,-0.0814 2.913547,-1.00132 5.386564,1.43383 -2.190262,1.9655 -3.470162,4.14618 -0.03875,7.44044 -1.756769,2.0041 -3.64907,1.86893 -5.405839,1.31757"
             style="fill:none;stroke:#000000;stroke-width:1.15062;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
          <path
             style="fill:none;stroke:#000000;stroke-width:1.15062;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
             d="m -16.624721,109.12637 c 0.252234,-1.80622 0.936207,-2.8569 2.015118,-3.21644"
             id="path1531"
             sodipodi:nodetypes="cc" />
        </g>
      </g>
    </g>
    <rect
       inkscape:export-ydpi="76.470062"
       inkscape:export-xdpi="76.470062"
       inkscape:export-filename="C:\Users\Steffen\Downloads\icon.png"
       y="38.557396"
       x="90.170799"
       height="23.11268"
       width="22.177485"
       id="rect1595"
       style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.30153;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal" />
    <g
       inkscape:export-ydpi="76.470062"
       inkscape:export-xdpi="76.470062"
       inkscape:export-filename="C:\Users\Steffen\Downloads\icon.png"
       transform="matrix(1.0288738,0,0,1.0288738,152.56168,-3.0253884)"
       id="g1623">
      <rect
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal"
         id="rect1597"
         width="1.9477507"
         height="1.9477507"
         x="-57.393719"
         y="49.148247" />
      <rect
         y="53.530685"
         x="-57.393723"
         height="1.9477507"
         width="1.9477507"
         id="rect1597-8"
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal" />
      <rect
         y="57.913124"
         x="-57.458649"
         height="1.9477507"
         width="1.9477507"
         id="rect1597-3"
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal" />
    </g>
    <g
       inkscape:export-ydpi="76.470062"
       inkscape:export-xdpi="76.470062"
       inkscape:export-filename="C:\Users\Steffen\Downloads\icon.png"
       transform="matrix(1.0288738,0,0,1.0288738,157.13745,-3.0253884)"
       id="g1623-6">
      <rect
         y="49.148247"
         x="-57.393719"
         height="1.9477507"
         width="1.9477507"
         id="rect1597-2"
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal" />
      <rect
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal"
         id="rect1597-8-8"
         width="1.9477507"
         height="1.9477507"
         x="-57.393723"
         y="53.530685" />
      <rect
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal"
         id="rect1597-3-6"
         width="1.9477507"
         height="1.9477507"
         x="-57.458649"
         y="57.913124" />
    </g>
    <g
       inkscape:export-ydpi="76.470062"
       inkscape:export-xdpi="76.470062"
       inkscape:export-filename="C:\Users\Steffen\Downloads\icon.png"
       transform="matrix(1.0288738,0,0,1.0288738,161.71323,-3.0253884)"
       id="g1623-4">
      <rect
         y="49.148247"
         x="-57.393719"
         height="1.9477507"
         width="1.9477507"
         id="rect1597-0"
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal" />
      <rect
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal"
         id="rect1597-8-6"
         width="1.9477507"
         height="1.9477507"
         x="-57.393723"
         y="53.530685" />
      <rect
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal"
         id="rect1597-3-1"
         width="1.9477507"
         height="1.9477507"
         x="-57.458649"
         y="57.913124" />
    </g>
    <g
       inkscape:export-ydpi="76.470062"
       inkscape:export-xdpi="76.470062"
       inkscape:export-filename="C:\Users\Steffen\Downloads\icon.png"
       transform="matrix(1.0288738,0,0,1.0288738,166.28901,-3.0253884)"
       id="g1623-5">
      <rect
         y="49.148247"
         x="-57.393719"
         height="1.9477507"
         width="1.9477507"
         id="rect1597-03"
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal" />
      <rect
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal"
         id="rect1597-8-81"
         width="1.9477507"
         height="1.9477507"
         x="-57.393723"
         y="53.530685" />
      <rect
         style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.265;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:5.6;stroke-dasharray:none;paint-order:normal"
         id="rect1597-3-8"
         width="1.9477507"
         height="1.9477507"
         x="-57.458649"
         y="57.913124" />
    </g>
    <circle
       inkscape:export-ydpi="76.470062"
       inkscape:export-xdpi="76.470062"
       inkscape:export-filename="C:\Users\Steffen\Downloads\icon.png"
       r="9.4187517"
       cy="59.465694"
       cx="115.08707"
       id="path1589"
       style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.31696;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none" />
    <path
       inkscape:export-ydpi="76.470062"
       inkscape:export-xdpi="76.470062"
       inkscape:export-filename="C:\Users\Steffen\Downloads\icon.png"
       id="path1593"
       d="m 114.88667,52.718928 v 6.679965 l 4.37538,4.375378"
       style="fill:none;stroke:#000000;stroke-width:2.02174;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
    <path
       inkscape:export-ydpi="76.470062"
       inkscape:export-xdpi="76.470062"
       inkscape:export-filename="C:\Users\Steffen\Downloads\icon.png"
       id="path1673"
       d="M 90.237602,44.302171 H 112.28149"
       style="fill:none;stroke:#000000;stroke-width:1.30153;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
    <g
       inkscape:export-ydpi="76.470062"
       inkscape:export-xdpi="76.470062"
       inkscape:export-filename="C:\Users\Steffen\Downloads\icon.png"
       transform="matrix(1.0288738,0,0,1.0288738,152.62848,-2.9919858)"
       id="g1708">
      <path
         style="fill:none;stroke:#000000;stroke-width:1.665;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
         d="m -56.549697,38.208377 v 4.739526"
         id="path1675" />
      <path
         id="path1675-8"
         d="m -52.134793,38.208378 v 4.739526"
         style="fill:none;stroke:#000000;stroke-width:1.665;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
      <path
         id="path1675-81"
         d="m -47.719893,38.208378 v 4.739526"
         style="fill:none;stroke:#000000;stroke-width:1.665;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
      <path
         id="path1675-5"
         d="m -43.304991,38.208378 v 4.739526"
         style="fill:none;stroke:#000000;stroke-width:1.665;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
    </g>
  </g>
</svg>

images/icon.png METASEP

doc/upcoming_details.png METASEP

doc/next-collections-date-and-days.png METASEP

doc/next-collection-type.png METASEP

doc/more-info-upcoming.png METASEP

doc/more-info-generic.png METASEP

doc/more-info-appointment-types.png METASEP

doc/default-entity.png METASEP

doc/days-to-next-collections.png METASEP

doc/date-of-next-collections.png METASEP

doc/configuration.yaml METASEP
waste_collection_schedule:
  sources:
    - name: muellmax_de
      args:
        service: His
        mm_frm_str_sel: <my_street>
      customize:
        - type: brown bin
          show: no
        - type: green bin
          alias: Yard Waste
        - type: black bin
          alias: Garbage

sensor:
  # next collection
  - platform: waste_collection_schedule
    name: next_collection
    
  # next yard waste collection
  - platform: waste_collection_schedule
    name: next_yard_collection
    types:
      - Yard Waste
    
  # next garbage collection
  - platform: waste_collection_schedule
    name: next_garbage_collection
    types:
      - Garbage

doc/calendar.png METASEP

doc/button-cards.png METASEP

info.md METASEP
# Waste Collection Schedule

Waste Collection Schedule provides schedules from waste collection service providers to Home Assistant. Additionally, it supports schedules from generic ICS files which can be stored locally or fetched from a web site. There is a high flexibility in providing the information to be displayed.

## Examples

Per default (without further configuration), the time to the next collection will be shown in an [entity card](https://www.home-assistant.io/lovelace/entity/):

![Default Lovelace Card](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/default-entity.png)

You can also setup dedicated entities per waste type and show the schedule in various formats:

![Days to next collections](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/days-to-next-collections.png)
![Date of next collections](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/date-of-next-collections.png)
![Date and days to next collections](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/next-collections-date-and-days.png)

The information in the more-info popup can be displayed in different formats:

1. List of upcoming collections:

   ![More info: upcoming](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/more-info-upcoming.png)

2. List of waste types and collection date:

   ![More info: waste types](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/more-info-appointment-types.png)

[Button Card](https://github.com/custom-cards/button-card) can be used to create individual Lovelace cards:

![Button Card](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/button-cards.png)

## Documentation

- [Full Documentation](https://github.com/mampfes/hacs_waste_collection_schedule)

## Supported Service Providers

Currently the following service providers are supported:

- [Generic ICS / iCal File](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/ics.md)

### Australia

- [Banyule City Council](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/banyule_vic_gov_au.md)
- [Brisbane City Council](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/brisbane_qld_gov_au.md)
- [North Adelaide Waste Management Authority, South Australia](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/nawma_sa_gov_au.md)
- [The Hills Council, Sydney](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/thehills_nsw_gov_au.md)
- [Macedon Ranges Shire Council, Melbourne](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/mrsc_vic_gov_au.md)
- [Stonnington City Council, Melbourne](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/stonnington_vic_gov_au.md)

### Austria

- [Data.Umweltprofis](https://github.com/mampfes/hacs_waste_collection_schedule/doc/source/data_umweltprofis_at.md)

### Belgium

- [Hygea](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/hygea_be.md)
- [Recycle! / RecycleApp.be](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/recycleapp_be.md)

### Germany

- [Abfall.IO / AbfallPlus.de](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/abfall_io.md)
- [AbfallNavi.de (RegioIT.de)](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/abfallnavi_de.md)
- [Abfallkalender Würzburg](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/wuerzburg_de.md)
- [Abfallwirtschaft Rendsburg](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/awr_de.md)
- [Abfallwirtschaft Stuttgart](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/stuttgart_de.md)
- [Abfallwirtschaft Südholstein](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/awsh_de.md)
- [Abfallwirtschaft Zollernalbkreis](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/abfall_zollernalbkreis_de.md)
- [AWB Bad Kreuznach](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/awb_bad_kreuznach_de.md)
- [AWBKoeln.de](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/awbkoeln_de.md)
- [AWIDO-online.de](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/awido_de.md)
- [Berlin-Recycling.de](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/berlin_recycling_de.md)
- [BSR.de / Berliner Stadtreinigungsbetriebe](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/bsr_de.md)
- [EGN-Abfallkalender.de](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master//doc/source/egn_abfallkalender_de.md)
- [Jumomind.de](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/jumomind_de.md)
- [KWB-Goslar.de](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/kwb_goslar_de.md)
- [Landkreis-Wittmund.de](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/landkreis_wittmund_de.md)
- [Muellmax.de](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/muellmax_de.md)
- [MyMuell App](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/jumomind_de.md)
- [Rhein-Hunsrück Entsorgung (RHE)](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/rh_entsorgung_de.md)
- [Sector27.de](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/sector27_de.md)
- [Stadtreinigung.Hamburg](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/stadtreinigung_hamburg.md)
- [Stadtreinigung-Leipzig.de](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/stadtreinigung_leipzig_de.md)
- [WAS Wolfsburg](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/was_wolfsburg_de.md)

### Netherlands

- [Ximmio](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/ximmio_nl.md)
- [HVCGroep](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/hvcgroep_nl.md)

### New Zealand

- [Auckland](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/aucklandcouncil_govt_nz.md)
- [Christchurch](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/ccc_govt_nz.md)
- [Gore, Invercargill & Southland](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/wastenet_org_nz.md)
- [Wellington](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/wellington_govt_nz.md)

### Sweden

- [Lerum.se](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/lerum_se.md)
- [Sysav.se](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/sysav_se.md)

### Switzerland

- [Lindau.ch](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/lindau_ch.md)

### United States of America

- [PGH.ST](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/pgh_st.md)
- [Seattle Public Utilities](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/seattle_gov.md)

### United Kingdom

- [Cambridge.gov.uk](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/cambridge_gov_uk.md)
- [Colchester.gov.uk](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/colchester_gov_uk.md)
- [York.gov.uk](https://github.com/mampfes/hacs_waste_collection_schedule/blob/master/doc/source/york_gov_uk.md)

hacs.json METASEP
{
  "name": "Waste Collection Schedule",
  "domains": ["sensor"],
  "iot_class": "cloud_polling"
}

README.md METASEP
# Waste Collection Schedule

Waste Collection Schedule provides schedules from waste collection service providers to Home Assistant. Additionally, it supports schedules from generic ICS files which can be stored locally or fetched from a web site. There is a high flexibility in providing the information to be displayed.

*For developers:* This framework can be easily enhanced to support further waste collection service providers or other services which provide schedules.

If you like this component, please give it a star on [github](https://github.com/mampfes/hacs_waste_collection_schedule).

## Table of Contents

- [Examples](#examples)
- [Supported Service Providers](#supported-service-providers)
- [Installation](#installation)
- [Configuration](#configuration)
- [FAQ](#faq)
- *For developers*: [How to add new sources](#how-to-add-new-sources)

## Examples

A complete example can be found [here](./doc/configuration.yaml).

Per default (without further configuration), the time to the next collection will be shown in an [entity card](https://www.home-assistant.io/lovelace/entity/):

![Default Lovelace Card](./doc/default-entity.png)

You can also setup dedicated entities per waste type and show the schedule in various formats:

![Days to next collections](./doc/days-to-next-collections.png)
![Date of next collections](./doc/date-of-next-collections.png)
![Date and days to next collections](./doc/next-collections-date-and-days.png)

The information in the more-info popup can be displayed in different formats:

1. List of upcoming collections:

   ![More info: upcoming](./doc/more-info-upcoming.png)

2. List of waste types and collection date:

   ![More info: waste types](./doc/more-info-appointment-types.png)

[Button Card](https://github.com/custom-cards/button-card) can be used to create individual Lovelace cards:

![Button Card](./doc/button-cards.png)

The collection schedule will be automatically integrated into the Home Assistant calendar:
![Calendar](./doc/calendar.png)

## Supported Service Providers

Currently the following service providers are supported:

- [Generic ICS / iCal File](./doc/source/ics.md)

### Australia

- [Banyule City Council](./doc/source/banyule_vic_gov_au.md)
- [Brisbane City Council](./doc/source/brisbane_qld_gov_au.md)
- [North Adelaide Waste Management Authority, South Australia](./doc/source/nawma_sa_gov_au.md)
- [The Hills Council, Sydney](./doc/source/thehills_nsw_gov_au.md)
- [Macedon Ranges Shire Council, Melbourne](./doc/source/mrsc_vic_gov_au.md)
- [Stonnington City Council, Melbourne](./doc/source/stonnington_vic_gov_au.md)

### Austria

- [Data.Umweltprofis](./doc/source/data_umweltprofis_at.md)

### Belgium

- [Hygea.be](./doc/source/hygea_be.md)
- [Recycle! / RecycleApp.be](./doc/source/recycleapp_be.md)

### Germany

- [Abfall.IO / AbfallPlus.de](./doc/source/abfall_io.md)
- [AbfallNavi.de (RegioIT.de)](./doc/source/abfallnavi_de.md)
- [Abfallkalender Würzburg](./doc/source/wuerzburg_de.md)
- [Abfallwirtschaft Rendsburg](./doc/source/awr_de.md)
- [Abfallwirtschaft Stuttgart](./doc/source/stuttgart_de.md)
- [Abfallwirtschaft Südholstein](./doc/source/awsh_de.md)
- [Abfallwirtschaft Zollernalbkreis](./doc/source/abfall_zollernalbkreis_de.md)
- [AWB Bad Kreuznach](./doc/source/awb_bad_kreuznach_de.md)
- [AWBKoeln.de](./doc/source/awbkoeln_de.md)
- [AWIDO-online.de](./doc/source/awido_de.md)
- [Berlin-Recycling.de](./doc/source/berlin_recycling_de.md)
- [BSR.de / Berliner Stadtreinigungsbetriebe](./doc/source/bsr_de.md)
- [EGN-Abfallkalender.de](./doc/source/egn_abfallkalender_de.md)
- [Jumomind.de](./doc/source/jumomind_de.md)
- [KWB-Goslar.de](./doc/source/kwb_goslar_de.md)
- [Landkreis-Wittmund.de](./doc/source/landkreis_wittmund_de.md)
- [Muellmax.de](./doc/source/muellmax_de.md)
- [MyMuell App](./doc/source/jumomind_de.md)
- [Rhein-Hunsrück Entsorgung (RHE)](./doc/source/rh_entsorgung_de.md)
- [Sector27.de](./doc/source/sector27_de.md)
- [Stadtreinigung.Hamburg](./doc/source/stadtreinigung_hamburg.md)
- [Stadtreinigung-Leipzig.de](./doc/source/stadtreinigung_leipzig_de.md)
- [WAS Wolfsburg](./doc/source/was_wolfsburg_de.md)

### Netherlands

- [Ximmio](./doc/source/ximmio_nl.md)
- [HVCGroep](./doc/source/hvcgroep_nl.md)

### New Zealand

- [Auckland](./doc/source/aucklandcouncil_govt_nz.md)
- [Christchurch](./doc/source/ccc_govt_nz.md)
- [Gore, Invercargill & Southland](./doc/source/wastenet_org_nz.md)
- [Wellington](./doc/source/wellington_govt_nz.md)

### Sweden

- [Lerum.se](./doc/source/lerum_se.md)
- [Sysav.se](./doc/source/sysav_se.md)

### Switzerland

- [Lindau.ch](./doc/source/lindau_ch.md)

### United States of America

- [PGH.ST](./doc/source/pgh_st.md)
- [Seattle Public Utilities](./doc/source/seattle_gov.md)

### United Kingdom

- [Cambridge.gov.uk](./doc/source/cambridge_gov_uk.md)
- [Colchester.gov.uk](./doc/source/colchester_gov_uk.md)
- [York.gov.uk](./doc/source/york_gov_uk.md)

## Installation

1. Ensure that [HACS](https://github.com/hacs/integration) is installed.
2. Install the "Waste Collection Schedule" integration.
3. [Configure the integration](#configuration).
4. Restart Home Assistant.

In case you would like to install manually:

1. Copy the folder `waste_collection_schedule` to `custom_components` in your Home Assistant `config` folder.
2. [Configure the integration](#configuration).
3. Restart Home Assistant.

## Configuration

The configuration consists of two entries in the file `configuration.yaml`:

1. Source configuration

   For each service provider, a source has to be added to the configuration. The source takes care of the arguments which are required to get the correct information from the service providers' web page, e.g. district, city, street, house number, etc.

   If you have to fetch data from multiple service providers, you have to add multiple sources. You can also add the same service provider multiple times (which only makes sense if you use this with different arguments), e.g. if you are looking for displaying the waste collection schedules for multiple districts.

2. Sensor configuration

   A sensor is used to visualize the retrieved information, e.g. waste type, next collection date or number of days to next collection. The sensor state (which is shown in a Lovelace card) can be customized using templates. As an example, you may display the collection type only or the next collection date or a combination of all available information.

   You can also add multiple sensors per source if you are going to display the information in separate entities like the available collection types or the next collection date.

   If you are looking for displaying one entity per collection type, you just have to add one sensor per collection type.

## 1. Configure the source(s)

```yaml
waste_collection_schedule:
  sources:
    - name: SOURCE
      args:
        SOURCE_SPECIFIC_ARGUMENTS
      customize:
        - type: TYPE
          alias: ALIAS
          show: SHOW
          icon: ICON
          picture: PICTURE
      calendar_title: CALENDAR_TITLE
  fetch_time: FETCH_TIME
  random_fetch_time_offset: RANDOM_FETCH_TIME_OFFSET
  day_switch_time: DAY_SWITCH_TIME
  separator: SEPARATOR
```

### Configuration Variables

**sources**

*(list) (required)*

List of service providers (waste collectors). See [Source Configuration Variables](#source-configuration-variables) for a list of available configuration variables.

**fetch_time**

*(time) (optional, default: ```"01:00"```)*

Time of day when to fetch new data from the source. Data will be fetched once per day.

**random_fetch_time_offset**

*(int) (optional, default: ```60```)*

Random offset to the `fetch_time` in minutes. Used to distribute the fetch commands of all Home Assistant instances over a larger period of time to avoid peak loads at the service providers.

**day_switch_time**

*(time) (optional, default: ```"10:00"```)*

Time of day when today's collection is going to expire and hence will not be displayed anymore.

How it works: If you set the ```day_switch_time``` to 10:00 the sensor will display today's collections until 10:00. After 10:00, today's collections will not be displayed anymore.

**separator**

*(string) (optional, default: ```", "```)*

Used to join entries if there are multiple entries for one day (n/a if value_templates are used).

### Source Configuration Variables

**name**

*(string) (required)*

Name of the source (service provider). Equates to the file name (without ```.py```) of the source. See [Supported Service Providers](#supported-service-providers) for a list of available sources.

**args**

*(dict) (optional)*

Source (service provider) specific arguments, e.g. district, city, street, waste type, etc. See [Supported Service Providers](#supported-service-providers) for details.

**customize**

*(dict) (optional)*

Used to customize the retrieved data from a source (service provider). See [Customize Source](#customize-source) for a list of available configuration variables.

**calendar_title**

*(string) (optional)*

Alternative title for source in Home Assistant calendar.

### Customize Source

Used to customize the retrieved data from a source (service provider).

**type**

*(dict) (required)*

Type of waste as it has been retrieved by the source (service provider).

**alias**

*(string) (optional, default: ```None```)*

Optional, usually better readable name for type of waste to be collected.

**show**

*(boolean) (optional, default: ```True```)*

Show or hide collections with the given waste type.

**icon**

*(string) (optional, default: ```None```)*

Alternative icon for waste type.

**picture**

*(string) (optional, default: ```None```)*

Optional picture for waste type.

## 2. Add sensor(s) to a source

Add the following lines to your `configuration.yaml` file:

```yaml
sensor:
  - platform: waste_collection_schedule
    source_index: SOURCE_INDEX
    name: NAME
    details_format: DETAILS_FORMAT
    count: COUNT
    leadtime: LEADTIME
    value_template: VALUE_TEMPLATE
    date_template: DATE_TEMPLATE
    add_days_to: ADD_DAYS_TO
    types:
      - Waste Type 1
      - Waste Type 2
```

### Configuration Variables

**source_index**

*(integer) (optional, default: ```0```)*

Reference to source (service provider). Used to assign a sensor to a specific source. Only required if you defined more than one source. The first defined source has the source_index 0, the second source 1, ...

**name**

*(string) (required)*

Name of the sensor.

**details_format**

*(string) (optional, default: ```"upcoming"```)*

Used to specify the format of the information displayed in the more-info popup of a Lovelace card.

Possible choices:

- ```upcoming``` shows a list of upcoming collections.

  ![Upcoming](./doc/more-info-upcoming.png)

- ```appointment_types``` shows a list of waste types and their next collection date.

  ![Waste Types](/doc/more-info-appointment-types.png)

- ```generic``` provides all attributes as generic Python data types. This can be used by a specialized Lovelace card (which doesn't exist so far).<br>
  
  ![Generic](./doc/more-info-generic.png)

**count**

*(integer) (optional, default = infinite)*

Used to limit the number of collections displayed in the more-info popup of a Lovelace card by ```count```.

**leadtime**

*(integer) (optional, default = infinite)*

Used to limit the number of collections displayed in the more-info popup of a Lovelace card. Only collections within the next ```leadtime``` days will be displayed.

**value_template**

*(string) (optional)*

Template string used to format the state of an entity.

See [Template Variables](#template-variables) for a list of available variables.

**date_template**

*(string) (optional)*

Template string used to format collection dates within the more-info popup.

See [Template Variables](#template-variables) for a list of available variables.

**add_days_to**

*(boolean) (optional, default: ```False```)*

Adds an attribute with the label `daysTo` and the number of days to the next collection to the entity state of the source.

**types**

*(list of strings) (optional)*

Used to filter waste types. The sensor will only display collections with these type(s).

## Template Variables

The following variables can be used within `value_template` and `date_template`:

Variable | Description | Type | Comments
--|--|--|--
```value.date``` | Collection date | [datetime.date](https://docs.python.org/3/library/datetime.html#datetime.date) | Use [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) to format the output.
```value.daysTo``` | Days to collection | int | 0 = today, 1 = tomorrow, ...
```value.types``` | Waste types | list of strings | Use `join` filter to join types.

## FAQ

### 1. How do I format dates?

Use [strftime](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) in `value_template` or `date_template`:

```yaml
# returns "20.03.2020"
value_template: '{{value.date.strftime("%d.%m.%Y")}}'
date_template: '{{value.date.strftime("%d.%m.%Y")}}'

# returns "03/20/2020"
value_template: '{{value.date.strftime("%m/%d/%Y")}}'
date_template: '{{value.date.strftime("%m/%d/%Y")}}'

# returns "Fri, 03/20/2020"
value_template: '{{value.date.strftime("%a, %m/%d/%Y")}}'
date_template: '{{value.date.strftime("%a, %m/%d/%Y")}}'
```

### 2. How do I show the number of days to the next collection?

Set `value_template` within the sensor configuration:

```yaml
value_template: 'in {{value.daysTo}} days'
```

### 3. How do I show *Today* / *Tomorrow* instead of *in 0/1 days*?

Set `value_template` within the sensor configuration:

```yaml
# returns "Today" if value.daysTo == 0
# returns "Tomorrow" if value.daysTo == 1
# returns "in X days" if value.daysTo > 1
value_template: '{% if value.daysTo == 0 %}Today{% elif value.daysTo == 1 %}Tomorrow{% else %}in {{value.daysTo}} days{% endif %}'
```

### 4. How do I join waste types in a `value_template`?

Use the `join` filter:

```yaml
# returns "Garbage, Recycle"
value_template: '{{value.types|join(", ")}}'

# returns "Garbage+Recycle"
value_template: '{{value.types|join("+")}}'
```

Note: If you don't specify a `value_template`, waste types will be joined using the `separator` configuration variable.

### 5. How do I setup a sensor which shows only the days to the next collection?

Set `value_template` within the sensor configuration:

```yaml
value_template: '{{value.daysTo}}'
```

### 6. How do I setup a sensor which shows only the date of the next collection?

Set `value_template` within the sensor configuration:

```yaml
value_template: '{{value.date.strftime("%m/%d/%Y")}}'
```

### 7. How do I configure a sensor which shows only the waste type of the next collection?

Set `value_template` within the sensor configuration:

```yaml
value_template: '{{value.types|join(", ")}}'
```

### 8. How do I configure a sensor to show only collections of a specific waste type?

Set `types` within the sensor configuration:

```yaml
sensor:
  - platform: waste_collection_schedule
    name: next_garbage_collection
    types:
      - Garbage

  - platform: waste_collection_schedule
    name: next_recycle_collection
    types:
      - Recycle
```

Note: If you have set an alias for a waste type, you must use the alias name.

### 9. How can I rename an waste type?

Set `alias` in the customize section of a source:

```yaml
waste_collection_schedule:
  sources:
    - name: NAME
      customize:
        - type: Very long garbage name
          alias: Garbage
        - type: Very long recycle name
          alias: Recycle
```

### 10. How can I hide inappropriate waste types?

Set `show` configuration variable to *false* in the customize section of a source:

```yaml
waste_collection_schedule:
  sources:
    - name: NAME
      customize:
        - type: Inappropriate Waste Type
          show: false
```

### 11. How do I show a colored Lovelace card depending on the due date?

You can use [Button Card](https://github.com/custom-cards/button-card) to create a colored Lovelace cards:

![Button Card](./doc/button-cards.png)

```yaml
# configuration.yaml
sensor:
  - platform: waste_collection_schedule
    name: MyButtonCardSensor
    value_template: '{{value.types|join(", ")}}|{{value.daysTo}}|{{value.date.strftime("%d.%m.%Y")}}|{{value.date.strftime("%a")}}'
```

```yaml
# button-card configuration
type: 'custom:button-card'
entity: sensor.mybuttoncardsensor
layout: icon_name_state2nd
show_label: true
label: |
  [[[
    var days_to = entity.state.split("|")[1]
    if (days_to == 0)
    { return "Today" }
    else if (days_to == 1)
    { return "Tomorrow" }
    else
    { return "in " + days_to + " days" }
  ]]]
show_name: true
name: |
  [[[
    return entity.state.split("|")[0]
  ]]]
state:
  - color: red
    operator: template
    value: '[[[ return entity.state.split("|")[1] == 0 ]]]'
  - color: orange
    operator: template
    value: '[[[ return entity.state.split("|")[1] == 1 ]]]'
  - value: default
```

### 12. Can I also use the **Garbage Collection Card** instead?

Yes, the [Garbage Collection Card](https://github.com/amaximus/garbage-collection-card) can also be used with *Waste Collection Schedule*:

```yaml
# configuration.yaml
sensor:
  - platform: waste_collection_schedule
    name: garbage_days
    details_format: appointment_types
    value_template: "{{ value.daysTo }}"
    types:
      - Garbage

  - platform: template
    sensors:
      garbage:
        value_template: >
          {% if states('sensor.garbage_days')|int > 2 %}
            2
          {% else %}
            {{ states('sensor.garbage_days')|int }}
          {% endif %}
        attribute_templates:
          next_date: "{{ state_attr('sensor.garbage_days', 'Garbage') }}"
          days: "{{ states('sensor.garbage_days')|int }}"
```

```yaml
# garbage-collection-card configuration
entity: sensor.garbage
type: 'custom:garbage-collection-card'
```

### 13. How can I sort waste type specific entities?

Prerequisites: You already have dedicated sensors per waste type and want to show the sensor with the next collection in a Lovelace card.

Add `add_days_to: True` to the configuration of all sensors you want to sort. This will add the attribute `daysTo` which can be used by e.g. [auto-entities](https://github.com/thomasloven/lovelace-auto-entities) to sort entities by day of next collection.

## How to add new sources

1. Create a new source in folder `custom_components/waste_collection_schedule/waste_collection_schedule/source` with the lower case url of your service provider (e.g. `abc_com.py` for `http://www.abc.com`).
2. Don't forget to add test cases (= sample data to query the service api).
3. Run `test_sources.py` script to ensure that your source works.
4. Add documentation in folder `docs/source` and add a link to your new source on `README.md` and `info.md`.

### Guidelines

- A source shall return data for all available waste types. A source shall **not** provide a configuration option to limit the returned waste types.
- A source shall return data for the entire available period (including past). A source shall **not** provide a configuration option to limit the requested period.

Filtering of data for waste types or time periods is a functionality of the framework and shall not be done by a source.

### Source Code Example

Example for `abc_com.py`:

```py
import datetime
from waste_collection_schedule import Collection


DESCRIPTION = "Example source for abc.com"  # Describe your source
URL = "abc.com"    # Insert url to service homepage
TEST_CASES = { # Insert arguments for test cases using test_sources.py script
    "TestName": {"arg1": 100, "arg2": "street"}
}


class Source:
    def __init__(self, arg1, arg2): # argX correspond to the args dict in the source configuration
        self._arg1 = arg1
        self._arg2 = arg2

    def fetch(self):
        entries = []

        entries.append(
            Collection(
                datetime.datetime(2020, 4, 11),
                "Waste Type",
            )
        )

        return entries
```

See also: [custom_components/waste_collection_schedule/waste_collection_schedule/source/example.py](./custom_components/waste_collection_schedule/waste_collection_schedule/source/example.py)

### Debugging

Debugging a source within Home Assistant is not recommended because startup of HA is far too slow for fast debugging cycles.

Instead, there is a test fixture which allows to run a source from the command line. The fixture is a Python script which is located here:

`custom_components/waste_collection_schedule/waste_collection_schedule/test/test_sources.py`.

The script uses the test cases defined in the source file and runs the source with the arguments of every test case.

By default (without additional arguments), the script tests every source file in the `source` folder and prints the number of found entries for every test case.

Example output for `abfall_io`:

```text
Testing source abfall_io ...
  found 269 entries for Waldenbuch
  found 55 entries for Landshut
  found 101 entries for Schoenmackers
  found 139 entries for Freudenstadt
  found 190 entries for Ludwigshafen am Rhein
```

The script supports the following options:

Option | Argument | Description
---- | ----- | ----
`-s` | SOURCE | [Source name](https://github.com/mampfes/hacs_waste_collection_schedule#source-configuration-variables) (source file name without ending `.py`)
`-l` | - | List all found dates
`-i` | - | Add icon name to output. Only effective together with `-l`.

For debugging purposes of a single source, it is recommended to use the `-s SOURCE` option.

Example for `abc_com.py`:

```bash
test_sources.py -s abc_com -l -i
```

## Videos

There is some video's on YouTube:

### German

- [Bunte Mülltonnenerinnerung mit Home Assistant](https://youtu.be/MzQgARDvRww)
- [Abfall Kalender in Home Assistant mit Erinnerung in Home Assistant](https://youtu.be/aCKLKGYiA7w)

Please note that all these videos are **not** created by the developer of this component and therefore may by outdated, point in the wrong direction or contain errors. If you have questions, please create an issue here on github. Do not ask your question in the YouTube comments because you may get wrong answers there.

custom_components/waste_collection_schedule/waste_collection_schedule/test/test.ics METASEP
BEGIN:VCALENDAR
PRODID:-//K4SYSTEMS//ABFALLPLUS//DE
VERSION:2.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-TIMEZONE:Europe/Berlin
X-WR-CALNAME:Ahornweg, Aichwald
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200104
DTEND;VALUE=DATE:20200105
DTSTAMP:20200506T091600Z
UID:70fb3078cce2327bf8c380ac8b069f3e
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200104
DTEND;VALUE=DATE:20200105
DTSTAMP:20200506T091600Z
UID:42a649f27f6f7f1b1049ccd0c93a16a0
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200111
DTEND;VALUE=DATE:20200112
DTSTAMP:20200506T091600Z
UID:dbf51fc06d67d4c8f55eb17714392639
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200111
DTEND;VALUE=DATE:20200112
DTSTAMP:20200506T091600Z
UID:042bdf42c69f8c1a3d3ec3c69de96113
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200117
DTEND;VALUE=DATE:20200118
DTSTAMP:20200506T091600Z
UID:95774858b3a60b8b19bf573a32682fe3
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200117
DTEND;VALUE=DATE:20200118
DTSTAMP:20200506T091600Z
UID:99137eaa5ebde2283f94697f111ee302
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200124
DTEND;VALUE=DATE:20200125
DTSTAMP:20200506T091600Z
UID:8c9e8ea61770f7a75656a20fa1523ee9
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200124
DTEND;VALUE=DATE:20200125
DTSTAMP:20200506T091600Z
UID:306a3da4f2248e29ebfa773f811b54a7
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200131
DTEND;VALUE=DATE:20200201
DTSTAMP:20200506T091600Z
UID:95512e1bb2b420d6ea87bb1fe84bce06
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200131
DTEND;VALUE=DATE:20200201
DTSTAMP:20200506T091600Z
UID:79dcdda2d9d6206fd32460b560f18885
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200207
DTEND;VALUE=DATE:20200208
DTSTAMP:20200506T091600Z
UID:76ec8bcc4ac6613d66cce65326fb6e3c
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200207
DTEND;VALUE=DATE:20200208
DTSTAMP:20200506T091600Z
UID:56346196dfcef7831db2f8b768a525e0
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200214
DTEND;VALUE=DATE:20200215
DTSTAMP:20200506T091600Z
UID:78b196b173dfde643c01e3eed8334821
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200214
DTEND;VALUE=DATE:20200215
DTSTAMP:20200506T091600Z
UID:82e977375d348db0a84dffd22b036b64
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200221
DTEND;VALUE=DATE:20200222
DTSTAMP:20200506T091600Z
UID:6db3e3696d3cd5f810a82eccff3c0935
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200221
DTEND;VALUE=DATE:20200222
DTSTAMP:20200506T091600Z
UID:b29af3034611a07d26fabdec11a753da
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200228
DTEND;VALUE=DATE:20200229
DTSTAMP:20200506T091600Z
UID:3f5735fe2dfd4bba1d37a24669730781
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200228
DTEND;VALUE=DATE:20200229
DTSTAMP:20200506T091600Z
UID:7d92935608bb672f02b0232bcaf13cdf
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200306
DTEND;VALUE=DATE:20200307
DTSTAMP:20200506T091600Z
UID:781b02a8d4f5b307f27a25231a885efb
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200306
DTEND;VALUE=DATE:20200307
DTSTAMP:20200506T091600Z
UID:eadcbf819692befa8ff8093b37959f21
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200313
DTEND;VALUE=DATE:20200314
DTSTAMP:20200506T091600Z
UID:ebf378c3353ed8ec75b247cfece6e64e
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200313
DTEND;VALUE=DATE:20200314
DTSTAMP:20200506T091600Z
UID:64710cc701a434b9c0526c027439c55a
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200320
DTEND;VALUE=DATE:20200321
DTSTAMP:20200506T091600Z
UID:90ad9a72e00883402eb5074704fe087d
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200320
DTEND;VALUE=DATE:20200321
DTSTAMP:20200506T091600Z
UID:8978063a65d2e5e18ef05c6983844107
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200327
DTEND;VALUE=DATE:20200328
DTSTAMP:20200506T091600Z
UID:6be838be654d8d565678965f1a940902
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200327
DTEND;VALUE=DATE:20200328
DTSTAMP:20200506T091600Z
UID:9fc24058c602f54ebdea746da7c6c959
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200403
DTEND;VALUE=DATE:20200404
DTSTAMP:20200506T091600Z
UID:107ccc968aae2b6bab91d9e7320656c5
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200403
DTEND;VALUE=DATE:20200404
DTSTAMP:20200506T091600Z
UID:e177e1b382bcdb885477fd5194e085f5
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200409
DTEND;VALUE=DATE:20200410
DTSTAMP:20200506T091600Z
UID:fd374560e276b7e4429dac02b9895f47
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200411
DTEND;VALUE=DATE:20200412
DTSTAMP:20200506T091600Z
UID:0dd271793864d5d245a5dfc084e0fd9b
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200418
DTEND;VALUE=DATE:20200419
DTSTAMP:20200506T091600Z
UID:0543e8b6078d9bc136c006a2c3ff18c9
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200418
DTEND;VALUE=DATE:20200419
DTSTAMP:20200506T091600Z
UID:069ebec91bd1d1e8e2c0c5d07d3ca290
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200424
DTEND;VALUE=DATE:20200425
DTSTAMP:20200506T091600Z
UID:5f16f5681cc341b1a3326fa40d66c772
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200424
DTEND;VALUE=DATE:20200425
DTSTAMP:20200506T091600Z
UID:d353eb91920812dfb95c534dbab00773
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200502
DTEND;VALUE=DATE:20200503
DTSTAMP:20200506T091600Z
UID:9231f5db7128614c941148772c4163b3
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200502
DTEND;VALUE=DATE:20200503
DTSTAMP:20200506T091600Z
UID:edb0c6a5182f3b6ca1631e9b729bbf19
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200508
DTEND;VALUE=DATE:20200509
DTSTAMP:20200506T091600Z
UID:a93927802458e7cfff94204839c2e22d
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200508
DTEND;VALUE=DATE:20200509
DTSTAMP:20200506T091600Z
UID:93e814fe6dc4d114f3cdf1bc03ddd45c
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200515
DTEND;VALUE=DATE:20200516
DTSTAMP:20200506T091600Z
UID:c29099f08e7168691b3e8d8668185df1
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200515
DTEND;VALUE=DATE:20200516
DTSTAMP:20200506T091600Z
UID:304fb66d5045c575bd69774455c06725
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200523
DTEND;VALUE=DATE:20200524
DTSTAMP:20200506T091600Z
UID:5ee1fa79f8817f0cea2ac3b26088512e
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200523
DTEND;VALUE=DATE:20200524
DTSTAMP:20200506T091600Z
UID:be47b8feec01ad61d08c42c9ec41233d
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200529
DTEND;VALUE=DATE:20200530
DTSTAMP:20200506T091600Z
UID:f1c4d074b477fae03c8f3c1d7b73bf1f
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200529
DTEND;VALUE=DATE:20200530
DTSTAMP:20200506T091600Z
UID:6524f6315835d5f3f008cf7ea96c061d
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200606
DTEND;VALUE=DATE:20200607
DTSTAMP:20200506T091600Z
UID:efb62407f0744a99a3dc7121d3c43bf5
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200606
DTEND;VALUE=DATE:20200607
DTSTAMP:20200506T091600Z
UID:24f47aa14b4a5d367a0ca60602db494c
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200613
DTEND;VALUE=DATE:20200614
DTSTAMP:20200506T091600Z
UID:23930b21e991e792d99f534cbb2f73ea
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200613
DTEND;VALUE=DATE:20200614
DTSTAMP:20200506T091600Z
UID:57f87c47076eb5c2b09546496c4ac61b
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200613
DTEND;VALUE=DATE:20200614
DTSTAMP:20200506T091600Z
UID:8aedc83c10d8832f5594f6a59412ef24
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200619
DTEND;VALUE=DATE:20200620
DTSTAMP:20200506T091600Z
UID:d22a21490b13486e0e46f3e875910e40
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200619
DTEND;VALUE=DATE:20200620
DTSTAMP:20200506T091600Z
UID:d0be28a6c04be0659676ca782a17286e
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200626
DTEND;VALUE=DATE:20200627
DTSTAMP:20200506T091600Z
UID:b194e4b2325c7b12c6d3b74936d6113b
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200626
DTEND;VALUE=DATE:20200627
DTSTAMP:20200506T091600Z
UID:42080fb2b7e933b0138c58dcb1aed4aa
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200626
DTEND;VALUE=DATE:20200627
DTSTAMP:20200506T091600Z
UID:87e0323fd6889335731b7f60e9517dcc
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200703
DTEND;VALUE=DATE:20200704
DTSTAMP:20200506T091600Z
UID:a0c8f40881ce91f016d59703054ebb0a
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200703
DTEND;VALUE=DATE:20200704
DTSTAMP:20200506T091600Z
UID:89b6c1924ce9a2549320c590b61e46f5
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200710
DTEND;VALUE=DATE:20200711
DTSTAMP:20200506T091600Z
UID:aad989574e72e3d6c114757252f2fdd6
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200710
DTEND;VALUE=DATE:20200711
DTSTAMP:20200506T091600Z
UID:a042b9af7b071f7a936c064ae6796b4c
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200710
DTEND;VALUE=DATE:20200711
DTSTAMP:20200506T091600Z
UID:574af75562b9d762437c3a4d6c30d531
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200717
DTEND;VALUE=DATE:20200718
DTSTAMP:20200506T091600Z
UID:3fefb5d0b3cca6e9d971f723eda24b01
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200717
DTEND;VALUE=DATE:20200718
DTSTAMP:20200506T091600Z
UID:f106bd84fb8a94664b703e2d6bd0b90e
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200724
DTEND;VALUE=DATE:20200725
DTSTAMP:20200506T091600Z
UID:ef32e27c1cbe265f1e46c83b55ea37d8
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200724
DTEND;VALUE=DATE:20200725
DTSTAMP:20200506T091600Z
UID:d0c07584a802b51bc9e15110601cac3d
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200724
DTEND;VALUE=DATE:20200725
DTSTAMP:20200506T091600Z
UID:88d1dc13a0586088b27429b75bd541cf
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200731
DTEND;VALUE=DATE:20200801
DTSTAMP:20200506T091600Z
UID:c4839da229e41e8966ddd9201e7ab996
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200731
DTEND;VALUE=DATE:20200801
DTSTAMP:20200506T091600Z
UID:e176f4c8505dbf39aa6cc8831ca5e969
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200807
DTEND;VALUE=DATE:20200808
DTSTAMP:20200506T091600Z
UID:a647cf41f86f1cc85bb42c54ae2bbd21
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200807
DTEND;VALUE=DATE:20200808
DTSTAMP:20200506T091600Z
UID:fc9bd1ce3bf3285d3d7ccf9ea35298fa
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200807
DTEND;VALUE=DATE:20200808
DTSTAMP:20200506T091600Z
UID:49dbcfe0157770a1438f3949f1ab7532
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200814
DTEND;VALUE=DATE:20200815
DTSTAMP:20200506T091600Z
UID:f61cc4b988d8dc6364c50f30b14408e9
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200814
DTEND;VALUE=DATE:20200815
DTSTAMP:20200506T091600Z
UID:c3a4558fd1765b776b1db959b3edfa9d
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200821
DTEND;VALUE=DATE:20200822
DTSTAMP:20200506T091600Z
UID:4e49ee125f7816e30549f7556af60622
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200821
DTEND;VALUE=DATE:20200822
DTSTAMP:20200506T091600Z
UID:faba777748dee4b168537640cd3f930a
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200821
DTEND;VALUE=DATE:20200822
DTSTAMP:20200506T091600Z
UID:d2dcc5a801a3d84217584027b29b61d0
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200828
DTEND;VALUE=DATE:20200829
DTSTAMP:20200506T091600Z
UID:a594633fea96358ec17019e8e8a39e0a
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200828
DTEND;VALUE=DATE:20200829
DTSTAMP:20200506T091600Z
UID:848729bb8b98d129a967fa028e5a60f8
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200904
DTEND;VALUE=DATE:20200905
DTSTAMP:20200506T091600Z
UID:823f2cd0c57aa86c1c2cf9587ad38171
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200904
DTEND;VALUE=DATE:20200905
DTSTAMP:20200506T091600Z
UID:5ae9d518c4afc199356d27ae82bc2e84
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200904
DTEND;VALUE=DATE:20200905
DTSTAMP:20200506T091600Z
UID:7904ea395b446353426cfba36e622bac
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200911
DTEND;VALUE=DATE:20200912
DTSTAMP:20200506T091600Z
UID:7b8d32cc7e9ee0eeaded20ffd84bc062
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200911
DTEND;VALUE=DATE:20200912
DTSTAMP:20200506T091600Z
UID:8c3df40ac514dca1c850e55d74f18720
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200918
DTEND;VALUE=DATE:20200919
DTSTAMP:20200506T091600Z
UID:59d313e46671680826c36ca373daa2f7
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200918
DTEND;VALUE=DATE:20200919
DTSTAMP:20200506T091600Z
UID:458dcfed643c1709d565aab19167afbf
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200918
DTEND;VALUE=DATE:20200919
DTSTAMP:20200506T091600Z
UID:797a1fa2b51b6373c507d1346ed53cdb
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200925
DTEND;VALUE=DATE:20200926
DTSTAMP:20200506T091600Z
UID:5ef76f8faf839fc4bc3eb1a948ab2c0f
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20200925
DTEND;VALUE=DATE:20200926
DTSTAMP:20200506T091600Z
UID:6649d5daf57560924b230e4e83d40880
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201002
DTEND;VALUE=DATE:20201003
DTSTAMP:20200506T091600Z
UID:9704877011294e3415c0e904abcda3eb
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201002
DTEND;VALUE=DATE:20201003
DTSTAMP:20200506T091600Z
UID:d81abfcc6c67e45cdebc66b535f6b7cb
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201009
DTEND;VALUE=DATE:20201010
DTSTAMP:20200506T091600Z
UID:4e2fc1f29c6382620c7b95c6823b435f
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201009
DTEND;VALUE=DATE:20201010
DTSTAMP:20200506T091600Z
UID:a62e4b131e5ce19463b344672109c263
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201016
DTEND;VALUE=DATE:20201017
DTSTAMP:20200506T091600Z
UID:460e0b43c873cb0e63a1838fe94119cd
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201016
DTEND;VALUE=DATE:20201017
DTSTAMP:20200506T091600Z
UID:9c09b6cae71cc5ea06a29b7a1edcdec2
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201023
DTEND;VALUE=DATE:20201024
DTSTAMP:20200506T091600Z
UID:0e2a76cd0f2d82dca380c6a8d61e6b1c
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201023
DTEND;VALUE=DATE:20201024
DTSTAMP:20200506T091600Z
UID:9b0abd615581977d8b432a3ed4a2dbaf
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201030
DTEND;VALUE=DATE:20201031
DTSTAMP:20200506T091600Z
UID:5564fa0b90ef2be6a89c87ce8675633a
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201030
DTEND;VALUE=DATE:20201031
DTSTAMP:20200506T091600Z
UID:9f8ba1d5b2144c23d47f04cda2284640
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201106
DTEND;VALUE=DATE:20201107
DTSTAMP:20200506T091600Z
UID:a4134c9c4f7c4a928abc1f764ad350d9
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201106
DTEND;VALUE=DATE:20201107
DTSTAMP:20200506T091600Z
UID:de7264e3d105a2fa6e61fae8d4a02ffa
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201113
DTEND;VALUE=DATE:20201114
DTSTAMP:20200506T091600Z
UID:53eb48c8627fa8b5762728bb90b6ec47
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201113
DTEND;VALUE=DATE:20201114
DTSTAMP:20200506T091600Z
UID:66a0c883ee750b690ebd7076c8325f5c
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201120
DTEND;VALUE=DATE:20201121
DTSTAMP:20200506T091600Z
UID:36d1bb1b577c362767e178d8ecd81951
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201120
DTEND;VALUE=DATE:20201121
DTSTAMP:20200506T091600Z
UID:2a0f80d5c37755e27969c03962718a85
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201127
DTEND;VALUE=DATE:20201128
DTSTAMP:20200506T091600Z
UID:a792172ef4c05d984747f8cf0d2f8cb3
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201127
DTEND;VALUE=DATE:20201128
DTSTAMP:20200506T091600Z
UID:6881a46402852fdfb3d656dc1095c1a9
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201204
DTEND;VALUE=DATE:20201205
DTSTAMP:20200506T091600Z
UID:e512e4e83430b48a58870e4ecf86f8cf
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201204
DTEND;VALUE=DATE:20201205
DTSTAMP:20200506T091600Z
UID:7632db8968caeb2dc66c7a0a27941187
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201211
DTEND;VALUE=DATE:20201212
DTSTAMP:20200506T091600Z
UID:9d3ae928e3b49d2cb10b61ccd1143db1
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201211
DTEND;VALUE=DATE:20201212
DTSTAMP:20200506T091600Z
UID:8534e986b8835b439cf00cc9e9743831
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 4-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201218
DTEND;VALUE=DATE:20201219
DTSTAMP:20200506T091600Z
UID:0d40142a6f44d8bed8500d25cefac854
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Gelbe/r Sack/Tonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201218
DTEND;VALUE=DATE:20201219
DTSTAMP:20200506T091600Z
UID:8f96610a10af3bad80fac955bdce908c
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Biotonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201224
DTEND;VALUE=DATE:20201225
DTSTAMP:20200506T091600Z
UID:c8042c71d5622dbf39b27605abaae6ae
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Papiertonne
TRANSP:TRANSPARENT
END:VEVENT
BEGIN:VEVENT
DTSTART;VALUE=DATE:20201224
DTEND;VALUE=DATE:20201225
DTSTAMP:20200506T091600Z
UID:bd862e622c71f65a8accfcd071d285da
CREATED:20200101T090000Z
DESCRIPTION:
LAST-MODIFIED:20200101T090000Z
LOCATION:Aichwald Ahornweg
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Restmüll 2-wöchentlich
TRANSP:TRANSPARENT
END:VEVENT
END:VCALENDAR
custom_components/waste_collection_schedule/waste_collection_schedule/test/recurring.ics METASEP
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//SabreDAV//SabreDAV//EN
X-WR-CALNAME:Sivom (zeguigui)
X-APPLE-CALENDAR-COLOR:#748be7
BEGIN:VEVENT
CREATED:20200509T073126Z
DTSTAMP:20200509T073349Z
LAST-MODIFIED:20200509T073349Z
UID:L911Y7UJ5NRKDSS4ARHOC
SUMMARY:Poubelle jaune
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TH
SEQUENCE:2
DESCRIPTION:yellow
DTSTART;VALUE=DATE:20200402
DTEND;VALUE=DATE:20200403
END:VEVENT
BEGIN:VEVENT
CREATED:20200508T203313Z
DTSTAMP:20200509T073457Z
LAST-MODIFIED:20200509T073457Z
UID:BB0RPY6WDYGZ9TFN2QATP
SUMMARY:Encombrants
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=4FR
SEQUENCE:4
DESCRIPTION:red
DTSTART;VALUE=DATE:20200522
DTEND;VALUE=DATE:20200523
END:VEVENT
BEGIN:VEVENT
CREATED:20200509T072937Z
DTSTAMP:20200509T073331Z
LAST-MODIFIED:20200509T073331Z
UID:BSUFEJSO5777YTKTZ0GAUU
SUMMARY:Poubelle verte
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=WEEKLY
SEQUENCE:1
DESCRIPTION:green
DTSTART;VALUE=DATE:20200406
DTEND;VALUE=DATE:20200407
END:VEVENT
BEGIN:VEVENT
CREATED:20200509T073015Z
DTSTAMP:20200509T073438Z
LAST-MODIFIED:20200509T073438Z
UID:UCZXXNAFZ6M095KX1HXHCZM
SUMMARY:Poubelle marron (matin)
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=WEEKLY
SEQUENCE:1
DESCRIPTION:DarkGoldenRod
DTSTART;VALUE=DATE:20200410
DTEND;VALUE=DATE:20200411
END:VEVENT
END:VCALENDAR

custom_components/waste_collection_schedule/manifest.json METASEP
{
  "domain": "waste_collection_schedule",
  "name": "waste_collection_schedule",
  "documentation": "https://github.com/mampfes/hacs_waste_collection_schedule#readme",
  "requirements": ["icalendar", "recurring_ical_events", "icalevents", "bs4"],
  "dependencies": [],
  "codeowners": ["@mampfes"],
  "iot_class": "cloud_polling",
  "version": "1.16.0"
}

custom_components/waste_collection_schedule/waste_collection_schedule/wizard/stuttgart_de.py METASEP
#!/usr/bin/env python3

import json

import inquirer
import requests


def main():
    # search for street
    questions = [inquirer.Text("street", message="Enter search string for street")]
    answers = inquirer.prompt(questions)

    # retrieve suggestions for street
    r = requests.get(
        "https://service.stuttgart.de/lhs-services/aws/strassennamen", params=answers
    )

    data = json.loads(r.text)
    street_choices = []
    for d in data["suggestions"]:
        street_choices.append((d["value"], d["data"]))

    # select street
    questions = [
        inquirer.List("street", choices=street_choices, message="Select street")
    ]
    results = inquirer.prompt(questions)

    # search for house number
    questions = [inquirer.Text("streetnr", message="Enter house number")]
    results.update(inquirer.prompt(questions))

    print("Copy the following statements into your configuration.yaml:\n")
    print("# waste_collection_schedule source configuration")
    print("waste_collection_schedule:")
    print("  sources:")
    print("    - name: stuttgart_de")
    print("      args:")
    for key, value in results.items():
        print(f"        {key}: {value}")


if __name__ == "__main__":
    main()

custom_components/waste_collection_schedule/waste_collection_schedule/wizard/stadtreinigung_hamburg.py METASEP
#!/usr/bin/env python3

from html.parser import HTMLParser

import inquirer
import requests


# Parser for HTML input
class InputParser(HTMLParser):
    def __init__(self, input_name):
        super().__init__()
        self._input_name = input_name
        self._value = None

    @property
    def value(self):
        return self._value

    def handle_starttag(self, tag, attrs):
        if tag == "input":
            for attr in attrs:
                if attr[0] == "name" and attr[1] == self._input_name:
                    for attr2 in attrs:
                        if attr2[0] == "value":
                            self._value = attr2[1]
                            break
                    break


# Parser for HTML option list
class OptionParser(HTMLParser):
    def __init__(self, select_name):
        super().__init__()
        self._select_name = select_name
        self._within_select = False
        self._within_option = False
        self._option_name = ""
        self._option_value = "-1"
        self._choices = []

    @property
    def choices(self):
        return self._choices

    def handle_starttag(self, tag, attrs):
        if tag == "select":
            for attr in attrs:
                if attr[0] == "name" and attr[1] == self._select_name:
                    self._within_select = True
                    break
        elif tag == "option" and self._within_select:
            self._within_option = True
            for attr in attrs:
                if attr[0] == "value":
                    self._option_value = attr[1]

    def handle_endtag(self, tag):
        if tag == "select":
            self._within_select = False
        elif tag == "option":
            if (
                self._within_select
                and self._within_option
                and len(self._option_name) > 0
                and self._option_value != ""
            ):
                self._choices.append((self._option_name, self._option_value))
            self._within_option = False
            self._option_name = ""
            self._option_value = "-1"

    def handle_data(self, data):
        if self._within_option:
            self._option_name += data


def main():
    # search for street
    questions = [
        inquirer.Text("strasse", message="Enter search string for street"),
        #        inquirer.Text("hausnummer", message="Enter search string for house number"),
    ]
    answers = inquirer.prompt(questions)

    answers["hausnummer"] = ""
    answers["bestaetigung"] = "true"
    answers["mode"] = "search"

    r = requests.post(
        "https://www.stadtreinigung.hamburg/privatkunden/abfuhrkalender/index.html",
        data=answers,
    )

    # search for street
    input_parser = InputParser(input_name="asId")
    input_parser.feed(r.text)

    if input_parser.value is not None:
        answers["asId"] = input_parser.value
    else:
        # query returned a list of streets
        parser = OptionParser(select_name="asId")
        parser.feed(r.text)

        questions = [
            inquirer.List("asId", choices=parser.choices, message="Select street")
        ]
        answers.update(inquirer.prompt(questions))

    # search for building number
    r = requests.post(
        "https://www.stadtreinigung.hamburg/privatkunden/abfuhrkalender/index.html",
        data=answers,
    )

    # parser HTML option list
    parser = OptionParser(select_name="hnId")
    parser.feed(r.text)

    if len(parser.choices) == 0:
        answers["hnId"] = ""
    else:
        questions = [
            inquirer.List("hnId", choices=parser.choices, message="Select house number")
        ]
        answers.update(inquirer.prompt(questions))

    print("Copy the following statements into your configuration.yaml:\n")
    print("# waste_collection_schedule source configuration")
    print("waste_collection_schedule:")
    print("  sources:")
    print("    - name: stadtreinigung_hamburg")
    print("      args:")
    print(f"        asId: {answers['asId']}")
    print(f"        hnId: {answers['hnId']}")


if __name__ == "__main__":
    main()

custom_components/waste_collection_schedule/waste_collection_schedule/wizard/muellmax_de.py METASEP
#!/usr/bin/env python3

from html.parser import HTMLParser

import inquirer
import requests


# Parser for HTML input (hidden) text
class InputTextParser(HTMLParser):
    def __init__(self, **identifiers):
        super().__init__()
        self._identifiers = identifiers
        self._value = None

    @property
    def value(self):
        return self._value

    def handle_starttag(self, tag, attrs):
        if tag == "input":
            d = dict(attrs)
            for key, value in self._identifiers.items():
                if key not in d or d[key] != value:
                    return
            self._value = d.get("value")


# Parser for HTML input select list
class InputSelectParser(HTMLParser):
    def __init__(self, **identifiers):
        super().__init__()
        self._identifiers = identifiers
        self._within_select = False
        self._within_option = False
        self._option_name = ""
        self._option_value = "-1"
        self._choices = []

    @property
    def choices(self):
        return self._choices

    def handle_starttag(self, tag, attrs):
        d = dict(attrs)
        if tag == "select":
            for key, value in self._identifiers.items():
                if key not in d or d[key] != value:
                    return
            self._within_select = True
        elif tag == "option" and self._within_select:
            self._within_option = True
            self._option_value = d.get("value")

    def handle_endtag(self, tag):
        if tag == "select":
            self._within_select = False
        elif tag == "option":
            if (
                self._within_select
                and self._within_option
                and len(self._option_name) > 0
                and self._option_value != ""
            ):
                self._choices.append((self._option_name, self._option_value))
            self._within_option = False
            self._option_name = ""
            self._option_value = "-1"

    def handle_data(self, data):
        if self._within_option:
            self._option_name += data


def main():
    results = {}

    # select service
    service_choices = [
        ("Frankfurt", "Fes"),
        ("Düsseldorf", "Dus"),
        ("Rhein-Sieg-Kreis", "Rsa"),
        ("Bochum", "Usb"),
        ("Münster", "Awm"),
        ("Mainz", "Ebm"),
        ("Entsorgungsverband Saar (EVS)", "Evs"),
        ("Landkreis Gießen", "Lkg"),
        ("Hamm", "Ash"),
        ("Darmstadt", "Ead"),
        ("Kaiserslautern", "Ask"),
        ("Hanau", "His"),
        ("Maintal", "Mai"),
        ("Haltern am See", "Hal"),
        ("Friedberg", "Efb"),
    ]
    questions = [
        inquirer.List(
            "service",
            choices=service_choices,
            message="Select service provider for district",
        )
    ]
    answers = inquirer.prompt(questions)
    results.update(answers)

    mm_ses = InputTextParser(type="hidden", name="mm_ses")

    url = f"https://www.muellmax.de/abfallkalender/{answers['service'].lower()}/res/{answers['service']}Start.php"
    r = requests.get(url)
    mm_ses.feed(r.text)

    # select "Abfuhrtermine", returns ort or an empty street search field
    args = {"mm_ses": mm_ses.value, "mm_aus_ort.x": 0, "mm_aus_ort.x": 0}
    r = requests.post(url, data=args)
    mm_ses.feed(r.text)

    # check if last request returns a list of cities
    mm_frm_ort_sel = InputSelectParser(name="mm_frm_ort_sel")
    mm_frm_ort_sel.feed(r.text)
    if len(mm_frm_ort_sel.choices) > 0:
        # select city
        questions = [
            inquirer.List(
                "mm_frm_ort_sel", choices=mm_frm_ort_sel.choices, message="Select city"
            )
        ]
        answers = inquirer.prompt(questions)
        results.update(answers)

        # get list of streets
        args = {
            "mm_ses": mm_ses.value,
            "xxx": 1,
            "mm_frm_ort_sel": answers["mm_frm_ort_sel"],
            "mm_aus_ort_submit": "weiter",
        }
        r = requests.post(url, data=args)
        mm_ses.feed(r.text)

    # get list of streets
    args = {
        "mm_ses": mm_ses.value,
        "xxx": 1,
        "mm_frm_str_name": "",
        "mm_aus_str_txt_submit": "suchen",
    }
    r = requests.post(url, data=args)
    mm_ses.feed(r.text)

    mm_frm_str_sel = InputSelectParser(name="mm_frm_str_sel")
    mm_frm_str_sel.feed(r.text)

    # select street
    questions = [
        inquirer.List(
            "mm_frm_str_sel", choices=mm_frm_str_sel.choices, message="Select street"
        )
    ]
    answers = inquirer.prompt(questions)
    results.update(answers)

    # get list of house numbers
    args = {
        "mm_ses": mm_ses.value,
        "xxx": 1,
        "mm_frm_str_sel": answers["mm_frm_str_sel"],
        "mm_aus_str_sel_submit": "weiter",
    }
    r = requests.post(url, data=args)
    mm_ses.feed(r.text)

    mm_frm_hnr_sel = InputSelectParser(name="mm_frm_hnr_sel")
    mm_frm_hnr_sel.feed(r.text)

    # select house number
    if len(mm_frm_hnr_sel.choices) > 0:
        questions = [
            inquirer.List(
                "mm_frm_hnr_sel",
                choices=mm_frm_hnr_sel.choices,
                message="Select house number",
            )
        ]
        answers = inquirer.prompt(questions)
        results.update(answers)

    print("Copy the following statements into your configuration.yaml:\n")
    print("# waste_collection_schedule source configuration")
    print("waste_collection_schedule:")
    print("  sources:")
    print("    - name: muellmax_de")
    print("      args:")
    for key, value in results.items():
        print(f"        {key}: {value}")


if __name__ == "__main__":
    main()

custom_components/waste_collection_schedule/waste_collection_schedule/wizard/jumomind_de.py METASEP
#!/usr/bin/env python3

import json

import inquirer
import requests


def main():
    # select service
    service_choices = [
        ("Altötting", "aoe"),
        ("Aurich", "lka"),
        ("Bad Homburg vdH", "hom"),
        ("Barnim", "bdg"),
        ("Darmstadt-Dieburg", "zaw"),
        # ("Groß-Gerau", "aws"),
        ("Hattersheim am Main", "hat"),
        ("Ingolstadt", "ingol"),
        ("Lübbecke", "lue"),
        ("Minden", "sbm"),
        ("MyMüll App", "mymuell"),
        ("Recklinghausen", "ksr"),
        ("Rhein-Hunsrück", "rhe"),
        ("Uckermark", "udg"),
    ]
    questions = [
        inquirer.List(
            "service_id",
            choices=service_choices,
            message="Select service provider for district",
        )
    ]
    answers = inquirer.prompt(questions)

    # get list of city by service
    args = {"r": "cities"}
    r = requests.get(
        f"https://{answers['service_id']}.jumomind.com/mmapp/api.php", params=args
    )
    cities = json.loads(r.text)

    # select city from list
    city_choices = []
    cityData = {}
    for city in cities:
        # {'name': 'Altötting', '_name': 'Altötting', 'id': '24', 'region_code': '02', 'area_id': '24', 'img': None, 'has_streets': True}
        city_choices.append((city["name"], city["id"]))
        cityData[city["id"]] = {
            "area_id": city["area_id"],
            "has_streets": city["has_streets"],
        }

    questions = [
        inquirer.List(
            "city_id", choices=city_choices, message="Select municipality [Kommune/Ort]"
        )
    ]
    answers.update(inquirer.prompt(questions))

    selected_city = cityData[answers["city_id"]]
    if not selected_city["has_streets"]:
        # city has no street, therefore use area_id directly
        answers["area_id"] = selected_city["area_id"]
    else:
        # city has streets, therefore get list of streets
        args = {"r": "streets", "city_id": answers["city_id"]}
        r = requests.get(
            f"https://{answers['service_id']}.jumomind.com/mmapp/api.php", params=args
        )
        streets = json.loads(r.text)

        # select street from list
        street_choices = []
        house_numbers = {}
        for street in streets:
            # {'name': 'Adalbert-Stifter-Str.', '_name': 'Adalbert-Stifter-Str.', 'id': '302', 'area_id': '48'}
            # {"name":"ACHATWEG","_name":"ACHATWEG","id":"355008","area_id":"085080001","houseNumberFrom":"0001","houseNumberTo":"0001","comment":"","houseNumbers":[["0001","085080001"],["0003","085080003"],["0004","085080004"],["0005","085080005"],["0006","085080006"]]},
            street_choices.append((street["name"], street["area_id"]))
            house_number_choices = []
            if "houseNumbers" in street:
                for hnr in street["houseNumbers"]:
                    house_number_choices.append((hnr[0], hnr[1]))
                house_numbers[street["area_id"]] = house_number_choices

        questions = [
            inquirer.List("area_id", choices=street_choices, message="Select street")
        ]
        answers.update(inquirer.prompt(questions))

        if answers["area_id"] in house_numbers:
            questions = [
                inquirer.List(
                    "area_id",
                    choices=house_numbers[answers["area_id"]],
                    message="Select house number",
                )
            ]
            answers.update(inquirer.prompt(questions))

    print("Copy the following statements into your configuration.yaml:\n")
    print("# waste_collection_schedule source configuration")
    print("waste_collection_schedule:")
    print("  sources:")
    print("    - name: jumomind_de")
    print("      args:")
    print(f"        service_id: {answers['service_id']}")
    print(f"        city_id: {answers['city_id']}")
    print(f"        area_id: {answers['area_id']}")


if __name__ == "__main__":
    main()

custom_components/waste_collection_schedule/waste_collection_schedule/wizard/bsr_de.py METASEP
#!/usr/bin/env python3

import json

import inquirer
import requests


def main():
    # get cookies
    r = requests.get("https://www.bsr.de/abfuhrkalender-20520.php")
    cookies = r.cookies

    while True:
        questions = [inquirer.Text("q", message="Enter search string for street")]
        answers = inquirer.prompt(questions)

        args = {"script": "dynamic_search", "step": 1, "q": answers["q"]}

        r = requests.get(
            "https://www.bsr.de/abfuhrkalender_ajax.php", params=args, cookies=cookies
        )

        data = json.loads(r.text)
        if (
            len(data) == 1 and data[0]["value"] == "Keine Adresse gefunden"
        ):  # {'value': 'Keine Adresse gefunden'}
            print("Search returned no result. Please try again.")
        else:
            break

    street_choices = []
    for d in data:
        street_choices.append(d["value"])

    # select street
    questions = [
        inquirer.List("abf_strasse", choices=street_choices, message="Select street")
    ]
    answers = inquirer.prompt(questions)

    # retrieve house number list
    args = {"script": "dynamic_search", "step": 2, "q": answers["abf_strasse"]}

    r = requests.get(
        "https://www.bsr.de/abfuhrkalender_ajax.php", params=args, cookies=cookies
    )

    # select house number
    data = json.loads(r.text)
    house_number_choices = []
    for d in data.values():
        house_number_choices.append((d["FullStreet"], d["HouseNo"]))

    questions = [
        inquirer.List(
            "abf_hausnr", choices=house_number_choices, message="Select house number"
        )
    ]
    answers.update(inquirer.prompt(questions))

    print("Copy the following statements into your configuration.yaml:\n")
    print("# waste_collection_schedule source configuration")
    print("waste_collection_schedule:")
    print("  sources:")
    print("    - name: bsr_de")
    print("      args:")
    for key, value in answers.items():
        print(f"        {key}: {value}")


if __name__ == "__main__":
    main()

custom_components/waste_collection_schedule/waste_collection_schedule/wizard/awbkoeln_de.py METASEP
#!/usr/bin/env python3

import json

import inquirer
import requests


def main():
    questions = [
        inquirer.Text("street_name", message="Enter search string for street"),
        inquirer.Text(
            "building_number", message="Enter search string for house number"
        ),
    ]
    answers = inquirer.prompt(questions)

    args = answers
    args["form"] = "json"
    r = requests.get("https://www.awbkoeln.de/api/streets", params=args)

    # "data":[{"street_name":"Bahnhofplatz","building_number":"5","building_number_plain":"5","building_number_addition":"","street_code":"4270",
    # "district":"Gremberghoven","zipcode":"51149","district_code":"4","area_code":"7","user_street_name":"Bahnhofplatz","user_building_number":"1"}
    data = json.loads(r.text)
    if len(data["data"]) == 0:
        print("no matching address found")
        return

    choices = []
    for d in data["data"]:
        value = {
            "street_code": d["street_code"],
            "building_number": d["building_number"],
        }
        choices.append(
            (
                f"{d['user_street_name']} {d['user_building_number']}, {d['zipcode']} Köln - {d['district']}",
                value,
            )
        )

    questions = [inquirer.List("data", choices=choices, message="Select address")]
    answers = inquirer.prompt(questions)

    print("Copy the following statements into your configuration.yaml:\n")
    print("# waste_collection_schedule source configuration")
    print("waste_collection_schedule:")
    print("  sources:")
    print("    - name: awbkoeln_de")
    print("      args:")
    print(f"        street_code: {answers['data']['street_code']}")
    print(f"        building_number: {answers['data']['building_number']}")


if __name__ == "__main__":
    main()

custom_components/waste_collection_schedule/waste_collection_schedule/wizard/abfallnavi_de.py METASEP
#!/usr/bin/env python3

import site
from pathlib import Path

import inquirer

# add module directory to path
package_dir = Path(__file__).resolve().parents[2]
site.addsitedir(str(package_dir))
from waste_collection_schedule.service.AbfallnaviDe import SERVICE_DOMAINS, AbfallnaviDe  # type: ignore # isort:skip # noqa: E402


def convert_dict_to_array(d):
    a = []
    for item in d.items():
        a.append((item[1], item[0]))
    return a


def main():
    args = {}

    # select service domain
    questions = [
        inquirer.List(
            "service_id",
            choices=convert_dict_to_array(SERVICE_DOMAINS),
            message="Select service provider for district [Landkreis]",
        )
    ]
    service_id = inquirer.prompt(questions)["service_id"]
    args["service"] = service_id

    # create service
    api = AbfallnaviDe(service_id)

    # select city
    cities = api.get_cities()
    questions = [
        inquirer.List(
            "city_id",
            choices=convert_dict_to_array(cities),
            message="Select municipality [Kommune/Ort]",
        )
    ]
    city_id = inquirer.prompt(questions)["city_id"]
    args["ort"] = cities[city_id]

    # select street
    streets = api.get_streets(city_id)
    questions = [
        inquirer.List(
            "street_id", choices=convert_dict_to_array(streets), message="Select street"
        )
    ]
    street_id = inquirer.prompt(questions)["street_id"]
    args["strasse"] = streets[street_id]

    # get list of house numbers
    house_numbers = api.get_house_numbers(street_id)
    if len(house_numbers) > 0:
        questions = [
            inquirer.List(
                "house_number_id",
                choices=convert_dict_to_array(house_numbers),
                message="Select house number",
            )
        ]
        house_number_id = inquirer.prompt(questions)["house_number_id"]
        args["hausnummer"] = house_numbers[house_number_id]

    print("Copy the following statements into your configuration.yaml:\n")
    print("# waste_collection_schedule source configuration")
    print("waste_collection_schedule:")
    print("  sources:")
    print("    - name: abfallnavi_de")
    print("      args:")
    for key, value in args.items():
        print(f"        {key}: {value}")


if __name__ == "__main__":
    main()

custom_components/waste_collection_schedule/waste_collection_schedule/wizard/abfall_io.py METASEP
#!/usr/bin/env python3

import re
from html.parser import HTMLParser

import inquirer
import requests

MODUS_KEY = "d6c5855a62cf32a4dadbc2831f0f295f"
HEADERS = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}

# IDs of CONFIG VARIABLES
CONFIG_VARIABLES = [
    "f_id_kommune",
    "f_id_bezirk",
    "f_id_strasse",
    "f_id_strasse_hnr",
    "f_abfallarten",
]

ACTION_EXTRACTOR_PATTERN = re.compile(
    '(?<=awk-data-onchange-submit-waction=")[^\\n\\r"]+'
)

DISTRICT_CHOICES = [
    ("ALBA Berlin", "9583a2fa1df97ed95363382c73b41b1b"),
    ("Böblingen", "8215c62763967916979e0e8566b6172e"),
    ("Calw", "690a3ae4906c52b232c1322e2f88550c"),
    ("eBe Essen", "9b5390f095c779b9128a51db35092c9c"),
    ("Freudenstadt", "595f903540a36fe8610ec39aa3a06f6a"),
    ("Göppingen", "365d791b58c7e39b20bb8f167bd33981"),
    ("Heilbronn (Landkreis)", "1a1e7b200165683738adddc4bd0199a2"),
    ("Kitzingen", "594f805eb33677ad5bc645aeeeaf2623"),
    ("Landsberg am Lech", "7df877d4f0e63decfb4d11686c54c5d6"),
    ("Landshut", "bd0c2d0177a0849a905cded5cb734a6f"),
    ("Ludwigshafen am Rhein", "6efba91e69a5b454ac0ae3497978fe1d"),
    ("MüllALARM / Schönmackers", "e5543a3e190cb8d91c645660ad60965f"),
    ("Ostalbkreis", "3ca331fb42d25e25f95014693ebcf855"),
    ("Rhein-Neckar-Kreis", "914fb9d000a9a05af4fd54cfba478860"),
    ("Rotenburg (Wümme)", "645adb3c27370a61f7eabbb2039de4f1"),
    ("Sigmaringen", "39886c5699d14e040063c0142cd0740b"),
    ("Unterallgäu", "c22b850ea4eff207a273e46847e417c5"),
    ("Westerwaldkreis", "248deacbb49b06e868d29cb53c8ef034"),
]


class OptionParser(HTMLParser):
    """Parser for HTML option list."""

    TEXTBOXES = "textboxes"

    def error(self, message):
        pass

    def __init__(self, target_var):
        super().__init__()
        self._target_var = target_var
        self._within_option = False
        self._option_name = ""
        self._option_value = "-1"
        self._choices = []
        self._is_selector = False
        self._is_text_input = False
        self._text_field_id = ""
        self._text_hint = ""
        self._text_name = ""
        self._label_for_id = ""
        self._label_contents = {}

    @property
    def choices(self):
        return self._choices

    def handle_starttag(self, tag, attrs):
        attributes = dict(attrs)

        if tag == "label":
            if "for" in attributes:
                self._label_for_id = attributes["for"]

        if tag == "input":
            if "type" in attributes:
                if attributes["type"] == "hidden":
                    if (
                        "name" in attributes
                        and "value" in attributes
                        and attributes["name"] == self._target_var
                    ):
                        # self._within_option = True
                        self._is_selector = True
                        self._option_value = attributes["value"]
                        self._choices.append((attributes["value"], attributes["value"]))
                elif (
                    self._target_var == OptionParser.TEXTBOXES
                    and attributes["type"] == "text"
                ):
                    self._is_text_input = True
                    if "id" in attributes:
                        self._text_field_id = attributes["id"]
                    if "placeholder" in attributes:
                        self._text_hint = attributes["placeholder"]
                    if "name" in attributes:
                        self._text_name = attributes["name"]

        if tag == "select":
            if "name" in attributes and attributes["name"] == self._target_var:
                self._is_selector = True

        if tag == "option" and self._is_selector:
            self._within_option = True
            if "value" in attributes:
                self._option_value = attributes["value"]

    def handle_endtag(self, tag):
        if (
            self._within_option
            and len(self._option_name) > 0
            and self._option_value != "-1"
        ):
            self._choices.append((self._option_name, self._option_value))
        self._within_option = False
        self._option_name = ""
        self._option_value = "-1"

    def handle_data(self, data):
        if self._within_option:
            self._option_name += data

        if len(self._label_for_id) > 0:
            self._label_contents[self._label_for_id] = data
            self._label_for_id = ""

    @property
    def is_selector(self):
        return self._is_selector

    @property
    def is_text_input(self):
        return self._is_text_input

    @property
    def text_name(self):
        return self._text_name

    @property
    def text_field_id(self):
        return self._text_field_id

    @property
    def label_contents(self):
        return self._label_contents

    @property
    def text_hint(self):
        return self._text_hint


def select_and_query(data, answers):
    relevant_config_vars = []
    for config_var in CONFIG_VARIABLES:
        if config_var not in answers and config_var in data:
            relevant_config_vars.append(config_var)

    for target_var in relevant_config_vars:
        # parser HTML option list
        parser = OptionParser(target_var)
        parser.feed(data)

        if parser.is_selector:
            questions = [
                inquirer.List(
                    target_var, choices=parser.choices, message=f"Select {target_var}",
                )
            ]
            answers.update(inquirer.prompt(questions))

    # Search for Textboxes (currently just supports one textbox per request)
    parser = OptionParser(OptionParser.TEXTBOXES)
    parser.feed(data)
    if parser.is_text_input:
        message = parser.label_contents[parser.text_field_id]
        if parser.text_hint != "":
            message = message + " (" + parser.text_hint + ")"

        questions = [inquirer.Text(parser.text_name, message=message)]
        answers.update(inquirer.prompt(questions))

    args = {
        "key": answers["key"],
        "modus": MODUS_KEY,
        "waction": ACTION_EXTRACTOR_PATTERN.findall(data)[0],
    }
    r = requests.post(
        "https://api.abfall.io", params=args, data=answers, headers=HEADERS
    )
    return r.text


def main():
    questions = [
        inquirer.List(
            "key", choices=DISTRICT_CHOICES, message="Select service provider"
        )
    ]
    answers = inquirer.prompt(questions)

    # prompt for first level
    args = {"key": answers["key"], "modus": MODUS_KEY, "waction": "init"}
    r = requests.get("https://api.abfall.io", params=args, headers=HEADERS)

    data = r.text
    while True:
        data = select_and_query(data, answers)

        if "f_id_abfalltyp" in data:
            break

    print("Copy the following statements into your configuration.yaml:\n")
    print("# waste_collection_schedule source configuration")
    print("waste_collection_schedule:")
    print("  sources:")
    print("    - name: abfall_io")
    print("      args:")
    for key, value in answers.items():
        if key in CONFIG_VARIABLES or key == "key":
            print(f"        {key}: {value}")


if __name__ == "__main__":
    main()

custom_components/waste_collection_schedule/waste_collection_schedule/wizard/__init__.py METASEP

custom_components/waste_collection_schedule/waste_collection_schedule/test/test_sources.py METASEP
#!/usr/bin/env python3

import argparse
import importlib
import re
import site
import traceback
from pathlib import Path

import yaml

SECRET_FILENAME = "secrets.yaml"
SECRET_REGEX = re.compile(r"!secret\s(\w+)")


def main():
    parser = argparse.ArgumentParser(description="Test sources.")
    parser.add_argument(
        "-s", "--source", action="append", help="Test given source file"
    )
    parser.add_argument(
        "-l", "--list", action="store_true", help="List retrieved entries"
    )
    parser.add_argument(
        "-i", "--icon", action="store_true", help="Show waste type icon"
    )
    args = parser.parse_args()

    # read secrets.yaml
    secrets = {}
    try:
        with open(SECRET_FILENAME) as stream:
            try:
                secrets = yaml.safe_load(stream)
            except yaml.YAMLError as exc:
                print(exc)
    except FileNotFoundError:
        # ignore missing secrets.yaml
        pass

    package_dir = Path(__file__).resolve().parents[2]
    source_dir = package_dir / "waste_collection_schedule" / "source"

    # add module directory to path
    site.addsitedir(str(package_dir))

    if args.source is not None:
        files = args.source
    else:
        files = filter(
            lambda x: x != "__init__", map(lambda x: x.stem, source_dir.glob("*.py")),
        )

    for f in files:
        # iterate through all *.py files in waste_collection_schedule/source
        print(f"Testing source {f} ...")
        module = importlib.import_module(f"waste_collection_schedule.source.{f}")

        # get all names within module
        names = set(dir(module))

        # test if all mandatory names exist
        assert "TITLE" in names
        assert "DESCRIPTION" in names
        assert "URL" in names
        assert "TEST_CASES" in names

        # run through all test-cases
        for name, tc in module.TEST_CASES.items():
            # replace secrets in arguments
            replace_secret(secrets, tc)

            # create source
            source = module.Source(**tc)
            try:
                result = source.fetch()
                print(f"  found {len(result)} entries for {name}")
                if args.list:
                    for x in result:
                        icon_str = f" [{x.icon}]" if args.icon else ""
                        print(f"    {x.date.isoformat()}: {x.type}{icon_str}")
            except KeyboardInterrupt:
                exit()
            except Exception:
                print(traceback.format_exc())


def replace_secret(secrets, d):
    for key in d.keys():
        value = d[key]
        if isinstance(value, dict):
            replace_secret(secrets, value)
        elif isinstance(value, str):
            match = SECRET_REGEX.fullmatch(value)
            if match is not None:
                id = match.group(1)
                if id in secrets:
                    d[key] = secrets[id]
                else:
                    print(f"identifier '{id}' not found in {SECRET_FILENAME}")


if __name__ == "__main__":
    main()

custom_components/waste_collection_schedule/waste_collection_schedule/service/__init__.py METASEP

custom_components/waste_collection_schedule/waste_collection_schedule/service/ICS_v1.py METASEP
import datetime
import logging
import re

import icalendar
import recurring_ical_events

_LOGGER = logging.getLogger(__name__)


class ICS_v1:
    def __init__(self, offset=None, regex=None, split_at=None):
        self._offset = offset
        self._regex = None
        if regex is not None:
            self._regex = re.compile(regex)
        self._split_at = split_at

    def convert(self, ics_data):
        # parse ics file
        try:
            calendar = icalendar.Calendar.from_ical(ics_data)
        except Exception as err:
            _LOGGER.error(f"Parsing ics data failed:{str(err)}")
            _LOGGER.debug(ics_data)
            return []

        # calculate start- and end-date for recurring events
        start_date = datetime.datetime.now().replace(
            hour=0, minute=0, second=0, microsecond=0
        )
        if self._offset is not None:
            start_date -= datetime.timedelta(days=self._offset)
        end_date = start_date.replace(year=start_date.year + 1)

        events = recurring_ical_events.of(calendar).between(start_date, end_date)

        entries = []
        for e in events:
            if e.name == "VEVENT":
                # calculate date
                dtstart = None
                if type(e.get("dtstart").dt) == datetime.date:
                    dtstart = e.get("dtstart").dt
                elif type(e.get("dtstart").dt) == datetime.datetime:
                    dtstart = e.get("dtstart").dt.date()
                if self._offset is not None:
                    dtstart += datetime.timedelta(days=self._offset)

                # calculate waste type
                summary = str(e.get("summary"))
                if self._regex is not None:
                    match = self._regex.match(summary)
                    if match:
                        summary = match.group(1)

                if self._split_at is not None:
                    summary = re.split(self._split_at, summary)
                    for t in summary:
                        entries.append((dtstart, t.strip().title()))
                else:
                    entries.append((dtstart, summary))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/service/ICS.py METASEP
import datetime
import logging
import re

from icalevents import icalevents

_LOGGER = logging.getLogger(__name__)


class ICS:
    def __init__(self, offset=None, regex=None, split_at=None):
        self._offset = offset
        self._regex = None
        if regex is not None:
            self._regex = re.compile(regex)
        self._split_at = split_at

    def convert(self, ics_data):
        # calculate start- and end-date for recurring events
        start_date = datetime.datetime.now().replace(
            hour=0, minute=0, second=0, microsecond=0
        )
        if self._offset is not None:
            start_date -= datetime.timedelta(days=self._offset)
        end_date = start_date.replace(year=start_date.year + 1)

        # parse ics data
        events = icalevents.events(
            start=start_date, end=end_date, string_content=ics_data.encode()
        )

        entries = []
        for e in events:
            # calculate date
            dtstart = None
            if type(e.start) == datetime.date:
                dtstart = e.start
            elif type(e.start) == datetime.datetime:
                dtstart = e.start.date()
            if self._offset is not None:
                dtstart += datetime.timedelta(days=self._offset)

            # calculate waste type
            summary = str(e.summary)
            if self._regex is not None:
                match = self._regex.match(summary)
                if match:
                    summary = match.group(1)

            if self._split_at is not None:
                summary = re.split(self._split_at, summary)
                for t in summary:
                    entries.append((dtstart, t.strip().title()))
            else:
                entries.append((dtstart, summary))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/service/AbfallnaviDe.py METASEP
#!/usr/bin/env python3

import datetime
import json

import requests

SERVICE_DOMAINS = {
    "aachen": "Aachen",
    "zew2": "AWA Entsorgungs GmbH",
    "aw-bgl2": "Bergisch Gladbach",
    "bav": "Bergischer Abfallwirtschaftverbund",
    "din": "Dinslaken",
    "dorsten": "Dorsten",
    "gt2": "Gütersloh",
    "hlv": "Halver",
    "coe": "Kreis Coesfeld",
    "krhs": "Kreis Heinsberg",
    "pi": "Kreis Pinneberg",
    "krwaf": "Kreis Warendorf",
    "lindlar": "Lindlar",
    "stl": "Lüdenscheid",
    "nds": "Norderstedt",
    "nuernberg": "Nürnberg",
    "roe": "Roetgen",
    "wml2": "EGW Westmünsterland",
}


class AbfallnaviDe:
    def __init__(self, service_domain):
        self._service_domain = service_domain
        self._service_url = f"https://{service_domain}-abfallapp.regioit.de/abfall-app-{service_domain}/rest"

    def _fetch(self, path, params=None):
        r = requests.get(f"{self._service_url}/{path}", params=params)
        r.encoding = "utf-8"  # requests doesn't guess the encoding correctly
        return r.text

    def _fetch_json(self, path, params=None):
        return json.loads(self._fetch(path, params=params))

    def get_cities(self):
        """Return all cities of service domain."""
        cities = self._fetch_json("orte")
        result = {}
        for city in cities:
            result[city["id"]] = city["name"]
        return result

    def get_city_id(self, city):
        """Return id for given city string."""
        cities = self.get_cities()
        return self._find_in_inverted_dict(cities, city)

    def get_streets(self, city_id):
        """Return all streets of a city."""
        streets = self._fetch_json(f"orte/{city_id}/strassen")
        result = {}
        for street in streets:
            result[street["id"]] = street["name"]
        return result

    def get_street_id(self, city_id, street):
        """Return id for given street string."""
        streets = self.get_streets(city_id)
        return self._find_in_inverted_dict(streets, street)

    def get_house_numbers(self, street_id):
        """Return all house numbers of a street."""
        house_numbers = self._fetch_json(f"strassen/{street_id}")
        result = {}
        for hausNr in house_numbers.get("hausNrList", {}):
            # {"id":5985445,"name":"Adalbert-Stifter-Straße","hausNrList":[{"id":5985446,"nr":"1"},
            result[hausNr["id"]] = hausNr["nr"]
        return result

    def get_house_number_id(self, street_id, house_number):
        """Return id for given house number string."""
        house_numbers = self.get_house_numbers(street_id)
        return self._find_in_inverted_dict(house_numbers, house_number)

    def get_waste_types(self):
        waste_types = self._fetch_json("fraktionen")
        result = {}
        for waste_type in waste_types:
            result[waste_type["id"]] = waste_type["name"]
        return result

    def _get_dates(self, target, id, waste_types=None):
        # retrieve collections
        args = []

        if waste_types is None:
            waste_types = self.get_waste_types()

        for f in waste_types.keys():
            args.append(("fraktion", f))

        results = self._fetch_json(f"{target}/{id}/termine", params=args)

        entries = []
        for r in results:
            date = datetime.datetime.strptime(r["datum"], "%Y-%m-%d").date()
            fraktion = waste_types[r["bezirk"]["fraktionId"]]
            entries.append([date, fraktion])
        return entries

    def get_dates_by_street_id(self, street_id):
        return self._get_dates("strassen", street_id, waste_types=None)

    def get_dates_by_house_number_id(self, house_number_id):
        return self._get_dates("hausnummern", house_number_id, waste_types=None)

    def get_dates(self, city, street, house_number=None):
        """Get dates by strings only for convenience."""
        # find city_id
        city_id = self.get_city_id(city)
        if city_id is None:
            raise Exception(f"No id found for city: {city}")

        # find street_id
        street_id = self.get_street_id(city_id, street)
        if street_id is None:
            raise Exception(f"No id found for street: {street}")

        # find house_number_id (which is optional: not all house number do have an id)
        house_number_id = self.get_house_number_id(street_id, house_number)

        # return dates for specific house number of street if house number
        # doesn't have an own id
        if house_number_id is not None:
            return self.get_dates_by_house_number_id(house_number_id)
        else:
            return self.get_dates_by_street_id(street_id)

    def _find_in_inverted_dict(self, mydict, value):
        inverted_dict = dict(map(reversed, mydict.items()))
        return inverted_dict.get(value)


def main():
    aachen = AbfallnaviDe("aachen")
    print(aachen.get_dates("Aachen", "Abteiplatz", "7"))

    lindlar = AbfallnaviDe("lindlar")
    print(lindlar.get_dates("Lindlar", "Aggerweg"))

    roe = AbfallnaviDe("roe")
    print(roe.get_dates("Roetgen", "Am Sportplatz", "2"))


if __name__ == "__main__":
    main()

custom_components/waste_collection_schedule/sensor.py METASEP
"""Sensor platform support for Waste Collection Schedule."""

import datetime
import logging
from enum import Enum

import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
from homeassistant.const import CONF_NAME, CONF_VALUE_TEMPLATE
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect

from .const import DOMAIN, UPDATE_SENSORS_SIGNAL

_LOGGER = logging.getLogger(__name__)

CONF_SOURCE_INDEX = "source_index"
CONF_DETAILS_FORMAT = "details_format"
CONF_COUNT = "count"
CONF_LEADTIME = "leadtime"
CONF_DATE_TEMPLATE = "date_template"
CONF_COLLECTION_TYPES = "types"
CONF_ADD_DAYS_TO = "add_days_to"


class DetailsFormat(Enum):
    """Values for CONF_DETAILS_FORMAT."""

    upcoming = "upcoming"  # list of "<date> <type1, type2, ...>"
    appointment_types = "appointment_types"  # list of "<type> <date>"
    generic = "generic"  # all values in separate attributes


PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
    {
        vol.Required(CONF_NAME): cv.string,
        vol.Optional(CONF_SOURCE_INDEX, default=0): cv.positive_int,
        vol.Optional(CONF_DETAILS_FORMAT, default="upcoming"): cv.enum(DetailsFormat),
        vol.Optional(CONF_COUNT): cv.positive_int,
        vol.Optional(CONF_LEADTIME): cv.positive_int,
        vol.Optional(CONF_COLLECTION_TYPES): cv.ensure_list,
        vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
        vol.Optional(CONF_DATE_TEMPLATE): cv.template,
        vol.Optional(CONF_ADD_DAYS_TO, default=False): cv.boolean,
    }
)


async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
    value_template = config.get(CONF_VALUE_TEMPLATE)
    if value_template is not None:
        value_template.hass = hass

    date_template = config.get(CONF_DATE_TEMPLATE)
    if date_template is not None:
        date_template.hass = hass

    entities = []

    entities.append(
        ScheduleSensor(
            hass=hass,
            api=hass.data[DOMAIN],
            name=config[CONF_NAME],
            source_index=config[CONF_SOURCE_INDEX],
            details_format=config[CONF_DETAILS_FORMAT],
            count=config.get(CONF_COUNT),
            leadtime=config.get(CONF_LEADTIME),
            collection_types=config.get(CONF_COLLECTION_TYPES),
            value_template=value_template,
            date_template=date_template,
            add_days_to=config.get(CONF_ADD_DAYS_TO),
        )
    )

    async_add_entities(entities)


class ScheduleSensor(SensorEntity):
    """Base for sensors."""

    def __init__(
        self,
        hass,
        api,
        name,
        source_index,
        details_format,
        count,
        leadtime,
        collection_types,
        value_template,
        date_template,
        add_days_to,
    ):
        """Initialize the entity."""
        self._api = api
        self._source_index = source_index
        self._details_format = details_format
        self._count = count
        self._leadtime = leadtime
        self._collection_types = collection_types
        self._value_template = value_template
        self._date_template = date_template
        self._add_days_to = add_days_to

        self._value = None

        # entity attributes
        self._attr_name = name
        self._attr_unique_id = name
        self._attr_should_poll = False

        async_dispatcher_connect(hass, UPDATE_SENSORS_SIGNAL, self._update_sensor)

    @property
    def native_value(self):
        """Return the state of the entity."""
        return self._value

    async def async_added_to_hass(self):
        """Entities have been added to hass."""
        self._update_sensor()

    @property
    def _scraper(self):
        return self._api.get_scraper(self._source_index)

    @property
    def _separator(self):
        """Return separator string used to join waste types."""
        return self._api.separator

    @property
    def _include_today(self):
        """Return true if collections for today shall be included in the results."""
        return datetime.datetime.now().time() < self._api._day_switch_time

    def _add_refreshtime(self):
        """Add refresh-time (= last fetch time) to device-state-attributes."""
        refreshtime = ""
        if self._scraper.refreshtime is not None:
            refreshtime = self._scraper.refreshtime.strftime("%x %X")
        self._attr_attribution = f"Last update: {refreshtime}"

    def _set_state(self, upcoming):
        """Set entity state with default format."""
        if len(upcoming) == 0:
            self._value = None
            self._attr_icon = "mdi:trash-can"
            self._attr_entity_picture = None
            return

        collection = upcoming[0]
        # collection::=CollectionGroup{date=2020-04-01, types=['Type1', 'Type2']}

        if self._value_template is not None:
            self._value = self._value_template.async_render_with_possible_json_value(
                collection, None
            )
        else:
            self._value = (
                f"{self._separator.join(collection.types)} in {collection.daysTo} days"
            )

        self._attr_icon = collection.icon or "mdi:trash-can"
        self._attr_entity_picture = collection.picture

    def _render_date(self, collection):
        if self._date_template is not None:
            return self._date_template.async_render_with_possible_json_value(
                collection, None
            )
        else:
            return collection.date.isoformat()

    @callback
    def _update_sensor(self):
        """Update the state and the device-state-attributes of the entity.

        Called if a new data has been fetched from the scraper source.
        """
        if self._scraper is None:
            _LOGGER.error(f"source_index {self._source_index} out of range")
            return None

        upcoming1 = self._scraper.get_upcoming_group_by_day(
            count=1, types=self._collection_types, include_today=self._include_today,
        )

        self._set_state(upcoming1)

        attributes = {}

        collection_types = (
            sorted(self._scraper.get_types())
            if self._collection_types is None
            else self._collection_types
        )

        if self._details_format == DetailsFormat.upcoming:
            # show upcoming events list in details
            upcoming = self._scraper.get_upcoming_group_by_day(
                count=self._count,
                leadtime=self._leadtime,
                types=self._collection_types,
                include_today=self._include_today,
            )
            for collection in upcoming:
                attributes[self._render_date(collection)] = self._separator.join(
                    collection.types
                )
        elif self._details_format == DetailsFormat.appointment_types:
            # show list of collections in details
            for t in collection_types:
                collections = self._scraper.get_upcoming(
                    count=1, types=[t], include_today=self._include_today
                )
                date = (
                    "" if len(collections) == 0 else self._render_date(collections[0])
                )
                attributes[t] = date
        elif self._details_format == DetailsFormat.generic:
            # insert generic attributes into details
            attributes["types"] = collection_types
            attributes["upcoming"] = self._scraper.get_upcoming(
                count=self._count,
                leadtime=self._leadtime,
                types=self._collection_types,
                include_today=self._include_today,
            )
            refreshtime = ""
            if self._scraper.refreshtime is not None:
                refreshtime = self._scraper.refreshtime.isoformat(timespec="seconds")
            attributes["last_update"] = refreshtime

        if len(upcoming1) > 0:
            if self._add_days_to:
                attributes["daysTo"] = upcoming1[0].daysTo

        self._attr_extra_state_attributes = attributes
        self._add_refreshtime()

        if self.hass is not None:
            self.async_write_ha_state()

custom_components/waste_collection_schedule/const.py METASEP
"""Constants for the Waste Collection Schedule component."""

# Component domain, used to store component data in hass data.
DOMAIN = "waste_collection_schedule"

UPDATE_SENSORS_SIGNAL = "wcs_update_sensors_signal"

custom_components/waste_collection_schedule/calendar.py METASEP
"""Calendar platform support for Waste Collection Schedule."""

import logging
from datetime import timedelta

from homeassistant.components.calendar import CalendarEventDevice

_LOGGER = logging.getLogger(__name__)


async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
    """Set up calendar platform."""
    # We only want this platform to be set up via discovery.
    if discovery_info is None:
        return

    entities = []

    api = discovery_info["api"]

    for scraper in api.scrapers:
        entities.append(WasteCollectionCalendar(api, scraper))

    async_add_entities(entities)


class WasteCollectionCalendar(CalendarEventDevice):
    """Calendar entity class."""

    def __init__(self, api, scraper):
        self._api = api
        self._scraper = scraper

    @property
    def name(self):
        """Return entity name."""
        return self._scraper.calendar_title

    @property
    def event(self):
        """Return next collection event."""
        collections = self._scraper.get_upcoming(count=1, include_today=True)
        if len(collections) == 0:
            return None
        else:
            return self._convert(collections[0])

    async def async_get_events(self, hass, start_date, end_date):
        """Return all events within specified time span."""
        collections = []
        for a in self._scraper.get_upcoming(include_today=True):
            if a.date >= start_date.date() and a.date <= end_date.date():
                collections.append(self._convert(a))
        return collections

    def _convert(self, collection):
        """Convert an collection into a Home Assistant calendar event."""
        return {
            "uid": f"{self._scraper.calendar_title}-{collection.date.isoformat()}-{collection.type}",
            "summary": collection.type,
            "start": {"date": collection.date.isoformat()},
            "end": {"date": (collection.date + timedelta(days=1)).isoformat()},
            "allDay": True,
        }

custom_components/waste_collection_schedule/__init__.py METASEP
"""Waste Collection Schedule Component."""
import logging
import site
from pathlib import Path
from random import randrange

import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
import voluptuous as vol
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import dispatcher_send

from .const import DOMAIN, UPDATE_SENSORS_SIGNAL

from homeassistant.helpers.event import async_call_later  # isort:skip
from homeassistant.helpers.event import async_track_time_change  # isort:skip

# add module directory to path
package_dir = Path(__file__).resolve().parents[0]
site.addsitedir(str(package_dir))
from waste_collection_schedule import Customize, Scraper  # type: ignore # isort:skip # noqa: E402

_LOGGER = logging.getLogger(__name__)

CONF_SOURCES = "sources"
CONF_SOURCE_NAME = "name"
CONF_SOURCE_ARGS = "args"  # scraper-source arguments
CONF_SOURCE_CALENDAR_TITLE = "calendar_title"
CONF_SEPARATOR = "separator"
CONF_FETCH_TIME = "fetch_time"
CONF_RANDOM_FETCH_TIME_OFFSET = "random_fetch_time_offset"
CONF_DAY_SWITCH_TIME = "day_switch_time"

CONF_CUSTOMIZE = "customize"
CONF_TYPE = "type"
CONF_ALIAS = "alias"
CONF_SHOW = "show"
CONF_ICON = "icon"
CONF_PICTURE = "picture"

CUSTOMIZE_CONFIG = vol.Schema(
    {
        vol.Optional(CONF_TYPE): cv.string,
        vol.Optional(CONF_ALIAS): cv.string,
        vol.Optional(CONF_SHOW): cv.boolean,
        vol.Optional(CONF_ICON): cv.icon,
        vol.Optional(CONF_PICTURE): cv.string,
    }
)

SOURCE_CONFIG = vol.Schema(
    {
        vol.Required(CONF_SOURCE_NAME): cv.string,
        vol.Required(CONF_SOURCE_ARGS): dict,
        vol.Optional(CONF_CUSTOMIZE, default=[]): vol.All(
            cv.ensure_list, [CUSTOMIZE_CONFIG]
        ),
        vol.Optional(CONF_SOURCE_CALENDAR_TITLE): cv.string,
    }
)

CONFIG_SCHEMA = vol.Schema(
    {
        DOMAIN: vol.Schema(
            {
                vol.Required(CONF_SOURCES): vol.All(cv.ensure_list, [SOURCE_CONFIG]),
                vol.Optional(CONF_SEPARATOR, default=", "): cv.string,
                vol.Optional(CONF_FETCH_TIME, default="01:00"): cv.time,
                vol.Optional(
                    CONF_RANDOM_FETCH_TIME_OFFSET, default=60
                ): cv.positive_int,
                vol.Optional(CONF_DAY_SWITCH_TIME, default="10:00"): cv.time,
            }
        )
    },
    extra=vol.ALLOW_EXTRA,
)


async def async_setup(hass: HomeAssistant, config: dict):
    """Set up the component. config contains data from configuration.yaml."""
    # create empty api object as singleton
    api = WasteCollectionApi(
        hass,
        separator=config[DOMAIN][CONF_SEPARATOR],
        fetch_time=config[DOMAIN][CONF_FETCH_TIME],
        random_fetch_time_offset=config[DOMAIN][CONF_RANDOM_FETCH_TIME_OFFSET],
        day_switch_time=config[DOMAIN][CONF_DAY_SWITCH_TIME],
    )

    # create scraper(s)
    for source in config[DOMAIN][CONF_SOURCES]:
        # create customize object
        customize = {}
        for c in source.get(CONF_CUSTOMIZE, {}):
            customize[c[CONF_TYPE]] = Customize(
                waste_type=c[CONF_TYPE],
                alias=c.get(CONF_ALIAS),
                show=c.get(CONF_SHOW, True),
                icon=c.get(CONF_ICON),
                picture=c.get(CONF_PICTURE),
            )
        api.add_scraper(
            source_name=source[CONF_SOURCE_NAME],
            customize=customize,
            calendar_title=source.get(CONF_SOURCE_CALENDAR_TITLE),
            source_args=source.get(CONF_SOURCE_ARGS, {}),
        )

    # store api object
    hass.data.setdefault(DOMAIN, api)

    # load calendar platform
    await hass.helpers.discovery.async_load_platform(
        "calendar", DOMAIN, {"api": api}, config
    )

    # initial fetch of all data
    hass.add_job(api._fetch)

    return True


class WasteCollectionApi:
    def __init__(
        self, hass, separator, fetch_time, random_fetch_time_offset, day_switch_time
    ):
        self._hass = hass
        self._scrapers = []
        self._separator = separator
        self._fetch_time = fetch_time
        self._random_fetch_time_offset = random_fetch_time_offset
        self._day_switch_time = day_switch_time

        # start timer to fetch date once per day
        async_track_time_change(
            hass,
            self._fetch_callback,
            self._fetch_time.hour,
            self._fetch_time.minute,
            self._fetch_time.second,
        )

        # start timer for day-switch time
        if self._day_switch_time != self._fetch_time:
            async_track_time_change(
                hass,
                self._update_sensors_callback,
                self._day_switch_time.hour,
                self._day_switch_time.minute,
                self._day_switch_time.second,
            )

        # add a timer at midnight (if not already there) to update days-to
        midnight = dt_util.parse_time("00:00")
        if midnight != self._fetch_time and midnight != self._day_switch_time:
            async_track_time_change(
                hass,
                self._update_sensors_callback,
                midnight.hour,
                midnight.minute,
                midnight.second,
            )

    @property
    def separator(self):
        """Separator string, used to separator waste types."""
        return self._separator

    @property
    def fetch_time(self):
        """When to fetch to data."""
        return self._fetch_time

    @property
    def day_switch_time(self):
        """When to hide entries for today."""
        return self._day_switch_time

    def add_scraper(self, source_name, customize, source_args, calendar_title):
        self._scrapers.append(
            Scraper.create(
                source_name=source_name,
                customize=customize,
                source_args=source_args,
                calendar_title=calendar_title,
            )
        )

    def _fetch(self, *_):
        for scraper in self._scrapers:
            scraper.fetch()

        self._update_sensors_callback()

    @property
    def scrapers(self):
        return self._scrapers

    def get_scraper(self, index):
        return self._scrapers[index] if index < len(self._scrapers) else None

    @callback
    def _fetch_callback(self, *_):
        async_call_later(
            self._hass,
            randrange(0, 60 * self._random_fetch_time_offset),
            self._fetch_now_callback,
        )

    @callback
    def _fetch_now_callback(self, *_):
        self._hass.add_job(self._fetch)

    @callback
    def _update_sensors_callback(self, *_):
        dispatcher_send(self._hass, UPDATE_SENSORS_SIGNAL)

custom_components/waste_collection_schedule/waste_collection_schedule/scraper.py METASEP
#!/usr/bin/env python3

import datetime
import importlib
import itertools
import logging
import traceback
from typing import Dict, List, Optional

from .collection import Collection, CollectionGroup

_LOGGER = logging.getLogger(__name__)


class Customize:
    """Customize one waste collection type."""

    def __init__(self, waste_type, alias=None, show=True, icon=None, picture=None):
        self._waste_type = waste_type
        self._alias = alias
        self._show = show
        self._icon = icon
        self._picture = picture

    @property
    def waste_type(self):
        return self._waste_type

    @property
    def alias(self):
        return self._alias

    @property
    def show(self):
        return self._show

    @property
    def icon(self):
        return self._icon

    @property
    def picture(self):
        return self._picture

    def __repr__(self):
        return f"Customize{{waste_type={self._waste_type}, alias={self._alias}, show={self._show}, icon={self._icon}, picture={self._picture}}}"


def filter_function(entry: Collection, customize: Dict[str, Customize]):
    c = customize.get(entry.type)
    if c is None:
        return True
    else:
        return c.show


def customize_function(entry: Collection, customize: Dict[str, Customize]):
    c = customize.get(entry.type)
    if c is not None:
        if c.alias is not None:
            entry.set_type(c.alias)
        if c.icon is not None:
            entry.set_icon(c.icon)
        if c.picture is not None:
            entry.set_picture(c.picture)
    return entry


class Scraper:
    def __init__(
        self,
        source,
        customize: Dict[str, Customize],
        title: str,
        description: str,
        url: Optional[str],
        calendar_title: Optional[str],
    ):
        self._source = source
        self._customize = customize
        self._title = title
        self._description = description
        self._url = url
        self._calendar_title = calendar_title
        self._refreshtime = None
        self._entries: List[Collection] = []

    @property
    def source(self):
        return self._source

    @property
    def refreshtime(self):
        return self._refreshtime

    @property
    def title(self):
        return self._title

    @property
    def description(self):
        return self._description

    @property
    def url(self):
        return self._url

    @property
    def calendar_title(self):
        return self._calendar_title or self._title

    def fetch(self):
        """Fetch data from source."""
        try:
            # fetch returns a list of Collection's
            entries = self._source.fetch()
        except Exception:
            _LOGGER.error(
                f"fetch failed for source {self._title}:\n{traceback.format_exc()}"
            )
            return
        self._refreshtime = datetime.datetime.now()

        # strip whitespaces
        for e in entries:
            e.set_type(e.type.strip())

        # filter hidden entries
        entries = filter(lambda x: filter_function(x, self._customize), entries)

        # customize fetched entries
        entries = map(lambda x: customize_function(x, self._customize), entries)

        self._entries = list(entries)

    def get_types(self):
        """Return set() of all collection types."""
        types = set()
        for e in self._entries:
            types.add(e.type)
        return types

    def get_upcoming(self, count=None, leadtime=None, types=None, include_today=False):
        """Return list of all entries, limited by count and/or leadtime.

        Keyword arguments:
        count -- limits the number of returned entries (default=10)
        leadtime -- limits the timespan in days of returned entries (default=7, 0 = today)
        """
        return self._filter(
            self._entries,
            count=count,
            leadtime=leadtime,
            types=types,
            include_today=include_today,
        )

    def get_upcoming_group_by_day(
        self, count=None, leadtime=None, types=None, include_today=False
    ):
        """Return list of all entries, grouped by day, limited by count and/or leadtime."""
        entries = []

        iterator = itertools.groupby(
            self._filter(
                self._entries,
                leadtime=leadtime,
                types=types,
                include_today=include_today,
            ),
            lambda e: e.date,
        )

        for key, group in iterator:
            entries.append(CollectionGroup.create(list(group)))
        if count is not None:
            entries = entries[:count]

        return entries

    def _filter(
        self, entries, count=None, leadtime=None, types=None, include_today=False
    ):
        # remove unwanted waste types
        if types is not None:
            # generate set
            types_set = {t for t in types}
            entries = list(filter(lambda e: e.type in types_set, self._entries))

        # remove expired entries
        now = datetime.datetime.now().date()
        if include_today:
            entries = list(filter(lambda e: e.date >= now, entries))
        else:
            entries = list(filter(lambda e: e.date > now, entries))

        # remove entries which are too far in the future (0 = today)
        if leadtime is not None:
            x = now + datetime.timedelta(days=leadtime)
            entries = list(filter(lambda e: e.date <= x, entries))

        # ensure that entries are sorted by date
        entries.sort(key=lambda e: e.date)

        # remove surplus entries
        if count is not None:
            entries = entries[:count]

        return entries

    @staticmethod
    def create(
        source_name: str,
        customize: Dict[str, Customize],
        source_args,
        calendar_title: Optional[str] = None,
    ):
        # load source module

        # for home-assistant, use the last 3 folders, e.g. custom_component/wave_collection_schedule/waste_collection_schedule
        # otherwise, only use waste_collection_schedule
        try:
            source_module = importlib.import_module(
                f"waste_collection_schedule.source.{source_name}"
            )
        except ImportError:
            _LOGGER.error(f"source not found: {source_name}")
            return

        # create source
        source = source_module.Source(**source_args)  # type: ignore

        # create scraper
        g = Scraper(
            source=source,
            customize=customize,
            title=source_module.TITLE,  # type: ignore[attr-defined]
            description=source_module.DESCRIPTION,  # type: ignore[attr-defined]
            url=source_module.URL,  # type: ignore[attr-defined]
            calendar_title=calendar_title,
        )

        return g

custom_components/waste_collection_schedule/waste_collection_schedule/collection.py METASEP
import datetime


class CollectionBase(dict):  # inherit from dict to enable JSON serialization
    def __init__(self, date: datetime.date, icon: str = None, picture: str = None):
        dict.__init__(self, date=date.isoformat(), icon=icon, picture=picture)
        self._date = date  # store date also as python date object

    @property
    def date(self):
        return self._date

    @property
    def daysTo(self):
        return (self._date - datetime.datetime.now().date()).days

    @property
    def icon(self):
        return self["icon"]

    def set_icon(self, icon: str):
        self["icon"] = icon

    @property
    def picture(self):
        return self["picture"]

    def set_picture(self, picture: str):
        self["picture"] = picture


class Collection(CollectionBase):
    def __init__(
        self, date: datetime.date, t: str, icon: str = None, picture: str = None
    ):
        CollectionBase.__init__(self, date=date, icon=icon, picture=picture)
        self["type"] = t

    @property
    def type(self):
        return self["type"]

    def set_type(self, t: str):
        self["type"] = t

    def __repr__(self):
        return f"Collection{{date={self.date}, type={self.type}}}"


class CollectionGroup(CollectionBase):
    def __init__(self, date: datetime.date):
        CollectionBase.__init__(self, date=date)

    @staticmethod
    def create(group):
        """Create from list of Collection's."""
        x = CollectionGroup(group[0].date)
        if len(group) == 1:
            x.set_icon(group[0].icon)
            x.set_picture(group[0].picture)
        else:
            x.set_icon(f"mdi:numeric-{len(group)}-box-multiple")
        x["types"] = list(it.type for it in group)
        return x

    @property
    def types(self):
        return self["types"]

    def __repr__(self):
        return f"CollectionGroup{{date={self.date}, types={self.types}}}"

custom_components/waste_collection_schedule/waste_collection_schedule/__init__.py METASEP
from .collection import Collection, CollectionBase, CollectionGroup  # type: ignore # isort:skip # noqa: F401
from .scraper import Customize, Scraper  # noqa: F401

custom_components/waste_collection_schedule/waste_collection_schedule/source/york_gov_uk.py METASEP
import json
from datetime import datetime

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "York.gov.uk"
DESCRIPTION = "Source for York.gov.uk services for the city of York, UK."
URL = "https://york.gov.uk"
TEST_CASES = {
    "Reighton Avenue, York": {"uprn": "100050580641"},
    "Granary Walk, York": {"uprn": "010093236548"},
}

ICONS = {
    "REFUSE": "mdi:trash-can",
    "RECYCLING": "mdi:recycle",
    "GARDEN": "mdi:leaf",
}


class Source:
    def __init__(self, uprn):
        self._uprn = uprn

    def fetch(self):
        # get json file
        r = requests.get(
            f"https://waste-api.york.gov.uk/api/GetBinCalendarDataForUprn/{self._uprn}"
        )

        # extract data from json
        data = json.loads(r.text)

        entries = []

        for collection in data["collections"]:
            try:
                entries.append(
                    Collection(
                        date=datetime.strptime(
                            collection["date"], "%Y-%m-%dT%H:%M:%S"
                        ).date(),
                        t=collection["roundType"].title(),
                        icon=ICONS[collection["roundType"]],
                    )
                )
            except ValueError:
                pass  # ignore date conversion failure for not scheduled collections

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/ximmio_nl.py METASEP
from datetime import datetime, timedelta

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Ximmio"
DESCRIPTION = "Source for Ximmio B.V. waste collection."
URL = "https://www.ximmio.nl"
TEST_CASES = {
    "ACV Group": {"company": "acv", "post_code": "6721MH", "house_number": 1},
    "Meerlanden": {"company": "meerlanden", "post_code": "1435BX", "house_number": 650},
    "Almere": {"company": "almere", "post_code": "1318NG", "house_number": 15},
}


SERVICE_URLS = {
    "meerlanden": "https://wasteprod2api.ximmio.com",
    "rad": "https://wasteprod2api.ximmio.com",
}

SERVICE_IDS = {
    "acv": "f8e2844a-095e-48f9-9f98-71fceb51d2c3",
    "almere": "53d8db94-7945-42fd-9742-9bbc71dbe4c1",
    "areareiniging": "adc418da-d19b-11e5-ab30-625662870761",
    "avri": "78cd4156-394b-413d-8936-d407e334559a",
    "bar": "bb58e633-de14-4b2a-9941-5bc419f1c4b0",
    "hellendoorn": "24434f5b-7244-412b-9306-3a2bd1e22bc1",
    "meerlanden": "800bf8d7-6dd1-4490-ba9d-b419d6dc8a45",
    "meppel": "b7a594c7-2490-4413-88f9-94749a3ec62a",
    "rad": "13a2cad9-36d0-4b01-b877-efcb421a864d",
    "reinis": "9dc25c8a-175a-4a41-b7a1-83f237a80b77",
    "twentemilieu": "8d97bb56-5afd-4cbc-a651-b4f7314264b4",
    "waardlanden": "942abcf6-3775-400d-ae5d-7380d728b23c",
    "ximmio": "800bf8d7-6dd1-4490-ba9d-b419d6dc8a45",
}


class Source:
    def __init__(self, company, post_code, house_number):
        self._post_code = post_code
        self._house_number = house_number
        self._url = SERVICE_URLS.get(company, "https://wasteapi.ximmio.com")
        self._company_code = SERVICE_IDS[company]

    def fetch(self):
        data = {
            "postCode": self._post_code,
            "houseNumber": self._house_number,
            "companyCode": self._company_code,
        }
        r = requests.post(f"{self._url}/api/FetchAdress", data=data)
        d = r.json()

        dataList = d["dataList"][0]
        data = {
            "uniqueAddressID": dataList["UniqueId"],
            "startDate": datetime.now().strftime("%Y-%m-%d"),
            "endDate": (datetime.now() + timedelta(days=365)).strftime("%Y-%m-%d"),
            "companyCode": self._company_code,
            "community": dataList.get("Community", ""),
        }
        r = requests.post(f"{self._url}/api/GetCalendar", data=data)
        d = r.json()

        entries = []
        for wasteType in d["dataList"]:
            for date in wasteType["pickupDates"]:
                entries.append(
                    Collection(
                        date=datetime.strptime(date, "%Y-%m-%dT%H:%M:%S").date(),
                        t=wasteType["_pickupTypeText"],
                    )
                )
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/wuerzburg_de.py METASEP
import datetime

import requests
from bs4 import BeautifulSoup
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Abfallkalender Würzburg"
DESCRIPTION = "Source for waste collection in the city of Würzburg, Germany."
URL = "https://www.wuerzburg.de/themen/umwelt-verkehr/vorsorge-entsorgung/abfallkalender/32208.Abfallkalender.html"
TEST_CASES = {
    "District only": {"district": "Altstadt"},
    "Street only": {"street": "Juliuspromenade"},
    "District + Street": {"district": "Altstadt", "street": "Juliuspromenade"},
    "District + Street diff": {"district": "Altstadt", "street": "Oberer Burgweg"},
}


class Source:
    def __init__(self, district: str = None, street: str = None):
        self._district = district
        self._street = street
        self._district_id = None

    @staticmethod
    def map_district_id(district: str = None, street: str = None):
        """Map `street` or `district` to `district_id`, giving priority to `street`.

        Parameters must exactly be the same as visible in dropdowns on `URL`.
        """
        if not district and not street:
            raise ValueError("One of ['district', 'street'] is required.")

        r = requests.get(URL)
        r.raise_for_status()
        selects = BeautifulSoup(r.content, "html.parser").body.find_all("select")

        if street:
            strlist = next(iter([s for s in selects if s["id"] == "strlist"]))
            strdict = {option.text: option["value"] for option in strlist}

            try:
                return strdict[street]
            except KeyError:
                raise KeyError(
                    f"Unable to find street '{street}'. Please compare exact typing with {URL}"
                )

        if district:
            reglist = next(iter([s for s in selects if s["id"] == "reglist"]))
            regdict = {
                option.text: option.attrs["value"] for option in reglist.contents
            }

            try:
                return regdict[district]
            except KeyError:
                raise KeyError(
                    f"Unable to find district '{district}'. Please compare exact typing with {URL}"
                )

    def fetch(self):
        # Get & parse full HTML only on first call to fetch() to map district or street to district_id
        if not self._district_id:
            self._district_id = self.map_district_id(self._district, self._street)

        if not self._district_id:
            raise ValueError("'_district_id' is not set!")

        now = datetime.datetime.now().date()

        r = requests.get(
            URL,
            params={
                "_func": "evList",
                "_mod": "events",
                "ev[start]": str(now),
                "ev[end]": str(now + datetime.timedelta(days=365)),
                "ev[addr]": self._district_id,
            },
        )
        r.raise_for_status()

        entries = []
        for event in r.json()["contents"].values():
            entries.append(
                Collection(
                    datetime.datetime.fromisoformat(event["start"]).date(),
                    event["title"],
                    picture=event.get("thumb", {}).get("url"),
                )
            )

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/wellington_govt_nz.py METASEP
import datetime
import json

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS

TITLE = "Wellington City Council"
DESCRIPTION = "Source for Wellington City Council."
URL = "https://wellington.govt.nz"
TEST_CASES = {
    "Chelsea St": {"streetName": "chelsea street"},  # Friday
    "Campbell St (ID Only)": {"streetId": "6515"},  # Wednesday
}


ICON_MAP = {
    "Rubbish Collection": "mdi:trash-can",
    "Glass crate": "mdi:glass-fragile",
    "Wheelie bin or recycling bags": "mdi:recycle",
}

PICTURE_MAP = {
    "Rubbish Collection": "https://wellington.govt.nz/assets/images/rubbish-recycling/rubbish-bag.png",
    "Glass crate": "https://wellington.govt.nz/assets/images/rubbish-recycling/glass-crate.png",
    "Wheelie bin or recycling bags": "https://wellington.govt.nz/assets/images/rubbish-recycling/wheelie-bin.png",
}


class Source:
    def __init__(self, streetId=None, streetName=None):
        self._streetId = streetId
        self._streetName = streetName
        self._ics = ICS()

    def fetch(self):
        # get token
        if self._streetName:
            url = "https://wellington.govt.nz/layouts/wcc/GeneralLayout.aspx/GetRubbishCollectionStreets"
            data = {"partialStreetName": self._streetName}
            r = requests.post(url, json=data)
            data = json.loads(r.text)
            if len(data["d"]) == 0:
                raise Exception(f"No result found for streetName {self._streetName}")
            if len(data["d"]) > 1:
                raise Exception(
                    f"More then one result returned for streetName {self._streetName}, be more specific or use streetId instead"
                )
            self._streetId = data["d"][0].get("Key")

        if not self._streetId:
            raise Exception("No streetId supplied")

        url = "https://wellington.govt.nz/~/ical/"
        params = {
            "type": "recycling",
            "streetId": self._streetId,
            "forDate": datetime.date.today(),
        }
        r = requests.get(url, params=params)

        if not r.text.startswith("BEGIN:VCALENDAR"):
            raise Exception(f"{self._streetId} is not a valid streetID")

        dates = self._ics.convert(r.text)

        entries = []
        for d in dates:
            for wasteType in d[1].split("&"):
                wasteType = wasteType.strip()
                entries.append(
                    Collection(
                        d[0],
                        wasteType,
                        picture=PICTURE_MAP[wasteType],
                        icon=ICON_MAP[wasteType],
                    )
                )
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/wastenet_org_nz.py METASEP
import datetime
import re
from html.parser import HTMLParser

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Wastenet"
DESCRIPTION = "Source for Wastenet.org.nz."
URL = "http://www.wastenet.org.nz"
TEST_CASES = {
    "166 Lewis Street": {"address": "166 Lewis Street INVERCARGILL"},  # Monday
    "199 Crawford Street": {"address": "199 Crawford Street INVERCARGILL"},  # Tuesday
    "156 Tay Street": {"address": "156 Tay Street INVERCARGILL"},  # Wednesday
    "31 Conyers Street": {"address": "31 Conyers Street INVERCARGILL"},  # Thursday
    "67 Chesney Street": {"address": "67 Chesney Street INVERCARGILL"},  # Friday
}

MONTH = {
    "January": 1,
    "February": 2,
    "March": 3,
    "April": 4,
    "May": 5,
    "June": 6,
    "July": 7,
    "August": 8,
    "September": 9,
    "October": 10,
    "November": 11,
    "December": 12,
}


def toDate(match):
    # match is based on following input string: 21 April 2021
    # regex: (\d+) (\w+) (\d+)
    return datetime.date(
        int(match.group(3)), MONTH[match.group(2)], int(match.group(1))
    )


# Parser for <div> element with class wasteSearchResults
class WasteSearchResultsParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self._entries = []
        self._wasteType = None
        self._withinCollectionDay = False
        self._todaysDate = None

    @property
    def entries(self):
        return self._entries

    def handle_starttag(self, tag, attrs):
        if tag == "div":
            d = dict(attrs)
            if d.get("class", "").startswith("wasteSearchResults "):
                self._wasteType = d["class"][19:]  # remove "wasteSearchResults "

    def handle_data(self, data):
        match = re.search(r"Todays Date: \w+, (\d+) (\w+) (\d+)", data)
        if match:
            self._todaysDate = toDate(match)
        elif data == "Next Collection Day":
            self._withinCollectionDay = True
        elif self._withinCollectionDay:
            date = None
            if data.strip().lower() == "today":
                date = self._todaysDate
            elif data.strip().lower() == "tomorrow":
                date = self._todaysDate + datetime.timedelta(days=1)
            else:
                date = toDate(re.search(r"(\d+) (\w+) (\d+)", data))

            if self._wasteType is not None:
                self._entries.append(Collection(date, self._wasteType))

            self._withinCollectionDay = False


class Source:
    def __init__(
        self, address,
    ):
        self._address = address

    def fetch(self):
        # get token
        params = {"view": 1, "address": self._address}

        r = requests.get(
            "http://www.wastenet.org.nz/RecycleRubbish/WasteCollectionSearch.aspx",
            params=params,
        )

        p = WasteSearchResultsParser()
        p.feed(r.text)
        return p.entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/was_wolfsburg_de.py METASEP
import datetime
import re

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS

TITLE = "Wolfsburger Abfallwirtschaft und Straßenreinigung"
DESCRIPTION = "Source for waste collections for WAS-Wolfsburg, Germany."
URL = "https://was-wolfsburg.de"
TEST_CASES = {"WAS": {"city": "Barnstorf", "street": "Bahnhofspassage"}}


class Source:
    def __init__(self, city, street):
        self._city = city
        self._street = street
        self._ics = ICS()

    def fetch(self):
        # fetch "Gelber Sack"
        args = {"g": self._city}
        r = requests.get(
            "https://was-wolfsburg.de/subgelberweihgarten/php/abfuhrgelber.php",
            params=args,
        )

        entries = []
        match = re.findall(r"(\d{2})\.(\d{2})\.(\d{4})", r.text)
        for m in match:
            date = datetime.date(day=int(m[0]), month=int(m[1]), year=int(m[2]))
            entries.append(Collection(date, "Gelber Sack"))

        # fetch remaining collections
        args = {"ortabf": self._street}
        r = requests.post(
            "https://was-wolfsburg.de/subabfuhrtermine/ics_abfuhrtermine3.php",
            data=args,
        )
        dates = self._ics.convert(r.text)
        for d in dates:
            entries.append(Collection(d[0], d[1]))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/thehills_nsw_gov_au.py METASEP
import datetime
import json
import logging

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "The Hills Shire Council"
DESCRIPTION = "Source for Hills Shire Council, Sydney, Australia waste collection."
URL = "https://www.thehills.nsw.gov.au/"
TEST_CASES = {
    "Annangrove, Amanda Place 10": {
        "suburb": "ANNANGROVE",
        "street": "Amanda Place",
        "houseNo": 10,
    }
}

_LOGGER = logging.getLogger(__name__)


class Source:
    def __init__(self, suburb, street, houseNo):
        self._suburb = suburb
        self._street = street
        self._houseNo = str(houseNo)
        self._url = "https://apps.thehills.nsw.gov.au/seamlessproxy/api"

    def fetch(self):
        # get list of suburbs
        r = requests.get(f"{self._url}/suburbs/get")
        data = json.loads(r.text)

        suburbs = {}
        for entry in data:
            suburbs[entry["Suburb"].strip()] = entry["SuburbKey"]

        # check if suburb exists
        if self._suburb not in suburbs:
            _LOGGER.error(f"suburb not found: {self._suburb}")
            return []
        suburbKey = suburbs[self._suburb]

        # get list of streets for selected suburb
        r = requests.get(f"{self._url}/streets/{suburbKey}")
        data = json.loads(r.text)

        streets = {}
        for entry in data:
            streets[entry["Street"].strip()] = entry["StreetKey"]

        # check if street exists
        if self._street not in streets:
            _LOGGER.error(f"street not found: {self._street}")
            return []
        streetKey = streets[self._street]

        # get list of house numbers for selected street
        params = {"streetkey": streetKey, "suburbKey": suburbKey}
        r = requests.get(
            f"{self._url}/properties/GetPropertiesByStreetAndSuburbKey", params=params,
        )
        data = json.loads(r.text)

        houseNos = {}
        for entry in data:
            houseNos[
                str(int(entry["HouseNo"])) + entry.get("HouseSuffix", "").strip()
            ] = entry["PropertyKey"]

        # check if house number exists
        if self._houseNo not in houseNos:
            _LOGGER.error(f"house number not found: {self._houseNo}")
            return []
        propertyKey = houseNos[self._houseNo]

        # get collection schedule
        r = requests.get(f"{self._url}/services/{propertyKey}")
        data = json.loads(r.text)

        entries = []
        for entry in data:
            name = entry["Name"]
            for dateStr in entry["CollectionDays"]:
                date = datetime.datetime.strptime(dateStr, "%Y-%m-%dT%H:%M:%S").date()
                entries.append(Collection(date, name))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/sysav_se.py METASEP
import json
from datetime import datetime

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Sysav Sophämntning"
DESCRIPTION = "Source for Sysav waste collection."
URL = "https://www.sysav.se/Privat/min-sophamtning/"
TEST_CASES = {
    "Home": {"street_address": "Sommargatan 1, Svedala"},
    "Polisen": {"street_address": "Stationsplan 1, Svedala"},
}


class Source:
    def __init__(self, street_address):
        self._street_address = street_address

    def fetch(self):
        params = {"address": self._street_address}
        response = requests.get(
            "https://www.sysav.se/api/my-pages/PickupSchedule/findAddress",
            params=params,
        )

        address_data = json.loads(response.text)
        address = None
        if address_data and len(address_data) > 0:
            address = address_data[0]

        if not address:
            return []

        params = {"address": address}
        response = requests.get(
            "https://www.sysav.se/api/my-pages/PickupSchedule/ScheduleForAddress",
            params=params,
        )

        data = json.loads(response.text)

        entries = []
        for item in data:
            waste_type = item["WasteType"]
            icon = "mdi:trash-can"
            if waste_type == "Trädgårdsavfall":
                icon = "mdi:leaf"
            next_pickup = item["NextPickupDate"]
            next_pickup_date = datetime.fromisoformat(next_pickup).date()
            entries.append(Collection(date=next_pickup_date, t=waste_type, icon=icon))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/stuttgart_de.py METASEP
import datetime
from html.parser import HTMLParser

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Abfall Stuttgart"
DESCRIPTION = "Source for waste collections for the city of Stuttgart, Germany."
URL = "https://service.stuttgart.de/lhs-services/aws/"
TEST_CASES = {"Im Steinengarten 7": {"street": "Im Steinengarten", "streetnr": 7}}


# Parser for HTML checkbox
class InputCheckboxParser(HTMLParser):
    def __init__(self, name):
        super().__init__()
        self._name = name
        self._value = []

    @property
    def value(self):
        return self._value

    def handle_starttag(self, tag, attrs):
        if tag == "input":
            d = dict(attrs)
            if d.get("name", "") == self._name:
                self._value.append(d.get("value"))


# Parser for HTML table
class TableParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self._within_table = False
        self._within_tr = False
        self._within_th = False
        self._within_td = False
        self._col_index = 0
        self._type = ""
        self._date = ""
        self._entries = []

    @property
    def entries(self):
        return self._entries

    def handle_starttag(self, tag, attrs):
        d = dict(attrs)
        if tag == "table":
            if d.get("id", "") == "awstable":
                self._within_table = True
        elif tag == "tr":
            if self._within_table:
                self._within_tr = True
        elif tag == "th":
            if self._within_tr:
                if self._col_index == 0:
                    self._type = ""
                self._within_th = True
                self._col_index += 1
        elif tag == "td":
            if self._within_tr:
                self._within_td = True
                self._col_index += 1

    def handle_endtag(self, tag):
        if tag == "table":
            self._within_table = False
            self._type = ""
            self._date = ""
        elif tag == "tr":
            if self._within_tr and len(self._date) > 0:
                date = datetime.datetime.strptime(self._date, "%d.%m.%Y").date()
                self._entries.append(Collection(date, self._type))
                self._date = ""
            self._within_tr = False
            self._within_th = False
            self._within_td = False
            self._col_index = 0
        elif tag == "th":
            self._within_th = False
            self._type = self._type.strip()
        elif tag == "td":
            self._within_td = False

    def handle_data(self, data):
        if self._within_th and self._col_index == 1:
            self._type += data
        elif self._within_td and self._col_index == 2:
            self._date += data


class Source:
    def __init__(self, street, streetnr):
        self._street = street
        self._streetnr = streetnr

    def fetch(self):
        # get waste types
        r = requests.get("https://service.stuttgart.de/lhs-services/aws/abfuhrtermine")
        wastetypes = InputCheckboxParser(name="calendar[wastetype][]")
        wastetypes.feed(r.text)

        now = datetime.datetime.now()
        args = [
            ("calendar[street]", self._street),
            ("calendar[streetnr]", self._streetnr),
            ("calendar[datefrom]", now.strftime("%d.%m.%Y")),
            ("calendar[dateto]", f"31.01.{now.year+1}"),
        ]
        for w in wastetypes.value:
            args.append(("calendar[wastetype][]", w))
        args.append(("calendar[submit]", ""))

        r = requests.post(
            "https://service.stuttgart.de/lhs-services/aws/abfuhrtermine", data=args
        )
        entries_parser = TableParser()
        entries_parser.feed(r.text)
        return entries_parser.entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/stonnington_vic_gov_au.py METASEP
import logging
from datetime import datetime
import re

import requests
from bs4 import BeautifulSoup
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Stonnington City Council"
DESCRIPTION = "Source for Stonnington City Council rubbish collection."
URL = "https://www.stonnington.vic.gov.au/Services/Waste-and-recycling"
TEST_CASES = {
    "The Jam Factory": {"street_address": "500 Chapel Street, South Yarra"},
    "Malvern Library": {"street_address": "1255 High Street, Malvern"},
}

_LOGGER = logging.getLogger(__name__)

ICON_MAP = {
    "Food and Green Waste": "mdi:leaf",
    "Hard Waste": "mdi:sofa",
    "Recycling": "mdi:recycle",
}


class Source:
    def __init__(self, street_address):
        self._street_address = street_address

    def fetch(self):
        session = requests.Session()

        response = session.get(
            "https://www.stonnington.vic.gov.au/Services/Waste-and-recycling"
        )
        response.raise_for_status()

        response = session.get(
            "https://www.stonnington.vic.gov.au/api/v1/myarea/search",
            params={"keywords": self._street_address},
        )
        response.raise_for_status()
        addressSearchApiResults = response.json()
        if (
            addressSearchApiResults["Items"] is None
            or len(addressSearchApiResults["Items"]) < 1
        ):
            _LOGGER.error(
                f"Address search for '{self._street_address}' returned no results. Check your address on https://www.stonnington.vic.gov.au/Services/Waste-and-recycling"
            )
            return []

        addressSearchTopHit = addressSearchApiResults["Items"][0]
        _LOGGER.debug("Address search top hit: %s", addressSearchTopHit)

        geolocationid = addressSearchTopHit["Id"]
        _LOGGER.debug("Geolocationid: %s", geolocationid)

        response = session.get(
            "https://www.stonnington.vic.gov.au/ocapi/Public/myarea/wasteservices?ocsvclang=en-AU",
            params={"geolocationid": geolocationid},
        )
        response.raise_for_status()

        wasteApiResult = response.json()
        _LOGGER.debug("Waste API result: %s", wasteApiResult)

        soup = BeautifulSoup(wasteApiResult["responseContent"], "html.parser")

        entries = []
        for article in soup.find_all("article"):
            waste_type = article.h3.string
            icon = ICON_MAP.get(waste_type, "mdi:trash-can")
            next_pickup = article.find(class_="next-service").string.strip()
            if re.match("[^\s]* \d{1,2}\/\d{1,2}\/\d{4}", next_pickup):
                next_pickup_date = datetime.strptime(
                    next_pickup.split(sep=" ")[1], "%d/%m/%Y"
                ).date()
                entries.append(Collection(date=next_pickup_date, t=waste_type, icon=icon))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/stadtreinigung_leipzig_de.py METASEP
import json
import logging

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS

_LOGGER = logging.getLogger(__name__)

TITLE = "Stadtreinigung Leipzig"
DESCRIPTION = "Source for Stadtreinigung Leipzig."
URL = "https://stadtreinigung-leipzig.de"
TEST_CASES = {"Bahnhofsallee": {"street": "Bahnhofsallee", "house_number": 7}}


class Source:
    def __init__(self, street, house_number):
        self._street = street
        self._house_number = house_number
        self._ics = ICS()

    def fetch(self):
        params = {
            "name": self._street,
        }

        # get list of streets and house numbers
        r = requests.get(
            "https://stadtreinigung-leipzig.de/rest/wastecalendarstreets", params=params
        )

        data = json.loads(r.text)
        if len(data["results"]) == 0:
            _LOGGER.error(f"street not found: {self._street}")
            return []
        street_entry = data["results"].get(self._street)
        if street_entry is None:
            _LOGGER.error(f"street not found: {self._street}")
            return []

        id = street_entry.get(str(self._house_number))
        if id is None:
            _LOGGER.error(f"house_number not found: {self._house_number}")
            return []

        # get ics file
        params = {
            "position_nos": id,
        }
        r = requests.get(
            "https://stadtreinigung-leipzig.de/wir-kommen-zu-ihnen/abfallkalender/ical.ics",
            params=params,
        )
        dates = self._ics.convert(r.text)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1].removesuffix(", ")))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/stadtreinigung_hamburg.py METASEP
import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS

TITLE = "Stadtreinigung Hamburg"
DESCRIPTION = "Source for Stadtreinigung Hamburg waste collection."
URL = "https://www.stadtreinigung.hamburg"
TEST_CASES = {
    "Zabelweg 1B": {"hnId": 53814},
}


class Source:
    def __init__(self, hnId, asId=None):
        self._hnId = hnId
        self._ics = ICS()

    def fetch(self):
        args = {"hnIds": self._hnId, "adresse": "MeineAdresse"}

        # get ics file
        r = requests.get(
            "https://backend.stadtreinigung.hamburg/kalender/abholtermine.ics",
            params=args,
        )

        dates = self._ics.convert(r.text)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1]))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/sector27_de.py METASEP
import datetime
import json
import logging
import re

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Sector 27"
DESCRIPTION = "Source for Muellkalender in Kreis RE."
URL = "https://muellkalender.sector27.de"
TEST_CASES = {
    "Datteln": {"city": "Datteln", "street": "Am Bahnhof"},
    "Marl": {"city": "Marl", "street": "Ahornweg"},
    "Oer-Erkenschick": {"city": "Oer-Erkenschwick", "street": "An der Zechenbahn"},
}

_LOGGER = logging.getLogger(__name__)

CITIES = {
    "Datteln": {"idCity": 9, "licenseKey": "DTTLN20137REKE382EHSE"},
    "Marl": {"idCity": 3, "licenseKey": "MRL3102HBBUHENWIP"},
    "Oer-Erkenschwick": {"idCity": 8, "licenseKey": "OSC1115KREHDFESEK"},
}

HEADERS = {"user-agent": "Mozilla/5.0"}


class Source:
    def __init__(self, city, street):
        self._city = city
        self._street = street

    def getviewYearRange(self):
        yRange = []

        now = datetime.datetime.now()

        month = now.month
        year = now.year

        d = datetime.datetime(year, 1, 1, hour=12)

        yRange.append(int(datetime.datetime.timestamp(d)))

        # in november & december always fetch next year also
        if month > 8:
            d = datetime.datetime(year + 1, 1, 1, hour=12)
            yRange.append(int(datetime.datetime.timestamp(d)))

        return yRange

    def fetch(self):
        city = CITIES.get(self._city)
        if city is None:
            _LOGGER.error(f"city not found {self._city}")
            return []

        args = city
        args["searchFor"] = self._street

        r = requests.get(
            "https://muellkalender.sector27.de/web/searchForStreets",
            params=args,
            headers=HEADERS,
        )
        streets = {
            e["name"].strip(): e["id"] for (e) in json.loads(extractJson(r.text))
        }

        if self._street not in streets:
            _LOGGER.error(f"street not found {self._street}")
            return []

        args = {
            "licenseKey": city["licenseKey"],
            "cityId": city["idCity"],
            "streetId": streets[self._street],
            "viewrange": "yearRange",
        }

        entries = []

        for dt in self.getviewYearRange():
            args["viewdate"] = dt

            r = requests.get(
                "https://muellkalender.sector27.de/web/fetchPickups",
                params=args,
                headers=HEADERS,
            )
            data = json.loads(extractJson(r.text))

            for ts, pickups in data["pickups"].items():
                for pickup in pickups:
                    type = pickup["label"]
                    pickupdate = datetime.date.fromtimestamp(int(ts))
                    entries.append(Collection(pickupdate, type))

        return entries


def extractJson(text):
    m = re.fullmatch(r"callbackFunc\((.*)\);", text)
    return m.group(1) if m else text

custom_components/waste_collection_schedule/waste_collection_schedule/source/seattle_gov.py METASEP
import datetime
import json

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Seattle Public Utilities"
DESCRIPTION = "Source for Seattle Public Utilities waste collection."
URL = "https://myutilities.seattle.gov/eportal/#/accountlookup/calendar"
TEST_CASES = {
    "City Hall": {"street_address": "600 4th Ave"},
    "Ballard Builders": {"street_address": "7022 12th Ave NW"},
    "Carmona Court": {"street_address": "1127 17th Ave E"},
    "2111 E John St": {"street_address":"2111 E John St","prem_code": "DRMGcnGxUEg+gu8pN8vesQ=="}
}


def get_service_icon(service_name):
    switcher = {"Garbage": "trash-can", "Recycle": "recycle", "Food/Yard Waste": "leaf"}
    return switcher.get(service_name, "trash-can")


class Source:
    def __init__(self, street_address, prem_code = None):
        self._street_address = street_address
        self._prem_code = prem_code

    def fetch(self):

        # Mimicking the same API calls the calendar lookup page uses:
        # 1. find account code
        # 2. find account ID for a given address
        # 3. get token (uses basic auth and the customerID = guest
        # 4. get account summary
        # 5. get calendar

        # step 1 - Look up premCode if it wasn't explicitly provided
        if self._prem_code is None:
            find_address_payload = {
                "address": {"addressLine1": self._street_address, "city": "", "zip": ""}
            }

            r = requests.post(
                "https://myutilities.seattle.gov/rest/serviceorder/findaddress",
                json=find_address_payload,
            )

            address_info = json.loads(r.text)
            prem_code = address_info["address"][0]["premCode"]
        else:
            prem_code = self._prem_code

        # step 2
        find_account_payload = {"address": {"premCode": prem_code}}

        r = requests.post(
            "https://myutilities.seattle.gov/rest/serviceorder/findAccount",
            json=find_account_payload,
        )

        account_info = json.loads(r.text)
        account_number = account_info["account"]["accountNumber"]

        # step 3
        token_payload = {
            "grant_type": "password",
            "username": "guest",
            "password": "guest",
        }

        r = requests.post(
            "https://myutilities.seattle.gov/rest/auth/guest", data=token_payload
        )

        token_info = json.loads(r.text)
        token = token_info["access_token"]

        headers = {"Authorization": f"Bearer {token}"}

        # step 4
        swsummary_payload = {
            "customerId": "guest",
            "accountContext": {
                "accountNumber": account_number,
                "personId": None,
                "companyCd": None,
                "serviceAddress": None,
            },
        }

        r = requests.post(
            "https://myutilities.seattle.gov/rest/guest/swsummary",
            json=swsummary_payload,
            headers=headers,
        )

        summary_info = json.loads(r.text)
        # the description property in each service in swServices it's either 'Garbage', 'Recycle', or 'Food/Yard Waste'

        swServices = summary_info["accountSummaryType"]["swServices"][0]["services"]
        personId = summary_info["accountContext"]["personId"]
        companyCd = summary_info["accountContext"]["companyCd"]

        # step 5
        waste_calendar_payload = {
            "customerId": "guest",
            "accountContext": {
                "accountNumber": account_number,
                "personId": personId,
                "companyCd": companyCd,
            },
            "servicePoints": [],
        }

        # fill out payload
        for service in swServices:
            waste_calendar_payload["servicePoints"].append(service["servicePointId"])

        r = requests.post(
            "https://myutilities.seattle.gov/rest/solidwastecalendar",
            json=waste_calendar_payload,
            headers=headers,
        )

        calendar_info = json.loads(r.text)

        # output
        entries = []

        for service in swServices:
            name = service["description"]
            servicePointId = service["servicePointId"]

            for collection_date in calendar_info["calendar"][servicePointId]:
                next_date = datetime.datetime.strptime(
                    collection_date, "%m/%d/%Y"
                ).date()
                service_icon = "mdi:" + get_service_icon(name)

                entries.append(Collection(date=next_date, t=name, icon=service_icon))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/rh_entsorgung_de.py METASEP
import re
from datetime import date
from html.parser import HTMLParser

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "RH Entsorgung"
DESCRIPTION = "Source for RHE (Rhein Hunsrück Entsorgung)."
URL = "https://www.rh-entsorgung.de"
TEST_CASES = {
    "Horn": {
        "city": "Rheinböllen",
        "street": "Erbacher Straße",
        "house_number": 13,
        "address_suffix": "A",
    },
    "Bärenbach": {
        "city": "Bärenbach",
        "street": "Schwarzener Straße",
        "house_number": 10,
    },
}


# Parser for HTML input (hidden) text
class HiddenInputParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self._args = {}

    @property
    def args(self):
        return self._args

    def handle_starttag(self, tag, attrs):
        if tag == "input":
            d = dict(attrs)
            if str(d["type"]).lower() == "hidden":
                self._args[d["name"]] = d["value"] if "value" in d else ""


class CollectionParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__()
        self._entries: list[Collection] = []
        self._current_type: str = None
        self._capture_type: bool = False
        self._capture_date: bool = False
        self._date_pattern = re.compile(
            r"(?P<day>\d{2})\.(?P<month>\d{2})\.(?P<year>\d{4})"
        )

    @property
    def entries(self):
        return self._entries

    def handle_starttag(self, tag: str, attrs) -> None:
        if tag == "p":
            d = dict(attrs)
            if str(d["class"]).lower() == "work":
                self._capture_type = True
        if self._current_type is not None and tag == "td":
            d = dict(attrs)
            if ("class" in d) and ("dia_c_abfuhrdatum" in str(d["class"])):
                self._capture_date = True

    def handle_data(self, data: str) -> None:
        if self._capture_type:
            self._current_type = data
        if self._capture_date:
            match = self._date_pattern.match(data)
            self._entries.append(
                Collection(
                    date(int(match.group(3)), int(match.group(2)), int(match.group(1))),
                    self._current_type,
                )
            )

    def handle_endtag(self, tag: str) -> None:
        if tag == "p" and self._capture_type:
            self._capture_type = False
        if tag == "td" and self._capture_date:
            self._capture_date = False


class Source:
    def __init__(
        self,
        city: str,
        street: str,
        house_number: int,
        address_suffix: str = "",
        garbage_types: list[int] = [1, 2, 3, 4, 5],
    ):
        self._city = city
        self._street = street
        self._hnr = house_number
        self._suffix = address_suffix
        self._garbage_types = garbage_types

    def fetch(self):
        r = requests.get(
            "https://aao.rh-entsorgung.de/WasteManagementRheinhunsrueck/WasteManagementServlet",
            params={"SubmitAction": "wasteDisposalServices", "InFrameMode": "TRUE"},
        )
        r.encoding = "utf-8"

        parser = HiddenInputParser()
        parser.feed(r.text)

        args = parser.args
        args["Ort"] = self._city
        args["Strasse"] = self._street
        args["Hausnummer"] = str(self._hnr)
        args["Hausnummerzusatz"] = self._suffix
        args["Zeitraum"] = "Die Leerungen der nächsten 3 Monate"
        args["SubmitAction"] = "forward"
        for type in range(1, 6):
            args[f"ContainerGewaehlt_{type}"] = (
                "on" if type in self._garbage_types else "off"
            )

        # First request returns wrong city. has to be called twice!
        r = requests.post(
            "https://aao.rh-entsorgung.de/WasteManagementRheinhunsrueck/WasteManagementServlet",
            data=args,
        )

        r = requests.post(
            "https://aao.rh-entsorgung.de/WasteManagementRheinhunsrueck/WasteManagementServlet",
            data=args,
        )
        r.encoding = "utf-8"

        date_parser = CollectionParser()
        date_parser.feed(r.text)
        return date_parser.entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/recycleapp_be.py METASEP
import logging
from datetime import datetime, timedelta

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Recycle!"
DESCRIPTION = "Source for RecycleApp.be"
URL = "https://www.recycleapp.be"
TEST_CASES = {
    "1140 Evere, Bazellaan 1": {
        "postcode": 1140,
        "street": "Bazellaan",
        "house_number": 1,
    },
    "3001, Waversebaan 276 with events": {
        "postcode": 3001,
        "street": "Waversebaan",
        "house_number": 276,
    },
    "3001, Waversebaan 276 without events": {
        "postcode": 3001,
        "street": "Waversebaan",
        "house_number": 276,
        "add_events": False,
    },
}

_LOGGER = logging.getLogger(__name__)


class Source:
    def __init__(self, postcode, street, house_number, add_events=True):
        self._postcode = postcode
        self._street = street
        self._house_number = house_number
        self._add_events = add_events

    def fetch(self):
        url = "https://recycleapp.be/api/app/v1"
        headers = {
            "x-secret": "Crgja3EGWe8jdapyr4EEoMBgZACYYjRRcRpaMQrLDW9HJBvmgkfGQyYqLgeXPavAGvnJqkV87PBB2b8zx43q46sUgzqio4yRZbABhtKeagkVKypTEDjKfPgGycjLyJTtLHYpzwJgp4YmmCuJZN9ZmJY8CGEoFs8MKfdJpU9RjkEVfngmmk2LYD4QzFegLNKUbcCeAdEW",
            "x-consumer": "recycleapp.be",
            "User-Agent": "",
            "Authorization": "",
        }
        r = requests.get(f"{url}/access-token", headers=headers)
        headers["Authorization"] = r.json()["accessToken"]

        params = {"q": self._postcode}
        r = requests.get(f"{url}/zipcodes", params=params, headers=headers)
        if r.status_code != 200:
            _LOGGER.error("Get zip code failed")
            return []
        zipcodeId = r.json()["items"][0]["id"]

        params = {"q": self._street, "zipcodes": zipcodeId}
        r = requests.get(f"{url}/streets", params=params, headers=headers)
        if r.status_code != 200:
            _LOGGER.error("Get street id failed")
            return []

        for item in r.json()["items"]:
            if item["name"] == self._street:
                streetId = item["id"]
        if streetId is None:
            streetId = r.json()["items"][0]["id"]

        now = datetime.now()
        fromDate = now.strftime("%Y-%m-%d")
        untilDate = (now + timedelta(days=365)).strftime("%Y-%m-%d")
        params = {
            "zipcodeId": zipcodeId,
            "streetId": streetId,
            "houseNumber": self._house_number,
            "fromDate": fromDate,
            "untilDate": untilDate,
            #            "size":100,
        }
        r = requests.get(f"{url}/collections", params=params, headers=headers)
        if r.status_code != 200:
            _LOGGER.error("Get data failed")
            return []

        entries = []
        for item in r.json()["items"]:
            if "exception" in item and "replacedBy" in item["exception"]:
                continue

            date = datetime.strptime(item["timestamp"], "%Y-%m-%dT%H:%M:%S.000Z").date()
            if item["type"] == "collection":
                entries.append(Collection(date, item["fraction"]["name"]["en"]))
            elif item["type"] == "event" and self._add_events:
                entries.append(Collection(date, item["event"]["title"]["en"]))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/pgh_st.py METASEP
import datetime
import json
from urllib.parse import quote

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "PGH.ST"
DESCRIPTION = "Source for PGH.ST services for the city of Pittsburgh, PA, USA."
URL = "http://www.pgh.st"
TEST_CASES = {}
TEST_CASES = {
    "Pittsburgh, Negley": {
        "house_number": 800,
        "street_name": "Negley",
        "zipcode": 15232,
    }
}


class Source:
    def __init__(self, house_number, street_name, zipcode):
        self._house_number = house_number
        self._street_name = street_name.replace(".", "").strip()
        self._zipcode = zipcode

    def fetch(self):
        # get json file
        r = requests.get(
            f"http://pgh.st/locate/{self._house_number}/{quote(self._street_name)}/{self._zipcode}"
        )

        # extract data from json
        data = json.loads(r.text)

        # create entries for trash, recycling, and yard waste
        entries = []

        try:
            entries.append(
                Collection(
                    date=datetime.datetime.strptime(
                        data[0]["next_pickup_date"], "%m-%d-%Y"
                    ).date(),
                    t="Trash",
                    icon="mdi:trash-can",
                )
            )
        except ValueError:
            pass  # ignore date conversion failure for not scheduled collections

        try:
            entries.append(
                Collection(
                    date=datetime.datetime.strptime(
                        data[0]["next_recycling_date"], "%m-%d-%Y"
                    ).date(),
                    t="Recycling",
                    icon="mdi:recycle",
                )
            )
        except ValueError:
            pass  # ignore date conversion failure for not scheduled collections

        try:
            entries.append(
                Collection(
                    date=datetime.datetime.strptime(
                        data[0]["next_yard_date"], "%m-%d-%Y"
                    ).date(),
                    t="Yard Waste",
                    icon="mdi:leaf",
                )
            )
        except ValueError:
            pass  # ignore date conversion failure for not scheduled collections

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/nawma_sa_gov_au.py METASEP
import datetime
import requests
from html.parser import HTMLParser
from waste_collection_schedule import Collection


TITLE = "North Adelaide Waste Management Authority"
DESCRIPTION = "Source for nawma.sa.gov.au (Salisbury, Playford, and Gawler South Australia)."
URL = "http://www.nawma.sa.gov.au"
TEST_CASES = {
    "128 Bridge Road": {"street_number": "128", "street_name": "Bridge Road", "suburb": "Pooraka"}, # Monday
    "226 Bridge Road": {"street_number": "226", "street_name": "Bridge Road", "suburb": "Pooraka"}, # Monday reverse
    "Whites Road": {"street_name": "Whites Road", "suburb": "Paralowie"}, # Tuesday
    "Hazel Avenue": {"street_name": "Hazel Avenue", "suburb": "Angle Value"}, # Wednesday
    "155 Murray St": {"street_name": "Murray Street (sec between Ayers and the railway line", "suburb": "Gawler"}, # Thursday
    "Edward Crescent": {"street_name": "Edward Crescent", "suburb": "Evanston Park"}, # Friday
}


class CollectionResultsParser(HTMLParser):
    """
    Parser for the collection results <div> element returned by the API.
    """

    def __init__(self):
        super().__init__()
        self._entries = []

        self._current_type = None

        # State machine
        self._in_entry = False
        self._read_type = True
        self._cell_count = 0

    @property
    def entries(self):
        return self._entries

    def handle_starttag(self, tag, attrs):
        attrs_dict = dict(attrs)
        if tag == "div" and attrs_dict.get("class") == "coll-content":
            self._in_entry = True
            self._cell_count = 0
        elif tag == "h6" and self._in_entry:
            self._read_type = True
        elif tag == "td":
            self._cell_count = self._cell_count + 1

    def handle_endtag(self, tag):
        if tag == "div":
            self._in_entry = False

    def handle_data(self, data):
        if self._read_type:
            self._current_type = data
            self._read_type = False

        elif self._in_entry and self._cell_count == 6:
            date = datetime.datetime.strptime(data.strip(), '%d %B %Y').date()

            icon = "mdi:trash-can"
            if "yellow" in self._current_type:
                icon = "mdi:recycle"
            elif "green" in self._current_type:
                icon = "mdi:leaf"

            self._entries.append(Collection(date, self._current_type, icon))

            # We're finished with this entry
            self._in_entry = False


class Source:
    def __init__(self, street_name, suburb, street_number="", pid="2444"):
        self._street_number = street_number  # Optional
        self.street_name = street_name
        self._suburb = suburb
        self._pid = pid  # Not really sure what this is!

    def fetch(self):
        params = {
            "action": "collection_day",
            "street_no": self._street_number,
            "street": self.street_name,
            "area": self._suburb,
            "pid": self._pid,
        }

        r = requests.post(
            "http://www.nawma.sa.gov.au/wp-admin/admin-ajax.php",
            data=params,  # The parameters are sent as the body of the post
        )

        p = CollectionResultsParser()
        p.feed(r.text)
        return p.entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/muellmax_de.py METASEP
from html.parser import HTMLParser

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS

TITLE = "Müllmax"
DESCRIPTION = "Source for Müllmax waste collection."
URL = "https://www.muellmax.de"
TEST_CASES = {
    "Rhein-Sieg-Kreis, Alfter": {
        "service": "Rsa",
        "mm_frm_ort_sel": "Alfter",
        "mm_frm_str_sel": "Ahrweg (105-Ende/94-Ende)",
    },
    "Münster, Achatiusweg": {"service": "Awm", "mm_frm_str_sel": "Achatiusweg"},
}


# Parser for HTML checkbox
class InputCheckboxParser(HTMLParser):
    def __init__(self, startswith):
        super().__init__()
        self._startswith = startswith
        self._value = {}

    @property
    def value(self):
        return self._value

    def handle_starttag(self, tag, attrs):
        if tag == "input":
            d = dict(attrs)
            if d.get("name", "").startswith(self._startswith):
                self._value[d["name"]] = d.get("value")


# Parser for HTML input (hidden) text
class InputTextParser(HTMLParser):
    def __init__(self, **identifiers):
        super().__init__()
        self._identifiers = identifiers
        self._value = None

    @property
    def value(self):
        return self._value

    def handle_starttag(self, tag, attrs):
        if tag == "input":
            d = dict(attrs)
            for key, value in self._identifiers.items():
                if key not in d or d[key] != value:
                    return
            self._value = d.get("value")


class Source:
    def __init__(
        self, service, mm_frm_ort_sel=None, mm_frm_str_sel=None, mm_frm_hnr_sel=None
    ):
        self._service = service
        self._mm_frm_ort_sel = mm_frm_ort_sel
        self._mm_frm_str_sel = mm_frm_str_sel
        self._mm_frm_hnr_sel = mm_frm_hnr_sel
        self._ics = ICS()

    def fetch(self):
        mm_ses = InputTextParser(name="mm_ses")

        url = f"https://www.muellmax.de/abfallkalender/{self._service.lower()}/res/{self._service}Start.php"
        r = requests.get(url)
        mm_ses.feed(r.text)

        # select "Abfuhrtermine", returns ort or an empty street search field
        args = {"mm_ses": mm_ses.value, "mm_aus_ort.x": 0, "mm_aus_ort.x": 0}
        r = requests.post(url, data=args)
        mm_ses.feed(r.text)

        if self._mm_frm_ort_sel is not None:
            # select city
            args = {
                "mm_ses": mm_ses.value,
                "xxx": 1,
                "mm_frm_ort_sel": self._mm_frm_ort_sel,
                "mm_aus_ort_submit": "weiter",
            }
            r = requests.post(url, data=args)
            mm_ses.feed(r.text)

        if self._mm_frm_str_sel is not None:
            # show street selection page
            args = {
                "mm_ses": mm_ses.value,
                "xxx": 1,
                "mm_frm_str_name": "",
                "mm_aus_str_txt_submit": "suchen",
            }
            r = requests.post(url, data=args)
            mm_ses.feed(r.text)

            # select street
            args = {
                "mm_ses": mm_ses.value,
                "xxx": 1,
                "mm_frm_str_sel": self._mm_frm_str_sel,
                "mm_aus_str_sel_submit": "weiter",
            }
            r = requests.post(url, data=args)
            mm_ses.feed(r.text)

        if self._mm_frm_hnr_sel is not None:
            # select house number
            args = {
                "mm_ses": mm_ses.value,
                "xxx": 1,
                "mm_frm_hnr_sel": self._mm_frm_hnr_sel,
                "mm_aus_hnr_sel_submit": "weiter",
            }
            r = requests.post(url, data=args)
            mm_ses.feed(r.text)

        # select to get ical
        args = {"mm_ses": mm_ses.value, "xxx": 1, "mm_ica_auswahl": "iCalendar-Datei"}
        r = requests.post(url, data=args)
        mm_ses.feed(r.text)

        mm_frm_fra = InputCheckboxParser(startswith="mm_frm_fra")
        mm_frm_fra.feed(r.text)

        # get ics file
        args = {"mm_ses": mm_ses.value, "xxx": 1, "mm_frm_type": "termine"}
        args.update(mm_frm_fra.value)
        args.update({"mm_ica_gen": "iCalendar-Datei laden"})
        r = requests.post(url, data=args)
        mm_ses.feed(r.text)

        entries = []

        # parse ics file
        dates = self._ics.convert(r.text)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1]))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/mrsc_vic_gov_au.py METASEP
import logging
from datetime import datetime
import re

import requests
from bs4 import BeautifulSoup
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Macedon Ranges Shire Council"
DESCRIPTION = "Source for Macedon Ranges Shire Council rubbish collection."
URL = "https://www.mrsc.vic.gov.au/Live-Work/Bins-Rubbish-Recycling/Bins-and-collection-days/Bin-collection-days"
TEST_CASES = {
    "Macedon IGA": {"street_address": "20 Victoria Street, Macedon"},
    "ALDI Gisborne": {"street_address": "45 Aitken Street, Gisborne"},
}

_LOGGER = logging.getLogger(__name__)

ICON_MAP = {
    "FOGO bin": "mdi:leaf",
    "Recycling bin": "mdi:recycle",
    "Glass-only bin": "mdi:glass-fragile",
}


class Source:
    def __init__(self, street_address):
        self._street_address = street_address

    def fetch(self):
        session = requests.Session()

        response = session.get(
            "https://www.mrsc.vic.gov.au/Live-Work/Bins-Rubbish-Recycling/Bins-and-collection-days/Bin-collection-days"
        )
        response.raise_for_status()

        response = session.get(
            "https://www.mrsc.vic.gov.au/api/v1/myarea/search",
            params={"keywords": self._street_address},
        )
        response.raise_for_status()
        addressSearchApiResults = response.json()
        if (
            addressSearchApiResults["Items"] is None
            or len(addressSearchApiResults["Items"]) < 1
        ):
            _LOGGER.error(
                f"Address search for '{self._street_address}' returned no results. Check your address on https://www.mrsc.vic.gov.au/Live-Work/Bins-Rubbish-Recycling/Bins-and-collection-days/Bin-collection-days"
            )
            return []

        addressSearchTopHit = addressSearchApiResults["Items"][0]
        _LOGGER.debug("Address search top hit: %s", addressSearchTopHit)

        geolocationid = addressSearchTopHit["Id"]
        _LOGGER.debug("Geolocationid: %s", geolocationid)

        response = session.get(
            "https://www.mrsc.vic.gov.au/ocapi/Public/myarea/wasteservices?ocsvclang=en-AU",
            params={"geolocationid": geolocationid},
        )
        response.raise_for_status()

        wasteApiResult = response.json()
        _LOGGER.debug("Waste API result: %s", wasteApiResult)

        soup = BeautifulSoup(wasteApiResult["responseContent"], "html.parser")

        entries = []
        for article in soup.find_all("article"):
            waste_type = article.h3.string
            icon = ICON_MAP.get(waste_type, "mdi:trash-can")
            next_pickup = article.find(class_="next-service").string.strip()
            if re.match("[^\s]* \d{1,2}\/\d{1,2}\/\d{4}", next_pickup):
                next_pickup_date = datetime.strptime(
                    next_pickup.split(sep=" ")[1], "%d/%m/%Y"
                ).date()
                entries.append(Collection(date=next_pickup_date, t=waste_type, icon=icon))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/lindau_ch.py METASEP
import json
from datetime import datetime

import requests
from bs4 import BeautifulSoup
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Abfall Lindau"
DESCRIPTION = "Source for Lindau waste collection."
URL = "https://www.lindau.ch/abfalldaten"
TEST_CASES = {
    "Tagelswangen": {"city": "Tagelswangen"},
    "Grafstal": {"city": "190"},
}


IconMap = {
    "kehricht": "mdi:trash-can",
    "grungut": "mdi:leaf",
    "hackseldienst": "mdi:leaf",
    "papier und karton": "mdi:package-variant",
    "altmetalle": "mdi:nail",
}


class Source:
    def __init__(self, city):
        self._city = city

    def fetch(self):
        response = requests.get("https://www.lindau.ch/abfalldaten")

        html = BeautifulSoup(response.text, "html.parser")

        table = html.find("table", attrs={"id": "icmsTable-abfallsammlung"})
        data = json.loads(table.attrs["data-entities"])

        entries = []
        for item in data["data"]:
            if (
                self._city in item["abfallkreisIds"]
                or self._city in item["abfallkreisNameList"]
            ):
                next_pickup = item["_anlassDate-sort"].split()[0]
                next_pickup_date = datetime.fromisoformat(next_pickup).date()

                waste_type = BeautifulSoup(item["name"], "html.parser").text
                waste_type_sorted = BeautifulSoup(item["name-sort"], "html.parser").text

                entries.append(
                    Collection(
                        date=next_pickup_date,
                        t=waste_type,
                        icon=IconMap.get(waste_type_sorted, "mdi:trash-can"),
                    )
                )

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/lerum_se.py METASEP
# coding: utf-8
from datetime import datetime
import json
from urllib.parse import urlencode

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Lerum Vatten och Avlopp"
DESCRIPTION = "Source for Lerum Vatten och Avlopp waste collection."
URL = "https://vatjanst.lerum.se/FutureWeb/SimpleWastePickup/SimpleWastePickup"
TEST_CASES = {
    "PRO": {"street_address": "Floda stationsväg 5, Floda"},
    "Polisen": {"street_address": "Göteborgsvägen 16, Lerum"},
}


class Source:
    def __init__(self, street_address):
        self._street_address = street_address

    def fetch(self):
        response = requests.post(
            "https://vatjanst.lerum.se/FutureWeb/SimpleWastePickup/SearchAdress",
            {"searchText": self._street_address}
        )

        address_data = json.loads(response.text)
        address = None
        if address_data["Succeeded"] and address_data["Succeeded"] is True:
            if address_data["Buildings"] and len(address_data["Buildings"]) > 0:
                address = address_data["Buildings"][0]

        if not address:
            return []

        query_params = urlencode({"address": address})
        response = requests.get(
            "https://vatjanst.lerum.se/FutureWeb/SimpleWastePickup/GetWastePickupSchedule?{}"
            .format(query_params)
        )
        data = json.loads(response.text)

        entries = []
        for item in data["RhServices"]:
            waste_type = item["WasteType"]
            icon = "mdi:trash-can"
            if waste_type == "Matavfall":
                icon = "mdi:leaf"
            next_pickup = item["NextWastePickup"]
            next_pickup_date = datetime.fromisoformat(next_pickup).date()
            entries.append(
                Collection(date=next_pickup_date, t=waste_type, icon=icon)
            )

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/landkreis_wittmund_de.py METASEP
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "Landkreis-Wittmund.de"
DESCRIPTION = "Source for Landkreis Wittmund waste collection."
URL = "https://www.landkreis-wittmund.de/Leben-Wohnen/Wohnen/Abfall/Abfuhrkalender/"
AUTOCOMPLETE_URL = "https://www.landkreis-wittmund.de/output/autocomplete.php?out=json&type=abto&mode=&select=2&refid={}&term="
DOWNLOAD_URL = "https://www.landkreis-wittmund.de/output/options.php?ModID=48&call=ical&ArtID%5B0%5D=3105.1&ArtID%5B1%5D=1.4&ArtID%5B2%5D=1.2&ArtID%5B3%5D=1.3&ArtID%5B4%5D=1.1&pois={}&alarm=0"

TEST_CASES = {
    "CityWithoutStreet": {
        "city": "Werdum",
    },
    "CityWithStreet": {
        "city": "Werdum",
        "street": "alle Straßen",
    },
}

class Source:
    def __init__(self, city, street=None):
        self._city = city
        self._street = street
        self._ics = ICS()

    def fetch(self):
        cityId = self.fetch_city_id(self._city)
        streetId = self.fetch_street_id(cityId, self._street)

        return self.fetch_ics(DOWNLOAD_URL.format(streetId))

    def is_city_selection(self, tag, cityName):
        return tag['value'] != "" and tag.string == self._city

    def fetch_city_id(self, cityName):
        r = requests.get(URL)
        if not r.ok:
            raise Exception(
                "Error: failed to fetch url: {}".format(
                    URL
                )
            )

        soup = BeautifulSoup(r.text, 'html.parser')
        citySelection = [ a for a in soup.select('#sf_locid > option[value]') if self.is_city_selection(a, cityName) ]
        if len(citySelection) == 0:
            raise Exception(
                "Error: could not find id for city: '{}'".format(
                    cityName
                )
            )

        if len(citySelection) > 1:
            raise Exception(
                "Error: non-unique match for city: '{}'".format(
                    cityName
                )
            )

        return citySelection[0]['value']

    def fetch_street_id(self, cityId, streetName):
        r = requests.get(AUTOCOMPLETE_URL.format(cityId, streetName), headers={
            "Referer": URL
        })

        if not r.ok:
            raise Exception(
                "Error: failed to fetch url: {}".format(
                    AUTOCOMPLETE_URL.format(cityId, streetName)
                )
            )

        streets = json.loads(r.text)
        if streetName != None:
            streetId = [ item[0] for item in streets if streetName in item[1] ]
        else:
            streetId = [ item[0] for item in streets ]

        if len(streetId) == 0:
            raise Exception(
                "Error: could not find streets for city id / street: {}, '{}'".format(
                    cityId,
                    streetName
                )
            )

        if len(streetId) > 1:
            raise Exception(
                "Error: non-unique match for city id / street: {}, '{}'".format(
                    cityId,
                    streetName
                )
            )

        return streetId[0]

    def fetch_ics(self, url):
        r = requests.get(url, headers={
            "Referer": URL
        })

        if not r.ok:
            raise Exception(
                "Error: failed to fetch url: {}".format(
                    url
                )
            )

        # parse ics file
        r.encoding = "utf-8"
        dates = self._ics.convert(r.text)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1]))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/kwb_goslar_de.py METASEP
import requests
from waste_collection_schedule import Collection
from waste_collection_schedule.service.ICS import ICS

TITLE = "KreisWirtschaftsBetriebe Goslar"
DESCRIPTION = "Source for kwb-goslar.de waste collection."
URL = "https://www.kwb-goslar.de/Abfallwirtschaft/Abfuhr/"
TEST_CASES = {
    "Berliner Straße (Clausthal-Zellerfeld)": {"pois": "2523.602"},
    "Braunschweiger Straße (Seesen)": {"pois": "2523.409"},
}

ICON_MAP = {
    "Baum- und Strauchschnitt": "mdi:leaf",
    "Biotonne": "mdi:bio",
    "Blaue Tonne": "mdi:newspaper-variant-multiple",
    "Gelber Sack": "mdi:recycle",
    "Restmülltonne": "mdi:trash-can",
    "Weihnachtsbäume": "mdi:pine-tree",
}


class Source:
    def __init__(self, pois):
        self.ics = ICS()
        self.pois = pois

    def fetch(self):
        r = requests.get(
            url="https://www.kwb-goslar.de/output/options.php",
            params={
                "ModID": "48",
                "call": "ical",
                "pois": self.pois,
            },
            headers={
                "Referer": "https://www.kwb-goslar.de",
            },
        )

        if not r.ok:
            raise Exception(f"Error: failed to fetch url: {r.request.url}")

        dates = self.ics.convert(r.text)

        entries = []
        for d in dates:
            date, waste_type = d
            icon = ICON_MAP.get(waste_type, "mdi:trash-can-outline")
            entries.append(Collection(date=date, t=waste_type, icon=icon))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/jumomind_de.py METASEP
import datetime
import json

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Jumomind"
DESCRIPTION = "Source for Jumomind.de waste collection."
URL = "https://www.jumomind.de"
TEST_CASES = {
    "ZAW": {"service_id": "zaw", "city_id": 106, "area_id": 94},
    "Bad Homburg": {"service_id": "hom", "city_id": 1, "area_id": 461},
    "Bad Buchau via MyMuell": {
        "service_id": "mymuell",
        "city_id": 3031,
        "area_id": 3031,
    },
}


class Source:
    def __init__(self, service_id, city_id, area_id):
        self._service_id = service_id
        self._city_id = city_id
        self._area_id = area_id

    def fetch(self):
        args = {"r": "dates/0", "city_id": self._city_id, "area_id": self._area_id}

        # get json file
        r = requests.get(
            f"https://{self._service_id}.jumomind.com/mmapp/api.php", params=args
        )

        entries = []

        data = json.loads(r.text)
        for d in data:
            entries.append(
                Collection(datetime.date.fromisoformat(d["day"]), d["title"])
            )

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/ics.py METASEP
import datetime
import logging
from pathlib import Path

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from waste_collection_schedule.service.ICS_v1 import ICS_v1

TITLE = "ICS"
DESCRIPTION = "Source for ICS based schedules."
URL = None
TEST_CASES = {
    "Dortmund, Dudenstr. 5": {
        "url": "https://www.edg.de/ical/kalender.ics?Strasse=Dudenstr.&Hausnummer=5&Erinnerung=-1&Abfallart=1,2,3,4"
    },
    "Leipzig, Sandgrubenweg 27": {
        "url": "https://stadtreinigung-leipzig.de/wir-kommen-zu-ihnen/abfallkalender/ical.ics?position_nos=38296&name=Sandgrubenweg%2027"
    },
    "Ludwigsburg": {
        "url": "https://www.avl-ludwigsburg.de/fileadmin/Files/Abfallkalender/ICS/Privat/Privat_{%Y}_Ossweil.ics"
    },
    "Esslingen, Bahnhof": {
        "url": "https://api.abfall.io/?kh=DaA02103019b46345f1998698563DaAd&t=ics&s=1a862df26f6943997cef90233877a4fe"
    },
    "Test File": {
        # Path is used here to allow to call the Source from any location.
        # This is not required in a yaml configuration!
        "file": str(Path(__file__).resolve().parents[1].joinpath("test/test.ics"))
    },
    "Test File (recurring)": {
        # Path is used here to allow to call the Source from any location.
        # This is not required in a yaml configuration!
        "file": str(Path(__file__).resolve().parents[1].joinpath("test/recurring.ics"))
    },
    "München, Bahnstr. 11": {
        "url": "https://www.awm-muenchen.de/entsorgen/abfuhrkalender?tx_awmabfuhrkalender_abfuhrkalender%5Bhausnummer%5D=11&tx_awmabfuhrkalender_abfuhrkalender%5Bleerungszyklus%5D%5BB%5D=1%2F2%3BU&tx_awmabfuhrkalender_abfuhrkalender%5Bleerungszyklus%5D%5BP%5D=1%2F2%3BG&tx_awmabfuhrkalender_abfuhrkalender%5Bleerungszyklus%5D%5BR%5D=001%3BU&tx_awmabfuhrkalender_abfuhrkalender%5Bsection%5D=ics&tx_awmabfuhrkalender_abfuhrkalender%5Bsinglestandplatz%5D=false&tx_awmabfuhrkalender_abfuhrkalender%5Bstandplatzwahl%5D=true&tx_awmabfuhrkalender_abfuhrkalender%5Bstellplatz%5D%5Bbio%5D=70024507&tx_awmabfuhrkalender_abfuhrkalender%5Bstellplatz%5D%5Bpapier%5D=70024507&tx_awmabfuhrkalender_abfuhrkalender%5Bstellplatz%5D%5Brestmuell%5D=70024507&tx_awmabfuhrkalender_abfuhrkalender%5Bstrasse%5D=bahnstr.&tx_awmabfuhrkalender_abfuhrkalender%5Byear%5D={%Y}",
        "version": 1,
    },
    "Buxtehude, Am Berg": {
        "url": "https://abfall.landkreis-stade.de/api_v2/collection_dates/1/ort/10/strasse/90/hausnummern/1/abfallarten/R02-R04-B02-D04-D12-P04-R12-R14-W0-R22-R24-R31/kalender.ics"
    },
    #    "Hausmüllinfo: ASR Chemnitz": {
    #        "url": "https://asc.hausmuell.info/ics/ics.php",
    #        "method": "POST",
    #        "params": {
    #            "hidden_id_egebiet": 439087,
    #            "input_ort": "Chemnitz",
    #            "input_str": "Straße der Nationen",
    #            "input_hnr": 2,
    #            "hidden_send_btn": "ics",
    #            # "hiddenYear": 2021,
    #            "hidden_id_ort": 10,
    #            "hidden_id_ortsteil": 0,
    #            "hidden_id_str": 17814,
    #            "hidden_id_hnr": 5538100,
    #            "hidden_kalenderart": "privat",
    #            "showBinsBio": "on",
    #            "showBinsRest": "on",
    #            "showBinsRest_rc": "on",
    #            "showBinsPapier": "on",
    #            "showBinsOrganic": "on",
    #            "showBinsXmas": "on",
    #            "showBinsDsd": "on",
    #            "showBinsProb": "on",
    #        },
    #        "year_field": "hiddenYear",
    #    },
    "Abfall Zollernalbkreis, Ebingen": {
        "url": "https://www.abfallkalender-zak.de",
        "params": {
            "city": "2,3,4",
            "street": "3",
            "types[]": [
                "restmuell",
                "gelbersack",
                "papiertonne",
                "biomuell",
                "gruenabfall",
                "schadstoffsammlung",
                "altpapiersammlung",
                "schrottsammlung",
                "weihnachtsbaeume",
                "elektrosammlung",
            ],
            "go_ics": "Download",
        },
        "year_field": "year",
    },
    "Detmold": {
        "url": "https://abfuhrkalender.detmold.de/icsmaker.php",
        "method": "GET",
        "params": {"strid": 338},
        "year_field": "year",
    },
    "EAW Rheingau Taunus": {
        "url": "https://www.eaw-rheingau-taunus.de/abfallkalender/calendar.ics?streetid=1429",
        "split_at": ",",
    },
    "Recollect, Ottawa": {
        "url": "https://recollect.a.ssl.fastly.net/api/places/BCCDF30E-578B-11E4-AD38-5839C200407A/services/208/events.en.ics",
        "split_at": "\\, [and ]*",
    },
    "Frankfurt am Main, Achenbachstrasse 3": {
        "url": "https://www.fes-frankfurt.de/abfallkalender/QWNoZW5iYWNoc3RyLnwzfDYwNTk2.ics"
    },
    "Erlensee, Am Haspel": {
        "url": "https://sperrmuell.erlensee.de/?type=reminder",
        "method": "POST",
        "params": {
            "street": 8,
            "eventType[]": [27, 23, 19, 20, 21, 24, 22, 25, 26],
            "timeframe": 23,
            "download": "ical",
        },
    },
}


HEADERS = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
_LOGGER = logging.getLogger(__name__)


class Source:
    def __init__(
        self,
        url=None,
        file=None,
        offset=None,
        params=None,
        year_field=None,
        method="GET",
        split_at=None,
        version=2,
    ):
        self._url = url
        self._file = file
        if bool(self._url is not None) == bool(self._file is not None):
            raise RuntimeError("Specify either url or file")
        if version == 1:
            self._ics = ICS_v1(offset=offset, split_at=split_at)
        else:
            self._ics = ICS(offset=offset, split_at=split_at)
        self._params = params
        self._year_field = year_field  # replace this field in params with current year
        self._method = method  # The method to send the params

    def fetch(self):
        if self._url is not None:
            if "{%Y}" in self._url or self._year_field is not None:
                # url contains wildcard or params contains year field
                now = datetime.datetime.now()

                # replace year in url
                url = self._url.replace("{%Y}", str(now.year))

                # replace year in params
                if self._year_field is not None:
                    if self._params is None:
                        raise RuntimeError("year_field specified without params")
                    self._params[self._year_field] = str(now.year)

                entries = self.fetch_url(url, self._params)

                if now.month == 12:
                    # also get data for next year if we are already in december
                    url = self._url.replace("{%Y}", str(now.year + 1))
                    if self._year_field is not None:
                        self._params[self._year_field] = str(now.year + 1)

                    try:
                        entries.extend(self.fetch_url(url, self._params))
                    except Exception:
                        # ignore if fetch for next year fails
                        pass
                return entries
            else:
                return self.fetch_url(self._url, self._params)
        elif self._file is not None:
            return self.fetch_file(self._file)

    def fetch_url(self, url, params=None):
        # get ics file
        if self._method == "GET":
            r = requests.get(url, params=params, headers=HEADERS)
        elif self._method == "POST":
            r = requests.post(url, data=params, headers=HEADERS)
        else:
            raise RuntimeError(
                "Error: unknown method to fetch URL, use GET or POST; got {self._method}"
            )
        r.encoding = "utf-8"  # requests doesn't guess the encoding correctly

        # check the return code
        if not r.ok:
            _LOGGER.error(
                "Error: the response is not ok; need code 200, but got code %s"
                % r.status_code
            )
            return []

        return self._convert(r.text)

    def fetch_file(self, file):
        f = open(file)
        return self._convert(f.read())

    def _convert(self, data):
        dates = self._ics.convert(data)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1]))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/hygea_be.py METASEP
import datetime
import json
import time

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Hygea"
DESCRIPTION = "Source for Hygea garbage collection"
URL = "https://www.hygea.be/"
TEST_CASES = {
    "Soignies": {"streetIndex": "3758"},
    "Frameries": {"streetIndex": "4203"},
    "Erquelinnes": {"cp": "6560"},
}

WASTE_MAP = {
    "om": {"type": "Ordures ménagères", "icon": "mdi:trash-can"},
    "pmc": {"type": "PMC", "icon": "mdi:recycle"},
    "sacvert": {"type": "Déchets Organiques", "icon": "mdi:trash-can"},
    "fourth": {"type": "Papier & cartons", "icon": "mdi:leaf"},
}


class Source:
    def __init__(self, streetIndex=None, cp=None):
        self._street_index = streetIndex
        self._cp = cp

    def fetch(self):
        params = {"start": int(time.time()), "end": int(time.time() + 2678400)}
        if self._street_index is not None:
            params["street"] = self._street_index
            response = requests.get(
                "https://www.hygea.be/displaycal.html", params=params
            )
        elif self._cp is not None:
            params["street"] = self._cp
            response = requests.get(
                "https://www.hygea.be/displaycalws.html", params=params
            )

        if not response.ok:
            return []
        data = json.loads(response.text)

        entries = []
        for day in data:
            date = datetime.datetime.strptime(
                day["start"], "%Y-%m-%dT%H:%M:%S%z"
            ).date()

            # example for day["className"]: 12  notadded pos136 om multi
            waste_types = set(day["className"].split())
            for abbr, map in WASTE_MAP.items():
                if abbr in waste_types:
                    c = Collection(date=date, t=map["type"], icon=map["icon"])
                    entries.append(c)

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/hvcgroep_nl.py METASEP
import json
import logging
from datetime import datetime

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "HVCGroep"
DESCRIPTION = "Source for the Dutch HVCGroep waste management."
URL = "https://www.hvcgroep.nl/zelf-regelen/afvalkalender"
TEST_CASES = {"Tollebeek": {"postal_code": "8309AV", "house_number": "1"}}

_LOGGER = logging.getLogger(__name__)


class Source:
    def __init__(self, postal_code, house_number):
        self.postal_code = postal_code
        self.house_number = house_number
        self.icons = {
            "plastic-blik-drinkpak": "mdi:recycle",
            "gft": "mdi:leaf",
            "papier-en-karton": "mdi:archive",
            "restafval": "mdi:trash-can",
        }

    def fetch(self):
        bag_id = 0

        # Retrieve bagid (unique waste management identifier)
        r = requests.get(
            f"https://inzamelkalender.hvcgroep.nl/adressen/{self.postal_code}:{self.house_number}"
        )
        data = json.loads(r.text)

        # Something must be wrong, maybe the address isn't valid? No need to do the extra requests so just return here.
        if len(data) == 0:
            _LOGGER.error("no data found for this address")
            return []

        bag_id = data[0]["bagid"]

        # Retrieve the details about different waste management flows (for example, paper, plastic etc.)
        r = requests.get(
            f"https://inzamelkalender.hvcgroep.nl/rest/adressen/{bag_id}/afvalstromen"
        )
        waste_flows = json.loads(r.text)

        # Retrieve the coming pickup dates for waste.
        r = requests.get(
            f"https://inzamelkalender.hvcgroep.nl/rest/adressen/{bag_id}/ophaaldata"
        )
        data = json.loads(r.text)

        entries = []

        for item in data:
            waste_details = [
                x for x in waste_flows if x["id"] == item["afvalstroom_id"]
            ]
            entries.append(
                Collection(
                    date=datetime.strptime(item["ophaaldatum"], "%Y-%m-%d").date(),
                    t=waste_details[0]["title"],
                    icon=self.icons.get(waste_details[0]["icon"], "mdi:trash-can"),
                )
            )

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/example.py METASEP
import datetime

from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Example Source"
DESCRIPTION = "Source for example waste collection."
URL = None
TEST_CASES = {"Example": {"days": 10}}


class Source:
    def __init__(self, days=20, per_day=2, types=5):
        self._days = days
        self._per_day = per_day
        self._types = types

    def fetch(self):
        now = datetime.datetime.now().date()

        entries = []
        ap_type = 0

        for day in range(self._days):
            for idx in range(self._per_day):
                entries.append(
                    Collection(
                        now + datetime.timedelta(days=day + 7),
                        f"Type{(ap_type % self._types) + 1}",
                    )
                )
                ap_type = ap_type + 1

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/egn_abfallkalender_de.py METASEP
import datetime
import logging

import requests
from bs4 import BeautifulSoup
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "EGN Abfallkalender"
DESCRIPTION = "Source for EGN Abfallkalender"
URL = "https://www.egn-abfallkalender.de/kalender"
TEST_CASES = {
    "Grevenbroich": {
        "city": "Grevenbroich",
        "district": "Noithausen",
        "street": "Von-Immelhausen-Straße",
        "housenumber": 12,
    },
    "Dormagen": {
        "city": "Dormagen",
        "district": "Hackenbroich",
        "street": "Aggerstraße",
        "housenumber": 2,
    },
    "Grefrath": {
        "city": "Grefrath",
        "district": "Grefrath",
        "street": "An Haus Bruch",
        "housenumber": 18,
    },
}

_LOGGER = logging.getLogger(__name__)

IconMap = {
    "Grau": "mdi:trash-can",
    "Gelb": "mdi:sack",
    "Blau": "mdi:package-variant",
    "Braun": "mdi:leaf",
}


class Source:
    def __init__(self, city, district, street, housenumber):
        self._city = city
        self._district = district
        self._street = street
        self._housenumber = housenumber

    def fetch(self):
        s = requests.session()
        r = s.get(URL)

        soup = BeautifulSoup(r.text, features="html.parser")
        tag = soup.find("meta", {"name": "csrf-token"})
        if tag is None:
            return []

        headers = {"x-csrf-token": tag["content"]}
        post_data = {
            "city": self._city,
            "district": self._district,
            "street": self._street,
            "street_number": self._housenumber,
        }
        r = s.post(URL, data=post_data, headers=headers)

        data = r.json()

        if data.get("error"):
            for type, errormsg in data["errors"].items():
                _LOGGER.error(f"{type} - {errormsg}")
            return []

        entries = []
        for year, months in data["waste_discharge"].items():
            for month, days in months.items():
                for day, types in days.items():
                    date = datetime.datetime(
                        year=int(year), month=int(month), day=int(day)
                    ).date()
                    for type in types:
                        color = (
                            data["trash_type_colors"]
                            .get(str(type).lower(), type)
                            .capitalize()
                        )
                        entries.append(
                            Collection(date=date, t=color, icon=IconMap.get(color))
                        )

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/data_umweltprofis_at.py METASEP
import logging
import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS

TITLE = "UMWELTPROFIS"
DESCRIPTION = "Source for Umweltprofis"
URL = "https://www.umweltprofis.at"
TEST_CASES = {
    "Ebensee": {"url": "https://data.umweltprofis.at/OpenData/AppointmentService/AppointmentService.asmx/GetIcalWastePickupCalendar?key=KXX_K0bIXDdk0NrTkk3xWqLM9-bsNgIVBE6FMXDObTqxmp9S39nIqwhf9LTIAX9shrlpfCYU7TG_8pS9NjkAJnM_ruQ1SYm3V9YXVRfLRws1"},
}

_LOGGER = logging.getLogger(__name__)


class Source:
    def __init__(self, url):
        self._url = url
        self._ics = ICS()

    def fetch(self):
        r = requests.get(self._url)
        if r.status_code != 200:
            _LOGGER.error("Error querying calendar data")
            return []

        fixed_text = r.text.replace("REFRESH - INTERVAL; VALUE = ", "REFRESH-INTERVAL;VALUE=")

        dates = self._ics.convert(fixed_text)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1]))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/colchester_gov_uk.py METASEP
import json
from datetime import datetime
from datetime import timedelta

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Colchester.gov.uk"
DESCRIPTION = "Source for Colchester.gov.uk services for the borough of Colchester, UK."
URL = "https://colchester.gov.uk"
TEST_CASES = {
    "High Street, Colchester": {"llpgid": "1197e725-3c27-e711-80fa-5065f38b5681"},  # Should be 0
    "Church Road, Colchester": {"llpgid": "30213e07-6027-e711-80fa-5065f38b56d1"},
    "The Lane, Colchester": {"llpgid": "7cd96a3d-6027-e711-80fa-5065f38b56d1"},
}

ICONS = {
    "Black bags": "mdi:trash-can",
    "Glass": "mdi:glass-fragile",
    "Cans": "mdi:trash-can",
    "Textiles": "mdi:hanger",
    "Paper/card": "mdi:recycle",
    "Plastics": "mdi:recycle",
    "Garden waste": "mdi:leaf",
    "Food waste": "mdi:food",
}


class Source:
    def __init__(self, llpgid):
        self._llpgid = llpgid

    def fetch(self):
        # get json file
        r = requests.get(
            f"https://new-llpg-app.azurewebsites.net/api/calendar/{self._llpgid}"
        )

        # extract data from json
        data = json.loads(r.text)

        entries = []

        for weeks in data["Weeks"]:
            rows = weeks["Rows"]
            for key in iter(rows):
                for day in rows[key]:
                    try:
                        # Colchester.gov.uk provide their rubbish collection information in the format of a 2-week
                        # cycle. These weeks represent 'Blue' weeks and 'Green' weeks (Traditionally, non-recyclables
                        # and recyclable weeks). The way the JSON response represents this is by specifying the
                        # `DatesOfFirstCollectionDays`, the first collection day of the cycle, and having a boolean
                        # `WeekOne` field in each week representing if it's the first week of the cycle, a 'Blue' week,
                        # or the second, a 'Green' week. If the week is not `WeekOne`, a 'Blue' week,  then 7 days need
                        # to be added to the `DatesOfFirstCollectionDays` date to provide the correct 'Green' week
                        # collection date.
                        date = datetime.strptime(data["DatesOfFirstCollectionDays"][key], "%Y-%m-%dT%H:%M:%S")
                        if not weeks["WeekOne"]:
                            date = date + timedelta(days=7)
                        if date > datetime.now():
                            entries.append(
                                Collection(
                                    date=date.date(),
                                    t=day["Name"].title(),
                                    icon=ICONS[day["Name"]],
                                )
                            )
                        # As Colchester.gov.uk only provides the current collection cycle, the next must be extrapolated
                        # from the current week. This is the same method the website uses to display further collection
                        # weeks.
                        entries.append(
                            Collection(
                                date=date.date() + timedelta(days=14),
                                t=day["Name"].title(),
                                icon=ICONS[day["Name"]],
                            )
                        )
                    except ValueError:
                        pass  # ignore date conversion failure for not scheduled collections

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/ccc_govt_nz.py METASEP
import datetime

import requests
from waste_collection_schedule import Collection

TITLE = "Christchurch City Council"
DESCRIPTION = "Source for Christchurch City Council."
URL = "https://ccc.govt.nz/services/rubbish-and-recycling/collections"
TEST_CASES = {"53 Hereford Street": {"address": "53 Hereford Street"}}


class Source:
    def __init__(self, address):
        self._address = address

    def fetch(self):
        entries = []

        # Find the Rating Unit ID by the physical address
        # While a property may have more than one address, bins are allocated by each Rating Unit
        addressQuery = {
            "q": self._address,
            "status": "current",
            "crs": "epsg:4326",
            "limit": 1,
        }
        r = requests.get(
            "https://opendata.ccc.govt.nz/CCCSearch/rest/address/suggest",
            params=addressQuery,
        )
        address = r.json()

        # Find the Bin service by Rating Unit ID
        binsQuery = {"ID": address[0]["RatingUnitID"]}
        r = requests.get(
            "https://ccc.govt.nz/services/rubbish-and-recycling/collections/getProperty",
            params=binsQuery,
        )
        bins = r.json()

        # Deduplicate the Bins in case the Rating Unit has more than one of the same Bin type
        bins = {each["material"]: each for each in bins["bins"]["collections"]}.values()

        # Process each Bin
        for bin in bins:
            entries.append(
                Collection(
                    datetime.datetime.strptime(
                        bin["next_planned_date"], "%Y-%m-%d"
                    ).date(),
                    bin["material"],
                )
            )

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/cambridge_gov_uk.py METASEP
import logging
from datetime import datetime

import requests
from waste_collection_schedule import Collection

TITLE = "Cambridge.gov.uk"
DESCRIPTION = (
    "Source for cambridge.gov.uk services for Cambridge and part of Cambridgeshire"
)
URL = "cambridge.gov.uk"
TEST_CASES = {
    "houseNumber": {"post_code": "CB13JD", "number": 37},
    "houseName": {"post_code": "cb215hd", "number": "ROSEMARY HOUSE"},
}

API_URLS = {
    "address_search": "https://servicelayer3c.azure-api.net/wastecalendar/address/search/",
    "collection": "https://servicelayer3c.azure-api.net/wastecalendar/collection/search/{}/",
}

ICONS = {
    "DOMESTIC": "mdi:trash-can",
    "RECYCLE": "mdi:recycle",
    "ORGANIC": "mdi:leaf",
}

_LOGGER = logging.getLogger(__name__)


class Source:
    def __init__(self, post_code: str, number: str):
        self._post_code = post_code
        self._number = str(number).capitalize()

    def fetch(self):
        # fetch location id
        r = requests.get(
            API_URLS["address_search"], params={"postCode": self._post_code}
        )
        r.raise_for_status()
        addresses = r.json()

        address_ids = [
            x["id"] for x in addresses if x["houseNumber"].capitalize() == self._number
        ]

        if len(address_ids) == 0:
            raise Exception(f"Could not find address {self._post_code} {self._number}")

        q = str(API_URLS["collection"]).format(address_ids[0])
        r = requests.get(q)
        r.raise_for_status()

        collections = r.json()["collections"]
        entries = []

        for collection in collections:
            for round_type in collection["roundTypes"]:
                entries.append(
                    Collection(
                        date=datetime.strptime(
                            collection["date"], "%Y-%m-%dT%H:%M:%SZ"
                        ).date(),
                        t=round_type.title(),
                        icon=ICONS[round_type],
                    )
                )

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/bsr_de.py METASEP
import urllib.parse

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS

TITLE = "BSR"
DESCRIPTION = "Source for Berliner Stadtreinigungsbetriebe waste collection."
URL = "https://bsr.de"
TEST_CASES = {
    "Bahnhofstr., 12159 Berlin (Tempelhof-Schöneberg)": {
        "abf_strasse": "Bahnhofstr., 12159 Berlin (Tempelhof-Schöneberg)",
        "abf_hausnr": 1,
    },
    "Am Ried, 13467 Berlin (Reinickendorf)": {
        "abf_strasse": "Am Ried, 13467 Berlin (Reinickendorf)",
        "abf_hausnr": "11G",
    },
}


def myquote(s):
    # bsr uses strange quoting
    return urllib.parse.quote(s, safe=",()")


class Source:
    def __init__(self, abf_strasse, abf_hausnr):
        self._abf_strasse = abf_strasse
        self._abf_hausnr = abf_hausnr
        self._ics = ICS()

    def fetch(self):
        # get cookie
        r = requests.get("https://www.bsr.de/abfuhrkalender-20520.php")
        cookies = r.cookies

        # get street name only (without PLZ)
        street = self._abf_strasse.split(",")[0]

        # start search using string name (without PLZ)
        args = {"script": "dynamic_search", "step": 1, "q": street}
        r = requests.get(
            "https://www.bsr.de/abfuhrkalender_ajax.php", params=args, cookies=cookies
        )

        # retrieve house number list
        args = {"script": "dynamic_search", "step": 2, "q": self._abf_strasse}
        r = requests.get(
            "https://www.bsr.de/abfuhrkalender_ajax.php", params=args, cookies=cookies
        )

        args = {
            "abf_strasse": street,
            "abf_hausnr": self._abf_hausnr,
            "tab_control": "Jahr",
            "abf_config_weihnachtsbaeume": "",
            "abf_config_restmuell": "on",
            "abf_config_biogut": "on",
            "abf_config_wertstoffe": "on",
            "abf_config_laubtonne": "on",
            # "abf_selectmonth": "5 2020",
            # "abf_datepicker": "28.04.2020",
            # "listitems":7,
        }
        r = requests.post(
            "https://www.bsr.de/abfuhrkalender_ajax.php?script=dynamic_kalender_ajax",
            data=args,
            cookies=cookies,
        )

        args = {
            "script": "dynamic_iCal_ajax",
            "abf_strasse": self._abf_strasse,
            "abf_hausnr": self._abf_hausnr,
            "tab_control": "Jahr",
            "abf_config_weihnachtsbaeume": "",
            "abf_config_restmuell": "on",
            "abf_config_biogut": "on",
            "abf_config_wertstoffe": "on",
            "abf_config_laubtonne": "on",
            # "abf_selectmonth": "5 2020",
            # "listitems":7,
        }

        # create url using private url encoding
        encoded = map(lambda key: f"{key}={myquote(str(args[key]))}", args.keys())
        url = "https://www.bsr.de/abfuhrkalender_ajax.php?" + "&".join(encoded)
        r = requests.get(url, cookies=cookies)

        # parse ics file
        dates = self._ics.convert(r.text)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1]))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/brisbane_qld_gov_au.py METASEP
import json
from datetime import date, timedelta

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Brisbane City Council"
DESCRIPTION = "Source for Brisbane City Council rubbish collection."
URL = "https://www.brisbane.qld.gov.au/clean-and-green/rubbish-tips-and-bins/rubbish-collections/bin-collection-calendar"
TEST_CASES = {
    "Suburban Social": {
        "suburb": "Chapel Hill",
        "street_name": "Moordale St",
        "street_number": "3",
    },
    "The Scratch Bar": {
        "suburb": "Milton",
        "street_name": "Park Rd",
        "street_number": "8/1",
    },
    "Green Beacon": {
        "suburb": "Teneriffe",
        "street_name": "Helen St",
        "street_number": "26",
    },
}

HEADERS = {"user-agent": "Mozilla/5.0"}


class Source:
    def __init__(self, suburb, street_name, street_number):
        self.suburb = suburb
        self.street_name = street_name
        self.street_number = street_number

    def fetch(self):

        suburb_id = 0
        street_id = 0
        property_id = 0
        today = date.today()
        nextmonth = today + timedelta(30)

        # Retrieve suburbs
        r = requests.get(
            "https://brisbane.waste-info.com.au/api/v1/localities.json", headers=HEADERS
        )
        data = json.loads(r.text)

        # Find the ID for our suburb
        for item in data["localities"]:
            if item["name"] == self.suburb:
                suburb_id = item["id"]
                break

        if suburb_id == 0:
            return []

        # Retrieve the streets in our suburb
        r = requests.get(
            f"https://brisbane.waste-info.com.au/api/v1/streets.json?locality={suburb_id}",
            headers=HEADERS,
        )
        data = json.loads(r.text)

        # Find the ID for our street
        for item in data["streets"]:
            if item["name"] == self.street_name:
                street_id = item["id"]
                break

        if street_id == 0:
            return []

        # Retrieve the properties in our street
        r = requests.get(
            f"https://brisbane.waste-info.com.au/api/v1/properties.json?street={street_id}",
            headers=HEADERS,
        )
        data = json.loads(r.text)

        # Find the ID for our property
        for item in data["properties"]:
            if item["name"] == f"{self.street_number} {self.street_name} {self.suburb}":
                property_id = item["id"]
                break

        if property_id == 0:
            return []

        # Retrieve the upcoming collections for our property
        r = requests.get(
            f"https://brisbane.waste-info.com.au/api/v1/properties/{property_id}.json?start={today}&end={nextmonth}",
            headers=HEADERS,
        )

        data = json.loads(r.text)

        entries = []

        for item in data:
            if "start" in item:
                collection_date = date.fromisoformat(item["start"])
                if (collection_date - today).days >= 0:
                    # Every collection day includes rubbish
                    entries.append(
                        Collection(
                            date=collection_date, t="Rubbish", icon="mdi:trash-can"
                        )
                    )
                    if item["event_type"] == "recycle":
                        entries.append(
                            Collection(
                                date=collection_date, t="Recycling", icon="mdi:recycle"
                            )
                        )
                    if item["event_type"] == "organic":
                        entries.append(
                            Collection(
                                date=collection_date, t="Garden", icon="mdi:leaf"
                            )
                        )

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/berlin_recycling_de.py METASEP
import json
from datetime import datetime
from html.parser import HTMLParser

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Berline Recycling"
DESCRIPTION = "Source for Berlin Recycling waste collection."
URL = "https://berlin-recycling.de"
TEST_CASES = {
    "Germanenstrasse": {
        "username": "!secret berlin_recycling_username",
        "password": "!secret berlin_recycling_password",
    },
}


# Parser for HTML input (hidden) text
class HiddenInputParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self._args = {}

    @property
    def args(self):
        return self._args

    def handle_starttag(self, tag, attrs):
        if tag == "input":
            d = dict(attrs)
            if str(d["type"]).lower() == "hidden":
                self._args[d["name"]] = d["value"] if "value" in d else ""


SERVICE_URL = "https://kundenportal.berlin-recycling.de/"


class Source:
    def __init__(self, username, password):
        self._username = username
        self._password = password

    def fetch(self):
        session = requests.session()

        # first get returns session specific url
        r = session.get(SERVICE_URL, allow_redirects=False)

        # get session id's
        r = session.get(r.url)

        parser = HiddenInputParser()
        parser.feed(r.text)
        args = parser.args
        args["__EVENTTARGET"] = "btnLog"
        args["__EVENTARGUMENT"] = None
        args["Username"] = self._username
        args["Password"] = self._password

        # login
        r = session.post(r.url, data=args)
        serviceUrl = r.url

        request_data = {"withhtml": "true"}
        r = session.post(serviceUrl + "/GetDashboard", json=request_data)

        request_data = {"datasettable": "ENWIS_ABFUHRKALENDER"}
        r = session.post(serviceUrl + "/ChangeDatasetTable", json=request_data)

        request_data = {
            "datasettablecode": "ENWIS_ABFUHRKALENDER",
            "startindex": 0,
            "searchtext": "",
            "rangefilter": "",
            "ordername": "",
            "orderdir": "",
            "ClientParameters": "",
            "headrecid": "",
        }
        r = session.post(serviceUrl + "/GetDatasetTableHead", json=request_data)

        data = json.loads(r.text)
        # load json again, because response is double coded
        data = json.loads(data["d"])

        entries = []
        for d in data["data"]:
            date = datetime.strptime(d["Task Date"], "%Y-%m-%d").date()
            entries.append(Collection(date, d["Material Description"]))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/banyule_vic_gov_au.py METASEP
import logging
import re
import typing
from datetime import datetime

import requests
from bs4 import BeautifulSoup
from waste_collection_schedule import Collection


TITLE = 'Banyule City Council'
DESCRIPTION = 'Source for Banyule City Council rubbish collection.'
URL = 'https://www.banyule.vic.gov.au/binday'
TEST_CASES = {
    'Monday A': {'street_address': '6 Mandall Avenue, IVANHOE'},
    'Monday A Geolocation ID': {'geolocation_id': '4f7ebfca-1526-4363-8b87-df3103a10a87'},
    'Monday B': {'street_address': '10 Burke Road North, IVANHOE EAST'},
    'Thursday A': {'street_address': '255 St Helena Road, GREENSBOROUGH'},
    'Thursday B': {'street_address': '35 Para Road, MONTMORENCY'}
}

_LOGGER = logging.getLogger(__name__)

ICON_MAP = {
    'green waste': 'mdi:leaf',
    'recycling': 'mdi:recycle'
}


class SourceConfigurationError(ValueError):
    pass


class SourceParseError(ValueError):
    pass


class Source:
    OC_GEOLOCATION_SEARCH_URL = 'https://www.banyule.vic.gov.au/api/v1/myarea/search'

    OC_SESSION_URL = 'https://www.banyule.vic.gov.au/Waste-environment/Waste-recycling/Bin-collection-services'
    OC_CALENDAR_URL = 'https://www.banyule.vic.gov.au/ocapi/Public/myarea/wasteservices'

    OC_RE_DATE_STR = re.compile(r'[^\s]+\s(\d{1,2}/\d{1,2}/\d{4})')

    def __init__(self, street_address: typing.Optional[str] = None, geolocation_id: typing.Optional[str] = None):
        if street_address is None and geolocation_id is None:
            raise SourceConfigurationError('Either street_address or geolocation_id must have a value')

        self._street_address = street_address
        self._geolocation_id = geolocation_id

    @property
    def geolocation_id(self) -> str:
        if self._geolocation_id is None:
            # Search for geolocation ID
            geolocation_response = requests.get(
                self.OC_GEOLOCATION_SEARCH_URL,
                params={
                    'keywords': self._street_address,
                    'maxresults': 1
                }
            )
            geolocation_response.raise_for_status()

            # Pull ID from results
            geolocation_result = geolocation_response.json()
            _LOGGER.debug(f"Search response: {geolocation_response!r}")

            if 'success' in geolocation_result and not geolocation_result['success']:
                raise SourceParseError('Unspecified server-side error when searching address')

            if 'Items' not in geolocation_result or \
                    geolocation_result['Items'] is None or \
                    len(geolocation_result['Items']) < 1:
                raise SourceParseError('Expected list of locations from address search, got empty or missing list')

            geolocation_data = geolocation_result['Items'][0]

            if 'Id' not in geolocation_data:
                raise SourceParseError('Location in address search result but missing geolocation ID')

            self._geolocation_id = geolocation_data['Id']
            _LOGGER.info(f"Address {self._street_address} mapped to geolocation ID {self._geolocation_id}")

        return self._geolocation_id

    def fetch(self) -> typing.List[Collection]:
        # Calendar lookup cares about a cookie, so a Session must be used
        calendar_session = requests.Session()

        calendar_request = calendar_session.get(self.OC_SESSION_URL)
        calendar_request.raise_for_status()

        calendar_request = calendar_session.get(
            self.OC_CALENDAR_URL,
            params={
                'geolocationid': self.geolocation_id,
                'ocsvclang': 'en-AU'
            }
        )
        calendar_request.raise_for_status()

        calendar_result = calendar_request.json()
        _LOGGER.debug(f"Calendar response: {calendar_result!r}")

        if 'success' in calendar_result and not calendar_result['success']:
            raise SourceParseError('Unspecified server-side error when getting calendar')

        # Extract entries from bundled HTML
        calendar_parser = BeautifulSoup(calendar_result['responseContent'], 'html.parser')

        pickup_entries = []

        for element in calendar_parser.find_all('article'):
            _LOGGER.debug(f"Parsing collection: {element!r}")

            waste_type = element.h3.string

            # Extract and parse collection date
            waste_date_match = self.OC_RE_DATE_STR.match(element.find(class_='next-service').string.strip())

            if waste_date_match is None:
                continue

            waste_date = datetime.strptime(waste_date_match[1], '%d/%m/%Y')

            # Base icon on type
            waste_icon = ICON_MAP.get(waste_type.lower(), 'mdi:trash-can')

            pickup_entries.append(Collection(waste_date, waste_type, waste_icon))
            _LOGGER.info(f"Collection for {waste_type} (icon: {waste_icon}) on {waste_date}")

        return pickup_entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/awsh_de.py METASEP
import json
import logging

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS

TITLE = "AWSH"
DESCRIPTION = "Source for Abfallwirtschaft Südholstein"
URL = "https://www.awsh.de"
TEST_CASES = {
    "Reinbek": {"city": "Reinbek", "street": "Ahornweg"},
}

_LOGGER = logging.getLogger(__name__)


class Source:
    def __init__(self, city, street):
        self._city = city
        self._street = street
        self._ics = ICS()

    def fetch(self):
        # retrieve list of cities
        r = requests.get("https://www.awsh.de/api_v2/collection_dates/1/orte")
        cities = json.loads(r.text)

        # create city to id map from retrieved cities
        city_to_id = {
            city["ortsbezeichnung"]: city["ortsnummer"] for (city) in cities["orte"]
        }

        if self._city not in city_to_id:
            _LOGGER.error(f"city not found: {self._city}")
            return []

        cityId = city_to_id[self._city]

        # retrieve list of streets
        r = requests.get(
            f"https://www.awsh.de/api_v2/collection_dates/1/ort/{cityId}/strassen"
        )
        streets = json.loads(r.text)

        # create street to id map from retrieved cities
        street_to_id = {
            street["strassenbezeichnung"]: street["strassennummer"]
            for (street) in streets["strassen"]
        }

        if self._street not in street_to_id:
            _LOGGER.error(f"street not found: {self._street}")
            return []

        streetId = street_to_id[self._street]

        # retrieve list of waste types
        r = requests.get(
            f"https://www.awsh.de/api_v2/collection_dates/1/ort/{cityId}/abfallarten"
        )
        waste_types = json.loads(r.text)
        wt = "-".join([t["id"] for t in waste_types["abfallarten"]])

        # get ics file
        r = requests.get(
            f"https://www.awsh.de/api_v2/collection_dates/1/ort/{cityId}/strasse/{streetId}/hausnummern/0/abfallarten/{wt}/kalender.ics"
        )

        dates = self._ics.convert(r.text)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1]))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/awr_de.py METASEP
import json
import logging

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS

TITLE = "AWR"
DESCRIPTION = "Source for Abfallwirtschaft Rendsburg"
URL = "https://www.awr.de"
TEST_CASES = {
    "Rendsburg": {"city": "Rendsburg", "street": "Hindenburgstraße"},
}

_LOGGER = logging.getLogger(__name__)


class Source:
    def __init__(self, city, street):
        self._city = city
        self._street = street
        self._ics = ICS()

    def fetch(self):
        # retrieve list of cities
        r = requests.get("https://www.awr.de/api_v2/collection_dates/1/orte")
        cities = json.loads(r.text)

        # create city to id map from retrieved cities
        city_to_id = {
            city["ortsbezeichnung"]: city["ortsnummer"] for (city) in cities["orte"]
        }

        if self._city not in city_to_id:
            _LOGGER.error(f"city not found: {self._city}")
            return []

        cityId = city_to_id[self._city]

        # retrieve list of streets
        r = requests.get(
            f"https://www.awr.de/api_v2/collection_dates/1/ort/{cityId}/strassen"
        )
        streets = json.loads(r.text)

        # create street to id map from retrieved cities
        street_to_id = {
            street["strassenbezeichnung"]: street["strassennummer"]
            for (street) in streets["strassen"]
        }

        if self._street not in street_to_id:
            _LOGGER.error(f"street not found: {self._street}")
            return []

        streetId = street_to_id[self._street]

        # retrieve list of waste types
        r = requests.get(
            f"https://www.awr.de/api_v2/collection_dates/1/ort/{cityId}/abfallarten"
        )
        waste_types = json.loads(r.text)
        wt = "-".join([t["id"] for t in waste_types["abfallarten"]])

        # get ics file
        r = requests.get(
            f"https://www.awr.de/api_v2/collection_dates/1/ort/{cityId}/strasse/{streetId}/hausnummern/0/abfallarten/{wt}/kalender.ics"
        )

        dates = self._ics.convert(r.text)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1]))
        return entries


custom_components/waste_collection_schedule/waste_collection_schedule/source/awido_de.py METASEP
import datetime
import json
import logging

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "AWIDO"
DESCRIPTION = "Source for AWIDO waste collection."
URL = "https://www.awido-online.de/"
TEST_CASES = {
    "Schorndorf, Miedelsbacher Straße 30 /1": {
        "customer": "rmk",
        "city": "Schorndorf",
        "street": "Miedelsbacher Straße",
        "housenumber": "30 /1",
    },
    "Altomünster, Maisbrunn": {
        "customer": "lra-dah",
        "city": "Altomünster",
        "street": "Maisbrunn",
    },
    "SOK-Alsmannsdorf": {"customer": "zaso", "city": "SOK-Alsmannsdorf"},
    "Kaufbeuren, Rehgrund": {
        "customer": "kaufbeuren",
        "city": "Kaufbeuren",
        "street": "Rehgrund",
    },
    "Tübingen, Dettenhausen": {"customer": "tuebingen", "city": "Dettenhausen"},
}

_LOGGER = logging.getLogger(__name__)


class Source:
    def __init__(self, customer, city, street=None, housenumber=None):
        self._customer = customer
        self._city = city
        self._street = street
        self._housenumber = housenumber

    def fetch(self):
        # Retrieve list of places
        r = requests.get(
            f"https://awido.cubefour.de/WebServices/Awido.Service.svc/secure/getPlaces/client={self._customer}"
        )
        places = json.loads(r.text)

        # create city to key map from retrieved places
        city_to_oid = {place["value"].strip(): place["key"] for (place) in places}

        if self._city not in city_to_oid:
            _LOGGER.error(f"city not found: {self._city}")
            return []

        oid = city_to_oid[self._city]

        if self._street is None:
            # test if we have to use city also as street name
            self._street = self._city
            r = requests.get(
                f"https://awido.cubefour.de/WebServices/Awido.Service.svc/secure/getGroupedStreets/{oid}",
                params={"client": self._customer},
            )
            streets = json.loads(r.text)

            # create street to key map from retrieved places
            street_to_oid = {
                street["value"].strip(): street["key"] for (street) in streets
            }

            if self._street in street_to_oid:
                oid = street_to_oid[self._street]

        else:
            # street specified
            r = requests.get(
                f"https://awido.cubefour.de/WebServices/Awido.Service.svc/secure/getGroupedStreets/{oid}",
                params={"client": self._customer},
            )
            streets = json.loads(r.text)

            # create street to key map from retrieved places
            street_to_oid = {
                street["value"].strip(): street["key"] for (street) in streets
            }

            if self._street not in street_to_oid:
                _LOGGER.error(f"street not found: {self._street}")
                return []

            oid = street_to_oid[self._street]

            if self._housenumber is not None:
                r = requests.get(
                    f"https://awido.cubefour.de/WebServices/Awido.Service.svc/secure/getStreetAddons/{oid}",
                    params={"client": self._customer},
                )
                hsnbrs = json.loads(r.text)

                # create housenumber to key map from retrieved places
                hsnbr_to_oid = {
                    hsnbr["value"].strip(): hsnbr["key"] for (hsnbr) in hsnbrs
                }

                if self._housenumber not in hsnbr_to_oid:
                    _LOGGER.error(f"housenumber not found: {self._housenumber}")
                    return []

                oid = hsnbr_to_oid[self._housenumber]

        # get calendar data
        r = requests.get(
            f"https://awido.cubefour.de/WebServices/Awido.Service.svc/secure/getData/{oid}",
            params={"fractions": "", "client": self._customer},
        )
        cal_json = json.loads(r.text)

        # map fraction code to fraction name
        fractions = {fract["snm"]: fract["nm"] for (fract) in cal_json["fracts"]}

        # calendar also contains public holidays. In this case, 'ad' is None
        calendar = [item for item in cal_json["calendar"] if item["ad"] is not None]

        entries = []
        for calitem in calendar:
            date = datetime.datetime.strptime(calitem["dt"], "%Y%m%d").date()

            # add all fractions for this date
            for fracitem in calitem["fr"]:
                waste_type = fractions[fracitem]
                entries.append(Collection(date, waste_type))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/awbkoeln_de.py METASEP
import datetime
import json

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "AWB Köln"
DESCRIPTION = "Source for Abfallwirtschaftsbetriebe Köln waste collection."
URL = "https://www.awbkoeln.de"
TEST_CASES = {"Koeln": {"street_code": 2, "building_number": 50}}


class Source:
    def __init__(self, street_code, building_number):
        self._street_code = street_code
        self._building_number = building_number

    def fetch(self):
        args = {
            "street_code": self._street_code,
            "building_number": self._building_number,
        }

        now = datetime.datetime.now()
        args["start_year"] = now.year
        args["start_month"] = now.month
        args["end_year"] = now.year + 1
        args["end_month"] = now.month

        # get json file
        r = requests.get("https://www.awbkoeln.de/api/calendar", params=args)

        data = json.loads(r.text)

        entries = []
        for d in data["data"]:
            date = datetime.date(year=d["year"], month=d["month"], day=d["day"])
            entries.append(Collection(date, d["type"]))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/awb_bad_kreuznach_de.py METASEP
import datetime
import json

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "AWB Bad Kreuznach"
DESCRIPTION = "Source for AWB Bad Kreuznach."
URL = "https://app.awb-bad-kreuznach.de/"
TEST_CASES = {
    "Hargesheim": {"ort": "Hargesheim", "strasse": "Winzenheimer Straße", "nummer": 16}
}

TYPES = ("restmuell", "bio", "wert", "papier")


class Source:
    def __init__(self, ort, strasse, nummer):
        self._ort = ort
        self._strasse = strasse
        self._nummer = nummer

    def fetch(self):
        pass
        args = {
            "ort": self._ort,
            "strasse": self._strasse,
            "nummer": self._nummer,
        }

        # get latitude/longitude file
        r = requests.post(
            "https://app.awb-bad-kreuznach.de/api/checkAddress.php", data=args
        )
        data = json.loads(r.text)

        # get dates
        del args["nummer"]
        args["mode"] = "web"
        args["lat"] = data["lat"]
        args["lon"] = data["lon"]
        print(args)
        r = requests.post(
            "https://app.awb-bad-kreuznach.de/api/loadDates.php", data=args
        )
        data = json.loads(r.text)

        entries = []

        for d in data["termine"]:
            date = datetime.date.fromisoformat(d["termin"])
            for type in TYPES:
                if d[type] != "0":
                    entries.append(Collection(date, type, date))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/aucklandcouncil_govt_nz.py METASEP
import datetime
from html.parser import HTMLParser

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]

TITLE = "Auckland council"
DESCRIPTION = "Source for Auckland council."
URL = "https://aucklandcouncil.govt.nz"
TEST_CASES = {
    "429 Sea View Road": {"area_number": "12342453293"},  # Monday
    "8 Dickson Road": {"area_number": "12342306525"},  # Thursday
}

MONTH = {
    "January": 1,
    "February": 2,
    "March": 3,
    "April": 4,
    "May": 5,
    "June": 6,
    "July": 7,
    "August": 8,
    "September": 9,
    "October": 10,
    "November": 11,
    "December": 12,
}


def toDate(formattedDate):
    items = formattedDate.split()
    return datetime.date(int(items[3]), MONTH[items[2]], int(items[1]))


# Parser for <div> element with class wasteSearchResults
class WasteSearchResultsParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self._entries = []
        self._wasteType = None
        self._withinWasteDateSpan = False
        self._withinHouseholdDiv = False
        self._withinRubbishLinks = False
        self._todaysDate = None
        self._workingWasteDate = None

    @property
    def entries(self):
        return self._entries

    def handle_endtag(self, tag):
        if tag == "span" and self._withinWasteDateSpan:
            self._withinWasteDateSpan = False
        if tag == "div" and self._withinRubbishLinks:
            self._withinRubbishLinks = False
            self._workingWasteDate = None

    def handle_starttag(self, tag, attrs):
        if tag == "div":
            d = dict(attrs)
            id = d.get("id", "")
            if id.endswith("HouseholdBlock"):
                self._withinHouseholdDiv = True
            if id.endswith("CommercialBlock"):
                self._withinHouseholdDiv = False

        if self._withinHouseholdDiv:
            s = dict(attrs)
            className = s.get("class", "")
            if tag == "div":
                if className == "links":
                    self._withinRubbishLinks = True
                else:
                    self._withinRubbishLinks = False

            if tag == "span":
                if className.startswith("m-r-1"):
                    self._withinWasteDateSpan = True

                if self._workingWasteDate is not None:
                    if className.startswith("icon-rubbish") or className.startswith(
                        "icon-recycle"
                    ):
                        type = s["class"][5:]  # remove "icon-"
                        self._entries.append(Collection(self._workingWasteDate, type))

    def handle_data(self, data):
        # date span comes first, doesn't have a year
        if self._withinWasteDateSpan:
            todays_date = datetime.date.today()
            # use current year, unless Jan is in data, and we are still in Dec
            year = todays_date.year
            if "January" in data and todays_date.month == 12:
                # then add 1
                year = year + 1
            fullDate = data + " " + f"{year}"
            self._workingWasteDate = toDate(fullDate)


class Source:
    def __init__(
        self, area_number,
    ):
        self._area_number = area_number

    def fetch(self):
        # get token
        params = {"an": self._area_number}

        r = requests.get(
            "https://www.aucklandcouncil.govt.nz/rubbish-recycling/rubbish-recycling-collections/Pages/collection-day-detail.aspx",
            params=params,
            verify=False,
        )
        p = WasteSearchResultsParser()
        p.feed(r.text)
        return p.entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/abfallnavi_de.py METASEP
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.AbfallnaviDe import AbfallnaviDe

TITLE = "AbfallNavi"
DESCRIPTION = (
    "Source for AbfallNavi waste collection. AbfallNavi is a brand name of regioit.de."
)
URL = "https://www.regioit.de"
TEST_CASES = {
    "Aachen, Abteiplatz 7": {
        "service": "aachen",
        "ort": "Aachen",
        "strasse": "Abteiplatz",
        "hausnummer": "7",
    },
    "Lindlar, Aggerweg": {
        "service": "lindlar",
        "ort": "Lindlar",
        "strasse": "Aggerweg",
    },
    "Roetgen, Am Sportplatz 2": {
        "service": "roe",
        "ort": "Roetgen",
        "strasse": "Am Sportplatz",
        "hausnummer": "2",
    },
}


class Source:
    def __init__(self, service, ort, strasse, hausnummer=None):
        self._api = AbfallnaviDe(service)
        self._ort = ort
        self._strasse = strasse
        self._hausnummer = hausnummer

    def fetch(self):
        dates = self._api.get_dates(self._ort, self._strasse, self._hausnummer)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1]))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/abfall_zollernalbkreis_de.py METASEP
from datetime import datetime

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS

TITLE = "Abfall Zollernalbkreis"
DESCRIPTION = "Source for Abfallwirtschaft Zollernalbkreis waste collection."
URL = "https://www.abfallkalender-zak.de"
TEST_CASES = {
    "Ebingen": {
        "city": "2,3,4",
        "street": "3",
        "types": [
            "restmuell",
            "gelbersack",
            "papiertonne",
            "biomuell",
            "gruenabfall",
            "schadstoffsammlung",
            "altpapiersammlung",
            "schrottsammlung",
            "weihnachtsbaeume",
            "elektrosammlung",
        ],
    },
    "Erlaheim": {
        "city": "79",
        "street": "",
        "types": [
            "restmuell",
            "gelbersack",
            "papiertonne",
            "biomuell",
            "gruenabfall",
            "schadstoffsammlung",
            "altpapiersammlung",
            "schrottsammlung",
            "weihnachtsbaeume",
            "elektrosammlung",
        ],
    },
}


class Source:
    def __init__(self, city, types, street=None):
        self._city = city
        self._street = street
        self._types = types
        self._ics = ICS()
        self._iconMap  = {
            "Restmüll": "mdi:trash-can",
            "Grünabfall" : "mdi:leaf",
            "Gelber Sack" : "mdi:sack",
            "Papiertonne" : "mdi:package-variant",
            "Bildschirm-/Kühlgeräte" : "mdi:television-classic",
            "Schadstoffsammlung" : "mdi:biohazard",
            "altmetalle" : "mdi:nail",
        } 

    def fetch(self):
        now = datetime.now()
        entries = self.fetch_year(now.year, self._city, self._street, self._types)
        if now.month == 12:
            # also get data for next year if we are already in december
            try:
                entries.extend(
                    self.fetch_year(
                        (now.year + 1), self._city, self._street, self._types
                    )
                )
            except Exception:
                # ignore if fetch for next year fails
                pass
        return entries

    def fetch_year(self, year, city, street, types):
        args = {
            "city": city,
            "street": street,
            "year": year,
            "types[]": types,
            "go_ics": "Download",
        }

        # get ics file
        r = requests.get("https://www.abfallkalender-zak.de", params=args)

        # parse ics file
        dates = self._ics.convert(r.text)

        entries = []
        for d in dates:
            waste_type = d[1]
            next_pickup_date = d[0]
            
            entries.append(Collection(date=next_pickup_date, t=waste_type, icon=self._iconMap.get(waste_type,"mdi:trash-can")))

        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/abfall_io.py METASEP
import datetime
from html.parser import HTMLParser

import requests
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS

TITLE = "AbfallPlus"
DESCRIPTION = (
    "Source for AbfallPlus.de waste collection. Service is hosted on abfall.io."
)
URL = "https://www.abfallplus.de"
TEST_CASES = {
    "Waldenbuch": {
        "key": "8215c62763967916979e0e8566b6172e",
        "f_id_kommune": 2999,
        "f_id_strasse": 1087,
        # "f_abfallarten": [50, 53, 31, 299, 328, 325]
    },
    "Landshut": {
        "key": "bd0c2d0177a0849a905cded5cb734a6f",
        "f_id_kommune": 2655,
        "f_id_bezirk": 2655,
        "f_id_strasse": 763,
        # "f_abfallarten": [31, 17, 19, 218]
    },
    "Schoenmackers": {
        "key": "e5543a3e190cb8d91c645660ad60965f",
        "f_id_kommune": 3682,
        "f_id_strasse": "3682adenauerplatz",
        "f_id_strasse_hnr": "20417",
        # "f_abfallarten": [691,692,696,695,694,701,700,693,703,704,697,699],
    },
    "Freudenstadt": {
        "key": "595f903540a36fe8610ec39aa3a06f6a",
        "f_id_kommune": 3447,
        "f_id_bezirk": 22017,
        "f_id_strasse": 22155,
    },
    "Ludwigshafen am Rhein": {
        "key": "6efba91e69a5b454ac0ae3497978fe1d",
        "f_id_kommune": "5916",
        "f_id_strasse": "5916abteistrasse",
        "f_id_strasse_hnr": 33,
    },
}

MODUS_KEY = "d6c5855a62cf32a4dadbc2831f0f295f"
HEADERS = {"user-agent": "Mozilla/5.0 (xxxx Windows NT 10.0; Win64; x64)"}


# Parser for HTML input (hidden) text
class HiddenInputParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self._args = {}

    @property
    def args(self):
        return self._args

    def handle_starttag(self, tag, attrs):
        if tag == "input":
            d = dict(attrs)
            if d["type"] == "hidden":
                self._args[d["name"]] = d["value"]


class Source:
    def __init__(
        self,
        key,
        f_id_kommune,
        f_id_strasse,
        f_id_bezirk=None,
        f_id_strasse_hnr=None,
        f_abfallarten=[],
    ):
        self._key = key
        self._kommune = f_id_kommune
        self._bezirk = f_id_bezirk
        self._strasse = f_id_strasse
        self._strasse_hnr = f_id_strasse_hnr
        self._abfallarten = f_abfallarten  # list of integers
        self._ics = ICS()

    def fetch(self):
        # get token
        params = {"key": self._key, "modus": MODUS_KEY, "waction": "init"}

        r = requests.post("https://api.abfall.io", params=params, headers=HEADERS)

        # add all hidden input fields to form data
        # There is one hidden field which acts as a token:
        # It consists of a UUID key and a UUID value.
        p = HiddenInputParser()
        p.feed(r.text)
        args = p.args

        args["f_id_kommune"] = self._kommune
        args["f_id_strasse"] = self._strasse

        if self._bezirk is not None:
            args["f_id_bezirk"] = self._bezirk

        if self._strasse_hnr is not None:
            args["f_id_strasse_hnr"] = self._strasse_hnr

        for i in range(len(self._abfallarten)):
            args[f"f_id_abfalltyp_{i}"] = self._abfallarten[i]

        args["f_abfallarten_index_max"] = len(self._abfallarten)
        args["f_abfallarten"] = ",".join(map(lambda x: str(x), self._abfallarten))

        now = datetime.datetime.now()
        date2 = now.replace(year=now.year + 1)
        args["f_zeitraum"] = f"{now.strftime('%Y%m%d')}-{date2.strftime('%Y%m%d')}"

        params = {"key": self._key, "modus": MODUS_KEY, "waction": "export_ics"}

        # get csv file
        r = requests.post(
            "https://api.abfall.io", params=params, data=args, headers=HEADERS
        )

        # parse ics file
        r.encoding = "utf-8"  # requests doesn't guess the encoding correctly
        ics_file = r.text

        dates = self._ics.convert(ics_file)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1]))
        return entries

custom_components/waste_collection_schedule/waste_collection_schedule/source/aw_harburg.py METASEP

import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
                    level_3_ids[option.text] = option.get("value")
            if level_3_ids == {}:
                raise Exception(
                    "Error: Level 3 Dictionary empty"
                    )
            if self._district_level_3 not in level_3_ids:
                raise Exception(
                    "Error: District 3 is not in the dictionary: {}".format(
                        (self._district_level_3, level_3_ids)
                    )
                )

        # Prepare data for the real web request
        if self._district_level_3 != None:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_3_ids[self._district_level_3] + '&owner=20100'
        else:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_2_ids[self._district_level_2] + '&owner=20100'

        response = s.get(url, headers=headers)
        # Sometimes there is no garbage calendar available
        if "Es sind keine Abfuhrbezirke hinterlegt." in response.text:
            raise Exception(
                "Error: \"Es sind keine Abfuhrbezirke hinterlegt.\" for \"" + self._district_level_3 + "\" please use different input data."
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        links = soup.find_all("a")
        ical_url = ""
        for any_link in links:
            if " als iCal" in any_link.text:
                ical_url = any_link.get("href")

        if "ical.html" not in ical_url:
            raise Exception(
                "No ical Link in the result: " + str(links)
            )

        # Get the final data
        response = s.post(ical_url, headers=headers)

        # Stop if something else as status code 200 is returned
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch ical_url: {}".format(
                    ical_url
                )
            )

infile
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
                    level_3_ids[option.text] = option.get("value")
            if level_3_ids == {}:
                raise Exception(
                    "Error: Level 3 Dictionary empty"
                    )
            if self._district_level_3 not in level_3_ids:
                raise Exception(
                    "Error: District 3 is not in the dictionary: {}".format(
                        (self._district_level_3, level_3_ids)
                    )
                )

        # Prepare data for the real web request
        if self._district_level_3 != None:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_3_ids[self._district_level_3] + '&owner=20100'
        else:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_2_ids[self._district_level_2] + '&owner=20100'

        response = s.get(url, headers=headers)
        # Sometimes there is no garbage calendar available
        if "Es sind keine Abfuhrbezirke hinterlegt." in response.text:
            raise Exception(
                "Error: \"Es sind keine Abfuhrbezirke hinterlegt.\" for \"" + self._district_level_3 + "\" please use different input data."
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        links = soup.find_all("a")
        ical_url = ""
        for any_link in links:
            if " als iCal" in any_link.text:
                ical_url = any_link.get("href")

        if "ical.html" not in ical_url:
            raise Exception(
                "No ical Link in the result: " + str(links)
            )

        # Get the final data
        response = s.post(ical_url, headers=headers)

        # Stop if something else as status code 200 is returned
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch ical_url: {}".format(
                    ical_url
                )
            )

        return self.fetch_ics(ical_url, headers=headers)

    def fetch_ics(self, url, headers={}):
        r = requests.get(url, headers=headers)

        if not r.ok:
            raise Exception(
                "Error: failed to fetch url: {}".format(
                    url
                )
            )
        
        # Parse ics file, fix broken encoding
        if r.encoding=="ISO-8859-1":
inproject
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
                    level_3_ids[option.text] = option.get("value")
            if level_3_ids == {}:
                raise Exception(
                    "Error: Level 3 Dictionary empty"
                    )
            if self._district_level_3 not in level_3_ids:
                raise Exception(
                    "Error: District 3 is not in the dictionary: {}".format(
                        (self._district_level_3, level_3_ids)
                    )
                )

        # Prepare data for the real web request
        if self._district_level_3 != None:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_3_ids[self._district_level_3] + '&owner=20100'
        else:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_2_ids[self._district_level_2] + '&owner=20100'

        response = s.get(url, headers=headers)
        # Sometimes there is no garbage calendar available
        if "Es sind keine Abfuhrbezirke hinterlegt." in response.text:
            raise Exception(
                "Error: \"Es sind keine Abfuhrbezirke hinterlegt.\" for \"" + self._district_level_3 + "\" please use different input data."
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        links = soup.find_all("a")
        ical_url = ""
        for any_link in links:
            if " als iCal" in any_link.text:
                ical_url = any_link.get("href")

        if "ical.html" not in ical_url:
            raise Exception(
                "No ical Link in the result: " + str(links)
            )

        # Get the final data
        response = s.post(ical_url, headers=headers)

        # Stop if something else as status code 200 is returned
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch ical_url: {}".format(
                    ical_url
                )
            )

        return self.fetch_ics(ical_url, headers=headers)

    def fetch_ics(self, url, headers={}):
        r = requests.get(url, headers=headers)

        if not r.ok:
            raise Exception(
                "Error: failed to fetch url: {}".format(
                    url
                )
            )
        
        # Parse ics file, fix broken encoding
        if r.encoding=="ISO-8859-1":
            dates = self._ics.convert(r.text.encode("latin_1").decode("utf-8"))
        else:
inproject
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
                    level_3_ids[option.text] = option.get("value")
            if level_3_ids == {}:
                raise Exception(
                    "Error: Level 3 Dictionary empty"
                    )
            if self._district_level_3 not in level_3_ids:
                raise Exception(
                    "Error: District 3 is not in the dictionary: {}".format(
                        (self._district_level_3, level_3_ids)
                    )
                )

        # Prepare data for the real web request
        if self._district_level_3 != None:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_3_ids[self._district_level_3] + '&owner=20100'
        else:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_2_ids[self._district_level_2] + '&owner=20100'

        response = s.get(url, headers=headers)
        # Sometimes there is no garbage calendar available
        if "Es sind keine Abfuhrbezirke hinterlegt." in response.text:
            raise Exception(
                "Error: \"Es sind keine Abfuhrbezirke hinterlegt.\" for \"" + self._district_level_3 + "\" please use different input data."
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        links = soup.find_all("a")
        ical_url = ""
        for any_link in links:
            if " als iCal" in any_link.text:
                ical_url = any_link.get("href")

        if "ical.html" not in ical_url:
            raise Exception(
                "No ical Link in the result: " + str(links)
            )

        # Get the final data
        response = s.post(ical_url, headers=headers)

        # Stop if something else as status code 200 is returned
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch ical_url: {}".format(
                    ical_url
                )
            )

        return self.fetch_ics(ical_url, headers=headers)

    def fetch_ics(self, url, headers={}):
        r = requests.get(url, headers=headers)

        if not r.ok:
            raise Exception(
                "Error: failed to fetch url: {}".format(
                    url
                )
            )
        
        # Parse ics file, fix broken encoding
        if r.encoding=="ISO-8859-1":
            dates = self._ics.convert(r.text.encode("latin_1").decode("utf-8"))
        else:
            dates = self._ics.convert(r.text)

inproject
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
                    level_3_ids[option.text] = option.get("value")
            if level_3_ids == {}:
                raise Exception(
                    "Error: Level 3 Dictionary empty"
                    )
            if self._district_level_3 not in level_3_ids:
                raise Exception(
                    "Error: District 3 is not in the dictionary: {}".format(
                        (self._district_level_3, level_3_ids)
                    )
                )

        # Prepare data for the real web request
        if self._district_level_3 != None:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_3_ids[self._district_level_3] + '&owner=20100'
        else:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_2_ids[self._district_level_2] + '&owner=20100'

        response = s.get(url, headers=headers)
        # Sometimes there is no garbage calendar available
        if "Es sind keine Abfuhrbezirke hinterlegt." in response.text:
            raise Exception(
                "Error: \"Es sind keine Abfuhrbezirke hinterlegt.\" for \"" + self._district_level_3 + "\" please use different input data."
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        links = soup.find_all("a")
        ical_url = ""
        for any_link in links:
            if " als iCal" in any_link.text:
                ical_url = any_link.get("href")

        if "ical.html" not in ical_url:
            raise Exception(
                "No ical Link in the result: " + str(links)
            )

        # Get the final data
        response = s.post(ical_url, headers=headers)

        # Stop if something else as status code 200 is returned
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch ical_url: {}".format(
                    ical_url
                )
            )

        return self.fetch_ics(ical_url, headers=headers)

    def fetch_ics(self, url, headers={}):
        r = requests.get(url, headers=headers)

        if not r.ok:
            raise Exception(
                "Error: failed to fetch url: {}".format(
                    url
                )
            )
        
        # Parse ics file, fix broken encoding
        if r.encoding=="ISO-8859-1":
            dates = self._ics.convert(r.text.encode("latin_1").decode("utf-8"))
        else:
            dates = self._ics.convert(r.text)

        entries = []
        for d in dates:
inproject
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
                    level_3_ids[option.text] = option.get("value")
            if level_3_ids == {}:
                raise Exception(
                    "Error: Level 3 Dictionary empty"
                    )
            if self._district_level_3 not in level_3_ids:
                raise Exception(
                    "Error: District 3 is not in the dictionary: {}".format(
                        (self._district_level_3, level_3_ids)
                    )
                )

        # Prepare data for the real web request
        if self._district_level_3 != None:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_3_ids[self._district_level_3] + '&owner=20100'
        else:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_2_ids[self._district_level_2] + '&owner=20100'

        response = s.get(url, headers=headers)
        # Sometimes there is no garbage calendar available
        if "Es sind keine Abfuhrbezirke hinterlegt." in response.text:
            raise Exception(
                "Error: \"Es sind keine Abfuhrbezirke hinterlegt.\" for \"" + self._district_level_3 + "\" please use different input data."
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        links = soup.find_all("a")
        ical_url = ""
        for any_link in links:
            if " als iCal" in any_link.text:
                ical_url = any_link.get("href")

        if "ical.html" not in ical_url:
            raise Exception(
                "No ical Link in the result: " + str(links)
            )

        # Get the final data
        response = s.post(ical_url, headers=headers)

        # Stop if something else as status code 200 is returned
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch ical_url: {}".format(
                    ical_url
                )
            )

        return self.fetch_ics(ical_url, headers=headers)

    def fetch_ics(self, url, headers={}):
        r = requests.get(url, headers=headers)

        if not r.ok:
            raise Exception(
                "Error: failed to fetch url: {}".format(
                    url
                )
            )
        
        # Parse ics file, fix broken encoding
        if r.encoding=="ISO-8859-1":
            dates = self._ics.convert(r.text.encode("latin_1").decode("utf-8"))
        else:
            dates = self._ics.convert(r.text)

        entries = []
        for d in dates:
            entries.append(Collection(d[0], d[1]))
inproject
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
inproject
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
                    level_3_ids[option.text] = option.get("value")
            if level_3_ids == {}:
                raise Exception(
                    "Error: Level 3 Dictionary empty"
                    )
            if self._district_level_3 not in level_3_ids:
                raise Exception(
                    "Error: District 3 is not in the dictionary: {}".format(
                        (self._district_level_3, level_3_ids)
                    )
                )

        # Prepare data for the real web request
        if self._district_level_3 != None:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_3_ids[self._district_level_3] + '&owner=20100'
        else:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_2_ids[self._district_level_2] + '&owner=20100'

inproject
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
inproject
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
                    level_3_ids[option.text] = option.get("value")
            if level_3_ids == {}:
                raise Exception(
                    "Error: Level 3 Dictionary empty"
                    )
            if self._district_level_3 not in level_3_ids:
                raise Exception(
                    "Error: District 3 is not in the dictionary: {}".format(
                        (self._district_level_3, level_3_ids)
                    )
                )

        # Prepare data for the real web request
        if self._district_level_3 != None:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_3_ids[self._district_level_3] + '&owner=20100'
        else:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_2_ids[self._district_level_2] + '&owner=20100'

        response = s.get(url, headers=headers)
        # Sometimes there is no garbage calendar available
        if "Es sind keine Abfuhrbezirke hinterlegt." in response.text:
            raise Exception(
                "Error: \"Es sind keine Abfuhrbezirke hinterlegt.\" for \"" + self._district_level_3 + "\" please use different input data."
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        links = soup.find_all("a")
        ical_url = ""
        for any_link in links:
            if " als iCal" in any_link.text:
                ical_url = any_link.get("href")

        if "ical.html" not in ical_url:
            raise Exception(
                "No ical Link in the result: " + str(links)
            )

        # Get the final data
        response = s.post(ical_url, headers=headers)

        # Stop if something else as status code 200 is returned
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch ical_url: {}".format(
                    ical_url
                )
            )

        return self.fetch_ics(ical_url, headers=headers)

    def fetch_ics(self, url, headers={}):
inproject
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
                    level_3_ids[option.text] = option.get("value")
            if level_3_ids == {}:
                raise Exception(
                    "Error: Level 3 Dictionary empty"
                    )
            if self._district_level_3 not in level_3_ids:
                raise Exception(
                    "Error: District 3 is not in the dictionary: {}".format(
                        (self._district_level_3, level_3_ids)
                    )
                )

        # Prepare data for the real web request
        if self._district_level_3 != None:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_3_ids[self._district_level_3] + '&owner=20100'
        else:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_2_ids[self._district_level_2] + '&owner=20100'

        response = s.get(url, headers=headers)
        # Sometimes there is no garbage calendar available
        if "Es sind keine Abfuhrbezirke hinterlegt." in response.text:
            raise Exception(
                "Error: \"Es sind keine Abfuhrbezirke hinterlegt.\" for \"" + self._district_level_3 + "\" please use different input data."
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        links = soup.find_all("a")
        ical_url = ""
        for any_link in links:
            if " als iCal" in any_link.text:
common
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
common
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
common
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
common
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
common
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
non_informative
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
                    level_3_ids[option.text] = option.get("value")
            if level_3_ids == {}:
                raise Exception(
                    "Error: Level 3 Dictionary empty"
                    )
            if self._district_level_3 not in level_3_ids:
                raise Exception(
                    "Error: District 3 is not in the dictionary: {}".format(
                        (self._district_level_3, level_3_ids)
                    )
                )

        # Prepare data for the real web request
        if self._district_level_3 != None:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_3_ids[self._district_level_3] + '&owner=20100'
        else:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_2_ids[self._district_level_2] + '&owner=20100'

        response = s.get(url, headers=headers)
        # Sometimes there is no garbage calendar available
        if "Es sind keine Abfuhrbezirke hinterlegt." in response.text:
            raise Exception(
                "Error: \"Es sind keine Abfuhrbezirke hinterlegt.\" for \"" + self._district_level_3 + "\" please use different input data."
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        links = soup.find_all("a")
        ical_url = ""
        for any_link in links:
            if " als iCal" in any_link.text:
                ical_url = any_link.get("href")

        if "ical.html" not in ical_url:
            raise Exception(
                "No ical Link in the result: " + str(links)
            )
non_informative
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
non_informative
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
non_informative
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
random
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
random
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
                    "Error: failed to fetch third url: {}".format(
                        url
                    )
                )
            soup = BeautifulSoup(response.text, features="html.parser")
            select_content = soup.find_all("select", id="strukturEbene3")
            soup = BeautifulSoup(str(select_content), features="html.parser")
            options_content = soup.find_all("option")
            level_3_ids = {}
            for option in options_content:
                # Ignore the "Bitte wählen..."
                if option.get("value")!="0":
                    level_3_ids[option.text] = option.get("value")
            if level_3_ids == {}:
                raise Exception(
                    "Error: Level 3 Dictionary empty"
                    )
            if self._district_level_3 not in level_3_ids:
                raise Exception(
                    "Error: District 3 is not in the dictionary: {}".format(
                        (self._district_level_3, level_3_ids)
                    )
                )

        # Prepare data for the real web request
        if self._district_level_3 != None:
            url = 'https://www.landkreis-harburg.de/abfallkalender/abfallkalender_struktur_daten_suche.html?selected_ebene=' + level_3_ids[self._district_level_3] + '&owner=20100'
random
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
    "CityWithTwoLevels": {
        "district_level_1": "Hanstedt",
        "district_level_2": "Evendorf",
    },
    "CityWithThreeLevels": {
        "district_level_1": "Buchholz",
        "district_level_2": "Buchholz mit Steinbeck (ohne Reindorf)",
        "district_level_3": "Seppenser Mühlenweg Haus-Nr. 1 / 2",
    },
}

class Source:
    def __init__(self, district_level_1, district_level_2, district_level_3=None):
        self._district_level_1 = district_level_1
        self._district_level_2 = district_level_2
        self._district_level_3 = district_level_3
        self._ics = ICS()

    def fetch(self):
        # Use a session to keep cookies and stuff
        s = requests.Session()
        
        # Creat some fake header because for some reason people seem to believe it is bad
        # to read public garbage collection data via a script
        headers = {
            'Connection': 'keep-alive',
            'Cache-Control': 'max-age=0',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Opera";v="84"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"Windows"',
            'Upgrade-Insecure-Requests': '1',
            'Content-Type': 'application/x-www-form-urlencoded',
            'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
            'Sec-Fetch-Site': 'same-origin',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-User': '?1',
            'Sec-Fetch-Dest': 'document',
            'Accept-Language': 'de-DE,de;q=0.9',
        }

        # Get the IDs of the districts on the first level
        # Double loading is on purpose because sometimes the webpage has an overlay
        # which is gone on the second try in a session
        response = s.get(URL, headers=headers)
        if "Zur aufgerufenen Seite" in response.text:
            response = s.get(URL, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch first url: {}".format(
                    URL
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene1")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_1_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_1_ids[option.text] = option.get("value")
        if level_1_ids == {}:
            raise Exception(
                "Error: Level 1 Dictionary empty"
                )
        if self._district_level_1 not in level_1_ids:
            raise Exception(
                "Error: District 1 is not in the dictionary: {}".format(
                    (self._district_level_1, level_1_ids)
                )
            )

        # Get the IDs of the districts on the second level
        url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_1_ids[self._district_level_1] + '&ebene=1&portal=1&selected_ebene=0'
        
        response = s.get(url, headers=headers)
        if response.status_code != 200:
            raise Exception(
                "Error: failed to fetch second url: {}".format(
                    url
                )
            )
        soup = BeautifulSoup(response.text, features="html.parser")
        select_content = soup.find_all("select", id="strukturEbene2")
        soup = BeautifulSoup(str(select_content), features="html.parser")
        options_content = soup.find_all("option")
        level_2_ids = {}
        for option in options_content:
            # Ignore the "Bitte wählen..."
            if option.get("value")!="0":
                level_2_ids[option.text] = option.get("value")
        if level_2_ids == {}:
            raise Exception(
                "Error: Level 2 Dictionary empty"
                )
        if self._district_level_2 not in level_2_ids:
            raise Exception(
                "Error: District 2 is not in the dictionary: {}".format(
                    (self._district_level_2, level_2_ids)
                )
            )

        # Get the IDs of the third level - if applicable
        if self._district_level_3 != None:
            # Get the IDs of the districts on the third level
            url = 'https://www.landkreis-harburg.de/ajax/abfall_gebiete_struktur_select.html?parent=' + level_2_ids[self._district_level_2] + '&ebene=2&portal=1&selected_ebene=0'
            
            response = s.get(url, headers=headers)
            if response.status_code != 200:
                raise Exception(
random
import requests
import json
from waste_collection_schedule import Collection  # type: ignore[attr-defined]
from waste_collection_schedule.service.ICS import ICS
from bs4 import BeautifulSoup

TITLE = "AW Harburg"
DESCRIPTION = "Abfallwirtschaft Landkreis Harburg"
URL = "https://www.landkreis-harburg.de/bauen-umwelt/abfallwirtschaft/abfallkalender/"

TEST_CASES = {
random
        return self.fetch_ics(ical_url, headers=headers)             dates = self._ics.convert(r.text.encode("latin_1").decode("utf-8"))             dates = self._ics.convert(r.text)         entries = []             entries.append(Collection(d[0], d[1]))         return entries                     url         response = s.get(url, headers=headers)         self._ics = ICS()         r = requests.get(url, headers=headers)                 ical_url = any_link.get("href")                     level_3_ids[option.text] = option.get("value")                 level_2_ids[option.text] = option.get("value")                 level_1_ids[option.text] = option.get("value")             response = s.get(URL, headers=headers)         # to read public garbage collection data via a script             'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 OPR/84.0.4316.21',         response = s.get(URL, headers=headers)                 "Error: District 2 is not in the dictionary: {}".format(             raise Exception(         else:                     "Error: failed to fetch third url: {}".format(     "CityWithTwoLevels": {