Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 7 additions & 80 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

Home Assistant custom integration (HACS) for tracking shipping packages from USPS, UPS, FedEx, and SpeedX. Split into two components:

1. **Scraper container** (`scraper/`) — standalone Docker app running Playwright + Chromium, scrapes carrier pages, exposes results via REST API
2. **HACS integration** (`custom_components/package_tracker/`) — lightweight HA integration (no browser deps) that polls the scraper API and exposes sensor entities + Lovelace card

Domain: `package_tracker`.
Home Assistant custom integration (HACS) for tracking shipping packages from USPS, UPS, FedEx, and SpeedX. Two components: a scraper Docker container (`scraper/`) that runs Playwright to scrape carrier pages, and a HACS integration (`custom_components/package_tracker/`) that polls the scraper and exposes sensor entities + Lovelace cards. Domain: `package_tracker`.

## Architecture

Expand All @@ -27,72 +22,8 @@ HA Coordinator ◄──────── GET /api/packages (polling)

- **HA is the UI layer** — users manage packages via HA config flow OR the `add_package` service, both of which forward to the scraper
- **Scraper is the data layer** — owns the package list in SQLite, runs the polling scheduler, caches results
- **Communication** — HA coordinator polls `GET /api/packages` on a 30-min jittered interval
- **Services** (`services.py`) — `add_package` and `refresh_packages` handlers live in `services.py`. `register_services(hass)` / `unregister_services(hass)` are called from `__init__.py` during setup/unload. Handlers take `hass` explicitly (bound via `functools.partial`). Registered once per domain; removed when the last config entry is unloaded. Reuses the coordinator's persistent `aiohttp` session via `coord._ensure_client()`

## Build Commands

First-time setup (installs uv if missing, Python 3.12, and all deps):
```bash
make setup
```

Individual targets:
```bash
make install # uv sync for HA integration (includes dev deps)
make install-scraper # uv sync for scraper (includes dev deps)
make test # Run HA integration tests
make test-scraper # Run scraper tests
make test-all # Run all tests
make coverage # Run tests with coverage
make lint # Run linter
make build-card # Build frontend Lovelace card
make docker-build # Build scraper Docker image
make docker-run # Run scraper container locally
make docker-stop # Stop scraper container
```

## Scraper Container (`scraper/`)

FastAPI app on port 8230 (configurable via `PORT` env var). SQLite DB at `/data/package_tracker.db` (Docker volume). Docker Hub image: `wolffruoff/package-tracker-scraper`.

**REST API:**
- `GET /api/packages` — all packages with latest tracking results
- `POST /api/packages` — add package `{tracking_number, carrier, label}`
- `DELETE /api/packages/{tracking_number}` — remove package
- `POST /api/packages/{tracking_number}/refresh` — force re-scrape
- `GET /api/health` — health check

**Carrier provider pattern (scraper side):** Abstract `CarrierProvider` in `scraper/carriers/base.py`. Each carrier implements `async_track(tracking_number, browser)`, `validate_tracking_number()`, and `tracking_url()`. The base class provides `_get_page_content(browser, url, wait_selector)` using a shared Playwright browser instance. Each carrier's `_parse_tracking_page(html, result)` uses BeautifulSoup.

**Scheduler:** Background asyncio task, 30-min interval with ±5-min jitter. Browser launched once at startup via FastAPI lifespan, reused across all scrapes.

## HACS Integration (`custom_components/package_tracker/`)

**No browser dependencies** — requirements list is empty. Communicates with scraper via `api_client.py` (wraps aiohttp calls).

**Config flow:** Initial setup collects scraper URL, validates via `GET /api/health`. Config version 2. Options flow forwards add/remove to scraper API.

**Coordinator:** Polls `GET /api/packages` from scraper, parses JSON into `TrackingResult` objects. Keeps jittered interval and delivered auto-removal logic HA-side. `_ensure_client()` returns the shared `ScraperApiClient` — use it instead of creating a new `aiohttp.ClientSession`.

**Services** (`services.py`): `add_package` — schema: `tracking_number` (required), `label` (required), `carrier` (optional, auto-detected via `detect_carrier()` if blank). `refresh_packages` — no fields, force re-scrapes all tracked packages. Both documented via `services.yaml` so HA Dev Tools shows full field descriptions.

**Carrier providers (HA side):** Stripped to validation + URL only. No scraping, no Playwright, no BeautifulSoup. Used for `detect_carrier()` auto-detection and tracking URL generation.

**Frontend cards:** Two Lit 3.x cards in `frontend-src/src/`:
- `package-tracker-card.ts` — displays all tracked sensors; has a visual editor (`package-tracker-card-editor`)
- `package-tracker-add-card.ts` — form card for adding packages via the `add_package` service; has a visual editor (`package-tracker-add-card-editor`) for the `title` field

## Testing

**HA integration tests** (`tests/`): Uses `pytest` + `pytest-asyncio`. Mock `aiohttp` responses via `mock_scraper_api` fixture. Carrier tests cover validation + URL only. `test_init.py` covers setup/unload orchestration (service registration, removal). `test_services.py` covers handler logic directly (calling `handle_add_package`/`handle_refresh_packages` with mock coordinator in `hass.data`).

**Scraper tests** (`scraper/tests/`): Uses `pytest` + `pytest-asyncio`. HTML fixtures in `scraper/tests/fixtures/`. Storage tests use in-memory SQLite. API tests use `httpx` `AsyncClient` with FastAPI's ASGI transport.

## CI/CD

