Skip to content

Repository files navigation

Parcel Integration

Provider-agnostic parcel tracking domain for Home Assistant. Provides normalized Shipment / ShipmentUpdate models, persistent state, an HA event bus, and a sensor entity. Carrier-specific integrations (PostNL, DHL, …) are separate HA integrations that push state into this domain via services or the coordinator API.

Installation

HACS (recommended) — add https://github.com/apiest/ha-parcel as a custom repository of type Integration, install Parcel, then restart Home Assistant.

Manual — copy custom_components/parcel/ into your Home Assistant config/custom_components/ directory and restart.

Then add the integration via Settings → Devices & Services → Add Integration → Parcel. There is nothing to configure — the flow is a single confirmation. Only one Parcel entry is supported.

Carrier integrations (PostNL, DHL, …) are installed separately and register themselves as providers automatically.

Configuration options

Available via Configure on the Parcel entry:

Option Default Description
orphan_ttl_days 14 Non-terminal shipments without updates for this many days are marked expired. Range 1–365.
delivered_window_days 7 How far back sensor.parcel_delivered_shipments reports. Range 1–365.
terminal_retention_days 30 Delivered, returned and expired shipments are deleted from storage after this many days. Range 1–365.

delivered_window_days may not exceed terminal_retention_days — the flow rejects it, because those shipments would be deleted before the sensor could show them.

Removal

Delete the entry via Settings → Devices & Services → Parcel → Delete. This removes the entities, the service device, and the stored shipment state at .storage/parcel_shipments_{entry_id}. Uninstall the repository from HACS (or delete custom_components/parcel/) and restart to remove the code itself.

Registered carrier integrations are unaffected; remove those separately.

Modules

File Purpose
__init__.py Entry setup: creates coordinator, registers ManualProvider, forwards sensor platform, registers services, registers frontend cards.
coordinator.py ParcelCoordinator — DataUpdateCoordinator with persistent Store, shipment diffing, push API, and HA event-bus emission.
models.py Shipment, ShipmentUpdate, ShipmentStatus, ShipmentKind, ShipmentDirection — frozen dataclasses and enums.
provider.py ProviderAdapter ABC, ManualProvider, ProviderInfo, ProviderCapability, registry API.
sensor.py ParcelSensor — a single entity class driven by ParcelSensorEntityDescription, producing the active, delivered and providers sensors.
services.py parcel.add_tracking, parcel.remove_tracking, parcel.update_shipment — capability-gated (where applicable), routed to registered providers.
diagnostics.py Config entry diagnostics — shipments (redacted), providers, config.
config_flow.py Confirm-only config flow (single entry, no user config).
const.py Domain constants, ProviderCapability IntFlag, event types, service names, storage keys.
frontend/ Vanilla JS Lovelace cards: parcel-deliveries-card, parcel-delivered-card, parcel-pickup-card, parcel-add-card, parcel-shipment-popup, plus the shared parcel-card-editor visual editor.
translations/ EN + NL strings for config flow and entity names.

Lovelace cards

All cards are registered automatically — no manual resource URLs. Every placeable card supports the visual editor, so they can be added and configured entirely from the UI.

Card Purpose Options
custom:parcel-deliveries-card Active shipments sorted by ETA entity, carriers, statuses, max_items
custom:parcel-delivered-card Recently delivered / returned / expired entity, carriers, statuses, max_items
custom:parcel-pickup-card Shipments ready for pickup, with an action button entity, carriers
custom:parcel-add-card Form to add a tracking code providers_entity
parcel-shipment-popup Detail dialog opened by the list cards — (not placed directly)

The pickup card reads the active sensor, not the delivered one: pickup_ready is not a terminal status, so a parcel waiting at a pickup point is still in flight.

Shared options

These apply to the three shipment-list cards. The add card is a form and takes only providers_entity (default sensor.parcel_providers).

Option Type Default Description
entity string sensor.parcel_active_shipments (sensor.parcel_delivered_shipments for the delivered card) Sensor holding the shipment list.
carriers list (empty — all carriers) Only show these carriers. Matched against a shipment's carrier, falling back to provider.
statuses list (empty — all statuses) Only show these shipment statuses. Not available on the pickup card, which is locked to pickup_ready.
max_items number 10 Maximum rows to render. Not available on the pickup card, which is actionable and must not hide parcels.

The carrier multiselect is populated from the carriers attribute on sensor.parcel_providers — the built-in carrier registry merged with every registered provider — so newly installed provider integrations appear without a card update, even while nothing is in transit.

statuses narrows within the chosen entity; it cannot widen it. The active sensor never contains delivered shipments, so statuses: [delivered] on the deliveries card matches nothing.

