diff --git a/custom_components/afvalwijzer/__init__.py b/custom_components/afvalwijzer/__init__.py index 496b7de..52900b8 100644 --- a/custom_components/afvalwijzer/__init__.py +++ b/custom_components/afvalwijzer/__init__.py @@ -1,31 +1,31 @@ -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType -from .const.const import DOMAIN +# from homeassistant.config_entries import ConfigEntry +# from homeassistant.core import HomeAssistant +# from homeassistant.helpers.typing import ConfigType +# from .const.const import DOMAIN -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Afvalwijzer integration.""" - hass.data.setdefault(DOMAIN, {}) - return True +# async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: +# """Set up the Afvalwijzer integration.""" +# hass.data.setdefault(DOMAIN, {}) +# return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Afvalwijzer from a config entry.""" - # Store config entry data - hass.data[DOMAIN][entry.entry_id] = entry.data +# async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +# """Set up Afvalwijzer from a config entry.""" +# # Store config entry data +# hass.data[DOMAIN][entry.entry_id] = entry.data - # Forward the setup to the sensor platform - await hass.config_entries.async_forward_entry_setups(entry, ["sensor"]) - return True +# # Forward the setup to the sensor platform +# await hass.config_entries.async_forward_entry_setups(entry, ["sensor"]) +# return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - # Remove stored data - if entry.entry_id in hass.data[DOMAIN]: - hass.data[DOMAIN].pop(entry.entry_id) +# async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +# """Unload a config entry.""" +# # Remove stored data +# if entry.entry_id in hass.data[DOMAIN]: +# hass.data[DOMAIN].pop(entry.entry_id) - # Unload the sensor platform - await hass.config_entries.async_forward_entry_unload(entry, "sensor") - return True +# # Unload the sensor platform +# await hass.config_entries.async_forward_entry_unload(entry, "sensor") +# return True diff --git a/custom_components/afvalwijzer/collector/klikogroep.py b/custom_components/afvalwijzer/collector/klikogroep.py index edfe390..5badb35 100644 --- a/custom_components/afvalwijzer/collector/klikogroep.py +++ b/custom_components/afvalwijzer/collector/klikogroep.py @@ -5,88 +5,98 @@ from urllib3.exceptions import InsecureRequestWarning requests.packages.urllib3.disable_warnings(InsecureRequestWarning) - def get_waste_data_raw(provider, username, password): - url = SENSOR_COLLECTORS_KLIKOGROEP[provider]['url'] - app = SENSOR_COLLECTORS_KLIKOGROEP[provider]['app'] + """Fetch raw waste collection data from the provider API.""" + try: + base_url = SENSOR_COLLECTORS_KLIKOGROEP[provider]['url'] + app = SENSOR_COLLECTORS_KLIKOGROEP[provider]['app'] - headers = { - 'Content-Type': 'application/json', - 'Referer': url, - } + headers = { + 'Content-Type': 'application/json', + 'Referer': base_url, + } + + # Login and get token + token = _login_and_get_token(base_url, headers, provider, username, password, app) + + # Get waste calendar + waste_data_raw = _fetch_waste_calendar(base_url, headers, token, provider, app) + + # Logout (optional, no error handling required here) + _logout(base_url, headers, token, provider, app) + + return waste_data_raw + except KeyError as err: + raise ValueError(f"Invalid provider configuration: {err}") from err - ########################################################################## - # First request: login and get token - ########################################################################## +def _login_and_get_token(base_url, headers, provider, username, password, app): + """Authenticate and retrieve a session token.""" + login_url = f"{base_url}/loginWithPassword" data = { "cardNumber": username, "password": password, "clientName": provider, "app": app, } - try: - raw_response = requests.post(url="{}/loginWithPassword".format(url), timeout=60, headers=headers, json=data) - raw_response.raise_for_status() + response = requests.post(url=login_url, timeout=60, headers=headers, json=data) + response.raise_for_status() + response_data = response.json() + if not response_data.get('success'): + raise ValueError('Login failed. Check username and/or password!') + return response_data["token"] except requests.exceptions.RequestException as err: - raise ValueError(err) from err - - try: - response = raw_response.json() + raise ValueError(f"Login request failed: {err}") from err except ValueError as err: - raise ValueError(f"Invalid and/or no data received from {url}/loginWithPassword") from err + raise ValueError(f"Invalid response from {login_url}: {err}") from err - if 'success' not in response or not response['success']: - _LOGGER.error('Login failed. Check card number (username) and / or password!') - return - - token = response["token"] - - ########################################################################## - # Second request: get the dates - ########################################################################## +def _fetch_waste_calendar(base_url, headers, token, provider, app): + """Retrieve the waste collection calendar.""" + calendar_url = f"{base_url}/getMyWasteCalendar" data = { "token": token, "clientName": provider, "app": app, } - try: - raw_response = requests.post(url="{}/getMyWasteCalendar".format(url), timeout=60, headers=headers, json=data) - raw_response.raise_for_status() + response = requests.post(url=calendar_url, timeout=60, headers=headers, json=data) + response.raise_for_status() + response_data = response.json() except requests.exceptions.RequestException as err: - raise ValueError(err) from err - - try: - response = raw_response.json() + raise ValueError(f"Waste calendar request failed: {err}") from err except ValueError as err: - raise ValueError(f"Invalid and/or no data received from {url}/getMyWasteCalendar") from err + raise ValueError(f"Invalid response from {calendar_url}: {err}") from err - waste_data_raw = [] - waste_type_mapping = {} - for waste_type in response['fractions']: - waste_type_mapping[waste_type['id']] = _waste_type_rename(waste_type['name'].lower()) + return _parse_waste_calendar(response_data) - for pickup_date in response["dates"]: - num_pickup = len(response["dates"][pickup_date][0]) - for idx in range(0, num_pickup): - pick_up = response["dates"][pickup_date][0][idx] - if pick_up != 0: +def _parse_waste_calendar(response): + """Parse the waste calendar response into a structured list.""" + waste_type_mapping = { + fraction['id']: _waste_type_rename(fraction['name'].lower()) + for fraction in response.get('fractions', []) + } + + waste_data_raw = [] + for pickup_date, pickups in response.get("dates", {}).items(): + for pick_up in pickups[0]: + if pick_up: waste_data_raw.append({ - "type": waste_type_mapping[pick_up], + "type": waste_type_mapping.get(pick_up, "unknown"), "date": pickup_date, }) - ########################################################################## - # Third request: invalidate token / close session - ########################################################################## + return waste_data_raw + +def _logout(base_url, headers, token, provider, app): + """Log out to invalidate the session token.""" + logout_url = f"{base_url}/logout" data = { "token": token, "clientName": provider, "app": app, } - - response = requests.post(url="{}/logout".format(url), timeout=60, headers=headers, json=data).json() - # We really don't care about this result, honestly. - - return waste_data_raw + try: + requests.post(url=logout_url, timeout=60, headers=headers, json=data) + except requests.exceptions.RequestException: + # Logout failures are non-critical, so we can safely ignore them. + pass diff --git a/custom_components/afvalwijzer/config_flow.py b/custom_components/afvalwijzer/config_flow.py index f172190..fdcc704 100644 --- a/custom_components/afvalwijzer/config_flow.py +++ b/custom_components/afvalwijzer/config_flow.py @@ -41,6 +41,10 @@ async def async_step_user(self, user_input=None): errors = {} if user_input is not None: + # Ensure CONF_* is saved lowercase + user_input[CONF_COLLECTOR] = user_input.get(CONF_COLLECTOR, "").lower() + user_input[CONF_EXCLUDE_LIST] = user_input.get(CONF_EXCLUDE_LIST, "").lower() + # Perform validation if not self._validate_postal_code(user_input.get(CONF_POSTAL_CODE)): errors["postal_code"] = "config.error.invalid_postal_code" diff --git a/custom_components/afvalwijzer/tests/config_flow.py b/custom_components/afvalwijzer/tests/config_flow.py deleted file mode 100644 index 2a2dff6..0000000 --- a/custom_components/afvalwijzer/tests/config_flow.py +++ /dev/null @@ -1,59 +0,0 @@ -import aiohttp -import voluptuous as vol -from homeassistant import config_entries, core -from homeassistant.helpers import config_validation as cv - -DOMAIN = "trash_collection" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required("provider", default="mijnafvalwijzer"): cv.string, - vol.Required("postal_code"): cv.string, - vol.Required("street_number"): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass, config): - return True - - -class TrashCollectionConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - async def async_step_user(self, user_input=None): - errors = {} - - if user_input is not None: - # Perform validation or configuration steps here - if not await validate_user_input(self.hass, user_input): - errors["base"] = "Invalid input, please check your information." - - if not errors: - return self.async_create_entry( - title="Trash Collection", data=user_input - ) - - return self.async_show_form( - step_id="user", - data_schema=CONFIG_SCHEMA[DOMAIN], - errors=errors, - ) - - -async def validate_user_input(hass, user_input): - provider = user_input["provider"] - postal_code = user_input["postal_code"] - street_number = user_input["street_number"] - - url = f"https://json.mijnafvalwijzer.nl/?method=postcodecheck&postcode={postal_code}&street=&huisnummer={street_number}&toevoeging=&apikey=" - - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - data = await response.json() - - # Replace the following condition with your own validation logic - return data.get("data", {}).get("ophaaldagen", {}).get(provider) is not None diff --git a/custom_components/afvalwijzer/tests/test.py b/custom_components/afvalwijzer/tests/test.py new file mode 100644 index 0000000..db541d0 --- /dev/null +++ b/custom_components/afvalwijzer/tests/test.py @@ -0,0 +1,136 @@ +# from homeassistant.helpers.event import async_track_point_in_utc_time +# from homeassistant.util import dt as dt_util +# from homeassistant.components import persistent_notification + +# from .const import * + +from abc import ABC, abstractmethod +import logging +from datetime import datetime +import requests +from urllib3.exceptions import InsecureRequestWarning +requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + +_LOGGER = logging.getLogger(__name__) + +class WasteCollection: + def __init__(self, date, waste_type): + self.date = date + self.waste_type = waste_type + + def __repr__(self): + return f"" + +class WasteCollectionRepository: + def __init__(self): + self.collections = [] + + def __iter__(self): + yield from self.collections + + def __len__(self): + return len(self.collections) + + def add(self, collection): + self.collections.append(collection) + + def remove_all(self): + self.collections = [] + + def get_sorted(self): + return sorted(self.collections, key=lambda x: x.date) + + def get_upcoming(self): + today = datetime.now() + return [x for x in self.get_sorted() if x.date.date() >= today.date()] + + def get_first_upcoming(self, waste_types=None): + upcoming = self.get_upcoming() + if not upcoming: + return None + + first_item_date = upcoming[0].date.date() + return [x for x in upcoming if x.date.date() == first_item_date and (not waste_types or x.waste_type.lower() in map(str.lower, waste_types))] + + def get_upcoming_by_type(self, waste_type): + today = datetime.now() + return [x for x in self.get_sorted() if x.date.date() >= today.date() and x.waste_type.lower() == waste_type.lower()] + + def get_first_upcoming_by_type(self, waste_type): + upcoming = self.get_upcoming_by_type(waste_type) + return upcoming[0] if upcoming else None + + def get_by_date(self, date, waste_types=None): + if not waste_types: + return [x for x in self.collections if x.date.date() == date.date()] + + return [ + x for x in self.collections + if x.date.date() == date.date() and x.waste_type.lower() in map(str.lower, waste_types) + ] + +class WasteCollectorBase(ABC): + @abstractmethod + def fetch_data(self): + pass + + @abstractmethod + def parse_data(self, data): + pass + +class AfvalwijzerCollector(WasteCollectorBase): + def __init__(self, provider, postal_code, street_number, suffix): + self.provider = provider + self.postal_code = postal_code + self.street_number = street_number + self.suffix = suffix + + def fetch_data(self): + try: + url = f"https://api.{self.provider}.nl/webservices/appsinput/?apikey=5ef443e778f41c4f75c69459eea6e6ae0c2d92de729aa0fc61653815fbd6a8ca&method=postcodecheck&postcode={self.postal_code}&street=&huisnummer={self.street_number}&toevoeging={self.suffix if self.suffix else ''}&app_name=afvalwijzer&platform=web&afvaldata={datetime.now().strftime('%Y-%m-%d')}&langs=nl&" + raw_response = requests.get(url, timeout=60, verify=False) + raw_response.raise_for_status() + return raw_response.json() + except requests.exceptions.RequestException as err: + raise ValueError(err) from err + + def parse_data(self, data): + try: + ophaaldagen_data = data.get("ophaaldagen", {}).get("data", []) + ophaaldagen_next_data = data.get("ophaaldagenNext", {}).get("data", [])[:10] + + if not ophaaldagen_data and not ophaaldagen_next_data: + _LOGGER.error("Address not found or no data available!") + raise KeyError + + collections = [] + for entry in ophaaldagen_data + ophaaldagen_next_data: + try: + collection_date = datetime.strptime(entry["date"], "%Y-%m-%d") + waste_type = entry["type"] + collections.append(WasteCollection(collection_date, waste_type)) + except (KeyError, ValueError) as parse_err: + _LOGGER.warning(f"Skipping invalid entry: {entry} - {parse_err}") + + return collections + except KeyError as err: + raise KeyError("Invalid and/or no data received") from err + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + + collector = AfvalwijzerCollector("mijnafvalwijzer", "5146EA", 73, "") + repository = WasteCollectionRepository() + + try: + data = collector.fetch_data() + collections = collector.parse_data(data) + + for collection in collections: + repository.add(collection) + + # print(collection.date) + + _LOGGER.info(f"Loaded waste collections: {repository.get_sorted()}") + except Exception as e: + _LOGGER.error(f"Error: {e}") diff --git a/custom_components/afvalwijzer/tests/test2.py b/custom_components/afvalwijzer/tests/test2.py new file mode 100644 index 0000000..1bf0e9c --- /dev/null +++ b/custom_components/afvalwijzer/tests/test2.py @@ -0,0 +1,130 @@ +import logging +from datetime import datetime +import requests +import sys +from urllib3.exceptions import InsecureRequestWarning + +requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + +_LOGGER = logging.getLogger(__name__) + +class WasteCollection: + def __init__(self, date, waste_type): + self.date = date + self.waste_type = waste_type + + def __repr__(self): + return f"" + +class WasteCollectionRepository: + def __init__(self): + self.collections = [] + + def __iter__(self): + yield from self.collections + + def __len__(self): + return len(self.collections) + + def add(self, collection): + self.collections.append(collection) + + def remove_all(self): + self.collections = [] + + def get_sorted(self): + return sorted(self.collections, key=lambda x: x.date) + + def get_upcoming(self): + today = datetime.now() + return [x for x in self.get_sorted() if x.date.date() >= today.date()] + + def get_first_upcoming(self, waste_types=None): + upcoming = self.get_upcoming() + if not upcoming: + return None + + first_item_date = upcoming[0].date.date() + return [x for x in upcoming if x.date.date() == first_item_date and (not waste_types or x.waste_type.lower() in map(str.lower, waste_types))] + + def get_upcoming_by_type(self, waste_type): + today = datetime.now() + return [x for x in self.get_sorted() if x.date.date() >= today.date() and x.waste_type.lower() == waste_type.lower()] + + def get_first_upcoming_by_type(self, waste_type): + upcoming = self.get_upcoming_by_type(waste_type) + return upcoming[0] if upcoming else None + + def get_by_date(self, date, waste_types=None): + if not waste_types: + return [x for x in self.collections if x.date.date() == date.date()] + + return [ + x for x in self.collections + if x.date.date() == date.date() and x.waste_type.lower() in map(str.lower, waste_types) + ] + +class AfvalwijzerCollector: + def __init__(self, provider, postal_code, street_number, suffix): + self.provider = provider + self.postal_code = postal_code + self.street_number = street_number + self.suffix = suffix + + def fetch_data(self): + try: + url = f"https://api.{self.provider}.nl/webservices/appsinput/?apikey=5ef443e778f41c4f75c69459eea6e6ae0c2d92de729aa0fc61653815fbd6a8ca&method=postcodecheck&postcode={self.postal_code}&street=&huisnummer={self.street_number}&toevoeging={self.suffix if self.suffix else ''}&app_name=afvalwijzer&platform=web&afvaldata={datetime.now().strftime('%Y-%m-%d')}&langs=nl&" + raw_response = requests.get(url, timeout=60, verify=False) + raw_response.raise_for_status() + return raw_response.json() + except requests.exceptions.RequestException as err: + raise ValueError(err) from err + + def parse_data(self, data): + try: + ophaaldagen_data = data.get("ophaaldagen", {}).get("data", []) + ophaaldagen_next_data = data.get("ophaaldagenNext", {}).get("data", []) + + if not ophaaldagen_data and not ophaaldagen_next_data: + _LOGGER.error("Address not found or no data available!") + raise KeyError + + collections = [] + for entry in ophaaldagen_data + ophaaldagen_next_data: + try: + collection_date = datetime.strptime(entry["date"], "%Y-%m-%d") + waste_type = entry["type"] + collections.append(WasteCollection(collection_date, waste_type)) + except (KeyError, ValueError) as parse_err: + _LOGGER.warning(f"Skipping invalid entry: {entry} - {parse_err}") + + return collections + except KeyError as err: + raise KeyError("Invalid and/or no data received") from err + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + + if len(sys.argv) < 4 or len(sys.argv) > 5: + print("Usage: python waste_collections.py ") + sys.exit(1) + + provider, postal_code, street_number = sys.argv[1:4] + suffix = sys.argv[4] if len(sys.argv) == 5 else '' + + repository = WasteCollectionRepository() + collector = AfvalwijzerCollector(provider, postal_code, street_number, suffix) + + try: + data = collector.fetch_data() + collections = collector.parse_data(data) + + for collection in collections: + repository.add(collection) + + print("Loaded waste collections:") + for collection in repository.get_sorted(): + print(collection) + except Exception as e: + _LOGGER.error(f"Error: {e}") + sys.exit(1) diff --git a/custom_components/afvalwijzer/tests/test_module.py b/custom_components/afvalwijzer/tests/test_module.py index be6b274..671c384 100644 --- a/custom_components/afvalwijzer/tests/test_module.py +++ b/custom_components/afvalwijzer/tests/test_module.py @@ -27,6 +27,8 @@ date_isoformat = "True" default_label = "geen" exclude_list = "" +username = "" +password = "" # DeAfvalapp # provider = "deafvalapp" @@ -144,6 +146,8 @@ postal_code, street_number, suffix, + username, + password, exclude_pickup_today, date_isoformat, exclude_list, diff --git a/custom_components/afvalwijzer/translations/en.json b/custom_components/afvalwijzer/translations/en.json index a32a594..813f067 100644 --- a/custom_components/afvalwijzer/translations/en.json +++ b/custom_components/afvalwijzer/translations/en.json @@ -9,8 +9,8 @@ "postal_code": "Postal code (e.g., 1234AB)", "street_number": "Street number", "suffix": "Address suffix", - "username": "Provider username", - "password": "Provider password", + "username": "Provider username (klikogroep only) ", + "password": "Provider password (klikogroep only)", "exclude_pickup_today": "Exclude today's pickup", "date_isoformat": "Use ISO date format", "default_label": "Default label when no data is available", diff --git a/custom_components/afvalwijzer/translations/nl.json b/custom_components/afvalwijzer/translations/nl.json index a4ba1db..4b4578c 100644 --- a/custom_components/afvalwijzer/translations/nl.json +++ b/custom_components/afvalwijzer/translations/nl.json @@ -9,8 +9,8 @@ "postal_code": "Postcode (bijv. 1234AB)", "street_number": "Huisnummer", "suffix": "Huisnummer toevoeging", - "username": "Provider gebruikersnaam", - "password": "Provider wachtwoord", + "username": "Provider gebruikersnaam (klikogroep)", + "password": "Provider wachtwoord (klikogroep)", "exclude_pickup_today": "Sluit ophalen van vandaag uit", "date_isoformat": "Gebruik ISO-datumformaat", "default_label": "Standaard label bij geen datum bekend",