diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..7603320
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,52 @@
+name: Deploy demo to GitHub Pages
+
+# Publishes the already-committed static demo site under docs/ to GitHub Pages.
+# This workflow deliberately does NOT run the precompute: the 95 MB spatial index
+# is gitignored and the committed docs/demo/data/*.json snapshot is published as-is.
+# The repo's other workflows run on self-hosted runners, but the Pages deploy
+# actions require GitHub-hosted runners, so this uses ubuntu-latest.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "docs/**"
+ - ".github/workflows/pages.yml"
+ workflow_dispatch:
+
+# Least-privilege permissions required by actions/deploy-pages.
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow one concurrent deployment; don't cancel an in-progress publish.
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Package docs/ artifact
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Configure Pages
+ uses: actions/configure-pages@v5
+ - name: Upload docs/ as Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: docs
+
+ deploy:
+ name: Deploy to GitHub Pages
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy artifact
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
index 295388f..2d44688 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ Supports all five NYC boroughs. Data is fetched live from NYC Open Data.
## Table of Contents
+- [Live Demo](#live-demo)
- [Installation](#installation)
- [Configuration](#configuration)
- [Requirements](#requirements)
@@ -24,6 +25,57 @@ Supports all five NYC boroughs. Data is fetched live from NYC Open Data.
---
+## Live Demo
+
+Want to see what the integration produces before installing anything? A self-contained
+demo page lives under [`docs/demo/`](docs/demo/). Open it in a browser, click a block on the
+Leaflet map, and you'll see exactly what ASP Parking resolves for that location: the parking
+rule for the block, the next time you'd need to move your car, the exact Home Assistant sensor
+entities and states it would create, and an animated calendar of the weekly cleaning windows.
+It's a plain HTML/CSS/JS page — no build step, no install, no server-side code.
+
+**Where the data comes from.** The demo does **not** call any live API from the browser.
+It reads a **dated snapshot** committed to the repo at
+[`docs/demo/data/demo.json`](docs/demo/data/demo.json) (snapshot date: **2026-07-28**),
+plus the matched segment geometries in `docs/demo/data/demo-segments.geojson`. The snapshot
+stores *weekly recurring patterns* rather than absolute datetimes, and the page recomputes the
+next move time in your browser at NYC time — so the "next move" stays correct even though the
+underlying data is frozen. Because it's a static snapshot, the demo works offline and never
+needs a token.
+
+**Regenerating the snapshot.** The dataset is produced offline (not in CI) by
+`scripts/build_demo_dataset.py`. From a checkout with the project installed:
+
+```bash
+.venv/bin/python scripts/build_demo_dataset.py --out-dir docs/demo/data
+```
+
+This requires two things the demo page itself does not: the **spatial index** must be present
+locally, and the script needs **network access to the NYC Open Data (SODA) API**. The index is
+gitignored (~95 MB), so build it once with `python scripts/build_index.py` **or** download the
+released `index-v1` asset from the [Releases page](https://github.com/Pascal-ZeGerman/GPS2ASP-Resolver/releases).
+Setting a `NYC_OPEN_DATA_APP_TOKEN` environment variable is optional but helps avoid SODA rate
+limiting. The generated `demo.json`/`demo-segments.geojson` are the only files committed — the
+index never is.
+
+**Running it locally.** Serve the folder over HTTP (opening `index.html` via `file://` won't
+let the page `fetch()` its JSON):
+
+```bash
+python -m http.server --directory docs/demo 8000
+```
+
+Then visit .
+
+**Hosting it.** The repo ships a [`.github/workflows/pages.yml`](.github/workflows/pages.yml)
+workflow that publishes the committed `docs/` tree to **GitHub Pages** on every push to `docs/`
+(and on demand via *workflow_dispatch*). The workflow publishes the snapshot as-is and never
+runs the precompute. A maintainer only needs to enable Pages once, under
+**Settings → Pages → Source: GitHub Actions**; after that the demo is reachable at the
+repository's GitHub Pages URL.
+
+---
+
## Installation
### Via HACS (recommended)
diff --git a/custom_components/asp_parking/caldav_sync.py b/custom_components/asp_parking/caldav_sync.py
index 2231e1d..bb20a00 100644
--- a/custom_components/asp_parking/caldav_sync.py
+++ b/custom_components/asp_parking/caldav_sync.py
@@ -43,6 +43,7 @@
from datetime import datetime, timezone
from typing import Any
from urllib.parse import quote as _url_quote
+from urllib.parse import urlparse
import caldav # top-level package — present on all caldav versions
from caldav.lib import error as caldav_error
@@ -547,17 +548,53 @@ def build_vevent_ical(
async def _get_calendar(client: Any, calendar_url: str) -> Any:
"""Resolve a calendar by URL using the authenticated principal.
- Uses ``principal.calendar(cal_url=...)`` for single-calendar lookup
- (no extra collection roundtrip — the principal already knows the
- calendar-home-set URL after ``get_principal``).
+ Tries ``principal.calendar(cal_url=...)`` first for single-calendar
+ lookup (no extra collection roundtrip — the principal already knows
+ the calendar-home-set URL after ``get_principal``). This is a
+ **synchronous** constructor in both caldav 2.x (via the
+ ``_CompatPrincipal`` shim) and caldav 3.x (``AsyncPrincipal.calendar``):
+ it builds a Calendar object from the URL without any network I/O, so
+ it must NOT be awaited.
+
+ iCloud shards each account's calendar data onto a per-account host
+ (e.g. ``p117-caldav.icloud.com``) that differs from the generic login
+ entry point (``caldav.icloud.com``) ``client`` is constructed with.
+ caldav 2.x's ``Principal.calendar(cal_url=...)`` resolves the URL via
+ a purely local ``self.client.url.join(cal_url)`` (caldav/lib/url.py)
+ which raises ``ValueError`` whenever the two hosts differ — always,
+ for iCloud, since the stored ``calendar_url`` (captured during
+ ``list_calendars()``, which follows the redirect over the network) is
+ on the sharded host while ``client.url`` stays pinned to the login
+ host. On that specific failure, fall back to ``principal.calendars()``
+ — a real network request that also follows the redirect, so the
+ returned Calendar objects carry the correct host — and match by URL
+ path, the one thing stable across iCloud's host sharding.
+
+ Only the specific ``URL.join`` cross-host failure triggers the
+ fallback — any other ``ValueError`` (e.g. a ``None`` client, or a
+ calendar_url containing spaces) is a genuine, unrelated bug and must
+ propagate unchanged rather than being misreported as "no calendar
+ found".
- Note: ``principal.calendar()`` is a **synchronous** constructor in both
- caldav 2.x (via the ``_CompatPrincipal`` shim) and caldav 3.x
- (``AsyncPrincipal.calendar``). It builds a Calendar object from the URL
- without any network I/O, so it must NOT be awaited.
+ Raises:
+ CalDAVWriteError: if the fallback finds no calendar whose path
+ matches ``calendar_url``.
"""
principal = await client.get_principal()
- return principal.calendar(cal_url=calendar_url)
+ try:
+ return principal.calendar(cal_url=calendar_url)
+ except ValueError as exc:
+ if "can't be joined with" not in str(exc):
+ raise
+ target_path = urlparse(calendar_url).path.rstrip("/")
+ calendars = await principal.calendars()
+ for cal in calendars:
+ if urlparse(str(cal.url)).path.rstrip("/") == target_path:
+ return cal
+ raise CalDAVWriteError(
+ f"No calendar found matching path {target_path!r} on this "
+ f"principal (configured calendar_url={calendar_url!r})"
+ ) from exc
def _build_event_url(calendar_url: Any, uid: str) -> str:
diff --git a/custom_components/asp_parking/manifest.json b/custom_components/asp_parking/manifest.json
index 6d8e1da..84923f8 100644
--- a/custom_components/asp_parking/manifest.json
+++ b/custom_components/asp_parking/manifest.json
@@ -9,5 +9,5 @@
"issue_tracker": "https://github.com/Pascal-ZeGerman/GPS2ASP-Resolver/issues",
"requirements": ["pyproj>=3.7.0", "rtree>=1.4.0", "shapely>=2.1.0", "numpy", "httpx>=0.28.0", "zstandard>=0.21.0", "icalendar>=6.3.1", "caldav==2.1.0"],
"single_config_entry": true,
- "version": "3.3.0-rc1"
+ "version": "3.3.0-rc3"
}
diff --git a/docs/.nojekyll b/docs/.nojekyll
new file mode 100644
index 0000000..e69de29
diff --git a/docs/demo/app.js b/docs/demo/app.js
new file mode 100644
index 0000000..cbf7b95
--- /dev/null
+++ b/docs/demo/app.js
@@ -0,0 +1,625 @@
+/* ==========================================================================
+ ASP Parking demo — client controller (Phase 41, Plan 41-04)
+
+ Single plain-ES controller (no bundler, no npm import) loaded via the
+ `defer`
+
+
+
+
+
+
+
Click a block on the map. This is the same result the Home Assistant integration puts on your dashboard — no install needed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click a pin to check this block
+
+
+
+
+
+
+
+
Pick a spot to see it in action
+
Click one of the highlighted demo blocks on the map to see its parking rule, the next time you'd need to move, and the exact Home Assistant sensors this integration creates.
+
+
+
+
+
Parking rule for this block
+
+
+
+
+
+ Try a different car:
+
+
+
+
+
+
+
+
+
+
Home Assistant sensors
+
+
+
sensor.asp_parking_monitor_next_move_time
+
+
+
+
+
+
+
+
sensor.asp_parking_monitor_resolved_street
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Next move (NYC time)
+
+
+
+
+
+
Demo data couldn't load. Refresh the page, or view the project on GitHub.