Skip to content

Separate computed primary entity state from _attr_primary - #861

Open
TheJulianJES wants to merge 9 commits into
zigpy:devfrom
TheJulianJES:tjj/separate-computed-primary-state
Open

Separate computed primary entity state from _attr_primary#861
TheJulianJES wants to merge 9 commits into
zigpy:devfrom
TheJulianJES:tjj/separate-computed-primary-state

Conversation

@TheJulianJES

@TheJulianJES TheJulianJES commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes the first item of #725.

The primary entity election previously stored its result in _attr_primary, the same field entity classes and quirks use to explicitly mark an entity as (not) primary. Conflating the two caused the election to get stuck:

  • Election losers were set to _attr_primary = False, permanently excluding them from all future elections (the candidate filter skips _attr_primary is False). If the winner was later removed, no remaining entity could ever become primary again.
  • A previous winner (_attr_primary = True) looked "explicitly primary" to later elections, which then short-circuited, so a stronger candidate appearing later could never take over. If a genuinely explicit primary entity appeared alongside a previous winner, the assert not explicitly_primary sanity check would crash instead.

Changes

Election state is a device concern, not an entity concern (design settled in the review discussion below — the first revision stored a per-entity computed flag instead):

  • _attr_primary now only ever holds the explicit declaration (entity class attribute or quirk primary=True/False) and is never written by the election.
  • The election result is a single device-owned reference, Device._primary_entity (exposed as Device.primary_entity), set for both the explicit and the weight-based paths and cleared when the referenced entity is removed. The election recomputes it from scratch, so stale per-entity primary state is no longer representable.
  • PlatformEntity.primary returns _attr_primary when set, falling back to device.primary_entity is self — so an explicit True/False from a quirk or entity class can never be overridden by the election. Group entities (no device, no election) report their explicit value or False.
  • The primary setter is removed; nothing outside the election ever wrote primary state.

Tests

  • test_primary_entity_reelection: a mock smart plug with OnOff + IasZone. Marking on_off as unsupported in the ZCL attribute cache makes the switch natively unsupported, so recompute_entities() removes it through the real removal path — the IAS zone (previous election loser) now wins the re-election. Writing an on_off value clears the unsupported flag, and the rediscovered switch wins back primary.
  • test_primary_entity_election_disabled_winner: a disabled previous winner loses its computed primary state on the next recomputation, so only the runner-up is primary. After being re-enabled, it wins back the election.
  • test_primary_entity_election_explicit_primary_takes_over: an entity marked explicitly primary (as a quirk would) takes over from a previously computed winner on the next recomputation, instead of tripping the sanity assert.

All three tests fail on dev without the fix, and passed unchanged across the switch from per-entity flags to the device-owned reference — they pin behavior, not storage.

Possible future TODOs for other PRs

  1. Trim the primary_weight property: pure pass-through to _attr_primary_weight with a single caller (the election), kept for now to match the repo-wide _attr_*-plus-property idiom.
  2. Multiple explicit primaries still assert: two quirk entities with primary=True hit assert not explicitly_primary and crash device init. Pre-existing; could be downgraded to a warning + tie handling.

The primary entity election previously stored its result in
`_attr_primary`, the same field entity classes and quirks use to
explicitly mark an entity as (not) primary. This conflation caused two
bugs:

- Election losers were set to `_attr_primary = False`, permanently
  excluding them from future elections (filtered by
  `_attr_primary is not False`). If the winner was later removed, no
  remaining entity could become primary.
- A previous winner looked "explicitly primary" to later elections,
  which then short-circuited (or hit the sanity assert when a genuinely
  explicit primary entity appeared), so a stronger candidate could never
  take over.

The election result is now stored in a separate, private
`__computed_primary` field. `_attr_primary` is only ever set explicitly
and always takes precedence, so quirks and entity classes setting
`primary` to `True`/`False` are never overridden by the election.
Instead of patching `_is_supported`, mark the `on_off` attribute as
unsupported in the zigpy attribute cache, so the switch entity natively
becomes unsupported and is removed by `recompute_entities()`.
@TheJulianJES TheJulianJES changed the title Separate computed primary entity state from explicit _attr_primary Separate computed primary entity state from _attr_primary Aug 6, 2026
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.17%. Comparing base (1629e7d) to head (bf6cd73).
⚠️ Report is 3 commits behind head on dev.

Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #861      +/-   ##
==========================================
+ Coverage   97.15%   97.17%   +0.01%     
==========================================
  Files          55       57       +2     
  Lines       10481    10542      +61     
==========================================
+ Hits        10183    10244      +61     
  Misses        298      298              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The fix is correct and well-targeted — splitting the election's result off from the explicit declaration resolves both stuck-election bugs, and I confirmed both new tests fail on dev (a2705409) and pass at this head. One gap remains in the same method: the election's clearing loops still don't cover every entity, so a stale computed winner can survive a re-election.

Must-address: stale computed primary when the previous winner isn't a candidate

_compute_primary_entity clears computed state over others (zha/zigbee/device.py:1650) and candidates (:1659), but candidates (:1636) filters out not e.enabled. An entity that won an earlier election and is then disabled keeps __computed_primary = True while the re-election hands primary to someone else — so two entities report primary at once. The if not candidates: return early exit (:1639) leaks the same way when every entity is filtered out.

disable() is a production path, not just a test hook: ha-core calls platform_entity.disable() when the registry entry is disabled (homeassistant/components/zha/helpers.py:667 and :1300), and enable()/disable() don't re-run the election, so the stale flag also survives a later re-enable.

Repro at this head — smart plug with OnOff (weight 10) + IasZone (weight 3):

switch = get_entity(zha_device, Platform.SWITCH, entity_type=Switch)
ias_zone = get_entity(zha_device, Platform.BINARY_SENSOR, entity_type=IASZone)
assert switch.primary and not ias_zone.primary

switch.disable()                       # what ha-core does for a disabled registry entry
await zha_device.recompute_entities()
# PR head: switch.primary = True  | ias_zone.primary = True   <-- two primaries
# dev:     switch.primary = True  | ias_zone.primary = False

Worth noting this is a behavior change rather than a pre-existing bug: on dev the stale _attr_primary = True makes the ex-winner look explicitly primary, so the election short-circuits and only ever one entity claims it. Since primary drives _attr_name = None in ha-core (entity.py:101), two primaries mean two entities claiming the bare device name once the disabled one is re-enabled.

Hoisting the clear to the top of the method covers all four exits at once, and makes the new loop at :1624-1627 redundant:

def _compute_primary_entity(self, entities: Sequence[PlatformEntity]) -> None:
    """Compute the primary entity from a given set of entities."""

    # Clear all previously computed primary state up front, so no stale winner
    # can survive a re-election on any code path below
    for entity in entities:
        entity.primary = False

    # First, check if any entity is explicitly primary
    explicitly_primary = [entity for entity in entities if entity._attr_primary]
    ...

I applied exactly that locally: the repro above then ends False / True, and all 9 -k primary tests in tests/test_device.py (both new ones included) still pass. It also lines up with TODO 1 in your description — a device-owned _primary_entity pointer would make this whole class of stale-flag bug unrepresentable.

Optional

entity._attr_primary (:1616) and e._attr_primary is not False (:1636) reach into a private attribute from Device. A small BaseEntity.explicitly_primary property returning _attr_primary would keep the election reading a public surface — cheap now that the getter no longer conflates the two.

