├── .gitattributes ├── requirements_dev.txt ├── custom_components ├── __init.py └── posten │ ├── manifest.json │ ├── translations │ ├── en.json │ └── nb.json │ ├── entity.py │ ├── const.py │ ├── binary_sensor.py │ ├── calendar.py │ ├── api.py │ ├── sensor.py │ ├── __init__.py │ └── config_flow.py ├── requirements_test.txt ├── hacs.json ├── .gitignore ├── scripts ├── setup └── develop ├── config └── configuration.yaml ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── .github └── workflows │ ├── hassfest.yml │ └── validate.yml ├── docker-compose.yaml ├── LICENSE ├── setup.cfg ├── .devcontainer.json ├── info.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | colorlog 2 | homeassistant -------------------------------------------------------------------------------- /custom_components/__init.py: -------------------------------------------------------------------------------- 1 | """Custom components module.""" -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | pytest-homeassistant-custom-component==0.4.0 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Når kommer posten", 3 | "country": [ 4 | "NO" 5 | ] 6 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | pythonenv* 3 | venv 4 | .venv 5 | .coverage 6 | .idea 7 | config/* 8 | 9 | !config/configuration.yaml -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements_dev.txt -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # NOTE!: Do NOT add sensetive data here 2 | 3 | default_config: 4 | debugpy: 5 | 6 | logger: 7 | default: info 8 | logs: 9 | custom_components.posten: debug 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.pylintEnabled": true, 3 | "python.linting.enabled": true, 4 | "python.pythonPath": "/usr/local/bin/python", 5 | "files.associations": { 6 | "*.yaml": "home-assistant" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: "home-assistant/actions/hassfest@master" -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | homeassistant: 4 | container_name: homeassistant 5 | image: 'ghcr.io/home-assistant/home-assistant:stable' 6 | volumes: 7 | - ./config:/config 8 | restart: unless-stopped 9 | privileged: true 10 | network_mode: bridge 11 | ports: 12 | - '8123:8123' 13 | expose: 14 | - '8123' 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" -------------------------------------------------------------------------------- /custom_components/posten/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "posten", 3 | "name": "Når kommer Posten", 4 | "codeowners": [ 5 | "@BobTheShoplifter" 6 | ], 7 | "config_flow": true, 8 | "documentation": "https://github.com/BobTheShoplifter/HomeAssistant-Posten", 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/BobTheShoplifter/HomeAssistant-Posten/issues", 11 | "version": "0.2.1", 12 | "supported_brands": [ 13 | "posten" 14 | ], 15 | "supported_platforms": [ 16 | "sensor", 17 | "binary_sensor", 18 | "calendar" 19 | ] 20 | } -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/integration_blueprint 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | hass --config "${PWD}/config" --debug -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "container start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "container check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "container install", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a specific version of Home Assistant", 24 | "type": "shell", 25 | "command": "container set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /custom_components/posten/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Når kommer posten", 6 | "description": "If you need help with the configuration have a look here: https://github.com/BobTheShoplifter/HomeAssistant-Posten", 7 | "data": { 8 | "postalcode": "Postcode" 9 | } 10 | } 11 | }, 12 | "error": { 13 | "auth": "Username/Password is wrong." 14 | }, 15 | "abort": { 16 | "single_instance_allowed": "Only a single instance is allowed." 17 | } 18 | }, 19 | "options": { 20 | "step": { 21 | "user": { 22 | "data": { 23 | "binary_sensor": "Binary sensor enabled", 24 | "sensor": "Sensor enabled" 25 | } 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /custom_components/posten/translations/nb.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Posten", 6 | "description": "Hvis du trenger hjep til konfigurasjon ta en titt her: https://github.com/BobTheShoplifter/HomeAssistant-Posten", 7 | "data": { 8 | "postalcode": "Postnummer" 9 | } 10 | } 11 | }, 12 | "error": { 13 | "auth": "Brukernavn/Passord er feil." 14 | }, 15 | "abort": { 16 | "single_instance_allowed": "Denne integrasjonen kan kun konfigureres en gang." 17 | } 18 | }, 19 | "options": { 20 | "step": { 21 | "user": { 22 | "data": { 23 | "binary_sensor": "Binær sensor aktivert", 24 | "sensor": "Sensor aktivert" 25 | } 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | // Example of attaching to local debug server 7 | "name": "Python: Attach Local", 8 | "type": "python", 9 | "request": "attach", 10 | "port": 5678, 11 | "host": "localhost", 12 | "pathMappings": [ 13 | { 14 | "localRoot": "${workspaceFolder}", 15 | "remoteRoot": "." 16 | } 17 | ] 18 | }, 19 | { 20 | // Example of attaching to my production server 21 | "name": "Python: Attach Remote", 22 | "type": "python", 23 | "request": "attach", 24 | "port": 5678, 25 | "host": "homeassistant.local", 26 | "pathMappings": [ 27 | { 28 | "localRoot": "${workspaceFolder}", 29 | "remoteRoot": "/usr/src/homeassistant" 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Joakim Sørensen @ludeeus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | doctests = True 4 | # To work with Black 5 | max-line-length = 88 6 | # E501: line too long 7 | # W503: Line break occurred before a binary operator 8 | # E203: Whitespace before ':' 9 | # D202 No blank lines allowed after function docstring 10 | # W504 line break after binary operator 11 | ignore = 12 | E501, 13 | W503, 14 | E203, 15 | D202, 16 | W504 17 | 18 | [isort] 19 | # https://github.com/timothycrosley/isort 20 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 21 | # splits long import on multiple lines indented by 4 spaces 22 | multi_line_output = 3 23 | include_trailing_comma=True 24 | force_grid_wrap=0 25 | use_parentheses=True 26 | line_length=88 27 | indent = " " 28 | # by default isort don't check module indexes 29 | not_skip = __init__.py 30 | # will group `import x` and `from x import` of the same module. 31 | force_sort_within_sections = true 32 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 33 | default_section = THIRDPARTY 34 | known_first_party = custom_components.posten, tests 35 | combine_as_imports = true -------------------------------------------------------------------------------- /custom_components/posten/entity.py: -------------------------------------------------------------------------------- 1 | """PostenEntity class""" 2 | import datetime 3 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 4 | 5 | from .const import DOMAIN, NAME, VERSION, ATTRIBUTION 6 | 7 | 8 | class IntegrationPostenEntity(CoordinatorEntity): 9 | def __init__(self, coordinator, config_entry): 10 | super().__init__(coordinator) 11 | self.config_entry = config_entry 12 | 13 | @property 14 | def _next_delivery(self) -> datetime.date: 15 | """Return the next delivery as datetime.date.""" 16 | return datetime.date( 17 | *map(int, self.coordinator.data.get("delivery_dates")[0].split("-")) 18 | ) 19 | 20 | @property 21 | def unique_id(self): 22 | """Return a unique ID to use for this entity.""" 23 | return self.config_entry.entry_id 24 | 25 | @property 26 | def device_info(self): 27 | return { 28 | "identifiers": {(DOMAIN, self.config_entry.entry_id)}, 29 | "name": NAME, 30 | "model": VERSION, 31 | "manufacturer": NAME, 32 | } 33 | 34 | @property 35 | def extra_state_attributes(self): 36 | """Return the state attributes.""" 37 | return { 38 | "attribution": ATTRIBUTION, 39 | "id": self.unique_id, 40 | "integration": DOMAIN, 41 | } 42 | -------------------------------------------------------------------------------- /custom_components/posten/const.py: -------------------------------------------------------------------------------- 1 | """Constants for posten.""" 2 | # Base component constants 3 | NAME = "Når kommer Posten" 4 | DOMAIN = "posten" 5 | DOMAIN_DATA = f"{DOMAIN}_data" 6 | VERSION = "0.2.1" 7 | ATTRIBUTION = "Data provided by https://www.posten.no/levering-av-post/_/component/main/1/leftRegion/9?postCode=xxxx" 8 | ISSUE_URL = "https://github.com/BobTheShoplifter/HomeAssistant-Posten/issues" 9 | 10 | # Icons 11 | ICON = "mdi:mailbox" 12 | ICON_OPEN = "mdi:mailbox-open" 13 | 14 | # Device classes 15 | BINARY_SENSOR_DEVICE_CLASS = "None" 16 | 17 | # Platforms 18 | BINARY_SENSOR = "binary_sensor" 19 | SENSOR = "sensor" 20 | CALENDAR = "calendar" 21 | PLATFORMS = [BINARY_SENSOR, SENSOR, CALENDAR] 22 | 23 | 24 | # Configuration and options 25 | CONF_ENABLED = "enabled" 26 | CONF_POSTALCODE = "postalcode" 27 | 28 | # Defaults 29 | DEFAULT_NAME = DOMAIN 30 | 31 | 32 | STARTUP_MESSAGE = f""" 33 | ------------------------------------------------------------------- 34 | {NAME} 35 | Version: {VERSION} 36 | This is a custom integration! 37 | If you have any issues with this you need to open an issue here: 38 | {ISSUE_URL} 39 | ------------------------------------------------------------------- 40 | """ 41 | 42 | DAYS = { 43 | "0": "Søndag", 44 | "1": "Mandag", 45 | "2": "Tirsdag", 46 | "3": "Onsdag", 47 | "4": "Torsdag", 48 | "5": "Fredag", 49 | "6": "Lørdag", 50 | } 51 | -------------------------------------------------------------------------------- /custom_components/posten/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensor platform for posten.""" 2 | import datetime 3 | from homeassistant.components.binary_sensor import BinarySensorEntity 4 | 5 | from .const import ( 6 | BINARY_SENSOR, 7 | BINARY_SENSOR_DEVICE_CLASS, 8 | DEFAULT_NAME, 9 | DOMAIN, 10 | ICON, 11 | ICON_OPEN, 12 | ) 13 | from .entity import IntegrationPostenEntity 14 | 15 | 16 | async def async_setup_entry(hass, entry, async_add_devices): 17 | """Setup binary_sensor platform.""" 18 | coordinator = hass.data[DOMAIN][entry.entry_id] 19 | async_add_devices([IntegrationPostenBinarySensor(coordinator, entry)]) 20 | 21 | 22 | class IntegrationPostenBinarySensor(IntegrationPostenEntity, BinarySensorEntity): 23 | """posten binary_sensor class.""" 24 | 25 | @property 26 | def name(self): 27 | """Return the name of the binary_sensor.""" 28 | return f"{DEFAULT_NAME}_{BINARY_SENSOR}" 29 | 30 | @property 31 | def device_class(self): 32 | """Return the class of this binary_sensor.""" 33 | return BINARY_SENSOR_DEVICE_CLASS 34 | 35 | @property 36 | def is_on(self): 37 | return datetime.date.today() == self._next_delivery 38 | 39 | @property 40 | def icon(self): 41 | """Return the icon of the sensor.""" 42 | if datetime.date.today() == self._next_delivery: 43 | return ICON_OPEN 44 | return ICON 45 | -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BobTheShoplifter/HomeAssistant-Posten", 3 | "image": "mcr.microsoft.com/vscode/devcontainers/python:3.12-bullseye", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [ 6 | 8123 7 | ], 8 | "portsAttributes": { 9 | "8123": { 10 | "label": "Home Assistant" 11 | }, 12 | "0-8122": { 13 | "label": "Auto-Forwarded - Other", 14 | "onAutoForward": "ignore" 15 | }, 16 | "8124-999999": { 17 | "label": "Auto-Forwarded - Other", 18 | "onAutoForward": "ignore" 19 | } 20 | }, 21 | "customizations": { 22 | "vscode": { 23 | "extensions": [ 24 | "ms-python.python", 25 | "github.vscode-pull-request-github", 26 | "ryanluker.vscode-coverage-gutters", 27 | "ms-python.vscode-pylance" 28 | ], 29 | "settings": { 30 | "files.eol": "\n", 31 | "editor.tabSize": 4, 32 | "python.pythonPath": "/usr/bin/python3", 33 | "python.analysis.autoSearchPaths": false, 34 | "python.linting.pylintEnabled": true, 35 | "python.linting.enabled": true, 36 | "python.formatting.provider": "black", 37 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 38 | "editor.formatOnPaste": false, 39 | "editor.formatOnSave": true, 40 | "editor.formatOnType": true, 41 | "files.trimTrailingWhitespace": true 42 | } 43 | } 44 | }, 45 | "remoteUser": "vscode", 46 | "features": { 47 | "rust": "latest" 48 | } 49 | } -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Info 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![GitHub Activity][commits-shield]][commits] 5 | [![License][license-shield]][license] 6 | 7 | [![Project Maintenance][maintenance-shield]][user_profile] 8 | 9 | [![Discord][discord-shield]][discord] 10 | 11 | _Component to integrate with [posten][posten]._ 12 | 13 | **This component will set up the following platforms.** 14 | 15 | | Platform | Description | 16 | | -------- | ------------------- | 17 | | `sensor` | Show info from API. | 18 | | `sensor_next` | Show the next delivery date. | 19 | | `binary_sensor` | Show info from API as True or False based on mail. | 20 | 21 | {% if not installed %} 22 | 23 | ## Installation 24 | 25 | 1. Click install. 26 | 1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Når kommer posten". 27 | 28 | {% endif %} 29 | 30 | ## Configuration is done in the UI 31 | 32 | 33 | 34 | --- 35 | 36 | [posten]: https://posten.no 37 | [commits-shield]: https://img.shields.io/github/commit-activity/y/BobTheShoplifter/HomeAssistant-Posten.svg?style=for-the-badge 38 | [commits]: https://github.com/BobTheShoplifter/HomeAssistant-Posten/commits/master 39 | [discord]: https://2o.no/discord 40 | [discord-shield]: https://img.shields.io/discord/856974237956177920.svg?style=for-the-badge 41 | [license]: https://github.com/BobTheShoplifter/HomeAssistant-Posten/blob/main/LICENSE 42 | [license-shield]: https://img.shields.io/github/license/BobTheShoplifter/HomeAssistant-Posten.svg?style=for-the-badge 43 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Daniel%20Christensen-blue.svg?style=for-the-badge 44 | [releases-shield]: https://img.shields.io/github/release/BobTheShoplifter/HomeAssistant-Posten.svg?style=for-the-badge 45 | [releases]: https://github.com/BobTheShoplifter/HomeAssistant-Posten/releases 46 | [user_profile]: https://github.com/BobTheShoplifter 47 | -------------------------------------------------------------------------------- /custom_components/posten/calendar.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.calendar import CalendarEntity, CalendarEvent 2 | from homeassistant.core import callback 3 | from homeassistant.util.dt import parse_datetime, as_utc, now 4 | import datetime 5 | from .const import DOMAIN 6 | 7 | class PostenCalendarEntity(CalendarEntity): 8 | """Representation of a Posten Calendar Entity.""" 9 | 10 | def __init__(self, coordinator, name, config_entry): 11 | """Initialize the calendar entity.""" 12 | self.coordinator = coordinator 13 | self._name = name 14 | self._events = [] 15 | self.config_entry = config_entry 16 | self._fetch_events() 17 | 18 | @property 19 | def name(self): 20 | """Return the name of the calendar entity.""" 21 | return self._name 22 | 23 | @property 24 | def event(self): 25 | """Return the next upcoming event.""" 26 | return self._get_next_event() 27 | 28 | @property 29 | def unique_id(self): 30 | """Return a unique ID to use for this entity.""" 31 | return f"{self.config_entry.entry_id}" 32 | 33 | async def async_get_events( 34 | self, 35 | hass, 36 | start_date: datetime.datetime, 37 | end_date: datetime.datetime, 38 | ) -> list[CalendarEvent]: 39 | """Return calendar events within a datetime range.""" 40 | start_date = as_utc(start_date) 41 | end_date = as_utc(end_date) 42 | return self._events 43 | 44 | async def async_update(self): 45 | """Update the calendar with new events from the API.""" 46 | self._events = self._fetch_events() 47 | 48 | def _fetch_events(self): 49 | """Call Posten API to fetch delivery dates.""" 50 | deliveries = self.coordinator.data.get("delivery_dates") 51 | events = [] 52 | for delivery in deliveries: 53 | delivery_date = parse_datetime(delivery).date() 54 | if delivery_date: 55 | events.append(CalendarEvent( 56 | summary="Levering", 57 | start=delivery_date, 58 | end=delivery_date + datetime.timedelta(days=1) 59 | )) 60 | self._events = events 61 | return events 62 | 63 | def _get_next_event(self): 64 | """Return the next upcoming event.""" 65 | if not self._events: 66 | return None 67 | return self._events[0] 68 | 69 | async def async_setup_entry(hass, config_entry, async_add_entities): 70 | coordinator = hass.data[DOMAIN][config_entry.entry_id] 71 | async_add_entities([PostenCalendarEntity(coordinator, "Posten Calendar", config_entry)]) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HomeAssistant-Posten 2 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/BobTheShoplifter/HomeAssistant-Posten) 3 | 4 | [![GitHub Release][releases-shield]][releases] 5 | [![GitHub Activity][commits-shield]][commits] 6 | [![License][license-shield]][license] 7 | 8 | [![Project Maintenance][maintenance-shield]][user_profile] 9 | 10 | [![Discord][discord-shield]][discord] 11 | 12 | _Component to integrate with [posten][posten]._ 13 | 14 | Posten integrasjon som trigger dagen når posten kommer: https://www.posten.no/levering-av-post 15 | 16 | ![screenshot](https://i.imgur.com/ZOISPzB.png) 17 | 18 | ![screenshot2](https://i.imgur.com/A09Ldga.png) 19 | 20 | ![image](https://github.com/user-attachments/assets/176e21eb-611f-4bf3-b9b7-c9636c71fc75) 21 | 22 | 23 | 24 | **This component will set up the following platforms.** 25 | 26 | | Platform | Description | 27 | | -------- | ------------------- | 28 | | `sensor` | Show info from API. | 29 | | `sensor_next` | Show the next delivery date. | 30 | | `binary_sensor` | Show info from API as True or False based on mail times. | 31 | | `calendar` | Home assistant local calendar api. | 32 | 33 | ## Installation 34 | 35 | ### Method 1 (Installation using HACS) 36 | 37 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=BobTheShoplifter&repository=HomeAssistant-Posten&category=integration) 38 | 39 | ### Method 2 (Manual) 40 | Copy contents of custom_components/posten/ to custom_components/posten/ in your Home Assistant config folder. 41 | 42 | 43 | ## Configuration is done in the UI 44 | 45 | 46 | 47 | --- 48 | 49 | [posten]: https://posten.no 50 | [commits-shield]: https://img.shields.io/github/commit-activity/y/BobTheShoplifter/HomeAssistant-Posten.svg?style=for-the-badge 51 | [commits]: https://github.com/BobTheShoplifter/HomeAssistant-Posten/commits/master 52 | [discord]: https://2o.no/discord 53 | [discord-shield]: https://img.shields.io/discord/856974237956177920.svg?style=for-the-badge 54 | [license]: https://github.com/BobTheShoplifter/HomeAssistant-Posten/blob/main/LICENSE 55 | [license-shield]: https://img.shields.io/github/license/BobTheShoplifter/HomeAssistant-Posten.svg?style=for-the-badge 56 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Daniel%20Christensen-blue.svg?style=for-the-badge 57 | [releases-shield]: https://img.shields.io/github/release/BobTheShoplifter/HomeAssistant-Posten.svg?style=for-the-badge 58 | [releases]: https://github.com/BobTheShoplifter/HomeAssistant-Posten/releases 59 | [user_profile]: https://github.com/BobTheShoplifter 60 | -------------------------------------------------------------------------------- /custom_components/posten/api.py: -------------------------------------------------------------------------------- 1 | """Posten API Client.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import base64 6 | import time 7 | import socket 8 | import aiohttp 9 | 10 | TIMEOUT = 10 11 | 12 | 13 | class PostenApiError(Exception): 14 | """Posten API error.""" 15 | 16 | 17 | class IntegrationPostenApiClient: 18 | """Posten API Client.""" 19 | 20 | def __init__(self, postalcode: str, session: aiohttp.ClientSession) -> None: 21 | """Psten API Client.""" 22 | self._session = session 23 | self._postalcode = postalcode 24 | 25 | async def async_get_data(self) -> dict: 26 | """Get data from the API. 27 | 28 | I think a way to get around postens stupid api keys, 29 | please do not change this posten we are just trying to get your data. 30 | Im pretty sure this data goes under offentleglova anyways so please stop changing it <3 31 | """ 32 | return await self.api_wrapper( 33 | method="get", 34 | url=f"https://www.posten.no/levering-av-post/_/service/no.posten.website/delivery-days?postalCode={self._postalcode}", 35 | headers={ 36 | "content-type": "application/json; charset=UTF-8", 37 | "x-requested-with": "XMLHttpRequest", 38 | "kp-api-token": base64.b64encode( 39 | bytes(base64.b64decode("f3ccd044MTY4MjYyODE2MQ==")[0:6]) 40 | + bytes(str(int(time.time())), "utf8") 41 | ) 42 | .decode() 43 | .replace("=", ""), 44 | }, 45 | ) 46 | 47 | async def api_wrapper( 48 | self, 49 | method: str, 50 | url: str, 51 | headers: dict | None = None, 52 | ) -> dict: 53 | """Get information from the API.""" 54 | try: 55 | response = await self._session.request( 56 | method, 57 | url, 58 | headers=headers or {}, 59 | timeout=aiohttp.ClientTimeout(total=TIMEOUT), 60 | ) 61 | return await response.json() 62 | 63 | except asyncio.TimeoutError as exception: 64 | raise PostenApiError( 65 | f"Timeout error fetching information from {url}" 66 | ) from exception 67 | 68 | except (KeyError, TypeError) as exception: 69 | raise PostenApiError( 70 | f"Error parsing information from {url} - {exception}" 71 | ) from exception 72 | 73 | except (aiohttp.ClientError, socket.gaierror) as exception: 74 | raise PostenApiError( 75 | f"Error fetching information from {url} - {exception}" 76 | ) from exception 77 | 78 | except Exception as exception: # pylint: disable=broad-except 79 | raise PostenApiError( 80 | f"Something really wrong happened! - {exception}" 81 | ) from exception 82 | -------------------------------------------------------------------------------- /custom_components/posten/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for posten.""" 2 | import datetime 3 | from homeassistant.components.sensor import SensorEntity 4 | 5 | from .const import DAYS, DEFAULT_NAME, DOMAIN, ICON, ICON_OPEN, SENSOR 6 | from .entity import IntegrationPostenEntity 7 | 8 | 9 | async def async_setup_entry(hass, entry, async_add_devices): 10 | """Setup sensor platform.""" 11 | coordinator = hass.data[DOMAIN][entry.entry_id] 12 | async_add_devices( 13 | [ 14 | IntegrationPostenSensor(coordinator, entry), 15 | IntegrationPostenSensorNext(coordinator, entry), 16 | IntegrationPostenSensorNextRelative(coordinator, entry), 17 | ] 18 | ) 19 | 20 | 21 | class IntegrationPostenSensor(IntegrationPostenEntity, SensorEntity): 22 | """posten Sensor class.""" 23 | 24 | @property 25 | def name(self): 26 | """Return the name of the sensor.""" 27 | return f"{DEFAULT_NAME}_{SENSOR}" 28 | 29 | @property 30 | def native_value(self): 31 | """Return the native value of the sensor.""" 32 | return self.coordinator.data.get("delivery_dates") 33 | 34 | @property 35 | def icon(self): 36 | """Return the icon of the sensor.""" 37 | if datetime.date.today() == self._next_delivery: 38 | return ICON_OPEN 39 | return ICON 40 | 41 | 42 | class IntegrationPostenSensorNext(IntegrationPostenEntity, SensorEntity): 43 | """posten Sensor class.""" 44 | 45 | @property 46 | def unique_id(self): 47 | """Return a unique ID to use for this entity.""" 48 | return f"{self.config_entry.entry_id}-next" 49 | 50 | @property 51 | def name(self): 52 | """Return the name of the sensor.""" 53 | return f"{DEFAULT_NAME}_{SENSOR}_next" 54 | 55 | @property 56 | def native_value(self): 57 | """Return the native value of the sensor.""" 58 | return self._next_delivery.strftime("%Y-%m-%d") 59 | 60 | @property 61 | def icon(self): 62 | """Return the icon of the sensor.""" 63 | if datetime.date.today() == self._next_delivery: 64 | return ICON_OPEN 65 | return ICON 66 | 67 | 68 | class IntegrationPostenSensorNextRelative(IntegrationPostenEntity, SensorEntity): 69 | """posten Sensor class.""" 70 | 71 | @property 72 | def unique_id(self): 73 | """Return a unique ID to use for this entity.""" 74 | return f"{self.config_entry.entry_id}-next-relative" 75 | 76 | @property 77 | def name(self): 78 | """Return the name of the sensor.""" 79 | return f"{DEFAULT_NAME}_{SENSOR}_next_relative" 80 | 81 | @property 82 | def native_value(self): 83 | """Return the native value of the sensor.""" 84 | today = datetime.date.today() 85 | if today == self._next_delivery: 86 | return "I dag" 87 | if (today + datetime.timedelta(days=1)) == self._next_delivery: 88 | return "I morgen" 89 | return DAYS[self._next_delivery.strftime("%w")] 90 | 91 | @property 92 | def icon(self): 93 | """Return the icon of the sensor.""" 94 | if datetime.date.today() == self._next_delivery: 95 | return ICON_OPEN 96 | return ICON 97 | -------------------------------------------------------------------------------- /custom_components/posten/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | Custom integration to integrate posten with Home Assistant. 4 | For more details about this integration, please refer to 5 | https://github.com/BobTheShoplifter/HomeAssistant-Posten 6 | """ 7 | import asyncio 8 | from datetime import timedelta 9 | import logging 10 | 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.helpers.typing import ConfigType 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.exceptions import ConfigEntryNotReady 15 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 16 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 17 | 18 | from .api import IntegrationPostenApiClient, PostenApiError 19 | 20 | from .const import ( 21 | CONF_POSTALCODE, 22 | DOMAIN, 23 | PLATFORMS, 24 | STARTUP_MESSAGE, 25 | ) 26 | 27 | SCAN_INTERVAL = timedelta(hours=1) 28 | 29 | _LOGGER: logging.Logger = logging.getLogger(__package__) 30 | 31 | 32 | async def async_setup(hass: HomeAssistant, config: ConfigType): 33 | """Set up this integration using YAML is not supported.""" 34 | return True 35 | 36 | 37 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 38 | """Set up this integration using UI.""" 39 | if hass.data.get(DOMAIN) is None: 40 | hass.data.setdefault(DOMAIN, {}) 41 | _LOGGER.info(STARTUP_MESSAGE) 42 | 43 | postalcode = entry.data.get(CONF_POSTALCODE) 44 | 45 | session = async_get_clientsession(hass) 46 | client = IntegrationPostenApiClient(postalcode, session) 47 | 48 | coordinator = PostenDataUpdateCoordinator(hass, client=client) 49 | 50 | await coordinator.async_config_entry_first_refresh() 51 | 52 | hass.data[DOMAIN][entry.entry_id] = coordinator 53 | 54 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 55 | 56 | entry.async_on_unload(entry.add_update_listener(async_reload_entry)) 57 | 58 | return True 59 | 60 | 61 | class PostenDataUpdateCoordinator(DataUpdateCoordinator): 62 | """Class to manage fetching data from the API.""" 63 | 64 | def __init__( 65 | self, hass: HomeAssistant, client: IntegrationPostenApiClient 66 | ) -> None: 67 | """Initialize.""" 68 | self.api = client 69 | self.platforms = [] 70 | 71 | super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) 72 | 73 | async def _async_update_data(self): 74 | """Update data via library.""" 75 | try: 76 | data = await self.api.async_get_data() 77 | except PostenApiError as exception: 78 | self.logger.error(exception) 79 | raise UpdateFailed(exception) from exception 80 | 81 | if not isinstance(data, dict): 82 | raise UpdateFailed(f"Invalid data from API {data}") 83 | 84 | return data 85 | 86 | 87 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 88 | """Handle removal of an entry.""" 89 | coordinator = hass.data[DOMAIN][entry.entry_id] 90 | unloaded = all( 91 | await asyncio.gather( 92 | *[ 93 | hass.config_entries.async_forward_entry_unload(entry, platform) 94 | for platform in PLATFORMS 95 | if platform in coordinator.platforms 96 | ] 97 | ) 98 | ) 99 | if unloaded: 100 | hass.data[DOMAIN].pop(entry.entry_id) 101 | 102 | return unloaded 103 | 104 | 105 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 106 | """Reload config entry.""" 107 | await async_unload_entry(hass, entry) 108 | await async_setup_entry(hass, entry) 109 | -------------------------------------------------------------------------------- /custom_components/posten/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for Posten.""" 2 | from homeassistant import config_entries 3 | from homeassistant.core import callback 4 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 5 | import voluptuous as vol 6 | 7 | from .api import IntegrationPostenApiClient 8 | from .const import ( 9 | CONF_POSTALCODE, 10 | DOMAIN, 11 | PLATFORMS, 12 | ) 13 | 14 | 15 | class PostenFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 16 | """Config flow for Posten.""" 17 | 18 | VERSION = 1 19 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 20 | 21 | def __init__(self): 22 | """Initialize.""" 23 | self._errors = {} 24 | 25 | async def async_step_user(self, user_input=None): 26 | """Handle a flow initialized by the user.""" 27 | self._errors = {} 28 | 29 | # Uncomment the next 2 lines if only a single instance of the integration is allowed: 30 | # if self._async_current_entries(): 31 | # return self.async_abort(reason="single_instance_allowed") 32 | 33 | if user_input is not None: 34 | valid = await self._test_credentials( 35 | user_input[CONF_POSTALCODE] 36 | ) 37 | if valid: 38 | return self.async_create_entry( 39 | title=user_input[CONF_POSTALCODE], data=user_input 40 | ) 41 | else: 42 | self._errors["base"] = "auth" 43 | 44 | 45 | return await self._show_config_form(user_input) 46 | 47 | user_input = {} 48 | # Provide defaults for form 49 | user_input[CONF_POSTALCODE] = "" 50 | 51 | return await self._show_config_form(user_input) 52 | 53 | @staticmethod 54 | @callback 55 | def async_get_options_flow(config_entry): 56 | return PostenOptionsFlowHandler(config_entry) 57 | 58 | async def _show_config_form(self, user_input): # pylint: disable=unused-argument 59 | """Show the configuration form to edit location data.""" 60 | return self.async_show_form( 61 | step_id="user", 62 | data_schema=vol.Schema( 63 | { 64 | vol.Required(CONF_POSTALCODE, default=user_input[CONF_POSTALCODE]): str, 65 | } 66 | ), 67 | errors=self._errors, 68 | ) 69 | 70 | async def _test_credentials(self, postalcode): 71 | """Return true if credentials is valid.""" 72 | try: 73 | session = async_create_clientsession(self.hass) 74 | client = IntegrationPostenApiClient(postalcode, session) 75 | await client.async_get_data() 76 | return True 77 | except Exception: # pylint: disable=broad-except 78 | pass 79 | return False 80 | 81 | 82 | class PostenOptionsFlowHandler(config_entries.OptionsFlow): 83 | """Posten config flow options handler.""" 84 | 85 | def __init__(self, config_entry): 86 | """Initialize HACS options flow.""" 87 | self._conf_app_id: str | None = None 88 | self.options = dict(config_entry.options) 89 | 90 | async def async_step_init(self, user_input=None): # pylint: disable=unused-argument 91 | """Manage the options.""" 92 | return await self.async_step_user() 93 | 94 | async def async_step_user(self, user_input=None): 95 | """Handle a flow initialized by the user.""" 96 | if user_input is not None: 97 | self.options.update(user_input) 98 | return await self._update_options() 99 | 100 | return self.async_show_form( 101 | step_id="user", 102 | data_schema=vol.Schema( 103 | { 104 | vol.Required(x, default=self.options.get(x, True)): bool 105 | for x in sorted(PLATFORMS) 106 | } 107 | ), 108 | ) 109 | 110 | async def _update_options(self): 111 | """Update config entry options.""" 112 | return self.async_create_entry( 113 | title=self.config_entry.data.get(CONF_POSTALCODE), data=self.options 114 | ) --------------------------------------------------------------------------------