Skip to content

Release v3.1.0-rc14 - #15

Merged
Pascal-ZeGerman merged 24 commits into
mainfrom
release/v3.1.0-rc14
Jul 8, 2026
Merged

Pascal-ZeGerman merged 24 commits into
mainfrom
release/v3.1.0-rc14

Conversation

@Pascal-ZeGerman

Copy link
Copy Markdown
Owner

Summary

  • GPS stale watchdog + pipeline health binary sensor (260601-feq-01/02)
  • 3 silent-failure fixes in suspension handling found in code review
  • Fix ASP suspension calendar fetch: switch to a browser User-Agent so NYC DOT's ICS endpoint stops returning 403 (was silently falling back to a stale, incorrect date list — e.g. missing 2026-07-03 Independence Day observance), and regenerate the fallback list from the real 36-entry authoritative calendar

Test plan

  • pytest tests/test_suspension.py tests/test_ha_integration.py — 111 passed
  • Verified live against NYC DOT ICS: fetch now returns 200 (previously 403)
  • Verified is_suspended(2026-07-03) returns True ("Independence Day") on the reloaded integration in the running Home Assistant container
  • Verified a normal non-suspended Friday (2026-07-10) still correctly returns not-suspended

🤖 Generated with Claude Code

Pascal-ZeGerman and others added 23 commits May 19, 2026 22:48
- New tests/test_strings_icu_escape.py with 4 module-level tests
- test_caldav_event_title_template_is_icu_escaped: asserts '{street}'/'{time}'/'{side}'
  wrap counts on line 115 tooltip + no raw placeholders remain (RED — current state)
- test_caldav_invalid_template_is_icu_escaped: same shape against line 132 error
  string (RED — current state)
- test_strings_and_en_json_byte_identical: Phase 31 byte-identity guard (GREEN today;
  catches one-file-only edits in Task 2)
- test_no_raw_curly_placeholders: belt-and-suspenders regex scan of both files (RED)
- No HA imports, no pytest markers — picked up by the offline suite filter
- No .py file other than the new test touched; no JSON files modified
- Wrap every literal {street}/{time}/{side} on lines 115 and 132 with ASCII U+0027
  single quotes so HA frontend FormatJS (intl-messageformat) treats them as quoted
  literals instead of i18n argument slots
- Tooltip 'caldav_event_title_template': 4 placeholders wrapped
  ('{street}','{time}','{side}','{street}')
- Error string 'caldav_invalid_template': 3 placeholders wrapped
  ('{street}','{side}','{time}')
- Same byte-identical edit applied to translations/en.json (Phase 31 discipline)
- All 4 unit tests in tests/test_strings_icu_escape.py now GREEN
- Python DEFAULT_CALDAV_EVENT_TITLE_TEMPLATE in const.py UNCHANGED (raw {street}
  is correct for str.format_map runtime path)

Fixes the formatjs MISSING_VALUE error overlay that masked the CalDAV options-flow
tooltip and validation-error message.
- 35-01-SUMMARY.md created at .planning/phases/35-caldav-tooltip-fix-formatjs-icu-escape/
- Documents the 2-line x 2-file ICU single-quote-wrap edit applied
- Records 4 unit tests in tests/test_strings_icu_escape.py
- Confirms full pytest suite green (570 passed / 0 failed) after the edit
- Notes Task 4 (human UAT) still pending — checkpoint returned to orchestrator
- Confirms const.py:78 DEFAULT_CALDAV_EVENT_TITLE_TEMPLATE unchanged (Pitfall 5)