type: custom:parcel-deliveries-card
entity: sensor.parcel_active_shipments
carriers:
  - postnl
  - dhl_nl
statuses:
  - out_for_delivery
  - delivery_window
max_items: 5

Known limitations

  • Raising terminal_retention_days grows every dashboard's payload. The full shipment list is pushed over the websocket to each connected browser on every state change — roughly 0.5 KiB per shipment, so 100 retained shipments is about 46 KiB per update. The shipments, providers and carriers attributes are excluded from the recorder, so database size is unaffected.
  • delivered_window_days cannot exceed terminal_retention_days. The options flow rejects it, since retention deletes the shipments first.
  • Raising orphan_ttl_days does not revive expired shipments. The TTL only governs when a non-terminal shipment becomes expired; entries already marked expired stay that way until retention removes them.
  • Empty sensors are usually correct, not a fault. Shipments older than delivered_window_days are hidden, and terminal shipments never appear on the active sensor. Check diagnostics for what is actually stored.

Architecture

┌──────────────────┐     ┌──────────────────┐
│  PostNL integration │──▶│                  │
│  (separate HA int.) │   │                  │     ┌──────────────┐
└──────────────────┘     │  Parcel domain   │────▶│   Sensor     │
┌──────────────────┐     │  (coordinator +  │     │   (active    │
│  DHL integration   │──▶│   store + events)│     │   shipments) │
│  (separate HA int.) │   │                  │     └──────────────┘
└──────────────────┘     │                  │────▶ HA events
┌──────────────────┐     │                  │      (parcel_event)
│  Manual tracking   │──▶│                  │────▶ Store (disk)
│  (built-in)        │   └──────────────────┘
└──────────────────┘
  • Parcel is a local_push domain — it does not poll. State changes arrive from external carrier integrations or manual service calls.
  • Carrier integrations (PostNL, DHL, etc.) are separate HA integrations that own the polling/webhook logic for their carrier API and push normalized ShipmentUpdates into the Parcel domain.
  • ManualProvider is the only built-in provider — for user-driven tracking without a carrier API.

Shipment model

The Shipment dataclass (frozen, slotted) is the stable center of the domain:

Field Type Notes
shipment_id str Provider-scoped, e.g. manual:a1b2c3d4
provider str manual, postnl, …
carrier str | None Carrier key for display/filtering; falls back to provider
shipment_kind ShipmentKind package or letter
direction ShipmentDirection inbound or outbound
tracking_code str | None Carrier tracking number
title str | None Human label
status ShipmentStatus 10 states (see below)
status_message str | None Provider-specific text
expected_date date | None Delivery day
expected_from / expected_to datetime | None Delivery window
expected_at datetime | None Single promised time
delivered_at datetime | None Actual delivery time
actionable_state str | None Extra action hint
tracking_url str | None Carrier tracking page
address_hint str | None Location hint for UI
location str | None Current location
origin_country str | None ISO country code of origin
destination_country str | None ISO country code of destination
last_updated datetime | None Last provider update
provider_metadata dict Raw provider extras

Shipment statuses

unknown → announced → in_transit → out_for_delivery → delivery_window → delivered / pickup_ready / exception / returned / expired

Terminal states: delivered, returned, expired.

Event types

Everything fires on the single bus event parcel_event. The specific type is carried in the payload's type field — there is no delivered bus event, so automations must match on event_data, not event_type.

type Meaning
shipment_added New shipment tracked
status_changed Status transition without a more specific type
delivery_window_changed expected_from / expected_to moved, status unchanged
pickup_ready Shipment waiting at pickup point
delivered Shipment delivered
shipment_expired Expired due to TTL, provider unload, or reconciliation

Transitions to returned, exception, and the in-transit states all classify as status_changed.

The payload is the full serialized shipment (every field in the table above, datetimes as ISO strings) plus:

Field Notes
type One of the types above
previous_status Status before the transition; None for shipment_added
automation:
  - triggers:
      - trigger: event
        event_type: parcel_event
        event_data:
          type: pickup_ready
    actions:
      - action: notify.mobile_app_phone
        data:
          message: >-
            {{ trigger.event.data.title }} is ready for pickup
            at {{ trigger.event.data.location }}

Provider contract

Providers implement two methods from ProviderAdapter:

async def async_add_tracking(self, request: TrackingRequest) -> Shipment
async def async_remove_tracking(self, shipment_id: str) -> None

Providers must not create HA entities or write to storage directly — they return normalized data and the coordinator handles persistence and events.

Provider capabilities (IntFlag)

Providers declare which operations they support using ProviderCapability:

Bit Value Meaning
ADD_TRACKING 1 Can register new tracking items
REMOVE_TRACKING 2 Can deregister tracking items

Service handlers check these bits before routing. If a provider lacks the required capability, the service call is rejected with a clear error.

Provider registry

External carrier integrations register with Parcel on their async_setup_entry:

from custom_components.parcel.provider import (
    ProviderInfo,
    async_register_provider,
    async_unregister_provider,
)
from custom_components.parcel.const import ProviderCapability


async def async_setup_entry(hass, entry):
    adapter = MyCarrierAdapter(entry)
    async_register_provider(
        hass,
        ProviderInfo(
            name="mycarrier",
            capabilities=(
                ProviderCapability.ADD_TRACKING | ProviderCapability.REMOVE_TRACKING
            ),
            adapter=adapter,
        ),
    )


async def async_unload_entry(hass, entry):
    async_unregister_provider(hass, "mycarrier")

Registry lookup functions:

  • get_provider(hass, name) → ProviderInfo | None
  • get_providers_with_capability(hass, cap) → list[ProviderInfo]

Push API

External providers push updates into the coordinator:

coordinator = hass.data[DOMAIN][entry_id]
await coordinator.async_push_updates([ShipmentUpdate(...)])

The coordinator diffs against existing state, fires events for meaningful transitions, and persists changes.

Implemented providers

  • ManualProvider — built-in, capabilities: ADD_TRACKING | REMOVE_TRACKING.

Carrier integrations (PostNL, DHL, etc.) are separate HA integrations that register via the provider registry.

Services

Service Schema Capability gate Behavior
parcel.add_tracking tracking_code (required), provider (optional), postal_code, title ADD_TRACKING Resolves provider → checks capability → creates shipment → persists → fires shipment_added
parcel.remove_tracking shipment_id (required) REMOVE_TRACKING Resolves shipment's provider → checks capability → removes from provider + store
parcel.update_shipment shipment_id (required), status, title, status_message, expected_date, tracking_url, location (all optional) — Updates fields on an existing shipment → persists → fires classified event

When provider is omitted from add_tracking and multiple providers support ADD_TRACKING, the call is rejected with an explicit error.

Orphan handling

Shipments that go stale or lose their provider are cleaned up automatically through three mechanisms:

TTL auto-expiry

Non-terminal shipments whose last_updated is older than the configured TTL (default: 14 days) are marked expired on every coordinator refresh (boot + every state change). Configurable via the integration’s options flow (1–365 days). Shipments with last_updated = None are never auto-expired.

Provider unload cleanup

When a carrier integration unloads and calls async_unregister_provider(), all non-terminal shipments from that provider are automatically marked expired. No action required from the provider integration.

Push-based reconciliation

After a full sync, providers may call:

await coordinator.async_reconcile_provider("mycarrier", {"mycarrier:1", "mycarrier:2"})

Shipments from that provider not in the known_ids set (and not already terminal) are marked expired.

Entities

Entity Type State Attributes Category
sensor.parcel_active_shipments sensor Count of active (non-terminal) shipments shipments: list of full serialized Shipment objects —
sensor.parcel_providers sensor Count of registered providers providers: list of provider names DIAGNOSTIC

Persistence

Shipments persist across HA restarts via Store at .storage/parcel_shipments_{entry_id}. The coordinator loads on setup and saves after every state change.

Testing

pytest tests/test_parcel_*.py -v

Test files:

  • test_parcel_models.py — model serialization, flags
  • test_parcel_const.py — constant + capability stability
  • test_parcel_config_flow.py — user, options and reconfigure flows
  • test_parcel_coordinator.py — coordinator CRUD, store round-trip, event classification, service handlers
  • test_parcel_registry.py — provider registry CRUD, capability filtering, service capability gating, push API
  • test_parcel_sensor.py — active shipments sensor, providers diagnostic sensor, carrier enrichment
  • test_parcel_diagnostics.py — diagnostics output shape, redaction, provider listing
  • test_parcel_frontend.py — card contract: resource registration, element naming, editor support, config options
  • test_parcel_frontend_resources.py — Lovelace resource sync and cache-busting versions

Design principles

  • Provider-agnostic: carrier-specific code stays in provider adapters
  • FamilyBoard-agnostic: the parcel domain is a standalone HA integration
  • Modern HA integration patterns: config entries, coordinator, store, typed models, async-first. Currently at the Silver quality scale; see custom_components/parcel/quality_scale.yaml for outstanding rules.
  • No YAML config for core behavior; no input_* helpers
  • Normalized models as the stable contract between providers and consumers

About

Home Assistant integration for tracking parcels from multiple postal providers

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages