Skip to content

climate: refresh HVAC capabilities on every coordinator poll#223

Merged
dlarrick merged 5 commits into
dlarrick:masterfrom
d-walsh:fix/refresh-capabilities-on-poll
Jun 16, 2026
Merged

climate: refresh HVAC capabilities on every coordinator poll#223
dlarrick merged 5 commits into
dlarrick:masterfrom
d-walsh:fix/refresh-capabilities-on-poll

Conversation

@d-walsh

@d-walsh d-walsh commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Summary

When a unit's WiFi adapter is offline at HA startup, KumoThermostat.__init__ calls the pykumo has_* methods against an empty profile and permanently caches _hvac_modes = [OFF, COOL]. After the adapter reconnects, pykumo's internal profile is updated on the next successful poll — but the capability lists in HA are never recalculated. The result is silent rejection of heat_cool (and heat, dry, fan_only) commands until the next HA restart.

Root cause

Capability derivation ran once at init time and was never repeated:

# __init__ (before this fix)
self._fan_modes = self._pykumo.get_fan_speeds()      # returns hard-coded default if adapter offline
self._swing_modes = self._pykumo.get_vane_directions()
self._hvac_modes = [HVACMode.OFF, HVACMode.COOL]
if self._pykumo.has_auto_mode():                      # False — profile is empty
    self._hvac_modes.append(HVACMode.HEAT_COOL)       # never added
    ...
# update() had no capability refresh at all

The fix

Extract capability derivation into _refresh_capabilities() and call it from both __init__ and update().

Profile-populated guard (key correctness fix)

All capability derivation is now gated on self._pykumo._profile being non-empty. pykumo initialises _profile = {} at construction and only populates it after a successful network poll. Without this guard, get_fan_speeds() returns a hard-coded 5-item fallback list (["superQuiet","quiet","low","powerful","superPowerful"]) whenever numberOfFanSpeeds is absent from the profile — i.e., on every call before the first successful poll — so a truthiness check on the return value does not distinguish real hardware data from the default. The same applies to has_dry_mode(), has_heat_mode(), etc., which all return False (not an error) against an empty profile.

By skipping _refresh_capabilities() entirely when _profile is empty, the entity keeps its safe init defaults ([OFF, COOL], empty fan/swing lists) until the first real poll. There is no public API to test profile population, so _profile is accessed directly; other existing code in hass-kumo already uses this pattern.

Upgrade-only merge for hvac_modes