Note: .planning/ is gitignored in this repo; force-add used so the orchestrator
can lift the SUMMARY from the worktree before the worktree is removed (#2070).
…3.x is sync

`calendar()` is a local constructor in caldav 3.x; awaiting it raised TypeError
which was silently swallowed, preventing all calendar event writes. Updated tests
to use MagicMock instead of AsyncMock to match the now-correct sync call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ICU single-quote-wrap all curly-brace placeholders in CalDAV
tooltip + error strings; 3-test unit guard; human UAT deferred.

Canonical commits: a47c6f4, fb7a344, 9c1688b
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- README.md: correct diagnostic sensor count (Ten → Eleven; adds ASPIndexLastRebuiltSensor)
- ARCHITECTURE.md: remove phantom sensor.asp_schedule_summary entity (schedule_summary is an attribute on ASPNextMoveTimeSensor, not a standalone sensor)
- All six GSD-managed docs refreshed with latest codebase state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove stale HTML comments, add table of contents with anchor links,
and add horizontal rule dividers between major sections for better
visual structure in the narrow HACS panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…elper

- Valid index passes (rtree + graph.json.zst)
- Corrupt segments.dat (4-byte garbage) raises IndexIntegrityError
- Garbage graph.json.zst raises IndexIntegrityError
- Plain graph.json (uncompressed) variant passes when readable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Re-opens the rtree by stem and decompresses 1 byte of graph.json.zst (or
reads 1 byte of plain graph.json) so the coordinator can distinguish
"files present" from "files usable" at startup. Forces a bbox query to
defeat rtree's lazy page-load so truncated segments.dat surfaces here
rather than later inside the coordinator.

- IndexIntegrityError module-level exception
- _sync_verify_index(index_dir) -> None raises on rtree/zst failure
- Reuses existing rtree_index + zstandard imports (no new deps)
- Missing graph file treated as Level 4 unavailable (not corrupt) —
  matches StreetGraph.load contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t safety

Three new defence-in-depth layers in async_setup_entry. Each layer surfaces
a previously silent failure mode as a HA Repair issue and auto-dismisses on
the next successful setup.

Layer 1 — _async_ensure_index integrity check:
  After existence check passes, call _sync_verify_index in executor. On
  IndexIntegrityError, log WARNING, wipe INDEX_DIR (via _sync_cleanup_stale +
  shutil.rmtree), and fall through to the existing download path. Other
  exception types still propagate.

Layer 2 — retry-aware Repair:
  New constants _SETUP_RETRY_COUNT_KEY_TPL and _RETRY_LIMIT_ISSUE_ID. Wrap
  _async_ensure_index in try/except ConfigEntryNotReady; increment per-entry
  counter, create Repair at >=5, re-raise. Counter cleared on next success
  after _async_ensure_index returns; Repair dismissed at top of every
  async_setup_entry.

Layer 3 — async_start safety wrapper:
  New constant _ASYNC_START_FAILURE_ISSUE_ID. Wrap coordinator.async_start()
  in try/except Exception; log ERROR, create distinct Repair (separate
  issue_id from import + retry Repairs), bare re-raise.

Translations:
  Two new keys under "issues" in translations/en.json — setup_retry_limit
  and async_start_failure — with user-facing title + description.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five new tests in tests/test_init.py verify the contract added by
260601-aru-02:

  Layer 1 (integrity check):
    test_corrupt_index_triggers_redownload — IndexIntegrityError from
    _sync_verify_index wipes INDEX_DIR and schedules a fresh download.

  Layer 2 (retry-limit Repair):
    test_setup_retry_creates_repair_after_5_failures — preset counter=4,
    one more CENR brings count to 5 and creates the Repair issue.
    test_setup_success_clears_retry_counter_and_repair — clean setup
    after counter=7 pops the key and dismisses the Repair.

  Layer 3 (async_start safety wrapper):
    test_async_start_failure_creates_repair_and_reraises — RuntimeError
    from coordinator.async_start surfaces async_start_failure Repair.
    test_setup_success_clears_async_start_repair — pre-existing Repair
    is removed by a clean setup.

All tests mirror the test_repair_issue.py conventions: ha_integration
marker, self-contained _make_entry helper, patches target the imported
binding at custom_components.asp_parking.* (not the source module).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…heck

Adds _index_has_graph_file() helper and removes graph.json from INDEX_FILES
so _async_ensure_index no longer enters a ConfigEntryNotReady boot-loop after
an auto-rebuild replaces graph.json with graph.json.zst.

Root cause identified and fixed by debug session gps-no-update-after-car-move.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolves conflict: worktree's _sync_verify_index (Layer 1) supersedes
the simpler _index_has_graph_file existence check; both imports kept in
test_index_io.py to cover both behaviours.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…flag to coordinator

- Add _gps_stale_unsub and _last_pipeline_error attributes in __init__
- Add _gps_watchdog_cancel and _gps_watchdog_rearm methods (mirrors _boundary_timer_cancel pattern)
- Set _last_pipeline_error=True in ValueError and bare Exception pipeline branches
- Clear _last_pipeline_error=False on pipeline success path
- Call _gps_watchdog_rearm() in _async_on_gps_update after last_gps_update set
- Add _gps_watchdog_cancel() call in async_stop to prevent unsub leak
- Sync strings.json with en.json (pre-existing drift from 260601-aru quick task)
…unit tests

- Add ASPGpsPipelineHealthBinarySensor class in binary_sensor.py (mirrors ASPIndexRebuildingBinarySensor pattern)
- is_on True when last_gps_update is recent (< stale_timeout hours) AND _last_pipeline_error is False
- extra_state_attributes exposes 'last_pipeline_error' bool for diagnostics
- Register new sensor in async_setup_entry list
- Add 'gps_pipeline_healthy' translation key to translations/en.json and strings.json
- Add 11 unit tests in tests/test_gps_pipeline_health_binary_sensor.py (SimpleNamespace stub pattern)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- coordinator: retain existing 311-sourced suspension on poll failure
  instead of unconditionally resetting to not-suspended; only fail open
  when no prior suspension is active
- suspension/load: catch CancelledError from _fetch_ics, populate
  fallback dates, and set _loaded=True before re-raising so
  is_suspended() never silently returns false negatives after a
  cancelled HA startup
- merge.py docstring: document ha_nyc311 as a valid source mapping to
  suspended_emergency alongside emergency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…CK_2026

NYC DOT's ICS calendar endpoint returns HTTP 403 for the integration's
custom User-Agent (Akamai bot protection), so every refresh silently fell
back to FALLBACK_2026 — which itself was wrong and missing real suspension
dates, including 2026-07-03 (Independence Day observance). Sensors reported
cars must move on suspended days as a result.

- Switch _FETCH_HEADERS User-Agent to a standard browser UA so the fetch
  succeeds (403 -> 200).
- Regenerate FALLBACK_2026 from the authoritative NYC DOT ICS (36 exact
  entries) so the fallback safety net is correct if the fetch ever fails
  again.

Debugged in .planning/debug/friday-move-not-suspended.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ividers polish

Resolves the release/v3.1.0-rc14 <-> main merge conflict by keeping main's
accuracy fixes (eleven diagnostic sensors, Installation restructure) and
re-applying this branch's Table of Contents + section dividers on top.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Pascal-ZeGerman

Copy link
Copy Markdown
Owner Author

Code review

Found 4 issues:

  1. Suspension fallback retains stale holiday-sourced suspension. The comment on line 2163 says this retains "an active 311 suspension" through transient API errors, but the guard checks only is_suspended, not source. If the previous day was a holiday (source="holiday") and today is not, a 311 API failure causes the stale holiday suspension to carry over — telling users ASP is suspended on an enforced day. Fix: gate on self.data.suspension_state.source in ("ha_nyc311", "emergency").

)
info = fallback_info
except Exception: # noqa: BLE001
logger.warning(
"311 suspension poll failed, failing open", exc_info=True
)
info = fallback_info
self._async_apply_suspension_state(info)
self._async_notify_entities()
async def _async_maybe_send_notification(self, schedule: ScheduleResult) -> None:
"""Send push notification if next ASP window is within self._notify_lead_time minutes.
Guards:
- CONF_NOTIFY_SERVICE must be configured (D-15)
- Notification suppressed when debug_enabled AND suppress_notifications (D-15)
- Only fires once per unique CleaningWindow (D-14)
"""
if not self._notify_service:
return
if self._debug_enabled and self._debug_suppress_notifications:
return
if not isinstance(schedule, ScheduleFound):
return
if schedule.next_window is None:
return

  1. Setup retry counter not cleared on entry unload. _SETUP_RETRY_COUNT_KEY_TPL is incremented on each ConfigEntryNotReady (line 219) and cleared only on successful setup (line 233), but async_unload_entry never pops it. If a user disables the integration while the counter is at 3, re-enables it, and hits 2 more failures, the Repair issue fires after only 2 retries in the new lifecycle — not 5. Fix: add hass.data.pop(_SETUP_RETRY_COUNT_KEY_TPL.format(entry_id=entry.entry_id), None) in async_unload_entry.

