Skip to content

Commit 3e97e16

Browse files
dcjclaude
andauthored
fix: reuse an existing model property, and refuse a child that shadows an ancestor (#69)
* fix: reuse an existing model property, and refuse a child that shadows 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> * fix: refuse two children of one parent sharing an id 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> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 06e9c3f commit 3e97e16

5 files changed

Lines changed: 210 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan
44

55
## [Unreleased]
66

7-
### Documentation
8-
9-
- `DeviceTreeBuilder` now states two parts of its contract that the API alone did not convey, both reported by a consumer reconciling an existing multi-device builder against it. First, whether a producer is expected to *adapt its own model* to `GroupedPropertyDict` or to *own one*: it is the second, which is the observable-model pattern the proxy guide prescribes, and the builder accepts rather than creates one so a single model can span a tree and so a producer holding one already can hand it over. Second, the limit of `add()`'s ordering: it orders late-bound ids and builds an unbuilt parent it was handed, but `DeviceSpec` is frozen and `parent` is a direct reference, so a child spec cannot be constructed before its parent spec exists. A caller deriving specs from a source that names parents indirectly still owns that dependency ordering. `add()` reads as though ordering is handled generally; it is handled for ids. Thanks to [@cayossarian](https://github.com/cayossarian) ([#49](https://github.com/electrification-bus/python-sdk/issues/49)).
10-
117
### Added
128

139
- `node_id` on `build_from_declarations` and `DeviceTreeBuilder`: a callable mapping a capability to the Homie node id it materializes onto, defaulting to the capability itself. The node id was hardcoded to the capability name, which is right until one device carries two instances of the same capability (two lugs, two meters), at which point the second silently lands on the first one's node. `node_type` and `node_name` were already callables, so the id was the one part of a node a caller could not choose. Renaming is all it does: the declaration's vocabulary stays `capability`, the model group still comes from the spec, and the returned map is still keyed by the declared capability, so a caller who ignores it sees no change. Pairs with `PropertySpec.model_group` from 0.21.0, which separates the same two instances in the model the way this separates them on the wire; using one without the other moves the collision rather than removing it. ([#47](https://github.com/electrification-bus/python-sdk/issues/47))
1410

11+
### Fixed
12+
13+
- `build_from_declarations` and `DeviceTreeBuilder` now REUSE an observable property the model already holds instead of replacing it. `_materialize` guarded the model group with `has_group` and then, two lines later, added the property unconditionally, and `GroupedPropertyDict.add_property` is a wholesale `self._properties[property_id] = property`. So a producer handing over a model it had already populated got that property swapped for a fresh one, losing its value and, worse, every callback and `entity_setter` attached to it: `$description` kept advertising `settable: true` while the actuator behind it was gone, and an arriving `/set` did nothing. Nor was it self-healing, since 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 the reason it accepts a model rather than creating one, which made the gap a documented guarantee the code did not provide. The builder now records only properties it actually created, so removing a device deletes what it added and leaves what the producer owned. A spec whose python type disagrees with the property already in the model raises rather than binding a Homie twin to a mismatched observable. ([#66](https://github.com/electrification-bus/python-sdk/issues/66))
14+
15+
- `Device` refuses a child whose id collides with any ancestor's. `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, with no exception and no warning. The obvious way to reach it was trying to express "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 have to be unique within a tree's ancestry, so the same id under a different root is unaffected. The same defect one step sideways is refused too: two children of one parent sharing an id derived the same base topic, so their `$description` publishes overwrote each other on the broker while the parent named that child twice in its own `children` list. `delete()` detaches a child, so recreating one after deleting it is not affected. ([#67](https://github.com/electrification-bus/python-sdk/issues/67))
16+
17+
### Documentation
18+
19+
- `DeviceTreeBuilder` now states two parts of its contract that the API alone did not convey, both reported by a consumer reconciling an existing multi-device builder against it. First, whether a producer is expected to *adapt its own model* to `GroupedPropertyDict` or to *own one*: it is the second, which is the observable-model pattern the proxy guide prescribes, and the builder accepts rather than creates one so a single model can span a tree and so a producer holding one already can hand it over. Second, the limit of `add()`'s ordering: it orders late-bound ids and builds an unbuilt parent it was handed, but `DeviceSpec` is frozen and `parent` is a direct reference, so a child spec cannot be constructed before its parent spec exists. A caller deriving specs from a source that names parents indirectly still owns that dependency ordering. `add()` reads as though ordering is handled generally; it is handled for ids. Thanks to [@cayossarian](https://github.com/cayossarian) ([#49](https://github.com/electrification-bus/python-sdk/issues/49)).
20+
1521
## [0.21.0] — 2026-08-20
1622

1723
### Added

src/ebus_sdk/declaration.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,10 @@ def build_from_declarations(
151151
152152
Groups `specs` by capability (one Homie node each) and, for every spec,
153153
creates an observable `Property` in `model` and a Homie property on the node,
154-
wired together with `bind_property_to_homie` (the outbound/report path). Runs
155-
inside one `device.state_transition()`. Returns
154+
wired together with `bind_property_to_homie` (the outbound/report path). An
155+
observable property the model ALREADY holds is reused, never replaced, since
156+
replacing it would discard the live value and every callback and
157+
`entity_setter` attached to it. Runs inside one `device.state_transition()`. Returns
156158
`{(capability, prop_id): homie.Property}`, keyed by WIRE identity; a spec
157159
with `internal_only=True` creates no Homie property and so is absent from it.
158160
@@ -238,6 +240,12 @@ def _materialize(
238240
devices, model groups keyed per device). Runs inside one
239241
`device.state_transition()`, so a device announces its structure once.
240242
243+
An observable property already present in `model` is REUSED rather than
244+
replaced, and is not recorded in `model_keys`, so a later teardown removes
245+
only what this call created. A spec whose python type disagrees with the
246+
property already there raises instead of silently binding a Homie property to
247+
a mismatched twin.
248+
241249
`node_id` maps a capability to the Homie node id it materializes onto,
242250
defaulting to the capability itself. Only the id is renamed: the model group
243251
still comes from the spec, and the returned map is still keyed by the
@@ -273,9 +281,21 @@ def _materialize(
273281
model.create_group(group)
274282
created_groups.append(group)
275283
py_type = spec.python_type if spec.python_type is not None else python_type_for(spec.datatype)
276-
model.add_property(group, ObservableProperty(id=spec.model_key, type=py_type))
284+
existing = model.get(group, spec.model_key)
285+
if existing is None:
286+
model.add_property(group, ObservableProperty(id=spec.model_key, type=py_type))
287+
# Only what this call created, so a later remove() deletes what
288+
# it added and leaves anything the producer owned first.
289+
model_keys.append((group, spec.model_key))
290+
elif existing.type() is not py_type:
291+
raise ValueError(
292+
f"{capability}/{spec.prop_id}: the model already holds "
293+
f"{group}/{spec.model_key} with type {existing.type()!r}, but this spec "
294+
f"declares {py_type!r}. Reusing it would publish values of one type through "
295+
"a property built for another; align the spec's datatype (or python_type) "
296+
"with the model, or give the spec its own source_id/model_group."
297+
)
277298
declared[(capability, spec.prop_id)] = spec
278-
model_keys.append((group, spec.model_key))
279299
# An entity_setter is the translator toward the entity, so it is
280300
# registered whenever one is given and the model can reach it.
281301
if spec.entity_setter is not None and (spec.settable or spec.internal_only):

src/ebus_sdk/homie.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,6 +1549,32 @@ def __init__(
15491549
raise ValueError(
15501550
f"Device id={id}: cannot pass both parent= and mqttc=; children share the root's MQTT connection"
15511551
)
1552+
# A device id that collides with an ancestor's is always a mistake, and a
1553+
# silent one: Device.__init__ appends to parent._children with no check, so
1554+
# a child carrying the root's id makes the root name itself as its own
1555+
# child in $description, and both publish to the same topics. Refuse it at
1556+
# construction, where the caller can still see which id was wrong.
1557+
if parent is not None:
1558+
ancestor: Optional[Device] = parent
1559+
while ancestor is not None:
1560+
if id == ancestor.id():
1561+
raise ValueError(
1562+
f"Device id={id}: a child cannot carry the same id as its "
1563+
f"{'parent' if ancestor is parent else 'ancestor'}; both would publish to the "
1564+
"same topics and the ancestor would name itself in its own children"
1565+
)
1566+
ancestor = ancestor.parent()
1567+
# Same defect one step sideways: two children of one parent sharing an
1568+
# id derive the same base topic, so their $description publishes
1569+
# overwrite each other on the broker and the parent names the child
1570+
# twice in its own `children`. The parent already tracks its children,
1571+
# so this costs no new state.
1572+
if any(child.id() == id for child in parent.children()):
1573+
raise ValueError(
1574+
f"Device id={id}: parent id={parent.id()} already has a child with this id; "
1575+
"both would publish to the same topics and the parent would name it twice"
1576+
)
1577+
15521578
# The domain is a per-TREE property, like the connection and the QoS: one
15531579
# tree publishes under one prefix, and a child under a different domain
15541580
# would sit outside its own root's subtree. Refuse it on a child rather

tests/test_declaration.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
DeviceSpec,
88
DeviceTreeBuilder,
99
GroupedPropertyDict,
10+
ObservableProperty,
1011
PropertyDatatype,
1112
PropertySpec,
1213
Unit,
@@ -502,3 +503,92 @@ def test_device_tree_builder_passes_node_id_through(mock_paho):
502503
)
503504
assert child.get_node("x-info") is not None
504505
assert child.get_node("info") is None
506+
507+
508+
# --- Reusing a model the builder does not own (GH #66) -----------------------
509+
510+
511+
def _live_model(group="dev-1", prop_id="serial-number"):
512+
"""A model a producer already populated, before any Homie tree existed."""
513+
model = GroupedPropertyDict()
514+
model.create_group(group)
515+
model.add_property(group, ObservableProperty(id=prop_id, type=str))
516+
model.set_value(group, prop_id, "SN-LIVE")
517+
return model
518+
519+
520+
def test_an_existing_model_property_is_reused_not_replaced(mock_paho):
521+
device = _device(mock_paho, "dev-reuse")
522+
model = _live_model(group="info")
523+
before = model.get("info", "serial-number")
524+
525+
build_from_declarations(device, model, [PropertySpec("info", "serial-number", PropertyDatatype.STRING)])
526+
527+
# The same object, so nothing attached to it was discarded.
528+
assert model.get("info", "serial-number") is before
529+
assert model.value("info", "serial-number") == "SN-LIVE"
530+
531+
532+
def test_reuse_preserves_callbacks_and_the_entity_setter(mock_paho):
533+
device = _device(mock_paho, "dev-reuse-cb")
534+
model = _live_model(group="info")
535+
changed, commanded = [], []
536+
model.add_property_on_change_callback("info", "serial-number", lambda p: changed.append(p.value()))
537+
model.set_entity_setter("info", "serial-number", commanded.append)
538+
539+
build_from_declarations(device, model, [PropertySpec("info", "serial-number", PropertyDatatype.STRING)])
540+
541+
model.set_value("info", "serial-number", "SN-NEW")
542+
assert changed == ["SN-NEW"], "the producer's on-change callback died with the replaced property"
543+
model.set_entity("info", "serial-number", "CMD")
544+
assert commanded == ["CMD"], "the producer's actuator died while $description still advertises settable"
545+
546+
547+
def test_the_tree_builder_reuses_a_producers_property_too(mock_paho):
548+
root = _device(mock_paho, "enclosure-1")
549+
model = _live_model(group="dev-1")
550+
builder = DeviceTreeBuilder(root, model)
551+
builder.add(
552+
DeviceSpec("circuit", [PropertySpec("info", "serial-number", PropertyDatatype.STRING)], device_id="dev-1")
553+
)
554+
assert model.value("dev-1", "serial-number") == "SN-LIVE"
555+
556+
557+
def test_remove_deletes_what_the_builder_created_and_nothing_else(mock_paho):
558+
root = _device(mock_paho, "enclosure-2")
559+
model = _live_model(group="dev-1")
560+
builder = DeviceTreeBuilder(root, model)
561+
spec = DeviceSpec(
562+
"circuit",
563+
[
564+
PropertySpec("info", "serial-number", PropertyDatatype.STRING), # the producer's
565+
PropertySpec("meter", "active-power", PropertyDatatype.FLOAT), # the builder's
566+
],
567+
device_id="dev-1",
568+
)
569+
builder.add(spec)
570+
assert model.get("dev-1", "active-power") is not None
571+
572+
builder.remove(spec)
573+
# What it created is gone; what it found is left where it was.
574+
assert model.get("dev-1", "active-power") is None
575+
assert model.get("dev-1", "serial-number") is not None
576+
assert model.value("dev-1", "serial-number") == "SN-LIVE"
577+
578+
579+
def test_a_type_disagreement_with_an_existing_property_is_refused(mock_paho):
580+
device = _device(mock_paho, "dev-mismatch")
581+
model = _live_model(group="info") # holds a str property
582+
with pytest.raises(ValueError, match="already holds"):
583+
build_from_declarations(device, model, [PropertySpec("info", "serial-number", PropertyDatatype.FLOAT)])
584+
585+
586+
def test_reuse_still_binds_and_publishes(mock_paho):
587+
"""Reusing the twin must not skip the wiring: a later model write still reaches Homie."""
588+
device = _device(mock_paho, "dev-reuse-bind")
589+
model = _live_model(group="info")
590+
homie_props = build_from_declarations(
591+
device, model, [PropertySpec("info", "serial-number", PropertyDatatype.STRING)]
592+
)
593+
model.set_value("info", "serial-number", "SN-NEW")
594+
assert homie_props[("info", "serial-number")].value() == "SN-NEW"

tests/test_homie_device.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3031,3 +3031,63 @@ def test_device_publish_keeps_its_own_severity_when_a_client_was_expected(self,
30313031
assert [
30323032
r for r in caplog.records if r.levelno == logging.INFO and "devicePublishNoMqttClient" in r.getMessage()
30333033
]
3034+
3035+
3036+
class TestChildIdCollidesWithAnAncestor:
3037+
"""A child carrying an ancestor's id is always a mistake, and used to be silent (GH #67).
3038+
3039+
Device.__init__ appends to parent._children with no check, so the ancestor
3040+
ended up naming itself in its own `children` and both devices published to
3041+
the same topics.
3042+
"""
3043+
3044+
def test_child_carrying_its_parents_id_is_refused(self, mock_paho):
3045+
root, _ = _make_device(mock_paho, device_id="enclosure-1")
3046+
with pytest.raises(ValueError, match="same id as its parent"):
3047+
Device("enclosure-1", parent=root)
3048+
3049+
def test_grandchild_carrying_the_roots_id_is_refused(self, mock_paho):
3050+
root, _ = _make_device(mock_paho, device_id="enclosure-1")
3051+
bess = Device("bess-1", parent=root)
3052+
with pytest.raises(ValueError, match="same id as its ancestor"):
3053+
Device("enclosure-1", parent=bess)
3054+
3055+
def test_the_root_never_names_itself_as_its_own_child(self, mock_paho):
3056+
root, _ = _make_device(mock_paho, device_id="enclosure-1")
3057+
with pytest.raises(ValueError):
3058+
Device("enclosure-1", parent=root)
3059+
assert root.children_ids() == []
3060+
assert "enclosure-1" not in root.description()["children"]
3061+
3062+
def test_a_legitimate_child_is_unaffected(self, mock_paho):
3063+
root, _ = _make_device(mock_paho, device_id="enclosure-1")
3064+
child = Device("circuit-1", parent=root)
3065+
grandchild = Device("mid-1", parent=child)
3066+
assert root.children_ids() == ["circuit-1"]
3067+
assert grandchild.root() is root
3068+
3069+
def test_the_same_id_under_a_different_root_is_fine(self, mock_paho):
3070+
"""Ids only have to be unique within a tree's ancestry, not globally."""
3071+
root_a, _ = _make_device(mock_paho, device_id="enclosure-a")
3072+
root_b, _ = _make_device(mock_paho, device_id="enclosure-b")
3073+
Device("circuit-1", parent=root_a)
3074+
Device("circuit-1", parent=root_b)
3075+
assert root_a.children_ids() == ["circuit-1"]
3076+
assert root_b.children_ids() == ["circuit-1"]
3077+
3078+
def test_two_children_of_one_parent_cannot_share_an_id(self, mock_paho):
3079+
"""Same defect one step sideways: identical base topics, and a child named twice."""
3080+
root, _ = _make_device(mock_paho, device_id="enclosure-1")
3081+
Device("circuit-1", parent=root)
3082+
with pytest.raises(ValueError, match="already has a child with this id"):
3083+
Device("circuit-1", parent=root)
3084+
assert root.children_ids() == ["circuit-1"]
3085+
3086+
def test_recreating_a_deleted_child_is_allowed(self, mock_paho):
3087+
"""delete() detaches, so rebuild-after-delete is not a false positive."""
3088+
root, _ = _make_device(mock_paho, device_id="enclosure-1")
3089+
child = Device("circuit-1", parent=root)
3090+
child.delete()
3091+
assert root.children_ids() == []
3092+
Device("circuit-1", parent=root) # must not raise
3093+
assert root.children_ids() == ["circuit-1"]

0 commit comments

Comments
 (0)