diff --git a/custom_components/postnl/__init__.py b/custom_components/postnl/__init__.py index 2584660..9ba1c00 100644 --- a/custom_components/postnl/__init__.py +++ b/custom_components/postnl/__init__.py @@ -3,17 +3,12 @@ import requests import urllib3 -from aiohttp.client_exceptions import ClientError, ClientResponseError -from gql.transport.exceptions import TransportQueryError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import (ConfigEntryNotReady, HomeAssistantError) -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.config_entry_oauth2_flow import ( - OAuth2Session, async_get_config_entry_implementation) +from .auth import PostNLAuth, PostNLAuthError from .const import DOMAIN, PLATFORMS from .graphql import PostNLGraphql from .login_api import PostNLLoginAPI @@ -21,26 +16,20 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> True: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up PostNL from config entry.""" _LOGGER.debug("Setup Entry PostNL") hass.data.setdefault(DOMAIN, {}) - implementation = await async_get_config_entry_implementation(hass, entry) - session = OAuth2Session(hass, entry, implementation) - auth = AsyncConfigEntryAuth(session) + auth = AsyncConfigEntryAuth(hass, entry) try: await auth.check_and_refresh_token() - except requests.exceptions.ConnectionError as exception: - raise ConfigEntryNotReady("Unable to retrieve oauth data from PostNL") from exception + except HomeAssistantError as exception: + raise ConfigEntryAuthFailed("Unable to authenticate with PostNL") from exception - hass.data[DOMAIN][entry.entry_id] = { - 'auth': auth - } - - _LOGGER.debug('Using access token: %s', auth.access_token) + hass.data[DOMAIN][entry.entry_id] = {"auth": auth} postnl_login_api = PostNLLoginAPI(auth.access_token) @@ -52,31 +41,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> True: if "error" in userinfo: raise ConfigEntryNotReady("Error in retrieving user information from PostNL.") - hass.data[DOMAIN][entry.entry_id]['userinfo'] = userinfo + hass.data[DOMAIN][entry.entry_id]["userinfo"] = userinfo device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) - for device_entry in dr.async_entries_for_config_entry( - device_registry, entry.entry_id - ): - if ( - device_entry.identifiers == {(DOMAIN, userinfo.get('account_id'))} - ): - _LOGGER.debug( - "Migrating entry %s", device_entry.identifiers - ) - for entity_entry in er.async_entries_for_device( - entity_registry, device_entry.id, True - ): - _LOGGER.debug('Migrating entity: %s', entity_entry.unique_id) - if entity_entry.unique_id.startswith(userinfo.get('account_id')): + for device_entry in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + if device_entry.identifiers == {(DOMAIN, userinfo.get("account_id"))}: + _LOGGER.debug("Migrating entry %s", device_entry.identifiers) + for entity_entry in er.async_entries_for_device(entity_registry, device_entry.id, True): + _LOGGER.debug("Migrating entity: %s", entity_entry.unique_id) + if entity_entry.unique_id.startswith(userinfo.get("account_id")): continue - unique_id_parts = entity_entry.unique_id.split("_") - entity_new_unique_id = userinfo.get('account_id') + "_" + ( - unique_id_parts[1] if len(unique_id_parts) > 1 else unique_id_parts[0]) - _LOGGER.debug('New unique ID for entity: %s', entity_new_unique_id) + entity_new_unique_id = userinfo.get("account_id") + "_" + ( + unique_id_parts[1] if len(unique_id_parts) > 1 else unique_id_parts[0] + ) + _LOGGER.debug("New unique ID for entity: %s", entity_new_unique_id) entity_registry.async_update_entity( entity_id=entity_entry.entity_id, new_unique_id=entity_new_unique_id ) @@ -88,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> True: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload PostNL config entry.""" - _LOGGER.debug('Reloading PostNL integration') + _LOGGER.debug("Unloading PostNL integration") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: @@ -98,44 +79,51 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class AsyncConfigEntryAuth: - """Provide PostNL authentication tied to an OAuth2 based config entry.""" + """Manage PostNL tokens stored in a config entry.""" - def __init__( - self, - oauth2_session: config_entry_oauth2_flow.OAuth2Session, - ) -> None: - """Initialize PostNL Auth.""" - self.oauth_session = oauth2_session + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + self._hass = hass + self._entry = entry @property def access_token(self) -> str: - """Return the access token.""" - return self.oauth_session.token[CONF_ACCESS_TOKEN] - - async def force_refresh_expire(self): - _LOGGER.debug('Force token refresh') - self.oauth_session.token["expires_at"] = time.time() - 600 + return self._entry.data["token"]["access_token"] async def check_and_refresh_token(self) -> str: - """Check the token.""" - - try: - await self.oauth_session.async_ensure_token_valid() - graphql = PostNLGraphql(self.access_token) - await self.oauth_session.hass.async_add_executor_job(graphql.profile) - - except (ClientResponseError, ClientError) as exception: - _LOGGER.debug("API error: %s", exception) - if exception.status == 400: - self.oauth_session.config_entry.async_start_reauth( - self.oauth_session.hass + token = self._entry.data.get("token") + + if not token or "access_token" not in token: + self._entry.async_start_reauth(self._hass) + raise HomeAssistantError("No valid token in config entry, reauth required") + + if time.time() < token.get("expires_at", 0) - 30: + return token["access_token"] + + _LOGGER.debug("Access token expired, refreshing") + refresh_token = token.get("refresh_token") + if refresh_token: + try: + new_token = await PostNLAuth.async_refresh_token(refresh_token) + self._hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, "token": new_token}, ) + return new_token["access_token"] + except PostNLAuthError as err: + _LOGGER.debug("Token refresh failed, falling back to re-login: %s", err) + + username = self._entry.data.get("username") + password = self._entry.data.get("password") + if username and password: + try: + new_token = await PostNLAuth(username, password).async_login() + self._hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, "token": new_token}, + ) + return new_token["access_token"] + except PostNLAuthError as err: + _LOGGER.debug("Re-login failed, triggering reauth: %s", err) - raise HomeAssistantError(exception) from exception - except TransportQueryError as exception: - _LOGGER.debug("GraphQL error: %s", exception) - - await self.force_refresh_expire() - await self.oauth_session.async_ensure_token_valid() - - return self.access_token + self._entry.async_start_reauth(self._hass) + raise HomeAssistantError("Unable to obtain a valid token") diff --git a/custom_components/postnl/application_credentials.py b/custom_components/postnl/application_credentials.py deleted file mode 100644 index d751a5b..0000000 --- a/custom_components/postnl/application_credentials.py +++ /dev/null @@ -1,81 +0,0 @@ -import base64 -import hashlib -import logging -import os -import re -from typing import Any - -from homeassistant.components.application_credentials import ( - AuthImplementation, AuthorizationServer, ClientCredential) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow - -from .const import (POSTNL_AUTH_URL, POSTNL_CLIENT_ID, POSTNL_REDIRECT_URI, - POSTNL_SCOPE, POSTNL_TOKEN_URL) - -_LOGGER = logging.getLogger(__name__) - - -class OAuth2Impl(AuthImplementation): - """Custom OAuth2 implementation.""" - - code_challenge: str | None - code_verifier: str | None - - def __init__(self, hass: HomeAssistant, auth_domain: str, credential: ClientCredential, - authorization_server: AuthorizationServer, code_challenge: str | None, code_verifier: str | None) -> None: - - super().__init__(hass, auth_domain, credential, authorization_server) - - self.code_verifier = code_verifier - self.code_challenge = code_challenge - - @property - def redirect_uri(self) -> str: - return POSTNL_REDIRECT_URI - - @property - def extra_authorize_data(self) -> dict: - return { - "scope": POSTNL_SCOPE, - "code_challenge": self.code_challenge, - "code_challenge_method": "S256" - } - - async def async_resolve_external_data(self, external_data: Any) -> dict: - """Resolve the authorization code to tokens.""" - return await self._token_request( - { - "grant_type": "authorization_code", - "code": external_data["code"], - "redirect_uri": external_data["state"]["redirect_uri"], - "code_verifier": self.code_verifier - } - ) - -async def async_get_auth_implementation( - hass: HomeAssistant, auth_domain: str, credential: ClientCredential -) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: - """Return auth implementation for a custom auth implementation.""" - - code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode('utf-8') - code_verifier = re.sub('[^a-zA-Z0-9]+', '', code_verifier) - - code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest() - code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8') - code_challenge = code_challenge.replace('=', '') - - return OAuth2Impl( - hass, - auth_domain, - ClientCredential( - client_id=POSTNL_CLIENT_ID, - client_secret="" - ), - AuthorizationServer( - authorize_url=POSTNL_AUTH_URL, - token_url=POSTNL_TOKEN_URL - ), - code_challenge=code_challenge, - code_verifier=code_verifier - ) diff --git a/custom_components/postnl/auth.py b/custom_components/postnl/auth.py new file mode 100644 index 0000000..64ee810 --- /dev/null +++ b/custom_components/postnl/auth.py @@ -0,0 +1,256 @@ +import base64 +import hashlib +import re +import secrets +import time +from urllib.parse import parse_qs, urlparse + +import aiohttp + +_BASE = "https://login.postnl.nl" +_TENANT = "101112a0-4a0f-4bbb-8176-2f1b2d370d7c" +_OIDC_CLIENT_ID = "bd9f1610-b56d-4e05-a09b-f696f05ddade" +_CAPTURE_CLIENT_ID = "dkyxkt9x888ye422mawmf769yfm9y44j" +_REDIRECT_URI = "https://www.postnl.nl/" +_SCOPE = "openid poa-profiles-api offline_access" +_FLOW_VERSION = "20250910094830574377" +_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/136.0.0.0 Safari/537.36" +) + + +class PostNLAuthError(Exception): + pass + + +class PostNLAuth: + def __init__(self, username: str, password: str) -> None: + self._username = username + self._password = password + + async def async_login(self) -> dict: + """Run the full PKCE + Capture login flow and return a token dict.""" + jar = aiohttp.CookieJar(unsafe=True) + async with aiohttp.ClientSession(cookie_jar=jar) as session: + return await self._login(session) + + async def _login(self, session: aiohttp.ClientSession) -> dict: + verifier, challenge = self._pkce_pair() + state = base64.urlsafe_b64encode(secrets.token_bytes(24)).rstrip(b"=").decode() + + # Step 1: open the OIDC authorize endpoint to establish cookies + async with session.get( + f"{_BASE}/{_TENANT}/login/authorize", + params={ + "client_id": _OIDC_CLIENT_ID, + "response_type": "code", + "scope": _SCOPE, + "redirect_uri": _REDIRECT_URI, + "state": state, + "nonce": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + headers={"User-Agent": _USER_AGENT}, + allow_redirects=True, + ) as resp: + login_url = str(resp.url) + body = await resp.text() + + csrf = self._csrf_from_jar(session.cookie_jar) or self._js_value(body, "aicCsrf:") + if not csrf: + raise PostNLAuthError("Could not find CSRF token") + + # Step 2: submit credentials to the Capture widget + txid = base64.urlsafe_b64encode(secrets.token_bytes(30)).rstrip(b"=").decode() + async with session.post( + f"{_BASE}/widget/traditional_signin.jsonp", + data={ + "utf8": "✓", + "signInEmailAddress": self._username, + "currentPassword": self._password, + "capture_screen": "signIn", + "js_version": "d445bf4", + "capture_transactionId": txid, + "form": "signInForm", + "flow": "standard", + "client_id": _CAPTURE_CLIENT_ID, + "redirect_uri": f"{login_url}&socialRedirect=True", + "response_type": "token", + "flow_version": _FLOW_VERSION, + "settings_version": "", + "locale": "en-US", + "recaptchaVersion": "2", + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Origin": _BASE, + "Referer": login_url, + "User-Agent": _USER_AGENT, + }, + ) as resp: + await resp.read() + + # Step 3: poll for Capture result + async with session.get( + f"{_BASE}/widget/get_result.jsonp", + params={ + "transactionId": txid, + "cache": str(int(time.time() * 1000)), + }, + headers={"User-Agent": _USER_AGENT}, + ) as resp: + result_body = await resp.text() + + capture_token = self._json_value(result_body, "accessToken") + if not capture_token: + raise PostNLAuthError( + "Capture did not return an access token — check your credentials" + ) + + # Step 4: exchange the Capture token for an OIDC auth code + qs = urlparse(login_url).query + token_url = f"{_BASE}/{_TENANT}/auth-ui/v2/token-url?{qs}" + + auth_url, body = await self._post_token_url( + session, + token_url=token_url, + referer=login_url, + data={ + "screen": "signIn", + "authenticated": "True", + "registering": "False", + "accessToken": capture_token, + "_csrf_token": csrf, + }, + ) + + if not auth_url: + # Server wants a second POST acknowledging the loginSuccess screen + existing_token = self._js_value(body, "existingToken:") + screen = self._js_value(body, "screenToRender:") + csrf = self._js_value(body, "aicCsrf:") + if screen != "loginSuccess" or not existing_token or not csrf: + raise PostNLAuthError("Hosted Login did not reach loginSuccess") + + auth_url, _ = await self._post_token_url( + session, + token_url=token_url, + referer=token_url, + data={ + "screen": "loginSuccess", + "accessToken": existing_token, + "_csrf_token": csrf, + }, + ) + + if not auth_url: + raise PostNLAuthError("Hosted Login did not return an authorize redirect") + + # Step 5: follow the authorize redirect to capture the auth code + async with session.get( + auth_url, + headers={"User-Agent": _USER_AGENT}, + allow_redirects=False, + ) as resp: + final_location = resp.headers.get("Location", "") + + qs_params = parse_qs(urlparse(final_location).query) + code = qs_params.get("code", [None])[0] + returned_state = qs_params.get("state", [None])[0] + + if not code: + raise PostNLAuthError("OIDC authorize did not return a code") + if returned_state != state: + raise PostNLAuthError("OIDC state mismatch") + + # Step 6: exchange code for tokens + return await self._token_request(session, { + "grant_type": "authorization_code", + "client_id": _OIDC_CLIENT_ID, + "code": code, + "redirect_uri": _REDIRECT_URI, + "code_verifier": verifier, + }) + + @staticmethod + async def async_refresh_token(refresh_token: str) -> dict: + """Exchange a refresh token for a new token dict.""" + jar = aiohttp.CookieJar(unsafe=True) + async with aiohttp.ClientSession(cookie_jar=jar) as session: + return await PostNLAuth._token_request(session, { + "grant_type": "refresh_token", + "client_id": _OIDC_CLIENT_ID, + "refresh_token": refresh_token, + }) + + @staticmethod + async def _post_token_url( + session: aiohttp.ClientSession, + token_url: str, + referer: str, + data: dict, + ) -> tuple[str | None, str]: + async with session.post( + token_url, + data=data, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Origin": _BASE, + "Referer": referer, + "User-Agent": _USER_AGENT, + }, + allow_redirects=False, + ) as resp: + body = await resp.text() + return resp.headers.get("Location"), body + + @staticmethod + async def _token_request(session: aiohttp.ClientSession, data: dict) -> dict: + async with session.post( + f"{_BASE}/{_TENANT}/login/token", + data=data, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": _USER_AGENT, + }, + ) as resp: + token_data = await resp.json(content_type=None) + + if "access_token" not in token_data: + error = token_data.get("error_description") or token_data.get("error", "unknown") + raise PostNLAuthError(f"Token request failed: {error}") + + return { + "access_token": token_data["access_token"], + "refresh_token": token_data.get("refresh_token"), + "expires_in": token_data.get("expires_in", 3600), + "expires_at": time.time() + token_data.get("expires_in", 3600), + "token_type": token_data.get("token_type", "Bearer"), + } + + @staticmethod + def _pkce_pair() -> tuple[str, str]: + verifier = base64.urlsafe_b64encode(secrets.token_bytes(96)).rstrip(b"=").decode() + digest = hashlib.sha256(verifier.encode()).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + return verifier, challenge + + @staticmethod + def _csrf_from_jar(jar: aiohttp.CookieJar) -> str | None: + for cookie in jar: + if cookie.key == "_csrf_token": + return cookie.value + return None + + @staticmethod + def _js_value(body: str, key: str) -> str | None: + m = re.search(rf"{re.escape(key)}\s*['\"]([^'\"]+)['\"]", body) + return m.group(1) if m else None + + @staticmethod + def _json_value(body: str, key: str) -> str | None: + m = re.search(rf'"{re.escape(key)}"\s*:\s*"([^"]+)"', body) + return m.group(1) if m else None diff --git a/custom_components/postnl/brand/icon.png b/custom_components/postnl/brand/icon.png new file mode 100644 index 0000000..004fc92 Binary files /dev/null and b/custom_components/postnl/brand/icon.png differ diff --git a/custom_components/postnl/config_flow.py b/custom_components/postnl/config_flow.py index 830d7fe..b65c533 100644 --- a/custom_components/postnl/config_flow.py +++ b/custom_components/postnl/config_flow.py @@ -1,46 +1,91 @@ import logging +from typing import Any -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers import config_entry_oauth2_flow +import aiohttp +import voluptuous as vol +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from .auth import PostNLAuth, PostNLAuthError from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class OAuth2FlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN -): - """Config flow to handle OAuth2 authentication.""" +_STEP_SCHEMA = vol.Schema({ + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, +}) - DOMAIN = DOMAIN - reauth_entry: ConfigEntry | None = None +class PostNLConfigFlow(ConfigFlow, domain=DOMAIN): + VERSION = 1 - @property - def logger(self) -> logging.Logger: - """Return logger.""" - return logging.getLogger(__name__) + _reauth_entry: ConfigEntry | None = None - async def async_step_reauth(self, user_input=None): - """Perform reauth upon an API authentication error.""" - self.reauth_entry = self.hass.config_entries.async_get_entry( + async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult: + errors: dict[str, str] = {} + + if user_input is not None: + token, errors = await self._do_login(user_input) + if not errors: + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + "token": token, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=_STEP_SCHEMA, + errors=errors, + ) + + async def async_step_reauth(self, user_input=None) -> FlowResult: + self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): - """Dialog that informs the user that reauth is required.""" - if user_input is None: - return self.async_show_form( - step_id="reauth_confirm" - ) - return await self.async_step_user() - - async def async_oauth_create_entry(self, data: dict) -> dict: - """Create an oauth config entry or update existing entry for reauth.""" - if self.reauth_entry: - self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) - await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") - - return await super().async_oauth_create_entry(data) + async def async_step_reauth_confirm(self, user_input: dict[str, Any] | None = None) -> FlowResult: + errors: dict[str, str] = {} + + if user_input is not None: + token, errors = await self._do_login(user_input) + if not errors: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={ + **self._reauth_entry.data, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + "token": token, + }, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=_STEP_SCHEMA, + errors=errors, + ) + + async def _do_login(self, user_input: dict) -> tuple[dict | None, dict[str, str]]: + try: + token = await PostNLAuth( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ).async_login() + return token, {} + except PostNLAuthError as err: + _LOGGER.debug("PostNL login failed: %s", err) + return None, {"base": "invalid_auth"} + except aiohttp.ClientError as err: + _LOGGER.debug("PostNL connection error: %s", err) + return None, {"base": "cannot_connect"} diff --git a/custom_components/postnl/const.py b/custom_components/postnl/const.py index 53bdc2e..0fc45da 100644 --- a/custom_components/postnl/const.py +++ b/custom_components/postnl/const.py @@ -1,13 +1,7 @@ from homeassistant.const import Platform DOMAIN = "postnl" -POSTNL_CLIENT_ID = "deb0a372-6d72-4e09-83fe-997beacbd137" -POSTNL_AUTH_URL = "https://login.postnl.nl/101112a0-4a0f-4bbb-8176-2f1b2d370d7c/login/authorize" -POSTNL_TOKEN_URL = "https://login.postnl.nl/101112a0-4a0f-4bbb-8176-2f1b2d370d7c/login/token" -POSTNL_REDIRECT_URI = "postnl://login" -POSTNL_SCOPE = "profile openid email address phone poa-profiles-api" - PLATFORMS = [ Platform.SENSOR -] \ No newline at end of file +] diff --git a/custom_components/postnl/coordinator.py b/custom_components/postnl/coordinator.py index 460a55d..4dfb4dc 100644 --- a/custom_components/postnl/coordinator.py +++ b/custom_components/postnl/coordinator.py @@ -3,7 +3,9 @@ from datetime import timedelta import requests +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator, UpdateFailed) @@ -19,7 +21,7 @@ class PostNLCoordinator(DataUpdateCoordinator): graphq_api: PostNLGraphql jouw_api: PostNLJouwAPI - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize PostNL coordinator.""" super().__init__( hass, @@ -27,6 +29,7 @@ def __init__(self, hass: HomeAssistant) -> None: name="PostNL", update_interval=timedelta(seconds=90), ) + self.config_entry = entry _LOGGER.debug("PostNLCoordinator initialized with update interval: %s", self.update_interval) async def _async_update_data(self) -> dict[str, list[Package]]: @@ -58,6 +61,8 @@ async def _async_update_data(self) -> dict[str, list[Package]]: _LOGGER.info("Updated PostNL data: %d receiver packages, %d sender packages.", len(data['receiver']), len(data['sender'])) return data + except HomeAssistantError as exception: + raise UpdateFailed("Authentication failed, reauth required") from exception except requests.exceptions.RequestException as exception: _LOGGER.error("Network error during PostNL data update: %s", exception, exc_info=True) raise UpdateFailed("Unable to update PostNL data") from exception @@ -74,6 +79,7 @@ async def transform_shipment(self, shipment) -> Package: name=shipment.get('title'), url=shipment.get('detailsUrl'), shipment_type=shipment.get('shipmentType'), + receiver_title=(shipment.get('receiverTitle') or '').strip() or None, status_message="Pakket is bezorgd", delivered=shipment.get('delivered'), delivery_date=shipment.get('deliveredTimeStamp'), @@ -123,6 +129,7 @@ async def transform_shipment(self, shipment) -> Package: name=shipment.get('title'), url=shipment.get('detailsUrl'), shipment_type=shipment.get('shipmentType'), + receiver_title=(shipment.get('receiverTitle') or '').strip() or None, status_message=status_message, delivered=shipment.get('delivered'), delivery_date=shipment.get('deliveredTimeStamp'), diff --git a/custom_components/postnl/graphql.py b/custom_components/postnl/graphql.py index a6389b4..569e309 100644 --- a/custom_components/postnl/graphql.py +++ b/custom_components/postnl/graphql.py @@ -75,6 +75,7 @@ def shipments(self): deliveryWindowType detailsUrl shipmentType + receiverTitle deliveryAddressType sourceAccountId sourceDisplayName diff --git a/custom_components/postnl/manifest.json b/custom_components/postnl/manifest.json index cc926d5..dfda277 100644 --- a/custom_components/postnl/manifest.json +++ b/custom_components/postnl/manifest.json @@ -3,11 +3,10 @@ "name": "PostNL", "codeowners": ["@arjenbos"], "config_flow": true, - "dependencies": ["application_credentials"], "documentation": "https://github.com/arjenbos/ha-postnl", "integration_type": "hub", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/arjenbos/ha-postnl/issues", - "requirements": ["gql"], - "version": "2.1.1" + "requirements": ["gql", "requests"], + "version": "2.2.0" } diff --git a/custom_components/postnl/sensor.py b/custom_components/postnl/sensor.py index 8c18b4d..2a8a103 100644 --- a/custom_components/postnl/sensor.py +++ b/custom_components/postnl/sensor.py @@ -1,10 +1,10 @@ """Sensor for PostNL packages.""" import logging +from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry @@ -19,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e """Set up the PostNL sensor platform.""" _LOGGER.debug("Setting up PostNL sensors") - coordinator = PostNLCoordinator(hass) + coordinator = PostNLCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() userinfo = hass.data[DOMAIN][entry.entry_id].get("userinfo", {}) @@ -46,7 +46,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e ]) _LOGGER.debug("PostNL sensors added") -class PostNLDelivery(CoordinatorEntity, Entity): +class PostNLDelivery(CoordinatorEntity, SensorEntity): + _attr_icon = "mdi:package-variant-closed" + _attr_native_unit_of_measurement = "packages" + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, coordinator, postnl_userinfo, unique_id, name, receiver: bool = True): """Initialize the PostNL sensor.""" super().__init__(coordinator, context=name) @@ -75,6 +79,8 @@ def device_info(self) -> DeviceInfo: }, name=self.postnl_userinfo.get('email'), manufacturer="PostNL", + entry_type=DeviceEntryType.SERVICE, + configuration_url="https://jouw.postnl.nl", ) @property @@ -83,25 +89,15 @@ def name(self) -> str: return self._name @property - def state(self): + def native_value(self): """Return the state of the sensor.""" return self._state - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return 'packages' - @property def extra_state_attributes(self): """Return the state attributes.""" return self._attributes - @property - def icon(self): - """Icon to use in the frontend.""" - return "mdi:package-variant-closed" - @callback def _handle_coordinator_update(self) -> None: _LOGGER.debug('Updating sensor %s', self.name) @@ -114,10 +110,13 @@ def handle_coordinator_data(self): self._attributes['delivered'] = [] self._attributes['enroute'] = [] + if not self.coordinator.data: + return + if self.receiver: - coordinator_data = self.coordinator.data['receiver'] + coordinator_data = self.coordinator.data.get('receiver', []) else: - coordinator_data = self.coordinator.data['sender'] + coordinator_data = self.coordinator.data.get('sender', []) for package in coordinator_data: if package.delivered: diff --git a/custom_components/postnl/strings.json b/custom_components/postnl/strings.json index ce66c13..e934cf1 100644 --- a/custom_components/postnl/strings.json +++ b/custom_components/postnl/strings.json @@ -1,5 +1,30 @@ { - "application_credentials": { - "description": "IMPORTANT: installing this integration is only possible if you use the Chrome extension (see Github repo). You can just put in random data in the Client ID and Client secret fields, the integration ignores the information." + "config": { + "step": { + "user": { + "title": "PostNL", + "description": "Enter your PostNL account credentials.", + "data": { + "username": "Email address", + "password": "Password" + } + }, + "reauth_confirm": { + "title": "PostNL — re-authentication required", + "description": "Your PostNL session has expired. Please enter your credentials again.", + "data": { + "username": "Email address", + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Invalid email address or password.", + "cannot_connect": "Unable to connect to PostNL. Please try again." + }, + "abort": { + "already_configured": "This account is already configured.", + "reauth_successful": "Re-authentication was successful." + } } } diff --git a/custom_components/postnl/structs/package.py b/custom_components/postnl/structs/package.py index e90a573..b9aadf9 100644 --- a/custom_components/postnl/structs/package.py +++ b/custom_components/postnl/structs/package.py @@ -3,6 +3,7 @@ class Package: name: str url: str shipment_type: str + receiver_title: str | None status_message: str delivered: bool delivery_date: str | None @@ -20,6 +21,7 @@ def __init__( shipment_type: str, status_message: str, delivered: bool, + receiver_title: str | None = None, delivery_date: str | None = None, delivery_address_type: str | None = None, planned_date: str | None = None, @@ -31,6 +33,7 @@ def __init__( self.name = name self.url = url self.shipment_type = shipment_type + self.receiver_title = receiver_title self.status_message = status_message self.delivered = delivered self.delivery_date = delivery_date diff --git a/custom_components/postnl/translations/en.json b/custom_components/postnl/translations/en.json new file mode 100644 index 0000000..e934cf1 --- /dev/null +++ b/custom_components/postnl/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "title": "PostNL", + "description": "Enter your PostNL account credentials.", + "data": { + "username": "Email address", + "password": "Password" + } + }, + "reauth_confirm": { + "title": "PostNL — re-authentication required", + "description": "Your PostNL session has expired. Please enter your credentials again.", + "data": { + "username": "Email address", + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Invalid email address or password.", + "cannot_connect": "Unable to connect to PostNL. Please try again." + }, + "abort": { + "already_configured": "This account is already configured.", + "reauth_successful": "Re-authentication was successful." + } + } +} diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..e06e0fe --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,31 @@ +# PostNL API Reference + +This directory contains reference documentation for the PostNL API endpoints used by this integration. Each file documents one endpoint or flow with its URL, authentication requirements, request format, and an annotated example response. + +## Endpoints + +| File | Endpoint(s) | Description | +|------|-------------|-------------| +| [login.md](login.md) | Multiple — see file | PKCE + Capture login flow; obtain access and refresh tokens | +| [userinfo.md](userinfo.md) | `GET /profiles/oidc/userinfo` | Fetch authenticated user profile | +| [graphql.md](graphql.md) | `POST /account/api/graphql` | `profile` and `trackedShipments` GraphQL queries | +| [track_and_trace.md](track_and_trace.md) | `GET /track-and-trace/api/trackAndTrace/{key}` | Live delivery status for a single shipment | + +## Base URLs + +| Host | Used for | +|------|----------| +| `https://login.postnl.nl` | Authentication (login, token refresh, userinfo) | +| `https://jouw.postnl.nl` | GraphQL and Track & Trace data | + +## Authentication + +All data endpoints require a valid OIDC access token obtained via the [login flow](login.md). + +The token is passed as a `Bearer` token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +Access tokens expire after a short TTL. The integration uses the stored `refresh_token` to obtain a new access token automatically. If the refresh token is also expired, HA triggers a reauth notification. diff --git a/docs/api/graphql.md b/docs/api/graphql.md new file mode 100644 index 0000000..79af1a4 --- /dev/null +++ b/docs/api/graphql.md @@ -0,0 +1,182 @@ +# POST /account/api/graphql + +GraphQL endpoint for PostNL account data. The integration uses two queries: `profile` (token validation) and `trackedShipments` (shipment list). + +## Request + +**URL:** `https://jouw.postnl.nl/account/api/graphql` +**Method:** `POST` +**Content-Type:** `application/json` + +### Headers + +| Header | Value | +|--------|-------| +| `Authorization` | `Bearer ` | + +--- + +## Query: `profile` + +Used to validate that the access token is still accepted by the data API. + +### Body + +```graphql +query { + profile { + ...ProfileData + __typename + } +} + +fragment ProfileData on Profile { + username + __typename +} +``` + +### Response + +```json +{ + "data": { + "profile": { + "username": "user@example.com", + "__typename": "Profile" + } + } +} +``` + +| Field | Description | +|-------|-------------| +| `username` | Account email address | + +--- + +## Query: `trackedShipments` + +Returns all active and recently delivered shipments for the account, split into incoming (receiver) and outgoing (sender) lists. + +### Body + +```graphql +query { + trackedShipments { + receiverShipments { + ...shipment + __typename + } + senderShipments { + ...shipment + __typename + } + __typename + } +} + +fragment shipment on TrackedShipmentResultType { + key + creationDateTime + title + barcode + delivered + deliveredTimeStamp + deliveryWindowFrom + deliveryWindowTo + deliveryWindowType + detailsUrl + shipmentType + receiverTitle + deliveryAddressType + sourceAccountId + sourceDisplayName + __typename +} +``` + +### Response + +```json +{ + "data": { + "trackedShipments": { + "receiverShipments": [ + { + "key": "3SABCD1234567890-NL-1234AB", + "creationDateTime": "2026-05-28T08:17:22+02:00", + "barcode": "3SABCD1234567890", + "title": "Bol.com", + "delivered": false, + "deliveredTimeStamp": null, + "deliveryWindowFrom": "2026-05-29T12:00:00", + "deliveryWindowTo": "2026-05-29T14:00:00", + "deliveryWindowType": null, + "shipmentType": "Parcel", + "receiverTitle": "Jane Doe", + "deliveryAddressType": "Recipient", + "detailsUrl": "https://jouw.postnl.nl/track-and-trace/3SABCD1234567890-NL-1234AB", + "sourceAccountId": null, + "sourceDisplayName": null, + "__typename": "TrackedShipmentResultType" + } + ], + "senderShipments": [ + { + "key": "3SEFGH9876543210-NL-5678CD", + "creationDateTime": "2026-05-20T10:00:00+02:00", + "barcode": "3SEFGH9876543210", + "title": "Retailer Aftersales", + "delivered": true, + "deliveredTimeStamp": "2026-05-21T12:14:29", + "deliveryWindowFrom": "2026-05-21T00:00:00", + "deliveryWindowTo": "2026-05-21T23:59:59", + "deliveryWindowType": null, + "shipmentType": "Parcel", + "receiverTitle": "Retailer Aftersales", + "deliveryAddressType": "Rerouted", + "detailsUrl": "https://jouw.postnl.nl/track-and-trace/3SEFGH9876543210-NL-5678CD", + "sourceAccountId": null, + "sourceDisplayName": null, + "__typename": "TrackedShipmentResultType" + } + ], + "__typename": "GetTrackedShipmentsResultType" + } + } +} +``` + +### Shipment fields + +| Field | Type | Description | +|-------|------|-------------| +| `key` | string | Full shipment identifier in `{barcode}-{country}-{postalcode}` format, e.g. `3SABCD1234567890-NL-1234AB`. Used as the identifier for Track & Trace lookups and the `detailsUrl`. | +| `creationDateTime` | string (ISO 8601 with TZ offset) | When the shipment was registered, e.g. `2026-05-28T08:17:22+02:00` | +| `barcode` | string | The bare barcode without country/postcode suffix, e.g. `3SABCD1234567890`. Used to look up the matching entry in the Track & Trace `colli` response. | +| `title` | string | Display name — typically the sender name. May have a leading space. | +| `delivered` | boolean | `true` when the parcel has been delivered. When `true`, no Track & Trace call is made. | +| `deliveredTimeStamp` | string\|null | Actual delivery timestamp when `delivered` is `true`, e.g. `2026-05-29T14:34:26` (no TZ offset) | +| `deliveryWindowFrom` | string\|null | Start of the estimated delivery window | +| `deliveryWindowTo` | string\|null | End of the estimated delivery window. When the window spans a full day, `From` is `00:00:00` and `To` is `23:59:59`. | +| `deliveryWindowType` | null | Always `null` in observed data | +| `shipmentType` | string | Parcel type. Observed values: `Parcel`, `LetterboxParcel` | +| `receiverTitle` | string\|null | Name of the recipient as printed on the label, e.g. `Jane Doe`. May have a leading space — the integration strips it. | +| `deliveryAddressType` | string\|null | Delivery destination type. Observed values: `Recipient` (home address), `ServicePoint` (pickup point), `Rerouted` (return shipment) | +| `detailsUrl` | string | Deep link to the shipment detail page on jouw.postnl.nl | +| `sourceAccountId` | null | Always `null` in observed data | +| `sourceDisplayName` | null | Always `null` in observed data | + +## How the integration uses this endpoint + +- `receiverShipments` → incoming parcel sensor (`PostNL_delivery`) +- `senderShipments` → outgoing parcel sensor (`PostNL_distribution`) +- `delivered: true` → parcel shown in `delivered` attribute; no Track & Trace call is made +- `delivered: false` → parcel shown in `enroute`; a [Track & Trace](track_and_trace.md) call is made for live status +- `barcode` → key for the `colli` lookup in the Track & Trace response +- `receiverTitle` → `receiver_title` attribute on the HA sensor entity + +## Error handling + +A `TransportQueryError` from the `gql` library (e.g. a GraphQL-level error response) causes the integration to force-expire the access token and retry with a refreshed token. diff --git a/docs/api/login.md b/docs/api/login.md new file mode 100644 index 0000000..e0c1c65 --- /dev/null +++ b/docs/api/login.md @@ -0,0 +1,225 @@ +# Login flow + +PostNL uses a multi-step PKCE + Janrain Capture widget flow. There is no simple username/password endpoint — credentials are submitted through Capture's widget API, which returns a short-lived token that is then exchanged for a standard OIDC authorization code. + +## Constants + +| Name | Value | +|------|-------| +| Tenant ID | `101112a0-4a0f-4bbb-8176-2f1b2d370d7c` | +| OIDC client ID | `bd9f1610-b56d-4e05-a09b-f696f05ddade` | +| Capture client ID | `dkyxkt9x888ye422mawmf769yfm9y44j` | +| Redirect URI | `https://www.postnl.nl/` | +| Scope | `openid poa-profiles-api offline_access` | +| Flow version | `20250910094830574377` *(timestamp-based, may go stale)* | + +--- + +## Step 1 — Open the OIDC authorize endpoint + +**URL:** `GET https://login.postnl.nl/{tenant}/login/authorize` +**Follow redirects:** yes + +### Query parameters + +| Parameter | Value | +|-----------|-------| +| `client_id` | OIDC client ID | +| `response_type` | `code` | +| `scope` | `openid poa-profiles-api offline_access` | +| `redirect_uri` | `https://www.postnl.nl/` | +| `state` | Random base64url string (24 bytes) | +| `nonce` | Same value as `state` | +| `code_challenge` | SHA-256 of the PKCE verifier, base64url-encoded, no padding | +| `code_challenge_method` | `S256` | + +### Result + +The server sets a `_csrf_token` cookie and returns the Hosted Login page HTML. The final URL after redirects becomes `login_url`, used as the `Referer` in subsequent requests. + +The CSRF token must be extracted from the cookie jar. If it is absent from the jar, it can be found in the page HTML as `aicCsrf: ''`. + +--- + +## Step 2 — Submit credentials to the Capture widget + +**URL:** `POST https://login.postnl.nl/widget/traditional_signin.jsonp` +**Content-Type:** `application/x-www-form-urlencoded` +**Origin:** `https://login.postnl.nl` +**Referer:** `login_url` (from step 1) + +### Body + +| Field | Value | +|-------|-------| +| `utf8` | `✓` | +| `signInEmailAddress` | User's email address | +| `currentPassword` | User's password | +| `capture_screen` | `signIn` | +| `js_version` | `d445bf4` | +| `capture_transactionId` | Random base64url string (30 bytes) — used to poll in step 3 | +| `form` | `signInForm` | +| `flow` | `standard` | +| `client_id` | Capture client ID | +| `redirect_uri` | `{login_url}&socialRedirect=True` | +| `response_type` | `token` | +| `flow_version` | `20250910094830574377` | +| `settings_version` | *(empty string)* | +| `locale` | `en-US` | +| `recaptchaVersion` | `2` | + +### Result + +An empty or minimal response body. The transaction is processed asynchronously — poll step 3 for the result. + +--- + +## Step 3 — Poll for the Capture result + +**URL:** `GET https://login.postnl.nl/widget/get_result.jsonp` + +### Query parameters + +| Parameter | Value | +|-----------|-------| +| `transactionId` | The `capture_transactionId` used in step 2 | +| `cache` | Current Unix timestamp in milliseconds (cache-buster) | + +### Response body (success) + +```json +{ + "accessToken": "", + "status": "success" +} +``` + +| Field | Description | +|-------|-------------| +| `accessToken` | Short-lived Capture access token. Used in step 4. | + +If `accessToken` is absent the credentials were incorrect. + +--- + +## Step 4 — Exchange the Capture token for an OIDC auth code + +**URL:** `POST https://login.postnl.nl/{tenant}/auth-ui/v2/token-url?{query_string_from_login_url}` +**Content-Type:** `application/x-www-form-urlencoded` +**Follow redirects:** no + +The query string is copied verbatim from the `login_url` obtained in step 1. + +### Body + +| Field | Value | +|-------|-------| +| `screen` | `signIn` | +| `authenticated` | `True` | +| `registering` | `False` | +| `accessToken` | Capture access token from step 3 | +| `_csrf_token` | CSRF token from step 1 | + +### Result — redirect (most cases) + +The server responds with a `302` redirect. The `Location` header is the OIDC `authorize` redirect URL that contains the auth code. Continue to step 5. + +### Result — no redirect (loginSuccess screen) + +Sometimes the server returns `200` with a page body instead of redirecting. The body contains embedded JavaScript variables: + +| JS variable | Description | +|-------------|-------------| +| `screenToRender: ''` | Must be `loginSuccess` | +| `existingToken: ''` | A second Capture token for the loginSuccess screen | +| `aicCsrf: ''` | A refreshed CSRF token | + +In this case, make a second POST to the same URL with: + +| Field | Value | +|-------|-------| +| `screen` | `loginSuccess` | +| `accessToken` | `existingToken` value | +| `_csrf_token` | Refreshed CSRF token | + +This second POST returns a `302` redirect with the auth URL. + +--- + +## Step 5 — Follow the authorize redirect + +**URL:** `GET {auth_url}` (Location from step 4) +**Follow redirects:** no + +The server responds with a `302` redirect whose `Location` is the `redirect_uri` with the authorization code appended: + +``` +https://www.postnl.nl/?code=&state= +``` + +Validate that the returned `state` matches the value generated in step 1. + +--- + +## Step 6 — Exchange the code for tokens + +**URL:** `POST https://login.postnl.nl/{tenant}/login/token` +**Content-Type:** `application/x-www-form-urlencoded` + +### Body + +| Field | Value | +|-------|-------| +| `grant_type` | `authorization_code` | +| `client_id` | OIDC client ID | +| `code` | Authorization code from step 5 | +| `redirect_uri` | `https://www.postnl.nl/` | +| `code_verifier` | The PKCE verifier generated before step 1 | + +### Response body + +```json +{ + "access_token": "", + "refresh_token": "", + "expires_in": 3600, + "token_type": "Bearer" +} +``` + +| Field | Description | +|-------|-------------| +| `access_token` | JWT used as `Bearer` token for all data API calls | +| `refresh_token` | Opaque token used to obtain a new access token without re-entering credentials | +| `expires_in` | Seconds until the access token expires (typically `3600`) | +| `token_type` | Always `Bearer` | + +--- + +## Token refresh + +When the access token expires the integration calls this endpoint directly, skipping the Capture flow. + +**URL:** `POST https://login.postnl.nl/{tenant}/login/token` +**Content-Type:** `application/x-www-form-urlencoded` + +### Body + +| Field | Value | +|-------|-------| +| `grant_type` | `refresh_token` | +| `client_id` | OIDC client ID | +| `refresh_token` | Stored refresh token | + +The response shape is identical to step 6. The integration stores the new `access_token` and `refresh_token` back into the config entry. If the refresh fails, HA triggers a reauth notification. + +--- + +## PKCE verifier and challenge + +The verifier is 96 random bytes encoded as base64url without padding (≈ 128 characters). The challenge is the SHA-256 digest of the verifier, also base64url-encoded without padding. + +``` +verifier = base64url(random_bytes(96)) +challenge = base64url(sha256(verifier)) +``` diff --git a/docs/api/track_and_trace.md b/docs/api/track_and_trace.md new file mode 100644 index 0000000..eedba11 --- /dev/null +++ b/docs/api/track_and_trace.md @@ -0,0 +1,95 @@ +# GET /track-and-trace/api/trackAndTrace/{key} + +Returns live delivery status details for a single shipment. Called for every shipment that has not yet been delivered, to obtain the current status message and ETA. + +## Request + +**URL:** `https://jouw.postnl.nl/track-and-trace/api/trackAndTrace/{key}?language=nl` +**Method:** `GET` + +### Path parameters + +| Parameter | Description | +|-----------|-------------| +| `key` | Shipment barcode / key from the [`trackedShipments` GraphQL query](graphql.md) | + +### Query parameters + +| Parameter | Value | +|-----------|-------| +| `language` | `nl` | + +### Headers + +| Header | Value | +|--------|-------| +| `Authorization` | `Bearer ` | + +## Response + +**Status:** `200 OK` + +### Body + +```json +{ + "colli": { + "3SABCD1234567890": { + "statusPhase": { + "message": "Pakket is onderweg" + }, + "routeInformation": { + "plannedDeliveryTime": "2026-05-29T13:00:00", + "plannedDeliveryTimeWindow": { + "startDateTime": "2026-05-29T12:00:00", + "endDateTime": "2026-05-29T14:00:00" + }, + "expectedDeliveryTime": "2026-05-29T13:15:00" + }, + "eta": null + } + } +} +``` + +### `colli` object + +The response is keyed by barcode. The integration looks up `colli[shipment.barcode]` to find the relevant entry. + +| Field | Type | Description | +|-------|------|-------------| +| `statusPhase.message` | string | Human-readable current status, e.g. `"Pakket is onderweg"`. Used as the sensor's `status_message` attribute. | +| `routeInformation` | object\|null | Present when the carrier has live route data | +| `routeInformation.plannedDeliveryTime` | string\|null | Single planned delivery timestamp | +| `routeInformation.plannedDeliveryTimeWindow.startDateTime` | string\|null | Start of the delivery window | +| `routeInformation.plannedDeliveryTimeWindow.endDateTime` | string\|null | End of the delivery window | +| `routeInformation.expectedDeliveryTime` | string\|null | Dynamically updated expected delivery time based on driver progress | +| `eta` | object\|null | Alternative ETA structure used when `routeInformation` is absent | +| `eta.start` | string\|null | ETA window start | +| `eta.end` | string\|null | ETA window end | + +### ETA resolution order + +The coordinator resolves delivery timing in this order: + +1. `routeInformation` — if present, uses `plannedDeliveryTime`, window start/end, and `expectedDeliveryTime` +2. `eta` — if `routeInformation` is absent, uses `eta.start` and `eta.end` +3. GraphQL fallback — uses `deliveryWindowFrom` / `deliveryWindowTo` from the shipment if neither field is present + +## How the integration uses this endpoint + +| API field | `Package` attribute | +|-----------|---------------------| +| `statusPhase.message` | `status_message` | +| `plannedDeliveryTime` / `eta.start` | `planned_date` | +| `startDateTime` / `eta.start` | `planned_from` | +| `endDateTime` / `eta.end` | `planned_to` | +| `expectedDeliveryTime` | `expected_datetime` | + +## Error handling + +| Condition | Behaviour | +|-----------|-----------| +| `colli` key absent | Warning is logged; GraphQL delivery window values are used as fallback | +| Barcode not found in `colli` | Warning is logged; GraphQL delivery window values are used as fallback | +| `requests.RequestException` | `UpdateFailed` is raised; coordinator retries on next poll interval | diff --git a/docs/api/userinfo.md b/docs/api/userinfo.md new file mode 100644 index 0000000..4baca46 --- /dev/null +++ b/docs/api/userinfo.md @@ -0,0 +1,54 @@ +# GET /profiles/oidc/userinfo + +Returns profile information for the authenticated user. Called once during integration setup to obtain the account ID used as the HA device identifier. + +## Request + +**URL:** `https://login.postnl.nl/101112a0-4a0f-4bbb-8176-2f1b2d370d7c/profiles/oidc/userinfo` +**Method:** `GET` + +### Headers + +| Header | Value | +|--------|-------| +| `Authorization` | `Bearer ` | + +## Response + +**Status:** `200 OK` + +### Body + +```json +{ + "account_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "email": "user@example.com", + "family_name": "Doe", + "gender": "Male", + "given_name": "Jane", + "global_sub": "capture-v1://eu.janraincapture.com/7g3uvwt64vjz9j8jxmw65nczde/user/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `account_id` | string (UUID) | PostNL account identifier. Used as the HA device identifier and as the prefix for all sensor unique IDs. | +| `email` | string | Account email address. Used as the HA device name. | +| `family_name` | string | Last name | +| `gender` | string | Gender as stored in the account profile | +| `given_name` | string | First name | +| `global_sub` | string | Janrain Capture-scoped subject identifier, scoped to the Capture tenant. Not used by the integration. | +| `sub` | string (UUID) | OIDC subject identifier — the Capture user ID. Distinct from `account_id`. Not used by the integration. | + +## How the integration uses this endpoint + +- `account_id` → HA device identifier (`{DOMAIN, account_id}`) and unique ID prefix for sensors +- `email` → device `name` in the HA device registry + +## Error handling + +| Status | Meaning | +|--------|---------| +| `200` | Success | +| `401` | Access token expired or invalid — the integration triggers a token refresh |