diff --git a/CLAUDE.md b/CLAUDE.md index 258b977..9f394d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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. diff --git a/README.md b/README.md index 644e21d..bc0e2a6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/custom_components/package_tracker/api_client.py b/custom_components/package_tracker/api_client.py index aaf5eb8..329b5a1 100644 --- a/custom_components/package_tracker/api_client.py +++ b/custom_components/package_tracker/api_client.py @@ -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: diff --git a/custom_components/package_tracker/coordinator.py b/custom_components/package_tracker/coordinator.py index d074918..7b0ba3f 100644 --- a/custom_components/package_tracker/coordinator.py +++ b/custom_components/package_tracker/coordinator.py @@ -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: @@ -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: diff --git a/custom_components/package_tracker/frontend/package-tracker-card.js b/custom_components/package_tracker/frontend/package-tracker-card.js index f1359be..92f4029 100644 --- a/custom_components/package_tracker/frontend/package-tracker-card.js +++ b/custom_components/package_tracker/frontend/package-tracker-card.js @@ -4,29 +4,29 @@ function t(t,e,i,r){var s,o=arguments.length,n=o<3?e:null===r?r=Object.getOwnPro * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const e=globalThis,i=e.ShadowRoot&&(void 0===e.ShadyCSS||e.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,r=Symbol(),s=new WeakMap;let o=class{constructor(t,e,i){if(this._$cssResult$=!0,i!==r)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(i&&void 0===t){const i=void 0!==e&&1===e.length;i&&(t=s.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),i&&s.set(e,t))}return t}toString(){return this.cssText}};const n=(t,...e)=>{const i=1===t.length?t[0]:e.reduce((e,i,r)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[r+1],t[0]);return new o(i,t,r)},a=i?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new o("string"==typeof t?t:t+"",void 0,r))(e)})(t):t,{is:c,defineProperty:l,getOwnPropertyDescriptor:d,getOwnPropertyNames:h,getOwnPropertySymbols:p,getPrototypeOf:u}=Object,g=globalThis,f=g.trustedTypes,_=f?f.emptyScript:"",v=g.reactiveElementPolyfillSupport,m=(t,e)=>t,$={toAttribute(t,e){switch(e){case Boolean:t=t?_:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},y=(t,e)=>!c(t,e),b={attribute:!0,type:String,converter:$,reflect:!1,useDefault:!1,hasChanged:y}; +const e=globalThis,i=e.ShadowRoot&&(void 0===e.ShadyCSS||e.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,r=Symbol(),s=new WeakMap;let o=class{constructor(t,e,i){if(this._$cssResult$=!0,i!==r)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const e=this.t;if(i&&void 0===t){const i=void 0!==e&&1===e.length;i&&(t=s.get(e)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),i&&s.set(e,t))}return t}toString(){return this.cssText}};const n=(t,...e)=>{const i=1===t.length?t[0]:e.reduce((e,i,r)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+t[r+1],t[0]);return new o(i,t,r)},a=i?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const i of t.cssRules)e+=i.cssText;return(t=>new o("string"==typeof t?t:t+"",void 0,r))(e)})(t):t,{is:c,defineProperty:d,getOwnPropertyDescriptor:l,getOwnPropertyNames:h,getOwnPropertySymbols:p,getPrototypeOf:u}=Object,g=globalThis,_=g.trustedTypes,f=_?_.emptyScript:"",m=g.reactiveElementPolyfillSupport,v=(t,e)=>t,$={toAttribute(t,e){switch(e){case Boolean:t=t?f:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let i=t;switch(e){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t)}catch(t){i=null}}return i}},y=(t,e)=>!c(t,e),b={attribute:!0,type:String,converter:$,reflect:!1,useDefault:!1,hasChanged:y}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */Symbol.metadata??=Symbol("metadata"),g.litPropertyMetadata??=new WeakMap;let k=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),r=this.getPropertyDescriptor(t,i,e);void 0!==r&&l(this.prototype,t,r)}}static getPropertyDescriptor(t,e,i){const{get:r,set:s}=d(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:r,set(e){const o=r?.call(this);s?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(m("elementProperties")))return;const t=u(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(m("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(m("properties"))){const t=this.properties,e=[...h(t),...p(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(a(t))}else void 0!==t&&e.push(a(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,r)=>{if(i)t.adoptedStyleSheets=r.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const i of r){const r=document.createElement("style"),s=e.litNonce;void 0!==s&&r.setAttribute("nonce",s),r.textContent=i.cssText,t.appendChild(r)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),r=this.constructor._$Eu(t,i);if(void 0!==r&&!0===i.reflect){const s=(void 0!==i.converter?.toAttribute?i.converter:$).toAttribute(e,i.type);this._$Em=t,null==s?this.removeAttribute(r):this.setAttribute(r,s),this._$Em=null}}_$AK(t,e){const i=this.constructor,r=i._$Eh.get(t);if(void 0!==r&&this._$Em!==r){const t=i.getPropertyOptions(r),s="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:$;this._$Em=r;const o=s.fromAttribute(e,t.type);this[r]=o??this._$Ej?.get(r)??o,this._$Em=null}}requestUpdate(t,e,i,r=!1,s){if(void 0!==t){const o=this.constructor;if(!1===r&&(s=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(s,e)||i.useDefault&&i.reflect&&s===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:r,wrapped:s},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==s||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===r&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,r=this[e];!0!==t||this._$AL.has(e)||void 0===r||this.C(e,void 0,i,r)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};k.elementStyles=[],k.shadowRootOptions={mode:"open"},k[m("elementProperties")]=new Map,k[m("finalized")]=new Map,v?.({ReactiveElement:k}),(g.reactiveElementVersions??=[]).push("2.1.2"); + */Symbol.metadata??=Symbol("metadata"),g.litPropertyMetadata??=new WeakMap;let k=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=b){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const i=Symbol(),r=this.getPropertyDescriptor(t,i,e);void 0!==r&&d(this.prototype,t,r)}}static getPropertyDescriptor(t,e,i){const{get:r,set:s}=l(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:r,set(e){const o=r?.call(this);s?.call(this,e),this.requestUpdate(t,o,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??b}static _$Ei(){if(this.hasOwnProperty(v("elementProperties")))return;const t=u(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(v("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(v("properties"))){const t=this.properties,e=[...h(t),...p(t)];for(const i of e)this.createProperty(i,t[i])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const i=this._$Eu(t,e);void 0!==i&&this._$Eh.set(i,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const i=new Set(t.flat(1/0).reverse());for(const t of i)e.unshift(a(t))}else void 0!==t&&e.push(a(t));return e}static _$Eu(t,e){const i=e.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const i of e.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((t,r)=>{if(i)t.adoptedStyleSheets=r.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const i of r){const r=document.createElement("style"),s=e.litNonce;void 0!==s&&r.setAttribute("nonce",s),r.textContent=i.cssText,t.appendChild(r)}})(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,i){this._$AK(t,i)}_$ET(t,e){const i=this.constructor.elementProperties.get(t),r=this.constructor._$Eu(t,i);if(void 0!==r&&!0===i.reflect){const s=(void 0!==i.converter?.toAttribute?i.converter:$).toAttribute(e,i.type);this._$Em=t,null==s?this.removeAttribute(r):this.setAttribute(r,s),this._$Em=null}}_$AK(t,e){const i=this.constructor,r=i._$Eh.get(t);if(void 0!==r&&this._$Em!==r){const t=i.getPropertyOptions(r),s="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:$;this._$Em=r;const o=s.fromAttribute(e,t.type);this[r]=o??this._$Ej?.get(r)??o,this._$Em=null}}requestUpdate(t,e,i,r=!1,s){if(void 0!==t){const o=this.constructor;if(!1===r&&(s=this[t]),i??=o.getPropertyOptions(t),!((i.hasChanged??y)(s,e)||i.useDefault&&i.reflect&&s===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,i))))return;this.C(t,e,i)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:i,reflect:r,wrapped:s},o){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),!0!==s||void 0!==o)||(this._$AL.has(t)||(this.hasUpdated||i||(e=void 0),this._$AL.set(t,e)),!0===r&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,i]of t){const{wrapped:t}=i,r=this[e];!0!==t||this._$AL.has(e)||void 0===r||this.C(e,void 0,i,r)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}};k.elementStyles=[],k.shadowRootOptions={mode:"open"},k[v("elementProperties")]=new Map,k[v("finalized")]=new Map,m?.({ReactiveElement:k}),(g.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const x=globalThis,A=t=>t,w=x.trustedTypes,E=w?w.createPolicy("lit-html",{createHTML:t=>t}):void 0,S="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,P="?"+C,U=`<${P}>`,O=document,T=()=>O.createComment(""),N=t=>null===t||"object"!=typeof t&&"function"!=typeof t,M=Array.isArray,R="[ \t\n\f\r]",z=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,H=/-->/g,j=/>/g,D=RegExp(`>|${R}(?:([^\\s"'>=/]+)(${R}*=${R}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,I=/"/g,B=/^(?:script|style|textarea|title)$/i,q=(t=>(e,...i)=>({_$litType$:t,strings:e,values:i}))(1),W=Symbol.for("lit-noChange"),V=Symbol.for("lit-nothing"),F=new WeakMap,J=O.createTreeWalker(O,129);function K(t,e){if(!M(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==E?E.createHTML(e):e}const Z=(t,e)=>{const i=t.length-1,r=[];let s,o=2===e?"":3===e?"":"",n=z;for(let e=0;e"===c[0]?(n=s??z,l=-1):void 0===c[1]?l=-2:(l=n.lastIndex-c[2].length,a=c[1],n=void 0===c[3]?D:'"'===c[3]?I:L):n===I||n===L?n=D:n===H||n===j?n=z:(n=D,s=void 0);const h=n===D&&t[e+1].startsWith("/>")?" ":"";o+=n===z?i+U:l>=0?(r.push(a),i.slice(0,l)+S+i.slice(l)+C+h):i+C+(-2===l?e:h)}return[K(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),r]};class G{constructor({strings:t,_$litType$:e},i){let r;this.parts=[];let s=0,o=0;const n=t.length-1,a=this.parts,[c,l]=Z(t,e);if(this.el=G.createElement(c,i),J.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(r=J.nextNode())&&a.length0){r.textContent=w?w.emptyScript:"";for(let i=0;iM(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==V&&N(this._$AH)?this._$AA.nextSibling.data=t:this.T(O.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,r="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=G.createElement(K(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===r)this._$AH.p(e);else{const t=new X(r,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=F.get(t.strings);return void 0===e&&F.set(t.strings,e=new G(t)),e}k(t){M(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,r=0;for(const s of t)r===e.length?e.push(i=new Y(this.O(T()),this.O(T()),this,this.options)):i=e[r],i._$AI(s),r++;r2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=V}_$AI(t,e=this,i,r){const s=this.strings;let o=!1;if(void 0===s)t=Q(this,t,e,0),o=!N(t)||t!==this._$AH&&t!==W,o&&(this._$AH=t);else{const r=t;let n,a;for(t=s[0],n=0;nt,A=x.trustedTypes,E=A?A.createPolicy("lit-html",{createHTML:t=>t}):void 0,S="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,P="?"+C,T=`<${P}>`,U=document,O=()=>U.createComment(""),N=t=>null===t||"object"!=typeof t&&"function"!=typeof t,z=Array.isArray,M="[ \t\n\f\r]",R=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,H=/-->/g,j=/>/g,D=RegExp(`>|${M}(?:([^\\s"'>=/]+)(${M}*=${M}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),I=/'/g,L=/"/g,B=/^(?:script|style|textarea|title)$/i,q=(t=>(e,...i)=>({_$litType$:t,strings:e,values:i}))(1),W=Symbol.for("lit-noChange"),V=Symbol.for("lit-nothing"),F=new WeakMap,X=U.createTreeWalker(U,129);function J(t,e){if(!z(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==E?E.createHTML(e):e}const K=(t,e)=>{const i=t.length-1,r=[];let s,o=2===e?"":3===e?"":"",n=R;for(let e=0;e"===c[0]?(n=s??R,d=-1):void 0===c[1]?d=-2:(d=n.lastIndex-c[2].length,a=c[1],n=void 0===c[3]?D:'"'===c[3]?L:I):n===L||n===I?n=D:n===H||n===j?n=R:(n=D,s=void 0);const h=n===D&&t[e+1].startsWith("/>")?" ":"";o+=n===R?i+T:d>=0?(r.push(a),i.slice(0,d)+S+i.slice(d)+C+h):i+C+(-2===d?e:h)}return[J(t,o+(t[i]||"")+(2===e?"":3===e?"":"")),r]};class Z{constructor({strings:t,_$litType$:e},i){let r;this.parts=[];let s=0,o=0;const n=t.length-1,a=this.parts,[c,d]=K(t,e);if(this.el=Z.createElement(c,i),X.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(r=X.nextNode())&&a.length0){r.textContent=A?A.emptyScript:"";for(let i=0;iz(t)||"function"==typeof t?.[Symbol.iterator])(t)?this.k(t):this._(t)}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t))}_(t){this._$AH!==V&&N(this._$AH)?this._$AA.nextSibling.data=t:this.T(U.createTextNode(t)),this._$AH=t}$(t){const{values:e,_$litType$:i}=t,r="number"==typeof i?this._$AC(t):(void 0===i.el&&(i.el=Z.createElement(J(i.h,i.h[0]),this.options)),i);if(this._$AH?._$AD===r)this._$AH.p(e);else{const t=new Q(r,this),i=t.u(this.options);t.p(e),this.T(i),this._$AH=t}}_$AC(t){let e=F.get(t.strings);return void 0===e&&F.set(t.strings,e=new Z(t)),e}k(t){z(this._$AH)||(this._$AH=[],this._$AR());const e=this._$AH;let i,r=0;for(const s of t)r===e.length?e.push(i=new Y(this.O(O()),this.O(O()),this,this.options)):i=e[r],i._$AI(s),r++;r2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=V}_$AI(t,e=this,i,r){const s=this.strings;let o=!1;if(void 0===s)t=G(this,t,e,0),o=!N(t)||t!==this._$AH&&t!==W,o&&(this._$AH=t);else{const r=t;let n,a;for(t=s[0],n=0;n{const r=i?.renderBefore??e;let s=r._$litPart$;if(void 0===s){const t=i?.renderBefore??null;r._$litPart$=s=new Y(e.insertBefore(T(),t),t,void 0,i??{})}return s._$AI(t),s})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return W}}at._$litElement$=!0,at.finalized=!0,nt.litElementHydrateSupport?.({LitElement:at});const ct=nt.litElementPolyfillSupport;ct?.({LitElement:at}),(nt.litElementVersions??=[]).push("4.2.2"); + */class at extends k{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=((t,e,i)=>{const r=i?.renderBefore??e;let s=r._$litPart$;if(void 0===s){const t=i?.renderBefore??null;r._$litPart$=s=new Y(e.insertBefore(O(),t),t,void 0,i??{})}return s._$AI(t),s})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return W}}at._$litElement$=!0,at.finalized=!0,nt.litElementHydrateSupport?.({LitElement:at});const ct=nt.litElementPolyfillSupport;ct?.({LitElement:at}),(nt.litElementVersions??=[]).push("4.2.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const lt=t=>(e,i)=>{void 0!==i?i.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)},dt={attribute:!0,type:String,converter:$,reflect:!1,hasChanged:y},ht=(t=dt,e,i)=>{const{kind:r,metadata:s}=i;let o=globalThis.litPropertyMetadata.get(s);if(void 0===o&&globalThis.litPropertyMetadata.set(s,o=new Map),"setter"===r&&((t=Object.create(t)).wrapped=!0),o.set(i.name,t),"accessor"===r){const{name:r}=i;return{set(i){const s=e.get.call(this);e.set.call(this,i),this.requestUpdate(r,s,t,!0,i)},init(e){return void 0!==e&&this.C(r,void 0,t,e),e}}}if("setter"===r){const{name:r}=i;return function(i){const s=this[r];e.call(this,i),this.requestUpdate(r,s,t,!0,i)}}throw Error("Unsupported decorator location: "+r)}; +const dt=t=>(e,i)=>{void 0!==i?i.addInitializer(()=>{customElements.define(t,e)}):customElements.define(t,e)},lt={attribute:!0,type:String,converter:$,reflect:!1,hasChanged:y},ht=(t=lt,e,i)=>{const{kind:r,metadata:s}=i;let o=globalThis.litPropertyMetadata.get(s);if(void 0===o&&globalThis.litPropertyMetadata.set(s,o=new Map),"setter"===r&&((t=Object.create(t)).wrapped=!0),o.set(i.name,t),"accessor"===r){const{name:r}=i;return{set(i){const s=e.get.call(this);e.set.call(this,i),this.requestUpdate(r,s,t,!0,i)},init(e){return void 0!==e&&this.C(r,void 0,t,e),e}}}if("setter"===r){const{name:r}=i;return function(i){const s=this[r];e.call(this,i),this.requestUpdate(r,s,t,!0,i)}}throw Error("Unsupported decorator location: "+r)}; /** * @license * Copyright 2017 Google LLC @@ -219,6 +219,11 @@ const lt=t=>(e,i)=>{void 0!==i?i.addInitializer(()=>{customElements.define(t,e)} white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + cursor: pointer; + } + + .tracking-number.copied { + color: #4caf50; } .eta { @@ -237,13 +242,23 @@ const lt=t=>(e,i)=>{void 0!==i?i.addInitializer(()=>{customElements.define(t,e)} overflow: hidden; text-overflow: ellipsis; } -`;let ft=class extends at{constructor(){super(...arguments),this._label="",this._trackingNumber="",this._carrier="",this._loading=!1,this._error="",this._success=!1}setConfig(t){this._config=t}getCardSize(){return 3}static getStubConfig(){return{type:"custom:package-tracker-add-card",title:"Add Package"}}static getConfigElement(){return document.createElement("package-tracker-add-card-editor")}render(){return this.hass&&this._config?q` + + .package-row-updated { + font-size: 0.75em; + color: var(--secondary-text-color); + opacity: 0.7; + text-align: right; + margin-top: 2px; + } +`;let _t=class extends at{constructor(){super(...arguments),this._label="",this._trackingNumber="",this._carrier="",this._loading=!1,this._error="",this._success=!1,this._carriers=null,this._carriersLoaded=!1}setConfig(t){this._config=t}getCardSize(){return 3}static getStubConfig(){return{type:"custom:package-tracker-add-card",title:"Add Package"}}static getConfigElement(){return document.createElement("package-tracker-add-card-editor")}updated(t){t.has("hass")&&this.hass&&!this._carriersLoaded&&(this._carriersLoaded=!0,this.hass.callService("package_tracker","get_carriers",{},void 0,!1,!0).then(t=>{this._carriers=t.response?.carriers??[]}).catch(()=>{this._carriers=[]}))}render(){return this.hass&&this._config?q`
${this._config.title??"Add Package"}
${this._error?q`
${this._error}
`:V} ${this._success?q`
Package added successfully!
`:V} + ${this._loading?q`
`:V} +
(e,i)=>{void 0!==i?i.addInitializer(()=>{customElements.define(t,e)} .value="${this._label}" placeholder="e.g. Amazon Order" @input="${t=>this._label=t.target.value}" + @keydown="${t=>"Enter"===t.key&&this._submit()}" />
@@ -261,19 +277,19 @@ const lt=t=>(e,i)=>{void 0!==i?i.addInitializer(()=>{customElements.define(t,e)} .value="${this._trackingNumber}" placeholder="Carrier auto-detected" @input="${t=>this._trackingNumber=t.target.value}" + @keydown="${t=>"Enter"===t.key&&this._submit()}" />
@@ -282,7 +298,7 @@ const lt=t=>(e,i)=>{void 0!==i?i.addInitializer(()=>{customElements.define(t,e)}
- `:V}async _submit(){if(this._error="",this._success=!1,this._label.trim()&&this._trackingNumber.trim()){this._loading=!0;try{await this.hass.callService("package_tracker","add_package",{label:this._label.trim(),tracking_number:this._trackingNumber.trim(),...this._carrier?{carrier:this._carrier}:{}}),this._success=!0,this._label="",this._trackingNumber="",this._carrier=""}catch(t){this._error=t?.message??"Failed to add package. Check your tracking number."}finally{this._loading=!1}}else this._error="Label and tracking number are required."}};ft.styles=n` + `:V}async _submit(){if(this._error="",this._success=!1,this._label.trim()&&this._trackingNumber.trim()){this._loading=!0;try{await this.hass.callService("package_tracker","add_package",{label:this._label.trim(),tracking_number:this._trackingNumber.trim(),...this._carrier?{carrier:this._carrier}:{}}),this._success=!0,setTimeout(()=>{this._success=!1},3e3),this._label="",this._trackingNumber="",this._carrier=""}catch(t){this._error=t?.message??"Failed to add package. Check your tracking number."}finally{this._loading=!1}}else this._error="Label and tracking number are required."}};_t.styles=n` ha-card { padding: 16px; } @@ -356,7 +372,28 @@ const lt=t=>(e,i)=>{void 0!==i?i.addInitializer(()=>{customElements.define(t,e)} background: rgba(76, 175, 80, 0.1); color: #4caf50; } - `,t([pt({attribute:!1})],ft.prototype,"hass",void 0),t([ut()],ft.prototype,"_config",void 0),t([ut()],ft.prototype,"_label",void 0),t([ut()],ft.prototype,"_trackingNumber",void 0),t([ut()],ft.prototype,"_carrier",void 0),t([ut()],ft.prototype,"_loading",void 0),t([ut()],ft.prototype,"_error",void 0),t([ut()],ft.prototype,"_success",void 0),ft=t([lt("package-tracker-add-card")],ft);let _t=class extends at{setConfig(t){this._config=t}render(){return this._config?q` + + .loading-bar { + height: 3px; + width: 100%; + border-radius: 2px; + overflow: hidden; + background: rgba(var(--rgb-primary-color, 33, 150, 243), 0.2); + } + + .loading-bar-inner { + height: 100%; + width: 40%; + border-radius: 2px; + background: var(--primary-color); + animation: loading-pulse 1.2s ease-in-out infinite; + } + + @keyframes loading-pulse { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(350%); } + } + `,t([pt({attribute:!1})],_t.prototype,"hass",void 0),t([ut()],_t.prototype,"_config",void 0),t([ut()],_t.prototype,"_label",void 0),t([ut()],_t.prototype,"_trackingNumber",void 0),t([ut()],_t.prototype,"_carrier",void 0),t([ut()],_t.prototype,"_loading",void 0),t([ut()],_t.prototype,"_error",void 0),t([ut()],_t.prototype,"_success",void 0),t([ut()],_t.prototype,"_carriers",void 0),_t=t([dt("package-tracker-add-card")],_t);let ft=class extends at{setConfig(t){this._config=t}render(){return this._config?q`
(e,i)=>{void 0!==i?i.addInitializer(()=>{customElements.define(t,e)} style="width:100%" >
- `:V}_titleChanged(t){this._config&&this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:{...this._config,title:t.target.value}},bubbles:!0,composed:!0}))}};t([pt({attribute:!1})],_t.prototype,"hass",void 0),t([ut()],_t.prototype,"_config",void 0),_t=t([lt("package-tracker-add-card-editor")],_t),window.customCards=window.customCards||[],window.customCards.push({type:"package-tracker-add-card",name:"Package Tracker — Add Package",description:"Form card to add a new package to track"});const vt={delivered:"mdi:package-variant-closed-check",in_transit:"mdi:truck-delivery",out_for_delivery:"mdi:truck-fast",pre_transit:"mdi:package-variant",exception:"mdi:alert-circle",expired:"mdi:clock-alert",unknown:"mdi:help-circle"},mt={delivered:"var(--pkg-delivered)",in_transit:"var(--pkg-in-transit)",out_for_delivery:"var(--pkg-out-for-delivery)",pre_transit:"var(--pkg-pre-transit)",exception:"var(--pkg-exception)",expired:"var(--pkg-expired)",unknown:"var(--pkg-unknown)"},$t={delivered:"Delivered",in_transit:"In Transit",out_for_delivery:"Out for Delivery",pre_transit:"Pre-Transit",exception:"Exception",expired:"Expired",unknown:"Unknown"};let yt=class extends at{constructor(){super(...arguments),this._refreshing=!1}setConfig(t){this._config={show_delivered:!0,...t}}getCardSize(){return 3}static getConfigElement(){return document.createElement("package-tracker-card-editor")}static getStubConfig(){return{type:"custom:package-tracker-card",title:"Package Tracker",show_delivered:!0}}render(){if(!this.hass||!this._config)return V;const t=this._getPackages(),e=this._config.title??"Package Tracker";return q` + `:V}_titleChanged(t){this._config&&this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:{...this._config,title:t.target.value}},bubbles:!0,composed:!0}))}};t([pt({attribute:!1})],ft.prototype,"hass",void 0),t([ut()],ft.prototype,"_config",void 0),ft=t([dt("package-tracker-add-card-editor")],ft),window.customCards=window.customCards||[],window.customCards.push({type:"package-tracker-add-card",name:"Package Tracker — Add Package",description:"Form card to add a new package to track"});const mt={delivered:"mdi:package-variant-closed-check",in_transit:"mdi:truck-delivery",out_for_delivery:"mdi:truck-fast",pre_transit:"mdi:package-variant",exception:"mdi:alert-circle",expired:"mdi:clock-alert",unknown:"mdi:help-circle"},vt={delivered:"var(--pkg-delivered)",in_transit:"var(--pkg-in-transit)",out_for_delivery:"var(--pkg-out-for-delivery)",pre_transit:"var(--pkg-pre-transit)",exception:"var(--pkg-exception)",expired:"var(--pkg-expired)",unknown:"var(--pkg-unknown)"},$t={delivered:"Delivered",in_transit:"In Transit",out_for_delivery:"Out for Delivery",pre_transit:"Pre-Transit",exception:"Exception",expired:"Expired",unknown:"Unknown"};let yt=class extends at{constructor(){super(...arguments),this._refreshing=!1,this._copiedId=null}setConfig(t){this._config={show_delivered:!0,...t}}getCardSize(){return 3}static getConfigElement(){return document.createElement("package-tracker-card-editor")}static getStubConfig(){return{type:"custom:package-tracker-card",title:"Package Tracker",show_delivered:!0}}render(){if(!this.hass||!this._config)return V;const t=this._getPackages(),e=this._config.title??"Package Tracker";return q`
${e} @@ -383,7 +420,7 @@ const lt=t=>(e,i)=>{void 0!==i?i.addInitializer(()=>{customElements.define(t,e)}
${0===t.length?q`
No packages being tracked
`:q`
${t.map(t=>this._renderPackage(t))}
`}
- `}async _handleRefresh(){if(!this._refreshing&&this.hass){this._refreshing=!0;try{await this.hass.callService("package_tracker","refresh_packages",{})}catch{}finally{this._refreshing=!1}}}_getPackages(){let t=Object.keys(this.hass.states).filter(t=>{if(!t.startsWith("sensor."))return!1;const e=this.hass.states[t].attributes;return void 0!==e.tracking_number&&void 0!==e.carrier}).map(t=>{const e=this.hass.states[t];return{entityId:t,state:e.state,attributes:e.attributes}});this._config?.show_delivered||(t=t.filter(t=>"delivered"!==t.state));const e={out_for_delivery:0,exception:1,in_transit:2,pre_transit:3,unknown:4,expired:5,delivered:6};return t.sort((t,i)=>{const r=e[t.state]??4,s=e[i.state]??4;if(r!==s)return r-s;return(t.attributes.estimated_delivery?new Date(t.attributes.estimated_delivery).getTime():1/0)-(i.attributes.estimated_delivery?new Date(i.attributes.estimated_delivery).getTime():1/0)}),t}_renderPackage(t){const e=t.state||"unknown",i=vt[e]||vt.unknown,r=mt[e]||mt.unknown,s=$t[e]||e,o=t.attributes;let n="";if(o.estimated_delivery)try{n=new Date(o.estimated_delivery).toLocaleDateString(void 0,{month:"short",day:"numeric"})}catch{}const a=o.events?.[0]?.description||"";return q` + `}async _copyTrackingNumber(t){await navigator.clipboard.writeText(t.attributes.tracking_number??""),this._copiedId=t.entityId,setTimeout(()=>{this._copiedId=null},2e3)}async _handleRefresh(){if(!this._refreshing&&this.hass){this._refreshing=!0;try{await this.hass.callService("package_tracker","refresh_packages",{})}catch{}finally{this._refreshing=!1}}}_getPackages(){let t=Object.keys(this.hass.states).filter(t=>{if(!t.startsWith("sensor."))return!1;const e=this.hass.states[t].attributes;return void 0!==e.tracking_number&&void 0!==e.carrier}).map(t=>{const e=this.hass.states[t];return{entityId:t,state:e.state,attributes:e.attributes}});this._config?.show_delivered||(t=t.filter(t=>"delivered"!==t.state));const e={out_for_delivery:0,exception:1,in_transit:2,pre_transit:3,unknown:4,expired:5,delivered:6};return t.sort((t,i)=>{const r=e[t.state]??4,s=e[i.state]??4;if(r!==s)return r-s;return(t.attributes.estimated_delivery?new Date(t.attributes.estimated_delivery).getTime():1/0)-(i.attributes.estimated_delivery?new Date(i.attributes.estimated_delivery).getTime():1/0)}),t}_renderPackage(t){const e=t.state||"unknown",i=mt[e]||mt.unknown,r=vt[e]||vt.unknown,s=$t[e]||e,o=t.attributes;let n="";if(o.estimated_delivery)try{n=new Date(o.estimated_delivery).toLocaleDateString(void 0,{month:"short",day:"numeric"})}catch{}const a=o.events?.[0]?.description||"";let c="";if(o.last_updated)try{c=new Date(o.last_updated).toLocaleString(void 0,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}catch{}return q`
${o.label||"Package"} @@ -408,10 +445,15 @@ const lt=t=>(e,i)=>{void 0!==i?i.addInitializer(()=>{customElements.define(t,e)}
${o.carrier||""} - ${o.tracking_number||""} + ${this._copiedId===t.entityId?"Copied!":o.tracking_number||""}
${n?q`${"delivered"===e?n:`ETA: ${n}`}`:V}
${a?q`
${a}
`:V} + ${c?q`
Updated: ${c}
`:V}
- `}};yt.styles=gt,t([pt({attribute:!1})],yt.prototype,"hass",void 0),t([ut()],yt.prototype,"_config",void 0),t([ut()],yt.prototype,"_refreshing",void 0),yt=t([lt("package-tracker-card")],yt),window.customCards=window.customCards||[],window.customCards.push({type:"package-tracker-card",name:"Package Tracker Card",description:"Track your shipping packages from USPS, UPS, and FedEx"});export{yt as PackageTrackerCard}; + `}};yt.styles=gt,t([pt({attribute:!1})],yt.prototype,"hass",void 0),t([ut()],yt.prototype,"_config",void 0),t([ut()],yt.prototype,"_refreshing",void 0),t([ut()],yt.prototype,"_copiedId",void 0),yt=t([dt("package-tracker-card")],yt),window.customCards=window.customCards||[],window.customCards.push({type:"package-tracker-card",name:"Package Tracker Card",description:"Track your shipping packages from USPS, UPS, FedEx, and SpeedX"});export{yt as PackageTrackerCard}; diff --git a/custom_components/package_tracker/services.py b/custom_components/package_tracker/services.py index fa3d7ba..b86523a 100644 --- a/custom_components/package_tracker/services.py +++ b/custom_components/package_tracker/services.py @@ -7,7 +7,7 @@ import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -86,6 +86,12 @@ async def handle_add_package(hass: HomeAssistant, call: ServiceCall) -> None: ) +async def handle_get_carriers(hass: HomeAssistant, call: ServiceCall) -> dict: + """Return the list of carriers supported by the scraper.""" + coord = _get_coordinator(hass) + return {"carriers": coord.supported_carriers} + + async def handle_refresh_packages(hass: HomeAssistant, call: ServiceCall) -> None: """Handle the refresh_packages service call.""" coord = _get_coordinator(hass) @@ -128,8 +134,17 @@ def register_services(hass: HomeAssistant) -> None: partial(handle_refresh_packages, hass), ) + if not hass.services.has_service(DOMAIN, "get_carriers"): + hass.services.async_register( + DOMAIN, + "get_carriers", + partial(handle_get_carriers, hass), + supports_response=SupportsResponse.ONLY, + ) + def unregister_services(hass: HomeAssistant) -> None: """Remove package tracker services.""" hass.services.async_remove(DOMAIN, "add_package") hass.services.async_remove(DOMAIN, "refresh_packages") + hass.services.async_remove(DOMAIN, "get_carriers") diff --git a/custom_components/package_tracker/services.yaml b/custom_components/package_tracker/services.yaml index 76ed976..8ea6784 100644 --- a/custom_components/package_tracker/services.yaml +++ b/custom_components/package_tracker/services.yaml @@ -18,7 +18,7 @@ add_package: text: carrier: name: Carrier - description: "Shipping carrier. Leave blank to auto-detect. Valid: usps, ups, fedex." + description: "Shipping carrier. Leave blank to auto-detect. Valid: usps, ups, fedex, speedx." required: false example: "usps" selector: @@ -32,8 +32,15 @@ add_package: value: ups - label: FedEx value: fedex + - label: SpeedX + value: speedx refresh_packages: name: Refresh Packages description: Force a re-scrape of all tracked packages from their carrier websites. fields: {} + +get_carriers: + name: Get Supported Carriers + description: Returns the list of carriers supported by the scraper. + fields: {} diff --git a/frontend-src/src/package-tracker-add-card.ts b/frontend-src/src/package-tracker-add-card.ts index 4cbf839..cf83590 100644 --- a/frontend-src/src/package-tracker-add-card.ts +++ b/frontend-src/src/package-tracker-add-card.ts @@ -12,6 +12,8 @@ export class PackageTrackerAddCard extends LitElement { @state() private _loading = false; @state() private _error = ""; @state() private _success = false; + @state() private _carriers: Array<{id: string; name: string}> | null = null; + private _carriersLoaded = false; static styles = css` ha-card { @@ -87,6 +89,27 @@ export class PackageTrackerAddCard extends LitElement { background: rgba(76, 175, 80, 0.1); color: #4caf50; } + + .loading-bar { + height: 3px; + width: 100%; + border-radius: 2px; + overflow: hidden; + background: rgba(var(--rgb-primary-color, 33, 150, 243), 0.2); + } + + .loading-bar-inner { + height: 100%; + width: 40%; + border-radius: 2px; + background: var(--primary-color); + animation: loading-pulse 1.2s ease-in-out infinite; + } + + @keyframes loading-pulse { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(350%); } + } `; public setConfig(config: { type: string; title?: string }): void { @@ -105,6 +128,16 @@ export class PackageTrackerAddCard extends LitElement { return document.createElement("package-tracker-add-card-editor"); } + updated(changedProps: Map) { + if (changedProps.has("hass") && this.hass && !this._carriersLoaded) { + this._carriersLoaded = true; + (this.hass as any) + .callService("package_tracker", "get_carriers", {}, undefined, false, true) + .then((result: any) => { this._carriers = result.response?.carriers ?? []; }) + .catch(() => { this._carriers = []; }); + } + } + protected render(): TemplateResult | typeof nothing { if (!this.hass || !this._config) return nothing; @@ -119,6 +152,10 @@ export class PackageTrackerAddCard extends LitElement { ? html`
Package added successfully!
` : nothing} + ${this._loading + ? html`
` + : nothing} +
@@ -138,20 +176,20 @@ export class PackageTrackerAddCard extends LitElement { placeholder="Carrier auto-detected" @input="${(e: InputEvent) => (this._trackingNumber = (e.target as HTMLInputElement).value)}" + @keydown="${(e: KeyboardEvent) => e.key === 'Enter' && this._submit()}" />
@@ -180,6 +218,7 @@ export class PackageTrackerAddCard extends LitElement { ...(this._carrier ? { carrier: this._carrier } : {}), }); this._success = true; + setTimeout(() => { this._success = false; }, 3000); this._label = ""; this._trackingNumber = ""; this._carrier = ""; diff --git a/frontend-src/src/package-tracker-card.ts b/frontend-src/src/package-tracker-card.ts index e2075f8..1b103cd 100644 --- a/frontend-src/src/package-tracker-card.ts +++ b/frontend-src/src/package-tracker-card.ts @@ -45,6 +45,7 @@ export class PackageTrackerCard extends LitElement { @property({ attribute: false }) public hass: any; @state() private _config?: PackageTrackerCardConfig; @state() private _refreshing = false; + @state() private _copiedId: string | null = null; static styles = cardStyles; @@ -102,6 +103,12 @@ export class PackageTrackerCard extends LitElement { `; } + private async _copyTrackingNumber(pkg: PackageData): Promise { + await navigator.clipboard.writeText(pkg.attributes.tracking_number ?? ""); + this._copiedId = pkg.entityId; + setTimeout(() => { this._copiedId = null; }, 2000); + } + private async _handleRefresh(): Promise { if (this._refreshing || !this.hass) return; this._refreshing = true; @@ -183,6 +190,20 @@ export class PackageTrackerCard extends LitElement { const latestEvent = attrs.events?.[0]?.description || ""; + let lastUpdatedStr = ""; + if (attrs.last_updated) { + try { + lastUpdatedStr = new Date(attrs.last_updated).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + } catch { + // ignore + } + } + return html`
@@ -210,13 +231,18 @@ export class PackageTrackerCard extends LitElement {
${attrs.carrier || ""} - ${attrs.tracking_number || ""} + ${this._copiedId === pkg.entityId ? "Copied!" : attrs.tracking_number || ""}
${etaStr ? html`${status === "delivered" ? etaStr : `ETA: ${etaStr}`}` : nothing}
${latestEvent ? html`
${latestEvent}
` : nothing} + ${lastUpdatedStr ? html`
Updated: ${lastUpdatedStr}
` : nothing}
`; } @@ -227,5 +253,5 @@ export class PackageTrackerCard extends LitElement { (window as any).customCards.push({ type: "package-tracker-card", name: "Package Tracker Card", - description: "Track your shipping packages from USPS, UPS, and FedEx", + description: "Track your shipping packages from USPS, UPS, FedEx, and SpeedX", }); diff --git a/frontend-src/src/styles.ts b/frontend-src/src/styles.ts index 1bf0d5c..13fb91b 100644 --- a/frontend-src/src/styles.ts +++ b/frontend-src/src/styles.ts @@ -183,6 +183,11 @@ export const cardStyles = css` white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + cursor: pointer; + } + + .tracking-number.copied { + color: #4caf50; } .eta { @@ -201,4 +206,12 @@ export const cardStyles = css` overflow: hidden; text-overflow: ellipsis; } + + .package-row-updated { + font-size: 0.75em; + color: var(--secondary-text-color); + opacity: 0.7; + text-align: right; + margin-top: 2px; + } `; diff --git a/scraper/app.py b/scraper/app.py index de9d572..1596e51 100644 --- a/scraper/app.py +++ b/scraper/app.py @@ -10,7 +10,7 @@ from camoufox.async_api import AsyncCamoufox from fastapi import FastAPI, HTTPException -from .carriers import get_provider +from .carriers import CARRIER_PROVIDERS, get_provider from .const import DB_PATH, DEFAULT_PORT, Carrier from .models import ( AddPackageRequest, @@ -134,6 +134,15 @@ async def refresh_package(tracking_number: str): return _build_package_response(pkg) +@app.get("/api/carriers") +async def get_carriers() -> list[dict]: + """Return the list of carriers supported by the scraper.""" + return [ + {"id": carrier.value, "name": provider_class().name} + for carrier, provider_class in CARRIER_PROVIDERS.items() + ] + + @app.get("/api/health", response_model=HealthResponse) async def health(): """Health check endpoint.""" diff --git a/tests/conftest.py b/tests/conftest.py index 9fb1c1b..f474914 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,4 +96,12 @@ def mock_scraper_api(): # Default: remove package succeeds mock_client.async_remove_package.return_value = None + # Default: carriers list + mock_client.async_get_carriers.return_value = [ + {"id": "usps", "name": "USPS"}, + {"id": "ups", "name": "UPS"}, + {"id": "fedex", "name": "FedEx"}, + {"id": "speedx", "name": "SpeedX"}, + ] + return mock_client diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 05e319e..5871c3b 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -45,6 +45,7 @@ def _make_coordinator(mock_hass, packages, auto_remove_days=1, mock_client=None) coord.logger = MagicMock() coord.name = "package_tracker" coord.update_interval = None + coord.supported_carriers = [] return coord @@ -65,6 +66,7 @@ def coordinator(mock_hass, mock_config_entry, mock_scraper_api): coord.logger = MagicMock() coord.name = "package_tracker" coord.update_interval = None + coord.supported_carriers = [] return coord