- **`validate.yml`** — runs on PRs and pushes to `main`. Two jobs: HACS validation (`hacs/action@main`) and tests (`pytest` with Codecov upload).
- **`release.yml`** — runs on pushes to `main`. Auto-determines version bump from conventional commits (`feat:` → minor, `BREAKING CHANGE` → major, else patch). Updates `manifest.json` version, creates a git tag + GitHub Release, then builds and pushes the scraper Docker image to `wolffruoff/package-tracker-scraper` on Docker Hub (tagged `latest` + version).
- **Services as endpoints** — HA services (not HTTP views, not entities) are the chosen mechanism for frontend↔HA communication. `SupportsResponse.ONLY` services act as "API endpoints" callable from the frontend via `callService(..., returnResponse=true)`. New cross-cutting data needs (like carrier lists) should follow this pattern, not introduce new entities or HTTP routes.
- **Scraper is the single source of truth for supported carriers** — `GET /api/carriers` drives both `coordinator.supported_carriers` and the frontend dropdown; no static carrier list exists in the frontend or `services.yaml` at runtime

## Carrier Scraping Strategy

Expand All @@ -104,17 +35,13 @@ When carriers add bot protection, the correct response is to improve Camoufox co

## Key Conventions

- `TrackingResult` and `TrackingEvent` are dataclasses — defined in both `carriers/base.py` (HA side) and `scraper/carriers/base.py` (scraper side)
- HA-side `TrackingResult` has an extra `tracking_url` field populated from scraper API response
- `detect_carrier()` in `carriers/__init__.py` runs each provider's `validate_tracking_number()` to auto-detect carrier
- Frontend uses Lit 3.x with TypeScript decorators; strict mode enabled
- Sensor unique IDs: `package_tracker_{tracking_number}`
- Sensor `extra_state_attributes` includes `tracking_url` from scraper API
- `services.yaml` in the integration directory makes the `add_package` and `refresh_packages` services visible with full field docs in HA Developer Tools
- `TrackingResult` and `TrackingEvent` are dataclasses defined in **both** `carriers/base.py` (HA side) and `scraper/carriers/base.py` (scraper side) — they are not shared; the HA-side `TrackingResult` has an extra `tracking_url` field populated from the scraper API response
- HA-side carrier providers are stripped to validation + URL only — no scraping, no Playwright, no BeautifulSoup

## Testing Gotchas

- **Never** `from package_tracker.__init__ import` in tests — this creates a separate module in `sys.modules` with a different `__dict__`, breaking `patch("package_tracker.X")`. Use `from package_tracker import` instead.
- Patch the coordinator where it's used: `patch("package_tracker.PackageTrackerCoordinator", return_value=mock_coord)`. Set `mock_coord.async_config_entry_first_refresh = AsyncMock()` inside the patch context.
- Service handler tests call `handle_add_package(hass, call)` / `handle_refresh_packages(hass, call)` directly with a mock coordinator in `hass.data[DOMAIN]` — no need for `_setup_entry` helpers.
- Service handler tests call `handle_add_package(hass, call)` / `handle_refresh_packages(hass, call)` / `handle_get_carriers(hass, call)` directly with a mock coordinator in `hass.data[DOMAIN]` — no need for `_setup_entry` helpers.
- Setup/unload tests patch `register_services`/`unregister_services` at the module level (`patch("package_tracker.register_services")`).
- Coordinator fixtures created via `__new__` (bypassing `__init__`) must manually set `coord.supported_carriers = []`. The `mock_scraper_api` conftest fixture includes a default `async_get_carriers` return value.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A HACS custom integration that tracks shipping packages from USPS, UPS, FedEx, a
- Built-in Lovelace card with status icons and color coding
- Add/remove packages via the UI options flow or the `add_package` service
- Add Package Lovelace card — form card for adding packages directly from the dashboard
- Modular carrier system — easy to extend with new carriers
- Modular carrier system — easy to extend with new carriers; the frontend dropdown and Dev Tools service definition stay in sync automatically via the scraper

## Installation

Expand Down Expand Up @@ -107,7 +107,7 @@ Fields: `tracking_number` (required), `label` (required), `carrier` (optional

### Via the Add Package card

Add `custom:package-tracker-add-card` to any dashboard for a form-based UI (see [Lovelace Cards](#lovelace-cards)).
Add `custom:package-tracker-add-card` to any dashboard for a form-based UI (see [Lovelace Cards](#lovelace-cards)). The carrier dropdown is populated dynamically from the scraper, so it always reflects the carriers it supports.

## Lovelace Cards

Expand Down
4 changes: 4 additions & 0 deletions custom_components/package_tracker/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ async def async_get_packages(self) -> list[dict]:
"""Fetch all packages with tracking results."""
return await self._get("/api/packages")

async def async_get_carriers(self) -> list[dict]:
"""Fetch the list of supported carriers from the scraper."""
return await self._get("/api/carriers")

async def async_add_package(
self, tracking_number: str, carrier: str, label: str
) -> dict:
Expand Down
7 changes: 7 additions & 0 deletions custom_components/package_tracker/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def __init__(
self._scraper_url = scraper_url
self._session: aiohttp.ClientSession | None = None
self._client: ScraperApiClient | None = None
self.supported_carriers: list[dict] = []

@staticmethod
def _jittered_interval() -> timedelta:
Expand Down Expand Up @@ -109,6 +110,12 @@ async def _async_update_data(self) -> dict[str, TrackingResult]:

client = self._ensure_client()

if not self.supported_carriers:
try:
self.supported_carriers = await client.async_get_carriers()
except Exception: # noqa: BLE001
pass # non-fatal — get_carriers service returns empty list until next poll

try:
packages_data = await client.async_get_packages()
except ScraperApiError:
Expand Down
Loading
Loading