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
50 changes: 39 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,21 @@ 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.
Want to see what the integration produces before installing anything? Two static pages are
hosted at **<https://pascal-zegerman.github.io/GPS2ASP-Resolver/>** — pick the one for you:

- **[Try the demo](https://pascal-zegerman.github.io/GPS2ASP-Resolver/demo/)** — for everyone.
Click a block on the Leaflet map and 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.
- **[Explore sign coverage](https://pascal-zegerman.github.io/GPS2ASP-Resolver/explorer/)** —
for maintainers and technical users. A citywide map of all ~105K street segments colored by
SODA-match confidence, with filters by borough/tier/level and GeoJSON export, for inspecting
data coverage and gaps.

Both are plain HTML/CSS/JS pages under [`docs/`](docs/) — no build step, no install, no
server-side code. [`docs/index.html`](docs/index.html) is the landing page linking to both.

**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
Expand Down Expand Up @@ -66,14 +75,13 @@ won't let the page `fetch()` its JSON, and serving `docs/demo` alone 404s on the
.venv/bin/python -m http.server --directory docs 8000
```

Then visit <http://localhost:8000/demo/>.
Then visit <http://localhost:8000/> for the landing page (or `/demo/`, `/explorer/` directly).

**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.
runs the precompute. Pages is already enabled for this repo (**Settings → Pages → Source: GitHub
Actions**), so every push to `docs/` on `main` redeploys automatically.

---

Expand Down Expand Up @@ -115,7 +123,25 @@ After installation, set up the integration from the HA UI — no YAML required.
5. **Step 3:** Optionally enter an **NYC311 API key** to receive real-time weather and emergency suspension alerts.
6. Click **Submit**. The sensors appear immediately under the new device.

After setup you can also configure a **parking area** (lat/lon/radius) and a **push notification service** (e.g., `notify.mobile_app_my_phone` with a configurable lead time, default 120 minutes before the next move) via **Settings → Devices & Services → ASP Parking → Configure**.
### Changing settings after setup

**To switch which device tracker the integration follows** (new phone, renamed entity, moving
to a built-in car tracker):

1. Go to **Settings → Devices & Services**.
2. Find the **ASP Parking** card and click it to expand.
3. Click the **⋮** (three-dot) menu in the top-right corner of the card.
4. Click **Reconfigure**.
5. Pick the new device tracker from the dropdown.
6. Click **Submit**. The integration reloads automatically; every other setting is kept.

**For everything else** — movement threshold, refresh interval, stale timeout, NYC 311 API
key/entity, push notification service, parking area, or CalDAV sync — click the **gear icon**
on the same card instead (labeled **Configure**).

These two menus look similar but open different forms: the device tracker picker lives
**only** under **Reconfigure** (⋮ menu), never under **Configure** (gear icon). This is the
most common place people get stuck looking for it.

---

Expand Down Expand Up @@ -143,6 +169,8 @@ The next move time sensor also carries rich attributes including `schedule_summa

Eleven additional **diagnostic sensors** are created automatically: car name, VIN, latitude, longitude, resolved street, resolution status, confidence score, SODA level, last resolved timestamp, last error, and index last rebuilt. These are hidden from the default dashboard but available for advanced automations and troubleshooting.

**Car Name** and **VIN** are read directly from your device tracker entity — Car Name from its friendly name, VIN from a `vin` attribute if the entity exposes one. Vehicle-specific integrations (like a manufacturer's connected-car integration) typically provide both. Phone-based trackers (Companion app, OwnTracks, iCloud, Google Maps) usually don't expose a VIN, so that sensor will simply show unavailable — this doesn't affect any other functionality.

Two additional diagnostic binary sensors track background health: **Index rebuilding** (ON while a spatial-index rebuild is in progress) and **GPS pipeline healthy** (ON while the resolution pipeline is functioning normally). A **Rebuild index** button is also available for triggering an on-demand index rebuild.

---
Expand Down
44 changes: 40 additions & 4 deletions custom_components/asp_parking/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import logging
import shutil
from typing import Any

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, ServiceCall
Expand All @@ -21,8 +22,11 @@
CONF_CALDAV_PASSWORD,
CONF_CALDAV_URL,
CONF_CALDAV_USERNAME,
CONF_STALE_TIMEOUT,
DEFAULT_STALE_TIMEOUT,
DOMAIN,
INDEX_DOWNLOAD_URL,
LEGACY_STALE_TIMEOUT_HOURS,
PLATFORMS,
)
from .coordinator import ASPParkingCoordinator
Expand Down Expand Up @@ -172,13 +176,45 @@ async def _async_download_index(hass: HomeAssistant) -> None:


async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate config entry from version 1 to 2.
"""Migrate a config entry forward to the current version / minor version.

No data shape change needed -- NYC311 API key defaults to not-configured.
v1 -> v2: no data shape change -- NYC311 API key defaults to not-configured.

v2.1 -> v2.2: raise the ``stale_timeout`` backstop from the old 8 h default
to 168 h (7 days). Changing ``DEFAULT_STALE_TIMEOUT`` alone would not reach
anybody, because the options flow writes the value explicitly into
``entry.options`` at setup -- every existing install carries a literal 8
that shadows the new default and would keep going unavailable on ordinary
overnight parking. Only the exact legacy default is rewritten; any other
stored value is treated as a deliberate user choice and left untouched.
"""
updates: dict[str, Any] = {}

if config_entry.version == 1:
hass.config_entries.async_update_entry(config_entry, version=2)
logger.info("Migrated ASP Parking config entry from v1 to v2")
updates["version"] = 2

if config_entry.minor_version < 2:
options = dict(config_entry.options)
if options.get(CONF_STALE_TIMEOUT) == LEGACY_STALE_TIMEOUT_HOURS:
options[CONF_STALE_TIMEOUT] = DEFAULT_STALE_TIMEOUT
logger.info(
"Migrated ASP Parking stale_timeout from the legacy %s h default "
"to the %s h (7-day) backstop; a parked car legitimately reports "
"no GPS updates for days",
LEGACY_STALE_TIMEOUT_HOURS,
DEFAULT_STALE_TIMEOUT,
)
updates["options"] = options
updates["minor_version"] = 2

if updates:
# Single combined write (rather than one call per version bump) so a
# never-migrated entry doesn't trigger two storage writes / reload
# listener notifications for what is logically one migration.
hass.config_entries.async_update_entry(config_entry, **updates)
if "version" in updates:
logger.info("Migrated ASP Parking config entry from v1 to v2")

return True


Expand Down
40 changes: 30 additions & 10 deletions custom_components/asp_parking/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util

from .gps2asp.schedule.models import ASPActiveNow
from .gps2asp.suspension import apply_suspension
Expand Down Expand Up @@ -82,6 +81,17 @@ def device_info(self) -> DeviceInfo:
sw_version=VERSION,
)

@property
def available(self) -> bool:
"""Return True when the underlying pipeline data should be trusted.

Shares ``coordinator.gps_data_available`` (the three-gate policy
also used by ``ASPNextMoveTimeSensor.available``) so this sensor
stops reporting "not active" as a confident answer -- rather than a
stale one -- under the same conditions the main sensor does.
"""
return self._coordinator.gps_data_available

@property
def is_on(self) -> bool:
"""Return True only when ASP cleaning is currently active and not suspended."""
Expand Down Expand Up @@ -160,12 +170,19 @@ class ASPGpsPipelineHealthBinarySensor(BinarySensorEntity):

ON when ``last_gps_update`` is within ``stale_timeout`` hours AND
``coordinator._last_pipeline_error`` is False. Reflects the GPS watchdog
and pipeline error state LIVE.
and pipeline error state LIVE via ``coordinator.gps_data_available`` --
the same three-gate policy used by ``ASPNextMoveTimeSensor.available``.

OFF when:
- ``last_gps_update`` is None (no GPS fix yet)
- GPS age >= stale_timeout * 3600 seconds (GPS has gone silent)
- the tracked device_tracker self-reports ``unavailable`` / ``unknown``
- GPS age > stale_timeout * 3600 seconds (GPS has gone silent)
- ``_last_pipeline_error`` is True (last pipeline run raised an exception)

The tracker-health check is what keeps this entity a useful diagnostic now
that ``stale_timeout`` defaults to 7 days: the age test alone would stay ON
for a week after the source integration died. Silence means "parked"; an
explicit unavailable state means "broken".
"""

_attr_has_entity_name = True
Expand Down Expand Up @@ -204,14 +221,17 @@ def device_info(self) -> DeviceInfo:

@property
def is_on(self) -> bool:
"""Return True when GPS is recent and the last pipeline run succeeded."""
last = self._coordinator.data.last_gps_update
if last is None:
return False
age = (dt_util.utcnow() - last).total_seconds()
if age >= self._coordinator.stale_timeout * 3600:
"""Return True when GPS is recent and the last pipeline run succeeded.

Delegates the pipeline-error / tracker-health / staleness checks to
``coordinator.gps_data_available`` -- the same three-gate policy used
by ``ASPNextMoveTimeSensor.available`` -- so the two entities cannot
silently drift apart. Unlike ``available``, "no GPS fix yet" is
reported as unhealthy (False) here rather than as trivially current.
"""
if self._coordinator.data.last_gps_update is None:
return False
return not self._coordinator._last_pipeline_error
return self._coordinator.gps_data_available

@property
def extra_state_attributes(self) -> dict[str, bool]:
Expand Down
5 changes: 5 additions & 0 deletions custom_components/asp_parking/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ class ASPParkingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""

VERSION = 2
# Bumped to 2 alongside the stale_timeout 8h -> 168h backstop change so
# async_migrate_entry can rewrite the shadowing legacy value on existing
# entries. Minor (not major) because the data shape is unchanged and old
# entries remain loadable.
MINOR_VERSION = 2

def __init__(self) -> None:
"""Initialize the config flow."""
Expand Down
24 changes: 23 additions & 1 deletion custom_components/asp_parking/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,29 @@
# Default values
DEFAULT_MOVEMENT_THRESHOLD = 50.0 # meters before re-resolve
DEFAULT_REFRESH_INTERVAL = 8 # hours between periodic refreshes
DEFAULT_STALE_TIMEOUT = 8 # hours before marking sensor unavailable

# Hours with NO location event from the device_tracker at all before entities
# report unavailable. This is a BACKSTOP, not a freshness SLA.
#
# It used to be 8 h, which produced a guaranteed false positive on ordinary
# parking: once a car is parked the tracker naturally stops emitting new fixes
# (telematics sleep on an ignition-off vehicle, or a manually-posted /
# Shortcut-driven tracker that only fires when the phone moves), so HA never
# raises another state_changed event and `last_gps_update` freezes even though
# the resolved curb position is still correct. Every overnight or workday park
# tripped it.
#
# 168 h (7 days) is chosen to match the domain: NYC ASP schedules recur on a
# 7-day cycle, so a car legitimately sitting untouched in the same spot for a
# full week is expected behaviour, not an edge case. Genuine integration
# failures are caught immediately by the tracker-health fast path
# (``ASPParkingCoordinator.tracker_unavailable``) rather than by this timer.
DEFAULT_STALE_TIMEOUT = 168 # hours (7 days) with no location event at all

# The pre-3.3.1 default. A config entry still carrying exactly this value is
# assumed never to have been customised and is migrated up to the 7-day
# backstop by ``async_migrate_entry``; any other stored value is left alone.
LEGACY_STALE_TIMEOUT_HOURS = 8

# Internal tuning
GPS_DEBOUNCE_COOLDOWN = 5.0 # seconds (debounce rapid GPS jitter)
Expand Down
Loading
Loading