Stops the coordinator and unloads entity platforms.
Args:
hass: Home Assistant instance.
entry: Config entry being unloaded.
Returns:
True if unload was successful.
"""
# Stop the coordinator (cancels listeners, debouncer)
await entry.runtime_data.async_stop()
# Unload entity platforms
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:

  1. GPS stale notification unconditionally dismissed on startup. async_start() (line 468) calls _gps_watchdog_rearm(), which always calls pn_dismiss (line 1621) with no check on current GPS age. If GPS was legitimately stale before an HA restart, the existing "GPS Signal Lost" notification is silently cleared and the user must wait another full stale_timeout hours (default: 8h) for it to reappear. Fix: skip the dismiss in _gps_watchdog_rearm when called from startup, or check whether data.last_gps_update already exceeds the timeout before dismissing.

``asp_parking_gps_stale`` persistent notification and triggers an entity
state refresh via ``_async_notify_entities()``.
"""
self._gps_watchdog_cancel()
from homeassistant.components.persistent_notification import (
async_dismiss as pn_dismiss,
)
pn_dismiss(self.hass, "asp_parking_gps_stale")
delay = float(self.stale_timeout * 3600)
@callback
def _on_gps_stale(_now: datetime) -> None:
from homeassistant.components.persistent_notification import (
async_create as pn_create,

  1. Lazy persistent_notification imports inside @callback methods. _gps_watchdog_rearm (sync @callback, line 1607) and its nested _on_gps_stale (@callback, line 1625) both do inline from homeassistant.components.persistent_notification import .... Every other usage of this import in coordinator.py is scoped to an async def method. Commit 2adf405 explicitly fixed the same pattern in binary_sensor.py citing inconsistency — this reintroduces it in sync callbacks that fire on every GPS state change.

pn_dismiss(self.hass, "asp_parking_gps_stale")
delay = float(self.stale_timeout * 3600)
@callback
def _on_gps_stale(_now: datetime) -> None:
from homeassistant.components.persistent_notification import (
async_create as pn_create,
)
pn_create(
self.hass,
f"No GPS update has been received for {self.stale_timeout} hour(s). "
"The GPS pipeline health sensor is now OFF. Check that your device "
"tracker is reporting location updates.",
title="ASP Parking: GPS Signal Lost",

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- Issue 1 (suspension fallback): Gate fallback_info on source in
  ("ha_nyc311", "emergency") so a prior holiday suspension is never
  retained into the next day when the 311 API call fails.

- Issue 2 (retry counter leak): Pop _SETUP_RETRY_COUNT_KEY_TPL in
  async_unload_entry so the counter resets on each disable/reload
  cycle, preventing premature Repair issue on subsequent failures.

- Issue 4 (lazy imports in @callback): Add pn_create/pn_dismiss at
  module level; remove the lazy imports from _gps_watchdog_rearm
  and its nested _on_gps_stale @callback methods.

Issue 3 (startup notification dismiss) was a false positive — the
grep confirmed _gps_watchdog_rearm is only called from GPS update
events, not from async_start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Pascal-ZeGerman
Pascal-ZeGerman merged commit cd82179 into main Jul 8, 2026
11 of 15 checks passed
@Pascal-ZeGerman
Pascal-ZeGerman deleted the release/v3.1.0-rc14 branch July 8, 2026 18:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant