Skip to content

fix(cover): declare _attr_is_closed on HotataRailCover - #12

Open
RainySat wants to merge 1 commit into
C3H3-AI:mainfrom
RainySat:fix/railcover-is-closed-attr
Open

RainySat wants to merge 1 commit into
C3H3-AI:mainfrom
RainySat:fix/railcover-is-closed-attr

Conversation

@RainySat

@RainySat RainySat commented Sep 16, 2026

Copy link
Copy Markdown

Problem

HotataRailCover never defines _attr_is_closed, but HA's CoverEntity declares it with a type annotation and no default — unlike its neighbours in the same block (components/cover/__init__.py, HA 2026.9.2):

217:    _attr_current_cover_position: int | None = None
220:    _attr_is_closed: bool | None          # annotation only, no default
221:    _attr_is_closing: bool | None = None
222:    _attr_is_opening: bool | None = None

and is_closed is a cached_property that reads it directly:

@cached_property
def is_closed(self) -> bool | None:
    return self._attr_is_closed

So on a P5 airer every state write failed:

ERROR (MainThread) [custom_components.hotata.coordinator] Unexpected error updating listener 4340703627984 for hotata
Traceback (most recent call last):
  ...
AttributeError: 'HotataRailCover' object has no attribute '_attr_is_closed'

Two details make it noisy rather than intermittent:

  • is_closed is reached twice per write — state() (if (closed := self.is_closed) is None:) and state_attributes() (data[IS_CLOSED] = self.is_closed)
  • cached_property does not cache a raised exception, so it re-raises every time → one error per poll (measured: every 30 s, 54 lines in two minutes)

Only HotataRailCover is affected; HotataAirerCover and _HotataCurtainBase both implement is_closed.

改了啥

class HotataRailCover(HotataEntity, CoverEntity):
    _attr_device_class = CoverDeviceClass.SHADE
    # These rails have no position feedback, so is_closed can only be unknown.
    # CoverEntity annotates _attr_is_closed without a default (unlike
    # _attr_is_closing / _attr_is_opening), so omitting it here makes the base
    # is_closed cached_property raise AttributeError on every state write.
    _attr_is_closed: bool | None = None

None is the correct value here, not a convenient fallback. The A/B poles only report APoleMotorControlMode / BPoleMotorControlMode (open / stop / close) and have no position feedback, so "closed" is genuinely unknown — the same reason HotataAirerCover.assumed_state reports True when Position is not a real enum. CoverEntity.is_closed is typed bool | None and state() has an explicit None branch, so HA renders the entity as unknown, which is what it renders today. Guessing True/False would feed HomeKit and automations a wrong state.

怎么测

  • py_compile passes for every module under custom_components/hotata/
  • Deployed against a live HA 2026.9.2 instance running integration v4.0.3 (P5 airer with A/B poles): restarted, then watched 3+ poll cycles → 0 Unexpected error updating listener lines (previously ~1 per 30 s)
  • 9.5 h later: still 0. The only hotata line in between is an unrelated api.link.aliyun.com … Network unreachable during a WAN outage, which recovered on its own
  • A/B pole entities still report unknown (no position feedback — expected); open / close / stop are unaffected
  • Swept the whole integration for the same class of bug (HA base classes annotate 53 _attr_* without defaults across 12 entity types) — this was the only missing one

关联 Issue

N/A — found on my own instance; searching is_closed in this repo returns 0 results, so I don't think it has been reported.

Checklist

  • py_compile 全部通过
  • hassfest CI 绿 — will rely on your CI
  • HACS CI 绿 — same
  • manifest.json 版本号已同步 — left to you, I didn't want to pick a version number
  • CHANGELOG 已更新 — left to you; happy to add an entry (or an [Unreleased] section) if you'd rather have it inside the PR

CoverEntity annotates _attr_is_closed with no default (unlike
_attr_is_closing / _attr_is_opening), so the base is_closed
cached_property reads a missing attribute and raises AttributeError.
is_closed is reached from both state() and state_attributes(), and a
raised exception is never cached, so it fires on every poll.

These rails have no position feedback, so None (unknown) is the only
honest value: HotataAirerCover and _HotataCurtainBase are unaffected.
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