├── hacs.json ├── custom_components └── min_renovasjon │ ├── manifest.json │ ├── translations │ ├── nb.json │ └── en.json │ ├── const.py │ ├── calendar.py │ ├── data.py │ ├── __init__.py │ ├── sensor.py │ ├── api.py │ └── config_flow.py ├── LICENSE ├── README.md └── .gitignore /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Min renovasjon", 3 | "render_readme": true, 4 | "domains": ["sensor", "calendar"], 5 | "country": ["NO"] 6 | } 7 | -------------------------------------------------------------------------------- /custom_components/min_renovasjon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "min_renovasjon", 3 | "name": "Min Renovasjon", 4 | "config_flow": true, 5 | "documentation": "https://github.com/eyesoft/home_assistant_min_renovasjon", 6 | "issue_tracker": "https://github.com/eyesoft/home_assistant_min_renovasjon/issues", 7 | "codeowners": ["@eyesoft"], 8 | "dependencies": [], 9 | "requirements": [], 10 | "ssdp": [], 11 | "zeroconf": [], 12 | "homekit": {}, 13 | "version": "2.6.1", 14 | "iot_class": "cloud_polling" 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 The Python Packaging Authority 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Min Renovasjon 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) 4 | 5 | Buy Me A Coffee 6 | 7 | Home Assistant integration of the norwegian Min Renovasjon app. 8 | 9 | ## Installation 10 | Under HACS -> Integrations, add custom repository "https://github.com/eyesoft/home_assistant_min_renovasjon/" with Category "Integration". 11 | 12 | Search for repository "Min Renovasjon" and download it. Restart Home Assistant. 13 | 14 | Go to Settings > Integrations and Add Integration "Min Renovasjon". Type in address to search, e.g. "Min gate 12, 0153" (street address comma zipcode). 15 | 16 | Click Configure and choose fractions to create sensors. 17 | 18 | Restart Home Assistant. 19 | 20 | ## Upgrade from version pre 2.0.0 21 | Install component and configure it as described under Installation. 22 | 23 | If everything work as before after the restart, the old integration "min_renovasjon" and sensor "min_renovasjon" can be deleted from configuration.yaml 24 | -------------------------------------------------------------------------------- /custom_components/min_renovasjon/translations/nb.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Min Renovasjon adresseinformasjon", 6 | "description": "Tast inn din adresse som 'gateadresse, postnummer'.", 7 | "data": { 8 | "street_name": "Gatenavn", 9 | "street_code": "Gatekode", 10 | "house_no": "Husnummer", 11 | "county_id": "FylkeId", 12 | "date_format": "Datoformat" 13 | } 14 | } 15 | }, 16 | "error": { 17 | "unknown": "Ukjent feil oppstod.", 18 | "municipality_not_customer": "Valgt kommune er ikke en kunde av Min Renovasjon!", 19 | "no_address_found": "Ingen adresse funnet.", 20 | "multiple_addresses_found": "Mer enn en adresse funnet. Vennligst begrens søket." 21 | }, 22 | "abort": { 23 | "single_instance_allowed": "Denne integrasjonen kan kun konfigureres én gang." 24 | } 25 | }, 26 | "options": { 27 | "step": { 28 | "init": { 29 | "title": "Min Renovasjon avfallstyper", 30 | "description": "Velg de avfallstypene det skal lages sensor for. Home Assistant må deretter startes på nytt.", 31 | "data": { 32 | "fraction_ids": "Avfallstyper", 33 | "date_format": "Datoformatering" 34 | } 35 | } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /custom_components/min_renovasjon/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Min Renovasjon address information", 6 | "description": "Enter your address information as 'street address, zip code'.", 7 | "data": { 8 | "street_name": "Street name", 9 | "street_code": "Street code", 10 | "house_no": "House no", 11 | "county_id": "County Id", 12 | "date_format": "Date format" 13 | } 14 | } 15 | }, 16 | "error": { 17 | "unknown": "Unknown error occured.", 18 | "municipality_not_customer": "Selected municipality is not a customer of Min Renovasjon!", 19 | "no_address_found": "No address found.", 20 | "multiple_addresses_found": "More than one addresses found. Only one address should be returned. Please narrow your search." 21 | }, 22 | "abort": { 23 | "single_instance_allowed": "Only a single instance is allowed." 24 | } 25 | }, 26 | "options": { 27 | "step": { 28 | "init": { 29 | "title": "Min Renovasjon fractions", 30 | "description": "Select fractions from which sensors are to be made. Home Assistant needs to be restartet when done.", 31 | "data": { 32 | "fraction_ids": "Fractions", 33 | "date_format": "Date formatting" 34 | } 35 | } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /custom_components/min_renovasjon/const.py: -------------------------------------------------------------------------------- 1 | VERSION = "2.6.1" 2 | NAME = "Min Renovasjon" 3 | ISSUE_URL = "https://github.com/eyesoft/home_assistant_min_renovasjon/issues" 4 | DOMAIN = "min_renovasjon" 5 | CONF_STREET_NAME = "street_name" 6 | CONF_STREET_CODE = "street_code" 7 | CONF_HOUSE_NO = "house_no" 8 | CONF_COUNTY_ID = "county_id" 9 | CONF_DATE_FORMAT = "date_format" 10 | DEFAULT_DATE_FORMAT = "%d/%m/%Y" 11 | CONST_KOMMUNE_NUMMER = "Kommunenr" 12 | CONST_APP_KEY = "RenovasjonAppKey" 13 | CONST_APP_KEY_VALUE = "AE13DEEC-804F-4615-A74E-B4FAC11F0A30" 14 | CONF_FRACTION_IDS = "fraction_ids" 15 | CONF_FRACTION_ID = "fraction_id" 16 | ADDRESS_LOOKUP_URL = "https://ws.geonorge.no/adresser/v1/sok?" 17 | APP_CUSTOMERS_URL = "https://www.webatlas.no/wacloud/servicerepository/CatalogueService.svc/json/GetRegisteredAppCustomers" 18 | KOMTEK_API_BASE_URL = "https://norkartrenovasjon.azurewebsites.net/proxyserver.ashx" 19 | CONST_URL_FRAKSJONER = KOMTEK_API_BASE_URL + "?server=https://komteksky.norkart.no/MinRenovasjon.Api/api/fraksjoner" 20 | CONST_URL_TOMMEKALENDER = KOMTEK_API_BASE_URL + "?server=https://komteksky.norkart.no/MinRenovasjon.Api/api/tommekalender/?gatenavn=[gatenavn]&gatekode=[gatekode]&husnr=[husnr]&fraDato=[fra_dato]&dato=[til_dato]&api-version=2" 21 | NUM_MONTHS = 6 22 | STARTUP_MESSAGE = f""" 23 | ------------------------------------------------------------------- 24 | {NAME} 25 | Version: {VERSION} 26 | This is a custom integration! 27 | If you have any issues with this you need to open an issue here: 28 | {ISSUE_URL} 29 | ------------------------------------------------------------------- 30 | """ -------------------------------------------------------------------------------- /custom_components/min_renovasjon/calendar.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from homeassistant.components.calendar import CalendarEntity, CalendarEvent 4 | from homeassistant.core import callback 5 | from homeassistant.util.dt import parse_datetime, as_utc, now 6 | from datetime import datetime 7 | from .const import (DOMAIN) 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | 12 | async def async_setup_entry(hass, config_entry, async_add_entities): 13 | min_renovasjon = hass.data[DOMAIN]["data"] 14 | async_add_entities([MinRenovasjonCalendarEntity("Min Renovasjon Calendar", config_entry, min_renovasjon)]) 15 | 16 | 17 | class MinRenovasjonCalendarEntity(CalendarEntity): 18 | """Representation of a MinRenovasjon Calendar Entity.""" 19 | 20 | def __init__(self, calendar_name, config_entry, min_renovasjon): 21 | """Initialize the calendar entity.""" 22 | self._calendar_name = calendar_name 23 | self._config_entry = config_entry 24 | self._min_renovasjon = min_renovasjon 25 | self._events = [] 26 | 27 | @property 28 | def name(self): 29 | """Return the name of the calendar entity.""" 30 | return self._calendar_name 31 | 32 | @property 33 | def event(self): 34 | """Return the next upcoming event.""" 35 | return self._get_next_event() 36 | 37 | @property 38 | def unique_id(self): 39 | """Return a unique ID to use for this entity.""" 40 | return f"{self._config_entry.entry_id}" 41 | 42 | async def async_get_events(self, hass, start_date: datetime, end_date: datetime) -> list[CalendarEvent]: 43 | """Return calendar events within a datetime range.""" 44 | start_date = as_utc(start_date) 45 | end_date = as_utc(end_date) 46 | event_list = [] 47 | 48 | for event in self._events: 49 | if event: 50 | if start_date.date() <= event.start <= end_date.date(): 51 | event_list.append(event) 52 | 53 | return event_list 54 | 55 | async def async_update(self): 56 | """Update the calendar with new events from the API.""" 57 | self._events = await self._fetch_events() 58 | 59 | async def _fetch_events(self): 60 | """Call Min Renovasjon to fetch delivery dates.""" 61 | events = [] 62 | calendar_list = await self._min_renovasjon.async_get_calendar_list() 63 | 64 | for entry in calendar_list: 65 | if entry: 66 | fraction_name = entry[1] 67 | pickup_dates = entry[5] 68 | 69 | for pickup_date in pickup_dates: 70 | if pickup_date: 71 | pickup_date_formatted = datetime.strptime(pickup_date, "%Y-%m-%dT%H:%M:%S") 72 | 73 | events.append(CalendarEvent( 74 | summary=fraction_name, 75 | start=pickup_date_formatted.date(), 76 | end=pickup_date_formatted.date() 77 | )) 78 | 79 | self._events = events 80 | return events 81 | 82 | def _get_next_event(self): 83 | """Return the next upcoming event.""" 84 | if not self._events: 85 | return None 86 | return self._events[0] 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # ############################# 107 | # JetBrain 108 | # User-specific stuff 109 | .idea/**/workspace.xml 110 | .idea/**/tasks.xml 111 | .idea/**/usage.statistics.xml 112 | .idea/**/dictionaries 113 | .idea/**/shelf 114 | 115 | # Generated files 116 | .idea/**/contentModel.xml 117 | 118 | # Sensitive or high-churn files 119 | .idea/**/dataSources/ 120 | .idea/**/dataSources.ids 121 | .idea/**/dataSources.local.xml 122 | .idea/**/sqlDataSources.xml 123 | .idea/**/dynamic.xml 124 | .idea/**/uiDesigner.xml 125 | .idea/**/dbnavigator.xml 126 | 127 | # Gradle 128 | .idea/**/gradle.xml 129 | .idea/**/libraries 130 | 131 | # Gradle and Maven with auto-import 132 | # When using Gradle or Maven with auto-import, you should exclude module files, 133 | # since they will be recreated, and may cause churn. Uncomment if using 134 | # auto-import. 135 | # .idea/modules.xml 136 | # .idea/*.iml 137 | # .idea/modules 138 | 139 | # CMake 140 | cmake-build-*/ 141 | 142 | # Mongo Explorer plugin 143 | .idea/**/mongoSettings.xml 144 | 145 | # File-based project format 146 | *.iws 147 | 148 | # IntelliJ 149 | out/ 150 | 151 | # mpeltonen/sbt-idea plugin 152 | .idea_modules/ 153 | 154 | # JIRA plugin 155 | atlassian-ide-plugin.xml 156 | 157 | # Cursive Clojure plugin 158 | .idea/replstate.xml 159 | 160 | # Crashlytics plugin (for Android Studio and IntelliJ) 161 | com_crashlytics_export_strings.xml 162 | crashlytics.properties 163 | crashlytics-build.properties 164 | fabric.properties 165 | 166 | # Editor-based Rest Client 167 | .idea/httpRequests 168 | -------------------------------------------------------------------------------- /custom_components/min_renovasjon/data.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import logging 3 | 4 | from dateutil.relativedelta import relativedelta 5 | from datetime import date 6 | from datetime import datetime 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 9 | from .const import (DOMAIN) 10 | from .api import ApiClient, ApiException 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class DataClient: 16 | 17 | def __init__(self, hass: HomeAssistant) -> None: 18 | self._hass = hass 19 | 20 | # Reads the calendar from Min Renovasjon and handles caching 21 | async def async_get_calendar(self, 22 | kommune_nummer: str, 23 | gatenavn: str, 24 | gatekode: str, 25 | husnummer: str 26 | ) -> dict: 27 | 28 | calendar_list = self._hass.data[DOMAIN]["calendar_list"] 29 | calendar_needs_refresh = self._check_for_refresh_of_data(calendar_list) 30 | 31 | if calendar_needs_refresh: 32 | session = async_get_clientsession(self._hass) 33 | client = ApiClient(session) 34 | 35 | tommekalender, fraksjoner = await client.async_get_data( 36 | kommune_nummer, 37 | gatenavn, 38 | gatekode, 39 | husnummer 40 | ) 41 | 42 | calendar_list = self._parse_calendar_list(tommekalender, fraksjoner) 43 | self._hass.data[DOMAIN]["calendar_list"] = calendar_list 44 | 45 | return calendar_list 46 | 47 | # Parses Tømmekalender and Fraksjoner and returns a combined calendar list 48 | @staticmethod 49 | def _parse_calendar_list(tommekalender, fraksjoner): 50 | calendar_list = [] 51 | 52 | if tommekalender is None or fraksjoner is None: 53 | _LOGGER.error("Could not fetch calendar. Check configuration parameters.") 54 | return None 55 | 56 | for calender_entry in tommekalender: 57 | fraksjon_id = calender_entry['FraksjonId'] 58 | tommedato_forste = None 59 | tommedato_neste = None 60 | tommedato_alle = None 61 | 62 | if len(calender_entry['Tommedatoer']) == 1: 63 | tommedato_forste = calender_entry['Tommedatoer'][0] 64 | else: 65 | tommedato_forste = calender_entry['Tommedatoer'][0] 66 | tommedato_neste = calender_entry['Tommedatoer'][1] 67 | 68 | tommedato_alle = calender_entry['Tommedatoer'] 69 | 70 | if tommedato_forste is not None: 71 | tommedato_forste = datetime.strptime(tommedato_forste, "%Y-%m-%dT%H:%M:%S") 72 | if tommedato_neste is not None: 73 | tommedato_neste = datetime.strptime(tommedato_neste, "%Y-%m-%dT%H:%M:%S") 74 | 75 | for fraksjon in fraksjoner: 76 | if int(fraksjon['Id']) == int(fraksjon_id): 77 | fraksjon_navn = fraksjon['Navn'] 78 | 79 | fraksjon_ikon = fraksjon['NorkartStandardFraksjonIkon'] 80 | if fraksjon_ikon is None: 81 | fraksjon_ikon = fraksjon['Ikon'] 82 | fraksjon_ikon = fraksjon_ikon.replace("http:", "https:") 83 | 84 | calendar_list.append((fraksjon_id, 85 | fraksjon_navn, 86 | fraksjon_ikon, 87 | tommedato_forste, 88 | tommedato_neste, 89 | tommedato_alle)) 90 | continue 91 | 92 | return calendar_list 93 | 94 | # Checks if calendar data needs to be updated 95 | @staticmethod 96 | def _check_for_refresh_of_data(calendar_list): 97 | 98 | if calendar_list is None: 99 | _LOGGER.debug("Calendar is empty. Data needs to be read.") 100 | return True 101 | 102 | for entry in calendar_list: 103 | _, _, _, tommedato_forste, tommedato_neste, _ = entry 104 | 105 | if tommedato_forste is None or tommedato_forste.date() < date.today() or ( 106 | tommedato_neste is not None and tommedato_neste.date() < date.today()): 107 | _LOGGER.debug("Date out of range. Data need to be read.") 108 | return True 109 | 110 | return False 111 | 112 | -------------------------------------------------------------------------------- /custom_components/min_renovasjon/__init__.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | import logging 3 | import homeassistant.helpers.config_validation as cv 4 | import voluptuous as vol 5 | 6 | from dateutil.relativedelta import relativedelta 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | from .data import DataClient 10 | 11 | from .const import ( 12 | DOMAIN, 13 | CONF_STREET_NAME, 14 | CONF_STREET_CODE, 15 | CONF_HOUSE_NO, 16 | CONF_COUNTY_ID, 17 | CONF_DATE_FORMAT, 18 | DEFAULT_DATE_FORMAT, 19 | STARTUP_MESSAGE 20 | ) 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | CONFIG_SCHEMA = vol.Schema({ 25 | DOMAIN: vol.Schema({ 26 | vol.Required(CONF_STREET_NAME): cv.string, 27 | vol.Required(CONF_STREET_CODE): cv.string, 28 | vol.Required(CONF_HOUSE_NO): cv.string, 29 | vol.Required(CONF_COUNTY_ID): cv.string, 30 | vol.Optional(CONF_DATE_FORMAT, default=DEFAULT_DATE_FORMAT): cv.string, 31 | }) 32 | }, extra=vol.ALLOW_EXTRA) 33 | 34 | 35 | async def async_setup(hass: HomeAssistant, config: dict): 36 | if DOMAIN not in config: 37 | return True 38 | 39 | hass.data.setdefault(DOMAIN, {}) 40 | hass.data[DOMAIN]["calendar_list"] = None 41 | 42 | street_name = config[DOMAIN][CONF_STREET_NAME] 43 | street_code = config[DOMAIN][CONF_STREET_CODE] 44 | house_no = config[DOMAIN][CONF_HOUSE_NO] 45 | county_id = config[DOMAIN][CONF_COUNTY_ID] 46 | date_format = config[DOMAIN][CONF_DATE_FORMAT] 47 | 48 | min_renovasjon = MinRenovasjon(hass, street_name, street_code, house_no, county_id, date_format) 49 | hass.data[DOMAIN]["data"] = min_renovasjon 50 | 51 | return True 52 | 53 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 54 | if hass.data.get(DOMAIN) is None: 55 | hass.data.setdefault(DOMAIN, {}) 56 | _LOGGER.info(STARTUP_MESSAGE) 57 | 58 | hass.data[DOMAIN]["calendar_list"] = None 59 | street_name = config_entry.data.get(CONF_STREET_NAME, "") 60 | street_code = config_entry.data.get(CONF_STREET_CODE, "") 61 | house_no = config_entry.data.get(CONF_HOUSE_NO, "") 62 | county_id = config_entry.data.get(CONF_COUNTY_ID, "") 63 | date_format = config_entry.options.get(CONF_DATE_FORMAT, DEFAULT_DATE_FORMAT) 64 | 65 | min_renovasjon = MinRenovasjon(hass, street_name, street_code, house_no, county_id, date_format) 66 | hass.data[DOMAIN]["data"] = min_renovasjon 67 | 68 | await hass.config_entries.async_forward_entry_setups(config_entry, ["sensor", "calendar"]) 69 | return True 70 | 71 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: 72 | return True 73 | 74 | 75 | class MinRenovasjon: 76 | def __init__(self, hass, gatenavn, gatekode, husnr, kommunenr, date_format): 77 | self._hass = hass 78 | self._gatenavn = self._url_encode(gatenavn) 79 | self._gatekode = gatekode 80 | self._husnr = husnr 81 | self._kommunenr = kommunenr 82 | self._date_format = date_format 83 | self._data_client = DataClient(hass) 84 | 85 | # Fetches the calender for the specified fractionId 86 | async def async_get_calender_for_fraction(self, fraction_id): 87 | calendar_list = await self._async_get_calendar() 88 | 89 | if calendar_list is None: 90 | return None 91 | 92 | for entry in calendar_list: 93 | if entry is not None: 94 | entry_fraction_id, _, _, tommedato_forste, tommedato_neste, _ = entry 95 | if int(fraction_id) == int(entry_fraction_id): 96 | return entry 97 | 98 | return None 99 | 100 | # Fetches the calendar for all fractions 101 | async def async_get_calendar_list(self): 102 | return await self._async_get_calendar() 103 | 104 | async def _async_get_calendar(self): 105 | return await self._data_client.async_get_calendar( 106 | self._kommunenr, 107 | self._gatenavn, 108 | self._gatekode, 109 | self._husnr 110 | ) 111 | 112 | def format_date(self, date): 113 | if self._date_format == "None": 114 | return date 115 | return date.strftime(self._date_format) 116 | 117 | @staticmethod 118 | def _url_encode(string): 119 | string_decoded_encoded = urllib.parse.quote(urllib.parse.unquote(string)) 120 | if string_decoded_encoded != string: 121 | string = string_decoded_encoded 122 | return string 123 | -------------------------------------------------------------------------------- /custom_components/min_renovasjon/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import homeassistant.helpers.config_validation as cv 3 | import voluptuous as vol 4 | 5 | from homeassistant.helpers.restore_state import RestoreEntity 6 | from homeassistant.components.sensor import PLATFORM_SCHEMA 7 | from homeassistant.helpers.entity import Entity 8 | from datetime import date 9 | from datetime import timedelta 10 | from .const import ( 11 | DOMAIN, 12 | CONF_FRACTION_IDS, 13 | CONF_FRACTION_ID 14 | ) 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | SCAN_INTERVAL = timedelta(hours=1) 18 | 19 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 20 | vol.Required(CONF_FRACTION_ID): vol.All(cv.ensure_list), 21 | }) 22 | 23 | 24 | async def async_setup_platform(hass, config, add_entities, discovery_info=None): 25 | min_renovasjon = hass.data[DOMAIN]["data"] 26 | calendar_list = await min_renovasjon.async_get_calendar_list() 27 | fraction_ids = config.get(CONF_FRACTION_ID) 28 | 29 | add_entities(MinRenovasjonSensor(min_renovasjon, fraction_id, calendar_list) for fraction_id in fraction_ids) 30 | 31 | 32 | async def async_setup_entry(hass, config_entry, async_add_entities): 33 | min_renovasjon = hass.data[DOMAIN]["data"] 34 | calendar_list = await min_renovasjon.async_get_calendar_list() 35 | 36 | entities = [] 37 | fraction_ids = config_entry.options.get(CONF_FRACTION_IDS, []) 38 | 39 | for fraction_id in fraction_ids: 40 | entities.append(MinRenovasjonSensor(min_renovasjon, fraction_id, calendar_list)) 41 | 42 | async_add_entities(entities) 43 | 44 | 45 | class MinRenovasjonSensor(Entity): 46 | 47 | def __init__(self, min_renovasjon, fraction_id, calendar_list): 48 | """Initialize with API object, device id.""" 49 | self._state = None 50 | self._calendar_list = calendar_list 51 | self._min_renovasjon = min_renovasjon 52 | self._fraction_id = int(fraction_id) 53 | self._available = True 54 | self._attributes = {} 55 | 56 | @property 57 | def should_poll(self): 58 | return True 59 | 60 | @property 61 | def device_class(self): 62 | """Return the class of this device.""" 63 | return "date" 64 | 65 | @property 66 | def available(self): 67 | """Could the device be accessed during the last update call.""" 68 | return self._available 69 | 70 | @property 71 | def entity_picture(self): 72 | """Return entity specific state attributes.""" 73 | if self._calendar_list is not None: 74 | for fraction in self._calendar_list: 75 | if int(fraction[0]) == self._fraction_id: 76 | return fraction[2] 77 | return None 78 | 79 | @property 80 | def extra_state_attributes(self): 81 | """Return entity specific state attributes.""" 82 | return self._attributes 83 | 84 | @property 85 | def name(self): 86 | """Return the name.""" 87 | if self._calendar_list is not None: 88 | for fraction in self._calendar_list: 89 | if int(fraction[0]) == self._fraction_id: 90 | return fraction[1] 91 | return None 92 | 93 | @property 94 | def unique_id(self) -> str: 95 | """Return unique ID.""" 96 | return "{0}_{1}".format(DOMAIN, self._fraction_id) 97 | 98 | @property 99 | def state(self): 100 | """Return the state/date of the fraction.""" 101 | return self._min_renovasjon.format_date(self._state) 102 | 103 | async def async_update(self): 104 | """Update calendar.""" 105 | fraction = await self._min_renovasjon.async_get_calender_for_fraction(self._fraction_id) 106 | 107 | if fraction is not None: 108 | if fraction[3] is not None: 109 | pickupDate = fraction[3].date() 110 | today = date.today() 111 | diff = pickupDate - today 112 | self._state = fraction[3] 113 | 114 | self._attributes['days_to_pickup'] = diff.days 115 | self._attributes['formatted_date'] = self._min_renovasjon.format_date(self._state) 116 | self._attributes['raw_date'] = self._state 117 | self._attributes['date_next_pickup'] = fraction[4] 118 | self._attributes['fraction_id'] = self._fraction_id 119 | self._attributes['fraction_name'] = fraction[1] 120 | self._attributes['fraction_icon'] = fraction[2] 121 | self._attributes['pickup_days'] = fraction[5] 122 | 123 | async def async_added_to_hass(self): 124 | await self.async_update() 125 | -------------------------------------------------------------------------------- /custom_components/min_renovasjon/api.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import asyncio 3 | import json 4 | import logging 5 | import socket 6 | 7 | from dateutil.relativedelta import relativedelta 8 | from datetime import date 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 11 | from .const import ( 12 | CONST_KOMMUNE_NUMMER, 13 | CONST_APP_KEY, 14 | CONST_URL_FRAKSJONER, 15 | CONST_URL_TOMMEKALENDER, 16 | CONST_APP_KEY_VALUE, 17 | NUM_MONTHS, 18 | APP_CUSTOMERS_URL, 19 | ADDRESS_LOOKUP_URL 20 | ) 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | class ApiClient: 26 | 27 | def __init__(self, session: aiohttp.ClientSession) -> None: 28 | self._session = session 29 | 30 | async def async_get_data(self, 31 | kommune_nummer: str, 32 | gatenavn: str, 33 | gatekode: str, 34 | husnummer: str 35 | ): 36 | 37 | tommekalender = await self.async_get_tommekalender(kommune_nummer, 38 | gatenavn, 39 | gatekode, 40 | husnummer) 41 | 42 | fraksjoner = await self.async_get_fraksjoner(kommune_nummer) 43 | 44 | return tommekalender, fraksjoner 45 | 46 | async def async_get_tommekalender(self, 47 | kommune_nummer: str, 48 | gatenavn: str, 49 | gatekode: str, 50 | husnummer: str 51 | ): 52 | headers = {CONST_KOMMUNE_NUMMER: kommune_nummer, CONST_APP_KEY: CONST_APP_KEY_VALUE} 53 | 54 | fra_dato = date.today().strftime("%Y-%m-%d") 55 | til_dato = (date.today() + relativedelta(months=NUM_MONTHS)).strftime("%Y-%m-%d") 56 | 57 | url = (CONST_URL_TOMMEKALENDER.replace('[gatenavn]', gatenavn) 58 | .replace('[gatekode]', gatekode) 59 | .replace('[husnr]', husnummer) 60 | .replace('[fra_dato]', fra_dato) 61 | .replace('[til_dato]', til_dato)) 62 | 63 | data = await self._client_session_get_data(url, headers) 64 | return json.loads(data) 65 | 66 | async def async_get_fraksjoner(self, kommune_nummer: str): 67 | headers = {CONST_KOMMUNE_NUMMER: kommune_nummer, CONST_APP_KEY: CONST_APP_KEY_VALUE} 68 | url = CONST_URL_FRAKSJONER 69 | 70 | data = await self._client_session_get_data(url, headers) 71 | return json.loads(data) 72 | 73 | async def async_municipality_is_app_customer(self, kommune_nummer) -> bool: 74 | customers = None 75 | url = APP_CUSTOMERS_URL 76 | params = {"Appid": "MobilOS-NorkartRenovasjon"} 77 | 78 | data = await self._client_session_get_data(url, params=params) 79 | customers = json.loads(data) 80 | 81 | return any( 82 | customer["Number"] == kommune_nummer for customer in customers 83 | ) 84 | 85 | async def async_address_lookup(self, search_string): 86 | url = ADDRESS_LOOKUP_URL 87 | params = { 88 | "sok": search_string, 89 | # Only get the relevant address fields 90 | "filtrer": "adresser.kommunenummer," 91 | "adresser.adressenavn," 92 | "adresser.adressekode," 93 | "adresser.nummer," 94 | "adresser.kommunenavn," 95 | "adresser.postnummer," 96 | "adresser.poststed", 97 | } 98 | 99 | data = await self._client_session_get_data(url, params=params) 100 | return json.loads(data) 101 | 102 | # Sends request to "url" and return data as "UTF-8" 103 | async def _client_session_get_data(self, 104 | url: str, 105 | headers: dict | None = None, 106 | params: dict | None = None 107 | ): 108 | try: 109 | _LOGGER.debug(f"Fetching data from server") 110 | 111 | async with self._session.get(url, params=params, headers=headers) as resp: 112 | data = await resp.read() 113 | 114 | if resp.ok: 115 | return data.decode("UTF-8") 116 | else: 117 | _LOGGER.error("GET returned: %s", resp) 118 | return None 119 | 120 | except asyncio.TimeoutError as exception: 121 | raise ApiException( 122 | f"Request timeout ({url})" 123 | ) from exception 124 | 125 | except (KeyError, TypeError) as exception: 126 | raise ApiException( 127 | f"Parse error: {exception} ({url})" 128 | ) from exception 129 | 130 | except (aiohttp.ClientError, socket.gaierror) as exception: 131 | raise ApiException( 132 | f"Request error: {exception} ({url})" 133 | ) from exception 134 | 135 | except Exception as exception: 136 | raise ApiException( 137 | f"Exception: {exception} ({url})" 138 | ) from exception 139 | 140 | 141 | class ApiException(Exception): 142 | """Exception""" 143 | -------------------------------------------------------------------------------- /custom_components/min_renovasjon/config_flow.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import re 3 | import logging 4 | import voluptuous as vol 5 | import homeassistant.helpers.config_validation as cv 6 | 7 | from typing import Dict, List, Tuple 8 | from homeassistant import config_entries, exceptions 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.core import callback 11 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 12 | from dateutil.relativedelta import relativedelta 13 | from .api import ApiClient, ApiException 14 | 15 | from .const import ( 16 | DOMAIN, 17 | CONF_STREET_NAME, 18 | CONF_STREET_CODE, 19 | CONF_HOUSE_NO, 20 | CONF_COUNTY_ID, 21 | CONF_DATE_FORMAT, 22 | DEFAULT_DATE_FORMAT, 23 | CONF_FRACTION_IDS 24 | ) 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 30 | VERSION = 1 31 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 32 | 33 | async def async_step_user(self, user_input=None): 34 | """Handle the initial step.""" 35 | errors = {} 36 | address = None 37 | 38 | if self._async_current_entries(): 39 | return self.async_abort(reason="single_instance_allowed") 40 | 41 | if user_input is not None: 42 | try: 43 | address = user_input["address"] 44 | error, address_info, title = await self._get_address_info(address) 45 | 46 | if error is not None: 47 | errors["base"] = error 48 | 49 | if address_info is not None: 50 | return self.async_create_entry(title=title, data=address_info) 51 | 52 | except Exception: 53 | _LOGGER.exception("Unexpected exception") 54 | errors["base"] = "unknown" 55 | 56 | # errors substitution in language file 57 | return self.async_show_form( 58 | step_id="user", 59 | 60 | data_schema=vol.Schema({ 61 | vol.Required("address", default=address): str 62 | }), 63 | errors=errors 64 | ) 65 | 66 | async def _get_address_info(self, address_search_string): 67 | error, address_info = await self._async_address_lookup(address_search_string) 68 | 69 | if error is not None: 70 | return error, None, None 71 | 72 | if address_info is not None: 73 | ( 74 | self.street, 75 | self.street_code, 76 | self.number, 77 | self.municipality, 78 | self.municipality_code, 79 | self.postal_code, 80 | self.postal, 81 | ) = address_info 82 | 83 | if await self.municipality_is_app_customer: 84 | text = "self.fractions = self._get_fractions()" 85 | else: 86 | return "municipality_not_customer", None, None 87 | 88 | address = { 89 | "street_name": self.street, 90 | "street_code": str(self.street_code), 91 | "house_no": str(self.number), 92 | "county_id": str(self.municipality_code) 93 | } 94 | 95 | title = f"{self.street} {self.number}, {self.postal_code} {self.postal}" 96 | 97 | return None, address, title 98 | 99 | async def _async_address_lookup(self, s: str) -> Tuple: 100 | """ 101 | Makes an API call to geonorge.no, the official resource for open geo data in Norway. 102 | This function is used to get deterministic address properties that is needed for 103 | further API calls with regards to Min Renovasjon, mainly municipality, municipality code, 104 | street name and street code. 105 | :param s: Search string for which address to search 106 | :return: Tuple of address fields 107 | """ 108 | error = None 109 | data = None 110 | 111 | regex = r"(.*ve)(i|g)(.*)" 112 | subst = "\\1*\\3" 113 | search_string = re.sub(regex, subst, s, 0, re.MULTILINE) 114 | 115 | session = async_get_clientsession(self.hass) 116 | client = ApiClient(session) 117 | data = await client.async_address_lookup(search_string) 118 | 119 | if data is None: 120 | return "no_address_found", None 121 | 122 | if not data["adresser"]: 123 | return "no_address_found", None 124 | 125 | if len(data["adresser"]) > 1: 126 | return "multiple_addresses_found", None 127 | 128 | return None, ( 129 | data["adresser"][0]["adressenavn"], 130 | data["adresser"][0]["adressekode"], 131 | data["adresser"][0]["nummer"], 132 | data["adresser"][0]["kommunenavn"], 133 | data["adresser"][0]["kommunenummer"], 134 | data["adresser"][0]["postnummer"], 135 | data["adresser"][0]["poststed"], 136 | ) 137 | 138 | @property 139 | async def municipality_is_app_customer(self) -> bool: 140 | """ 141 | Make an API call to get all customers of the NorkartRenovasjon service which 142 | supports the Min Renovasjon app. Then check if this municipality is actually 143 | a customer or not. 144 | :return: Boolean indicating if this municipality is a customer or not. 145 | """ 146 | session = async_get_clientsession(self.hass) 147 | client = ApiClient(session) 148 | return await client.async_municipality_is_app_customer(self.municipality_code) 149 | 150 | @staticmethod 151 | @callback 152 | def async_get_options_flow(config_entry): 153 | """Get options flow.""" 154 | return MinRenovasjonFlowHandler() 155 | 156 | 157 | class MinRenovasjonFlowHandler(config_entries.OptionsFlow): 158 | """Options flow handler.""" 159 | 160 | @property 161 | def config_entry(self): 162 | return self.hass.config_entries.async_get_entry(self.handler) 163 | 164 | async def async_step_init(self, user_input=None): 165 | """Manage the options.""" 166 | 167 | if user_input is not None: 168 | if "date_format" not in user_input: 169 | user_input["date_format"] = "None" 170 | 171 | return self.async_create_entry(title=DOMAIN, data=user_input) 172 | 173 | options = self.config_entry.options 174 | fraction_ids = options.get(CONF_FRACTION_IDS, []) 175 | date_format = options.get(CONF_DATE_FORMAT, DEFAULT_DATE_FORMAT) 176 | 177 | municipality_code = self.config_entry.data.get(CONF_COUNTY_ID, "") 178 | street_name = self.config_entry.data.get(CONF_STREET_NAME, "") 179 | street_code = self.config_entry.data.get(CONF_STREET_CODE, "") 180 | house_no = self.config_entry.data.get(CONF_HOUSE_NO, "") 181 | 182 | fraction_list = await self._get_fractions(municipality_code) 183 | calendar = await self._get_calendar(municipality_code, street_name, street_code, house_no) 184 | fractions = {} 185 | 186 | for fraction in fraction_list: 187 | if calendar is not None: 188 | if [item for item in calendar if item["FraksjonId"] == fraction["Id"]]: 189 | fractions[str(fraction["Id"])] = fraction["Navn"] 190 | else: 191 | fractions[str(fraction["Id"])] = fraction["Navn"] 192 | 193 | return self.async_show_form( 194 | step_id="init", 195 | data_schema=vol.Schema( 196 | { 197 | vol.Required(CONF_FRACTION_IDS, default=fraction_ids): cv.multi_select(fractions), 198 | vol.Optional(CONF_DATE_FORMAT, description={"suggested_value": date_format}): cv.string 199 | } 200 | ) 201 | ) 202 | 203 | async def _get_fractions(self, kommune_nummer) -> Dict: 204 | session = async_get_clientsession(self.hass) 205 | client = ApiClient(session) 206 | return await client.async_get_fraksjoner(kommune_nummer) 207 | 208 | async def _get_calendar(self, municipality_code, street_name, street_code, house_no) -> Dict: 209 | session = async_get_clientsession(self.hass) 210 | client = ApiClient(session) 211 | return await client.async_get_tommekalender(municipality_code, 212 | street_name, 213 | street_code, 214 | house_no) 215 | --------------------------------------------------------------------------------