Separate computed primary entity state from _attr_primary - #861
Separate computed primary entity state from _attr_primary#861TheJulianJES wants to merge 9 commits into
_attr_primary#861Conversation
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()`.
_attr_primary_attr_primary
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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 = FalseWorth 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 at14744da9—test_primary_entity_reelectionandtest_primary_entity_election_explicit_primary_takes_over, run in separate worktrees. - The stale-primary repro was run against both
devand this head; the outputs quoted above are actual runs, not reasoning. - Proposed hoisted-clear patch applied locally: repro fixed and 9/9
-k primarytests intests/test_device.pystill pass. mypy zha/inside the worktree venv (real deps, unlike the CI hook's dependency-free env, where everyzigpy.*import collapses toAny): clean, no regression.GroupEntity.__init__chains toBaseEntity.__init__, so the name-mangled__computed_primaryis always initialized — noAttributeErroron the group path.- Narrowing the
primarysetter frombool | Nonetoboolbreaks no external consumer: ha-core only readsmeta.primary(entity.py:101) and never assigns it. _add_pending_entitiesalready emitsmaybe_emit_state_changed_event()for pre-existing entities after the election, so a flippedprimarydoes reach consumers.- Copilot (GPT-5.5) second opinion: independently flagged the same disabled-winner stale-flag issue, and nothing else.
|
|
||
| # 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] |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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_winnerfails withzha/reverted to14744da9and passes ated9d98f6— the fix commit is load-bearing, not just a refactor.- All 9
-k primarytests intests/test_device.pypass at this head;tests/test_device.py+tests/test_discover.pytogether: 939 passed. - The disabled-explicit-primary observation above is an actual run in the worktree, not reasoning — the quoted
light.primary/motion.primaryvalues are the probe's output. mypy zha/inside the worktree venv (real deps, unlike the CI hook's dependency-free env where everyzigpy.*import collapses toAny):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 = Trueis a dead object — it can't be resurrected carrying a stale flag (relevant to the removal path intest_primary_entity_reelection).entity.primary = …is assigned nowhere inzha/outside_compute_primary_entity, and ha-core only readsmeta.primary(entity.py:101) — narrowing the setter frombool | Nonetoboolbreaks 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.
|
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? |
|
The below PR will fix that enabled/disabled issue and should be rebased + merge after this PR is merged. The current logic of checking |
…d-primary-state # Conflicts: # tests/test_device.py
|
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 |
|
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.
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.pyfrom this branch onto unmodifieddev(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 ofdev(clean, no conflicts — the branch is 2 commits behind, including the neweventplatform) 1372 passed. mypy zha/clean inside the worktree venv, i.e. with realzigpy/zhaquirksinstalled rather than the CI hook's isolated env where those collapse toAny.ruff checkandruff format --checkclean.- 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}). Themaybe_emit_state_changed_event()loop atzha/zigbee/device.py:1205-1207does 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_entitycan only ever hold an entity whose ownprimaryproperty also returnsTrue: 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 Noneand falls through to thedevice.primary_entity is selfbranch. There is no combination that makesDeviceandPlatformEntitydisagree, which is exactly what the old denormalized version couldn't guarantee. - No dangling reference.
_platform_entitiesis only ever mutated in_add_entityand_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.
The reset on the first line speaks for itself.
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:_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._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, theassert not explicitly_primarysanity 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_primarynow only ever holds the explicit declaration (entity class attribute or quirkprimary=True/False) and is never written by the election.Device._primary_entity(exposed asDevice.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.primaryreturns_attr_primarywhen set, falling back todevice.primary_entity is self— so an explicitTrue/Falsefrom a quirk or entity class can never be overridden by the election. Group entities (no device, no election) report their explicit value orFalse.primarysetter is removed; nothing outside the election ever wrote primary state.Tests
test_primary_entity_reelection: a mock smart plug withOnOff+IasZone. Markingon_offas unsupported in the ZCL attribute cache makes the switch natively unsupported, sorecompute_entities()removes it through the real removal path — the IAS zone (previous election loser) now wins the re-election. Writing anon_offvalue 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
devwithout 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
primary_weightproperty: pure pass-through to_attr_primary_weightwith a single caller (the election), kept for now to match the repo-wide_attr_*-plus-property idiom.primary=Truehitassert not explicitly_primaryand crash device init. Pre-existing; could be downgraded to a warning + tie handling.