Skip to content

Warm the ViCare service cache on refresh instead of bypassing it - #180344

Closed
lackas wants to merge 4 commits into
home-assistant:devfrom
lackas:vicare-coordinator-warm-cache
Closed

Warm the ViCare service cache on refresh instead of bypassing it#180344
lackas wants to merge 4 commits into
home-assistant:devfrom
lackas:vicare-coordinator-warm-cache

Conversation

@lackas

@lackas lackas commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Proposed change

The coordinator refreshes with clear_cache() + fetch_all_features(), which on ViCareCachedService does not warm the cache: that class inherits ViCareService.fetch_all_features(), which fetches and returns without writing _cache. Only ViCareCachedServiceViaGateway overrides it, and Home Assistant never enables that mode.

The payload is therefore discarded and every entity read fetches again. Since #173776 native_value is a property instead of a sync update() run in the executor, so that second fetch happens in the event loop:

RuntimeError: Caught blocking call to putrequest with args (... 'GET',
  '/iot/v2/features/installations/.../devices/0/features/') inside the event loop

Measured on a live installation with 5 devices: 49 of these per refresh, one per entity, and no state is written. The integration sets up and then never updates again.

Reading one property instead goes through the cached path, so a refresh costs one request and the entity reads are served from the cache.

This is a workaround. The proper fix is upstream in PyViCare, hoisting the override into ViCareCachedServiceBase (openviess/PyViCare#813), which would let this call go back to fetch_all_features(). That needs a release, so this PR is the version that can make the 2026.9 beta on its own. Happy to drop it for the library fix if a release lands in time.

Why this was missed

Both this and the setup crash in #180315 come from the same blind spot: MockViCareService does not behave like a real service. fetch_all_features was a bare Mock accepting any signature, clear_cache is a no-op, and getProperty reads the fixture directly without consulting a cache. Cache behaviour and request counts are therefore invisible to the suite, which is why it stayed green while the real code path did the opposite of what the docstring claims.

Stacked on #180315.

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New integration (thank you!)
  • New feature (which adds functionality to an existing integration)
  • Deprecation (breaking change to happen in the future)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

  • This PR fixes or closes issue: fixes #
  • This PR is related to issue:
  • Link to documentation pull request:
  • Link to developer documentation pull request:
  • Link to frontend pull request:

Checklist

  • I understand the code I am submitting and can explain how it works.
  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the development checklist
  • I have followed the perfect PR recommendations
  • The code has been formatted using Ruff (ruff format homeassistant tests)
  • Tests have been added to verify that the new code works.
  • Any generated code has been carefully reviewed for correctness and compliance with project standards.

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • The manifest file has all fields filled out correctly.
    Updated and included derived files by running: python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt.
    Updated by running python3 -m script.gen_requirements_all.
  • For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description.

To help with the load of incoming pull requests:

Copilot AI balanced review requested due to automatic review settings August 26, 2026 20:24
@home-assistant

Copy link
Copy Markdown
Contributor

Hey there @CFenner, mind taking a look at this pull request as it has been labeled with an integration (vicare) you are listed as a code owner for? Thanks!

Code owner commands

Code owners of vicare can trigger bot actions by commenting:

  • @home-assistant close Closes the pull request.
  • @home-assistant mark-draft Mark the pull request as draft.
  • @home-assistant ready-for-review Remove the draft status from the pull request.
  • @home-assistant rename Awesome new title Renames the pull request.
  • @home-assistant reopen Reopen the pull request.
  • @home-assistant unassign vicare Removes the current integration label and assignees on the pull request, add the integration domain after the command.
  • @home-assistant update-branch Update the pull request branch with the base branch.
  • @home-assistant add-label needs-more-information Add a label (needs-more-information, problem in dependency, problem in custom component, problem in config, problem in device, feature-request) to the pull request.
  • @home-assistant remove-label needs-more-information Remove a label (needs-more-information, problem in dependency, problem in custom component, problem in config, problem in device, feature-request) on the pull request.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Warms PyViCare’s cache during coordinator refreshes, preventing blocking API calls during entity state reads.

Changes:

  • Refreshes through the cached property path.
  • Uses device accessors for coordinator identity.
  • Updates mocks and failure tests for the real read path.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
homeassistant/components/vicare/coordinator.py Warms the service cache safely.
tests/components/vicare/conftest.py Aligns service mocks with PyViCare.
tests/components/vicare/test_init.py Tests refresh failures through getProperty.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@zigpy-review-bot zigpy-review-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach

The approach is right, and a couple of details are righter than they look. Routing through _get_or_update_cache fetches the same full-features URL, so no coverage is lost; the clear_cache() first is load-bearing, because it leaves _cache empty and so disables the stale-cache fallback that would otherwise mask genuine comm/500 errors; and a device whose payload has no device.serial still ends up with a warm cache in one call, since the fetch happens before readFeature raises. Using raw getProperty rather than Device.getSerial() is also the safer choice — getSerial() goes through @handleNotSupported, whose behaviour depends on the process-global Feature.raise_exception_on_not_supported_device_feature.

Measured against real PyViCare 2.61.0: 1 GET per refresh with the entity read free, against 3 on the current head of #180315.

Error paths

Two things here, and both come from the same root — the suppress and the except clauses are not matched to what _get_or_update_cache can actually raise, which is a wider set than fetch_all_features could. Details inline.

A single change would cover both: keep the suppress narrow and assert the cache actually warmed, e.g. check service.is_cache_invalid() after the probe and raise UpdateFailed if it is still invalid — plus add PyViCareInvalidDataError to the UpdateFailed tuple.

Which fix for the beta

openviess/PyViCare#813 is the better one. Hoisting the override into ViCareCachedServiceBase makes both cached services symmetric and removes the need for Home Assistant to work around the asymmetry at all — and it sidesteps both error-path issues above, since the coordinator would keep calling fetch_all_features() rather than routing a probe through _get_or_update_cache. It needs a PyViCare release, though.

Weighed against that: 2026.9.0b0 ships ViCare completely broken. It carries #173776 and none of the fixes, so coordinator.py:49 raises AttributeError during coordinator construction and setup never even reaches the refresh — the integration does not load at all. Given that, landing the Home Assistant-side fix for b1 and treating openviess/PyViCare#813 as the follow-up once released seems the right trade, provided the two error-path points above are addressed first.

# cached service the latter bypasses the cache, so every entity read
# would hit the API again, from the event loop.
with suppress(PyViCareNotSupportedFeatureError):
self._device.getProperty("device.serial")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_get_or_update_cache raises PyViCareInvalidDataError when the response has no data key, and that is in neither except clause, so a malformed payload surfaces as Unexpected error fetching vicare_… data with a traceback on every refresh instead of a clean UpdateFailed. The old raw fetch_all_features() never validated the payload, so this path is new here.

Malformed payload (no 'data'):    this PR  -> PyViCareInvalidDataError, mapped? False
                                  previous -> no raise
Rate limit / invalid credentials: both     -> mapped? True

Still better than the old behaviour, where the same payload detonated at the entity read inside the event loop — it just wants adding to the UpdateFailed tuple.

# Read one property instead of calling fetch_all_features(): on the
# cached service the latter bypasses the cache, so every entity read
# would hit the API again, from the event loop.
with suppress(PyViCareNotSupportedFeatureError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suppress is broader than the device.serial-is-optional case it is aimed at. After clear_cache(), _get_or_update_cache converts PyViCareNotPaidForError into PyViCareNotSupportedFeatureError("PACKAGE_NOT_PAID_FOR"), which this swallows — so a wholly unpaid account gets a refresh that reports success with an empty cache:

this PR  -> suppressed -> last_update_success True -> entities AVAILABLE
   cache populated? False
   entity read 1: PyViCareNotSupportedFeatureError | cumulative GETs: 2
   entity read 2: PyViCareNotSupportedFeatureError | cumulative GETs: 3
previous -> PyViCareNotPaidForError escapes -> last_update_success False -> entities UNAVAILABLE

Two consequences. The condition becomes undiagnosable — entities stay available reading unknown, with nothing logged, where before they went unavailable. And because the cache never populates, _stringify_state does read self.state, so every entity read re-enters _get_or_update_cache and hits the API from the event loop — the blocking-call problem this PR fixes, reappearing on the unpaid path.

Checking that the cache actually warmed after the suppressed probe distinguishes the benign case from this one.

@TheJulianJES TheJulianJES added this to the 2026.9.0 milestone Aug 26, 2026
@TheJulianJES
TheJulianJES marked this pull request as draft August 26, 2026 22:33

@TheJulianJES TheJulianJES left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for other reviewers:

I've added this to the beta milestone for now. As of b0, ViCare is broken and does not set up (because of #173776).

#180315 needs to be merged anyway as a follow-up for the above PR, but this PR here (#180344) is a temporary workaround until a library release with openviess/PyViCare#813 is made. When that happens, that should be pulled into the beta.

However, as it's unclear when that release can be made, we may want to merge this PR temporarily as a hotfix first. And as soon as the library bump is in, it can be reverted.

I'm aware we generally don't do this but as it's essentially a temporary one/two-line workaround, I'd be inclined to not revert #173776, as it does fix an annoying issue (if it worked).

Reading through the cache validates the payload, which the raw
fetch_all_features() never did, so PyViCareInvalidDataError is a new
error path on the refresh.
Copilot AI review requested due to automatic review settings August 27, 2026 05:59
@lackas

lackas commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Finding 1 taken: PyViCareInvalidDataError added to the UpdateFailed tuple, with a test asserting a malformed payload does not log Unexpected error fetching. That path is indeed new here, the raw fetch_all_features() never validated the payload.

Finding 2 declined. Failing the refresh on an empty cache is the behaviour we deliberately moved away from: on the first refresh UpdateFailed becomes ConfigEntryNotReady, so an account with an unpaid feature package retries setup forever instead of loading with the unsupported features skipped. #176163 carries that decision and a test for it.

The sub-point is right though. On the unpaid path the cache never warms, so entity reads keep hitting the API from the event loop. That is equally true for the per-gateway coordinator, so it belongs in #176163 rather than in a workaround that gets reverted once openviess/PyViCare#813 is released.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

homeassistant/components/vicare/coordinator.py:70

  • Add a cache-aware regression test that verifies one coordinator fetch warms the cache and subsequent entity reads perform no requests. The updated fake still makes clear_cache() a no-op and reads fixtures directly, so the failure mode this workaround addresses remains invisible and the current tests would pass if this call stopped warming the real PyViCare cache.
            with suppress(PyViCareNotSupportedFeatureError):
                self._device.getProperty("device.serial")

@TheJulianJES

Copy link
Copy Markdown
Member

As the library PRs are merged now, I think we should move forward with that, instead of this PR. But we still have to wait for a new library version to be released.

@lackas lackas mentioned this pull request Aug 27, 2026
21 tasks
@lackas

lackas commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

2.62.0 is out and #180395 is up. Verified live against it: refreshes succeed, no Detected blocking call left.

Closing, reopen if the bump cannot go into the beta.

@lackas lackas closed this Aug 27, 2026
@TheJulianJES TheJulianJES removed this from the 2026.9.0 milestone Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants