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.
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.
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.
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.
| 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. |
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.
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- Raising
terminal_retention_daysgrows 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. Theshipments,providersandcarriersattributes are excluded from the recorder, so database size is unaffected. delivered_window_dayscannot exceedterminal_retention_days. The options flow rejects it, since retention deletes the shipments first.- Raising
orphan_ttl_daysdoes not revive expired shipments. The TTL only governs when a non-terminal shipment becomesexpired; entries already markedexpiredstay that way until retention removes them. - Empty sensors are usually correct, not a fault. Shipments older than
delivered_window_daysare hidden, and terminal shipments never appear on the active sensor. Checkdiagnosticsfor what is actually stored.
┌──────────────────┐ ┌──────────────────┐
│ 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_pushdomain — 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.
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 |
unknown → announced → in_transit → out_for_delivery → delivery_window
→ delivered / pickup_ready / exception / returned / expired
Terminal states: delivered, returned, expired.
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 }}Providers implement two methods from ProviderAdapter:
async def async_add_tracking(self, request: TrackingRequest) -> Shipment
async def async_remove_tracking(self, shipment_id: str) -> NoneProviders must not create HA entities or write to storage directly — they return normalized data and the coordinator handles persistence and events.
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.
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 | Noneget_providers_with_capability(hass, cap)→list[ProviderInfo]
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.
ManualProvider— built-in, capabilities:ADD_TRACKING | REMOVE_TRACKING.
Carrier integrations (PostNL, DHL, etc.) are separate HA integrations that register via the provider registry.
| 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.
Shipments that go stale or lose their provider are cleaned up automatically through three mechanisms:
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.
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.
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.
| 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 |
Shipments persist across HA restarts via Store at
.storage/parcel_shipments_{entry_id}. The coordinator loads on setup and
saves after every state change.
pytest tests/test_parcel_*.py -vTest files:
test_parcel_models.py— model serialization, flagstest_parcel_const.py— constant + capability stabilitytest_parcel_config_flow.py— user, options and reconfigure flowstest_parcel_coordinator.py— coordinator CRUD, store round-trip, event classification, service handlerstest_parcel_registry.py— provider registry CRUD, capability filtering, service capability gating, push APItest_parcel_sensor.py— active shipments sensor, providers diagnostic sensor, carrier enrichmenttest_parcel_diagnostics.py— diagnostics output shape, redaction, provider listingtest_parcel_frontend.py— card contract: resource registration, element naming, editor support, config optionstest_parcel_frontend_resources.py— Lovelace resource sync and cache-busting versions
- 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.yamlfor outstanding rules. - No YAML config for core behavior; no
input_*helpers - Normalized models as the stable contract between providers and consumers