Verified (8 checks)
  • Both new tests fail on dev (a2705409) and pass at 14744da9test_primary_entity_reelection and test_primary_entity_election_explicit_primary_takes_over, run in separate worktrees.
  • The stale-primary repro was run against both dev and this head; the outputs quoted above are actual runs, not reasoning.
  • Proposed hoisted-clear patch applied locally: repro fixed and 9/9 -k primary tests in tests/test_device.py still pass.
  • mypy zha/ inside the worktree venv (real deps, unlike the CI hook's dependency-free env, where every zigpy.* import collapses to Any): clean, no regression.
  • GroupEntity.__init__ chains to BaseEntity.__init__, so the name-mangled __computed_primary is always initialized — no AttributeError on the group path.
  • Narrowing the primary setter from bool | None to bool breaks no external consumer: ha-core only reads meta.primary (entity.py:101) and never assigns it.
  • _add_pending_entities already emits maybe_emit_state_changed_event() for pre-existing entities after the election, so a flipped primary does reach consumers.
  • Copilot (GPT-5.5) second opinion: independently flagged the same disabled-winner stale-flag issue, and nothing else.

Comment thread zha/zigbee/device.py Outdated
Comment thread zha/zigbee/device.py

# First, check if any entity is explicitly primary
explicitly_primary = [entity for entity in entities if entity.primary]
explicitly_primary = [entity for entity in entities if entity._attr_primary]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Optional: this and the e._attr_primary is not False filter on line 1636 read a private attribute from Device. A BaseEntity.explicitly_primary property returning _attr_primary would keep the election on a public surface — cheap now that primary no longer conflates explicit and computed state.

A previous winner that is no longer an election candidate (e.g. after
being disabled via the entity registry) kept its stale computed primary
state while the re-election handed primary to another entity, leaving
two entities claiming primary at once. Clearing all computed state at
the start of the election covers every code path and replaces the
per-branch clearing loops.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The stale-computed-primary gap from my previous review is fixed by ed9d98f exactly as proposed — hoisting the clear to the top of _compute_primary_entity covers all four exits, and test_primary_entity_election_disabled_winner pins it (it fails with zha/ reverted to 14744da9, passes at this head). No blockers or must-address items left. Still marked draft, so approving on the code as it stands rather than on merge-readiness.

Optional follow-ups — neither is a regression, both behave the same on dev

The explicit-primary path still ignores enabled. zha/zigbee/device.py:1622 collects explicit primaries from all entities, while candidates (:1636) filters on e.enabled. So an explicitly primary entity the user disabled in the entity registry still short-circuits the election and nothing enabled gets promoted — same shape as the bug ed9d98f just fixed, one path over. Probed at this head on third-reality-inc-3rsnl02043z: with the light marked _attr_primary = True and then disable()d, recompute_entities() leaves light.primary=True / motion.primary=False, i.e. the device's only primary is an entity HA won't load. It may well be deliberate — a quirk's explicit declaration arguably shouldn't be silently reassigned to something else — in which case a short comment at :1622 would settle it; otherwise it fits the TODO list in the description.

Nothing re-runs the election on enable/disable. ha-core calls platform_entity.disable() / enable() (homeassistant/components/zha/helpers.py:667-669 and :1300) and there is no recompute_entities() call anywhere in the integration, so a disabled winner keeps primary until the next _add_pending_entities() happens to run — which is why the new test has to call recompute_entities() by hand. Unchanged from dev, so nothing to fix in this PR; possibly worth a TODO 5.

My earlier optional inline suggesting a BaseEntity.explicitly_primary property still stands (it would keep Device off _attr_primary at :1622 and :1636), but it is purely cosmetic — leaving the thread open rather than re-raising it here.

Verified (8 checks)
  • test_primary_entity_election_disabled_winner fails with zha/ reverted to 14744da9 and passes at ed9d98f6 — the fix commit is load-bearing, not just a refactor.
  • All 9 -k primary tests in tests/test_device.py pass at this head; tests/test_device.py + tests/test_discover.py together: 939 passed.
  • The disabled-explicit-primary observation above is an actual run in the worktree, not reasoning — the quoted light.primary / motion.primary values are the probe's output.
  • mypy zha/ inside the worktree venv (real deps, unlike the CI hook's dependency-free env where every zigpy.* import collapses to Any): Success: no issues found in 57 source files.
  • _discover_new_entities() clears and rebuilds entity objects every pass, so an entity removed while still holding __computed_primary = True is a dead object — it can't be resurrected carrying a stale flag (relevant to the removal path in test_primary_entity_reelection).
  • entity.primary = … is assigned nowhere in zha/ outside _compute_primary_entity, and ha-core only reads meta.primary (entity.py:101) — narrowing the setter from bool | None to bool breaks no consumer.
  • Title matches recent merged-PR conventions.
  • Copilot (GPT-5.5, --effort high) second opinion at this head, pointed at the election paths, the getter/setter precedence and name mangling, event emission, and the ha-core consumer: no findings.

@TheJulianJES

TheJulianJES commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Hmm, I think enabled and disabled entities should behave the same, in that a disabled entity could be the primary entity still and not allow others to take its primary spot? But on the other side, I do kind of see some benefit to excluding (user-)disabled entities from the primary entity election?

@TheJulianJES

TheJulianJES commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

The below PR will fix that enabled/disabled issue and should be rebased + merge after this PR is merged.

The current logic of checking enabled doesn't make any sense because of multiple reasons, including that we don't recompute the primary entity when enabling/disabling an entity and HA disables the LQI/RSSI entities too late on the ZHA side when the primary entity computation is already done. So effectively, the computation never really used enabled anyway, hence the above PR.

…d-primary-state

# Conflicts:
#	tests/test_device.py
@puddly

puddly commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Ah, you're very right, this attribute does indeed perform double duty.

I think the TODO you listed is a good alternative approach: denormalization is the problem here because primary entity computation is a device problem, not an entity problem, and IMO should be something the device figures out and maintains. This would retain _attr_primary as an entity-level hint and then let the device object itself hold a PlatformEntity | None reference to a primary entity. What do you think?

@TheJulianJES

Copy link
Copy Markdown
Contributor Author

Yeah, I think that would be the better approach. I can have a look at that later.

Election state is a device concern, not an entity concern: the device
now holds a single `_primary_entity` reference (exposed as
`Device.primary_entity`) instead of every entity carrying a computed
primary flag. `_attr_primary` remains the entity-level hint and still
takes precedence in the `primary` getter.

This removes the per-entity `__computed_primary` field, the `primary`
setter, and the up-front clearing loop — the election just reassigns
the reference, so stale per-entity state is no longer representable.
The reference is cleared when the entity it points to is removed.
@TheJulianJES
TheJulianJES requested a balanced review from Copilot August 20, 2026 18:39

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

Separates explicit entity primary declarations from device-owned election state, fixing stale winners and enabling reliable re-election.

Changes:

  • Adds device-managed primary entity state.
  • Updates entity primary resolution while preserving explicit overrides.
  • Adds regression tests for removal, disabling, and explicit takeover.

Reviewed changes

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

File Description
zha/zigbee/device.py Stores and recomputes the elected primary entity.
zha/application/platforms/__init__.py Separates explicit and computed primary state.
tests/test_device.py Covers primary entity re-election scenarios.

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

Comment thread zha/application/platforms/__init__.py
Comment thread tests/test_device.py
Comment on lines +1051 to +1064
# When the switch is disabled, it is no longer an election candidate and must
# not keep its computed primary state, so only the runner-up is primary
switch.disable()
await zha_device.recompute_entities()

assert not switch.primary
assert ias_zone.primary

# When the switch is re-enabled, it wins back the election
switch.enable()
await zha_device.recompute_entities()

assert switch.primary
assert not ias_zone.primary

@TheJulianJES TheJulianJES Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Note: This current/pre-existing behavior, of disabled/enabled entities (trying to) influence the primary entity, is bad. The test just asserts it. After this PR, #862 will adjust it to remove that (will need to be rebased on this one – small PR then).

With that PR, it's also accurate to how it currently works in HA. HA calls disable() on disabled entities too late on startup. So already currently, there's no practical difference for the primary entity when an entity is enabled or disabled. But it's a bug it already works that way in Core (currently, as we don't ever call recompute_entities() automatically yet).

If we ever do want enabled/disabled entities to intentionally affect the primary entity (which we likely don't because it doesn't really make sense, as names would just swap "randomly" if the user enables/disables entities), we need to rework the logic of HA enabling/disabling entities. Either way, that's not for this PR, nor #862, which just converts the current ZHA behavior to work more like how Core uses it.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at 79a230d8. This revision moves the election result to a device-owned Device._primary_entity, along the lines puddly proposed in the comments, and it holds up: the "stuck election" and "two entities both report primary" failure modes are gone, and I could not construct a state where the invariant breaks.

Verified

  • The three new tests are real regression tests. Cherry-picked tests/test_device.py from this branch onto unmodified dev (3da3ed92): all three fail. At the PR head they pass. They pin behavior, not the new storage shape.
  • Suite green, and green on current dev. 1349 passed at the PR head; after a local merge of dev (clean, no conflicts — the branch is 2 commits behind, including the new event platform) 1372 passed.
  • mypy zha/ clean inside the worktree venv, i.e. with real zigpy/zhaquirks installed rather than the CI hook's isolated env where those collapse to Any. ruff check and ruff format --check clean.
  • No diagnostics snapshot churn. Every fixture under tests/data/devices/ carries a per-entity "primary" field, and the suite passes with the snapshots untouched — so the election picks the same winner on every fixture device. That's the strongest available evidence that this is a pure fix for the stuck/stale cases rather than a behavior change for ordinary devices.
  • The flip reaches Home Assistant on both sides, not just the new winner. I probed the case that actually matters — a previous winner losing to the runner-up across recompute_entities() — with listeners on both entities' STATE_CHANGED, and got ('ias', {'primary': True}) and ('switch', {'enabled': False, 'primary': False}). The maybe_emit_state_changed_event() loop at zha/zigbee/device.py:1205-1207 does cover the old primary, so consumers see the name give-up as well as the take-over.
  • "Exactly one primary" is now structural rather than maintained. _primary_entity can only ever hold an entity whose own primary property also returns True: the explicit path picks only from truthy _attr_primary, and the weight path's candidate filter excludes _attr_primary is False — so an elected entity always has _attr_primary is None and falls through to the device.primary_entity is self branch. There is no combination that makes Device and PlatformEntity disagree, which is exactly what the old denormalized version couldn't guarantee.
  • No dangling reference. _platform_entities is only ever mutated in _add_entity and _remove_entity, and the latter clears _primary_entity — so the device can't keep a strong reference to a removed entity.
  • An independent second-opinion pass (GitHub Copilot, GPT-5.6 Sol, high effort) returned no findings.

Optional

_compute_primary_entity still reads the private entity._attr_primary from Device, now at two sites (zha/zigbee/device.py:1629 and the candidate filter at :1647). That's my open thread from the previous round; it applies a bit more now than it did then, since the whole point of this PR is that _attr_primary is the explicit declaration and nothing else. A BaseEntity.explicitly_primary property returning _attr_primary would keep the election on a public surface. Entirely your call — no behavioral consequence either way.

Plus the inline note below on _remove_entity.

Both follow-ups listed in the PR body read as correct calls to defer: trimming primary_weight is cosmetic, and the multiple-explicit-primaries assert is genuinely pre-existing — this PR neither worsens nor is blocked by it.

Comment thread zha/zigbee/device.py
@TheJulianJES
TheJulianJES marked this pull request as ready for review August 20, 2026 19:44
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.

4 participants