Skip to content

fix: reuse an existing model property, and refuse a child that shadows an ancestor - #69

Merged
dcj merged 2 commits into
mainfrom
fix/reuse-existing-model-properties
Aug 21, 2026
Merged

fix: reuse an existing model property, and refuse a child that shadows an ancestor#69
dcj merged 2 commits into
mainfrom
fix/reuse-existing-model-properties

Conversation

@dcj

@dcj dcj commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes #66, and fixes the silent half of #67.

#66: the builder replaced properties in a model it does not own

_materialize guarded the model group with has_group, then two lines later added the property unconditionally, and GroupedPropertyDict.add_property is a wholesale self._properties[id] = property.

The value loss was the least of it. _change_callbacks, _set_callbacks and _entity_setter are instance state on the replaced object, so a producer that wired inbound control lost the actuator while $description kept advertising settable: true. An arriving /set did nothing.

It did not self-heal either: the builder path seeds only a static initial_value, and Property.set_value fires callbacks only on a real change, so a value written once at group creation never republished.

This is the exact case DeviceTreeBuilder documents as its reason for accepting a model rather than creating one, so it was a documented guarantee the code did not provide.

Fix: reuse an existing property; record only properties the builder created, so remove() deletes what it added and leaves what the producer owned; raise when a spec's python type disagrees with the property already there, rather than binding a Homie twin to a mismatched observable. That last one answers the open question in #66.

#67 (silent half): a child could shadow an ancestor

Device.__init__ appended to parent._children with no check, so a child carrying the root's id made the root name itself in its own children and put two devices on the same topics. No exception, no warning.

The obvious way to reach it was expressing "capabilities on the root" as a DeviceSpec with parent=None and the root's own id, which is a real thing to want and which the builder does not support yet. Failing loudly beats materializing a malformed tree. Ids still only need to be unique within a tree's ancestry, so the same id under a different root is unaffected.

#67's actual feature request, materializing capabilities onto the root, is not addressed here.

Verification

669 tests pass (658 before, 11 new), including the reproductions from both issues verbatim. ruff and markdownlint clean.

🤖 Generated with Claude Code

dcj and others added 2 commits August 20, 2026 18:10
…s an ancestor

Two defects reported against 0.21.0's DeviceTreeBuilder, both silent.

lines later, added the PROPERTY unconditionally. GroupedPropertyDict
.add_property is a wholesale `self._properties[id] = property`, so a
producer handing over a model it had already populated got that property
swapped for a fresh one. The value was the least of it: _change_callbacks,
_set_callbacks and _entity_setter are instance state on the replaced
object, so a producer that wired inbound control lost the actuator while
$description kept advertising settable: true, and an arriving /set did
nothing.

Nor did it self-heal. The builder path seeds only a static initial_value,
and Property.set_value fires callbacks only on an actual change, so a
value written once at group creation never republished.

This is the exact case DeviceTreeBuilder documents as its reason for
accepting a model rather than creating one, which made it a documented
guarantee the code did not provide. An existing property is now reused,
and the builder records only properties it actually created, so remove()
deletes what it added and leaves what the producer owned. A spec whose
python type disagrees with the property already there raises rather than
binding a Homie twin to a mismatched observable.

no check, so a child carrying an ancestor's id made that ancestor name
itself in its own children and put two devices on the same topics, with
no exception and no warning. The obvious way to reach it was expressing
"capabilities on the root" as a DeviceSpec with parent=None and the
root's own id, which is a real thing to want and which the builder does
not yet support. Failing loudly beats materializing a malformed tree.
Ids still only need to be unique within a tree's ancestry.

Closes #66

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same defect as the ancestor guard, one step sideways, and equally
silent. Two children of one parent with the same id derive the same base
topic, so their $description publishes overwrite each other on the broker
and whichever wrote last defines the device; the parent meanwhile names
that child twice in its own `children` list, which is malformed.

Observed before the guard:

    children_ids()        : ['circuit-1', 'circuit-1']
    a's nodes             : ['meter']
    b's nodes             : ['switch']
    ...both retained at   : ebus/5/circuit-1/$description

Costs no new state: the parent already tracks its children, so this is a
scan of parent.children() on a construction that already walks the
ancestor chain. delete() detaches a child, so recreating one after
deleting it is not a false positive, and DeviceTreeBuilder.add() already
handles re-fired lifecycles at its own level.

Refs #67

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dcj
dcj force-pushed the fix/reuse-existing-model-properties branch from acbe551 to 63a53c7 Compare August 21, 2026 01:10
@dcj
dcj merged commit 3e97e16 into main Aug 21, 2026
5 checks passed
@dcj
dcj deleted the fix/reuse-existing-model-properties branch August 21, 2026 01:11
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.

DeviceTreeBuilder.add() replaces live properties in a model it does not own

1 participant