Once the profile is populated, modes are only ever added, never removed. A transient poll failure (which leaves pykumo's _profile unchanged) cannot strip a capability that was already confirmed. Once heat_cool appears it stays in _hvac_modes for the lifetime of the entity.

fan_modes / swing_modes

Always overwritten from the live (populated) profile. pykumo preserves the last-good profile across poll failures, so this is safe and keeps the lists current.

ClimateEntityFeature.SWING_MODE

Also upgrade-only — added as soon as has_vane_direction() returns True, never cleared.

DEBUG log

Emitted after each refresh (or skip) so capability changes are visible in HA logs without extra verbosity.

Why upgrade-only for hvac_modes?

If a poll times out mid-stream, pykumo may return partial or stale data. Allowing _hvac_modes to shrink on a bad poll would cause the same user-visible regression (mode commands silently rejected) that this PR is fixing. Upgrade-only ensures advertised capabilities only ever grow toward true hardware capability, never regress on transient failures.

pykumo version note

Correct heat_cool detection for units with autoModePrevention (the _compute_has_mode_auto logic introduced in #204) requires pykumo >= 0.4.2. This dependency is already satisfied: manifest.json pins pykumo>=0.5.0, so no additional constraint is needed. The note here is for reviewer awareness only.

Related work

This is complementary to #204 (auto/heat_cool mode suppressed by autoModePrevention, implemented in pykumo v0.4.2). That change addresses the case where the adapter reports a capability flag that wrongly suppresses heat_cool, and already recomputes that one mode on each update. This PR addresses a different trigger — the adapter being unreachable at entity init, which strips all capabilities (heat, dry, fan_only, swing, fan speeds, not just heat_cool) — and generalises the "recompute on update" approach to the full capability set with upgrade-only safety. The two fixes stack cleanly.

Testing

Tested on a multi-zone SVZ-KP18NA install. A zone with an intermittent WiFi adapter previously came up cool-only after every HA restart and required a manual restart to recover. With this fix, the entity self-heals after the next successful coordinator poll (~30 s) without any user intervention or HA restart.

python3 -m py_compile custom_components/kumo/climate.py passes cleanly. Full integration tests require a live HA environment (the upstream test suite has the same dependency on homeassistant being installed).

Addresses #105.

Fixes the silent capability-strip bug reported in dlarrick#105: when a unit's
WiFi adapter is offline at HA startup, pykumo returns empty profiles and
`__init__` caches `_hvac_modes = [OFF, COOL]` permanently.  Heat-cool
commands are then silently rejected until the next HA restart, even after
the adapter reconnects.

Changes:
- Extract the capability derivation block into `_refresh_capabilities()`.
- Call it from `__init__` (same behavior as before for online units) and
  from `update()` after every successful coordinator poll.
- Use an **upgrade-only** merge for `_hvac_modes`: modes are only ever
  added, never removed.  A transient poll failure cannot strip a
  capability that was already confirmed.  `fan_modes` and `swing_modes`
  are refreshed from the live profile unconditionally (pykumo preserves
  the last-good profile across failures, so this is always safe).
- Add a DEBUG log line after each refresh so capability changes are
  visible in HA logs without enabling extra verbosity.

Co-Authored-By: Claude <noreply@anthropic.com>
@d-walsh d-walsh changed the title climate: refresh HVAC capabilities on every coordinator poll (fixes #105) climate: refresh HVAC capabilities on every coordinator poll Jun 7, 2026
pykumo initialises _profile to {} and only populates it after a
successful network poll.  The previous code guarded fan_modes with
`if fan_speeds:`, but get_fan_speeds() falls back to a hard-coded
5-item list when numberOfFanSpeeds is absent (KeyError → speeds = 5),
so the guard always passed and the stale default list was cached as
real hardware data.

Fix: skip all capability derivation when _profile is empty.  The
entity keeps its safe init defaults ([OFF, COOL], empty fan/swing
lists) until the first successful poll populates the profile.  Once
populated, the existing upgrade-only logic for hvac_modes and the
overwrite logic for fan/swing modes work correctly.

Co-Authored-By: Claude <noreply@anthropic.com>
The profile-populated guard currently accesses pykumo's private
_profile attribute directly. dlarrick/pykumo#72 adds a public
has_profile() method for exactly this purpose. Add a TODO comment
so the migration path is clear once pykumo cuts a release with that
method and the requirement can be bumped.

Co-Authored-By: Claude <noreply@anthropic.com>
@d-walsh

d-walsh commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

I've opened a companion PR in pykumo — dlarrick/pykumo#72 — that adds PyKumoBase.has_profile() -> bool for exactly this use case.

The implementation is one line: return bool(self._profile). Once that is released, the if not self._pykumo._profile: guard here can be replaced with if not self._pykumo.has_profile():, the noqa: SLF001 suppression can be dropped, and the pykumo requirement can be bumped to that release.

For now, the TODO comment in the latest commit on this branch makes the migration path explicit. The private access is safe and correct as written — this just tidies it up for the future.

@dlarrick

dlarrick commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Thanks, I should have a chance to merge these this weekend. I'll merge the pykumo one first (bumping minor version) so if you want to update this PR with that in mind it'll go quicker.

Copilot AI 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.

Pull request overview

This PR addresses a Home Assistant capability-caching issue in the Kumo climate entity where HVAC/fan/swing capabilities derived during __init__ could remain permanently incorrect if the adapter profile was empty at startup and only became populated after later successful polls.

Changes:

  • Initializes capability-related fields to safe defaults and centralizes capability derivation in a new _refresh_capabilities() helper.
  • Gates capability derivation on the pykumo profile being populated and refreshes capabilities after every coordinator update.
  • Uses an “upgrade-only” strategy for hvac_modes (and related feature flags) to avoid transient poll failures removing confirmed capabilities.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +215 to +221
# --- fan / swing: overwrite from current (real) profile ---
fan_speeds = self._pykumo.get_fan_speeds()
if fan_speeds:
self._fan_modes = fan_speeds
vane_dirs = self._pykumo.get_vane_directions()
if vane_dirs:
self._swing_modes = vane_dirs

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I disagree with Copilot here, this is fine, in fact is the point of the feature. Maybe update the docstring.

The previous docstring said fan_modes/swing_modes are overwritten on
every populated-profile call.  The code actually uses a truthy guard
(if fan_speeds: / if vane_dirs:) so a transient empty read never
clobbers a previously confirmed list.  Update the docstring to match.
No logic changes.

Co-Authored-By: Claude <noreply@anthropic.com>
@d-walsh

d-walsh commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Updated the docstring to match the truthy-guard behavior as you suggested — left the logic as-is. Will switch to has_profile() and bump the pykumo pin once #72 is released.

@dlarrick

Copy link
Copy Markdown
Owner

You'll need to update the pykumo required version (to v.0.5.2) in manifest.json in your branch. GitHub doesn't let me make that edit on your PR without just making it my own.

pykumo 0.5.2 ships PyKumoBase.has_profile() (merged from dlarrick/pykumo#72),
replacing the private _profile attribute access that was guarded with a noqa.

- manifest.json: pykumo>=0.5.0 → pykumo>=0.5.2
- climate.py _refresh_capabilities(): replace `if not self._pykumo._profile:`
  (with noqa: SLF001 and TODO comment) with `if not self._pykumo.has_profile()`

Co-Authored-By: Claude <noreply@anthropic.com>
@d-walsh

d-walsh commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Bumped the pykumo requirement to >=0.5.2 and migrated the profile guard from the private _profile access to the public has_profile() method (landed in 0.5.2 via dlarrick/pykumo#72). Removed the noqa: SLF001 suppression and the TODO comment. Ready for merge — thanks for cutting the release.

Copilot AI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment on lines +207 to +212
if not self._pykumo.has_profile():
_LOGGER.debug(
"Kumo %s: profile not yet populated, skipping capability refresh",
self._name,
)
return

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

No, Copilot, that's a terrible idea.

@dlarrick

Copy link
Copy Markdown
Owner

Merging this now. I will cut a prerelease later.

@dlarrick dlarrick merged commit 2c79d6e into dlarrick:master Jun 16, 2026
3 checks passed
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.

3 participants