Skip to content

Commit f459152

Browse files
dcjclaude
andcommitted
fix: five defects in the declarative builder, all silent in production
Reported by a consumer converting a multi-device adapter onto the builder, each with a runnable reproduction. All five verified against the reproductions before and after. #72 conditionally_settable was inert: _materialize never read it. The property did come out not-settable, which is the half that looked right; the entity_setter was registered only when settable was true, so the caller's later set_settable(True) opened a /set topic with no translator behind it. The property advertised that it accepts commands and discarded them, which is the exact failure the field exists to avoid, one step further along. The translator is now wired at build time even though the property starts not-settable. The test that shipped with the feature asserted only the not-settable half. A test written from the design rationale checks the rationale, not the feature. #77 A value the model already held never reached the wire. The binding is on-change and a fresh twin starts empty, so a producer whose model predates the tree announced its declared default on every property, and it did not self-heal, because set_value fires only on an actual change. Reachable only since 0.22.0: before the reuse fix the model property was replaced, so twin and model started equally empty. Also: a declared initial_value now seeds rather than overwrites, since a live model value is fresher than a declaration. #73 remove() raised when the producer's model had already dropped the group. delete_group removes it BEFORE firing GROUP_DELETED and dispatch is synchronous, so a consumer driving remove() from that event always hit it, after device.delete() and before the bookkeeping pop: gone from the broker, still a corpse in the builder short-circuiting the next add(). #75 remove() left deferred descendants queued, so resolve_deferred() rebuilt a device that had been torn down. #76 add() recorded bookkeeping after materializing, so a raise left a live broker-visible device with no record, and the same window admitted re-entry from a producer observing its own model. Closes #72 Closes #73 Closes #75 Closes #76 Closes #77 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4bb4577 commit f459152

4 files changed

Lines changed: 292 additions & 19 deletions

File tree

CHANGELOG.md

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

55
## [Unreleased]
66

7+
### Fixed
8+
9+
- `conditionally_settable` was inert: `_materialize` never read it. The half that looked right is that the property did come out not-settable; the half that bit is that the `entity_setter` was registered only when `settable` was true, so the caller's later `set_settable(True)` opened a `/set` topic with no translator behind it. The property then advertised that it accepts commands and silently discarded them, which is the exact failure the field was introduced to avoid, one step further along. It is the only route the API offers for per-instance settability decided at runtime, and it was the route that did not work. The translator is now wired at build time even though the property starts not-settable. The test that shipped with the feature asserted only the not-settable half, which is why this survived review: a test written from the design rationale checks the rationale rather than the feature. ([#72](https://github.com/electrification-bus/python-sdk/issues/72))
10+
11+
- A value the model already held is now published when its Homie twin is built. The binding is on-change and a fresh twin starts empty, so a producer whose model predates the tree (the arrangement `DeviceTreeBuilder` documents as its reason for accepting an external model) announced its declared default instead of the live value, on every property. It did not self-heal, because `set_value` fires callbacks only on an actual change, so a value written once at group creation stayed wrong for the process lifetime. This became reachable in 0.22.0: before the reuse fix the model property was replaced, so twin and model started equally empty. Relatedly, a declared `initial_value` now SEEDS rather than overwrites, since a model already holding a value holds a fresher one than the declaration; an explicit `values` entry still wins, being a statement about this run. ([#77](https://github.com/electrification-bus/python-sdk/issues/77))
12+
13+
- `DeviceTreeBuilder.remove()` no longer raises when the producer's model has already dropped the group. `GroupedPropertyDict.delete_group` removes the group BEFORE firing `GROUP_DELETED` and dispatch is synchronous, so a consumer driving `remove()` from that event was guaranteed to hit it. It raised after `device.delete()` and before the bookkeeping pop, leaving the device gone from the broker while the builder still held a corpse that short-circuited the next `add()`: unrecoverable for the process lifetime, and inside an observer callback it surfaced as a single swallowed warning. Bookkeeping is now dropped in a `finally`, and the per-property `deletePropertyGroupNotFound` warning burst is gone with it, which matters on bounded-disk fleet devices. ([#73](https://github.com/electrification-bus/python-sdk/issues/73))
14+
15+
- `DeviceTreeBuilder.remove()` now prunes deferred descendants, not only the removed spec. A deferred child holds a frozen reference to its parent spec, so `resolve_deferred()` would rebuild a device that had been deliberately torn down. The shape most likely to hit it is a mandatory child deferred on a late identifier, which sits in the queue for exactly the window in which its parent might be removed. ([#75](https://github.com/electrification-bus/python-sdk/issues/75))
16+
17+
- `DeviceTreeBuilder.add()` records its bookkeeping before materializing rather than after. The device is constructed, attached and broker-visible by then, so a raise left a live device the builder had no record of: `device_for()` returned None, `remove()` was a silent no-op, and the retained topics were stranded. The same window admitted re-entry, since the model's events dispatch synchronously and a producer observing its own model could call `add()` again before the cache entry existed. ([#76](https://github.com/electrification-bus/python-sdk/issues/76))
18+
719
## [0.22.0] — 2026-08-20
820

921
### Added

src/ebus_sdk/declaration.py

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,11 @@ class PropertySpec:
8181
runtime, per instance. The builder materializes it NOT settable, which
8282
keeps `$description` truthful and leaves no `/set` subscription open on a
8383
property that would reject the command; the caller enables it with
84-
`homie.Property.set_settable(True)` inside a `state_transition()`. It is
85-
mutually exclusive with `settable`, which means "settable now".
84+
`homie.Property.set_settable(True)` inside a `state_transition()`. The
85+
`entity_setter` is wired at build time even though the property starts
86+
not-settable, because `set_settable(True)` subscribes immediately and a
87+
`/set` topic with no translator behind it accepts commands and discards
88+
them. It is mutually exclusive with `settable`, which means "settable now".
8689
* `source_id` / `model_group`: the observable-model identity, when it
8790
differs from the wire identity. `source_id` defaults to `prop_id` and
8891
`model_group` to `capability`, so they are fused unless split. Splitting
@@ -306,7 +309,9 @@ def _materialize(
306309
declared[(capability, spec.prop_id)] = spec
307310
# An entity_setter is the translator toward the entity, so it is
308311
# registered whenever one is given and the model can reach it.
309-
if spec.entity_setter is not None and (spec.settable or spec.internal_only):
312+
if spec.entity_setter is not None and (
313+
spec.settable or spec.conditionally_settable or spec.internal_only
314+
):
310315
model.set_entity_setter(group, spec.model_key, spec.entity_setter)
311316
if spec.internal_only or node is None:
312317
continue
@@ -333,10 +338,23 @@ def _materialize(
333338
continue
334339
homie_prop = node.add_property_from_dict(prop_dict)
335340
bind_property_to_homie(model, group, spec.model_key, homie_prop)
341+
# The binding is on-change, and the twin starts empty, so a value
342+
# the model was already holding would never reach the wire: a
343+
# producer whose model predates the tree would publish its
344+
# declared default forever, and it would not self-heal, because
345+
# set_value fires callbacks only on an actual change.
346+
current = model.value(group, spec.model_key)
347+
if current is not None:
348+
homie_prop.set_value(current)
336349
# Inbound/control path for a settable property with a translator:
337350
# /set payload -> model.set_entity -> entity_setter. The /set
338351
# subscription is already live from add_property -> set_subscribe.
339-
if spec.settable and spec.entity_setter is not None:
352+
# conditionally_settable too, and this is the whole point of it:
353+
# the property is built not-settable, so no /set topic is open
354+
# yet, but the caller flips it with set_settable(True) later and
355+
# that subscribes immediately. Wiring the translator now is what
356+
# stops that topic from accepting commands and discarding them.
357+
if spec.entity_setter is not None and (spec.settable or spec.conditionally_settable):
340358
homie_prop.set_set_callback(partial(model.set_entity, group, spec.model_key))
341359
homie_props[(capability, spec.prop_id)] = homie_prop
342360

@@ -381,8 +399,14 @@ def _seed(
381399
them: a caller passing a runtime map is being more specific than the static
382400
declaration. Entries naming an undeclared property are ignored.
383401
"""
402+
# A declared initial_value SEEDS, it does not overwrite: a model that already
403+
# holds a value for this property holds a fresher one than the declaration.
404+
# An explicit `values` entry still wins below, since that caller is being
405+
# specific about this run rather than about the property in general.
384406
seed: dict[tuple, Any] = {
385-
key: spec.initial_value for key, spec in declared.items() if spec.initial_value is not None
407+
key: spec.initial_value
408+
for key, spec in declared.items()
409+
if spec.initial_value is not None and model.value(_group_for(spec, default_group), spec.model_key) is None
386410
}
387411
if values:
388412
seed.update({key: value for key, value in values.items() if key in declared})
@@ -560,6 +584,17 @@ def add(self, spec: DeviceSpec) -> Optional[Device]:
560584
type=spec.resolve_device_type(),
561585
parent=parent_device,
562586
)
587+
# Record BEFORE materializing. The device is already constructed,
588+
# attached and visible on the broker, so a raise below (or a re-entrant
589+
# add() from a producer observing its own model, since the model's events
590+
# dispatch synchronously) must not leave a live device the builder has no
591+
# record of: device_for() would return None and remove() would be a
592+
# silent no-op, stranding retained topics.
593+
self._devices[spec] = device
594+
self._homie_props[spec] = {}
595+
self._model_keys[spec] = []
596+
self._created_groups[spec] = []
597+
563598
group = spec.resolve_model_group(device_id)
564599
built = _materialize(
565600
device,
@@ -572,10 +607,9 @@ def add(self, spec: DeviceSpec) -> Optional[Device]:
572607
)
573608
_seed(self._model, built.declared, default_group=group)
574609

575-
self._devices[spec] = device
576-
self._homie_props[spec] = built.homie_props
577-
self._model_keys[spec] = built.model_keys
578-
self._created_groups[spec] = built.created_groups
610+
self._homie_props[spec].update(built.homie_props)
611+
self._model_keys[spec].extend(built.model_keys)
612+
self._created_groups[spec].extend(built.created_groups)
579613
if spec in self._deferred:
580614
self._deferred.remove(spec)
581615
if spec.on_created is not None:
@@ -609,23 +643,38 @@ def remove(self, spec: DeviceSpec) -> None:
609643
too, along with any group it created that is now empty; a group the
610644
caller created, or one still in use, is left alone.
611645
"""
646+
# Deferred descendants go whether or not this spec was ever built: a
647+
# deferred child holds a frozen reference to its parent spec, so leaving
648+
# it in the queue lets resolve_deferred() rebuild a device that was
649+
# deliberately torn down.
650+
self._deferred = [s for s in self._deferred if not _descends_from(s, spec)]
651+
612652
device = self._devices.get(spec)
613653
if device is None:
614-
if spec in self._deferred:
615-
self._deferred.remove(spec) # never built, just stop waiting for it
616-
return
654+
return # never built (or already removed); the queue is now clean
617655

618656
doomed = {id(d) for d in _descendants(device)}
619657
removed = [s for s, d in self._devices.items() if id(d) in doomed]
620658
device.delete()
621659
for gone in removed:
622-
for group, model_key in self._model_keys.pop(gone, []):
623-
self._model.delete_property(group, model_key)
624-
for group in self._created_groups.pop(gone, []):
625-
if not self._model.items(group):
626-
self._model.delete_group(group)
627-
self._devices.pop(gone, None)
628-
self._homie_props.pop(gone, None)
660+
# Bookkeeping is dropped whatever the model does, so a teardown can
661+
# never leave a corpse in _devices that short-circuits the next
662+
# add(). The model may legitimately have moved on already: a consumer
663+
# driving remove() from a GROUP_DELETED observer is guaranteed to
664+
# arrive after the group is gone, since delete_group removes it
665+
# before firing and dispatch is synchronous.
666+
try:
667+
for group, model_key in self._model_keys.get(gone, []):
668+
if self._model.has_group(group) and self._model.get(group, model_key) is not None:
669+
self._model.delete_property(group, model_key)
670+
for group in self._created_groups.get(gone, []):
671+
if self._model.has_group(group) and not self._model.items(group):
672+
self._model.delete_group(group)
673+
finally:
674+
self._model_keys.pop(gone, None)
675+
self._created_groups.pop(gone, None)
676+
self._devices.pop(gone, None)
677+
self._homie_props.pop(gone, None)
629678

630679
def add_root_capabilities(self, specs: Iterable[PropertySpec], *, model_group: Optional[str] = None) -> dict:
631680
"""Materialize capabilities onto the tree's ROOT device.
@@ -724,6 +773,16 @@ def _defer(self, spec: DeviceSpec) -> None:
724773
self._deferred.append(spec)
725774

726775

776+
def _descends_from(spec: DeviceSpec, ancestor: DeviceSpec) -> bool:
777+
"""True when `spec` is `ancestor` or is declared beneath it."""
778+
current: Optional[DeviceSpec] = spec
779+
while current is not None:
780+
if current is ancestor:
781+
return True
782+
current = current.parent
783+
return False
784+
785+
727786
def _descendants(device: Device) -> list:
728787
"""`device` and every device beneath it, parents before children."""
729788
found = [device]

tests/test_declaration.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,3 +592,105 @@ def test_reuse_still_binds_and_publishes(mock_paho):
592592
)
593593
model.set_value("info", "serial-number", "SN-NEW")
594594
assert homie_props[("info", "serial-number")].value() == "SN-NEW"
595+
596+
597+
# --- conditionally_settable reaches the inbound path (GH #72) ----------------
598+
599+
600+
def test_conditionally_settable_registers_its_entity_setter(mock_paho):
601+
device = _device(mock_paho, "dev-cond-setter")
602+
model = GroupedPropertyDict()
603+
received = []
604+
build_from_declarations(
605+
device,
606+
model,
607+
[
608+
PropertySpec(
609+
"control",
610+
"limit",
611+
PropertyDatatype.FLOAT,
612+
conditionally_settable=True,
613+
entity_setter=received.append,
614+
)
615+
],
616+
)
617+
model.set_entity("control", "limit", 42.0)
618+
assert received == [42.0], "the translator was never registered on the model"
619+
620+
621+
def test_enabling_a_conditionally_settable_property_gives_a_working_set_topic(mock_paho):
622+
"""The whole point of the field: the /set topic opened later must have a handler."""
623+
device = _device(mock_paho, "dev-cond-flip")
624+
model = GroupedPropertyDict()
625+
received = []
626+
props = build_from_declarations(
627+
device,
628+
model,
629+
[
630+
PropertySpec(
631+
"control",
632+
"limit",
633+
PropertyDatatype.FLOAT,
634+
conditionally_settable=True,
635+
entity_setter=received.append,
636+
)
637+
],
638+
)
639+
hp = props[("control", "limit")]
640+
assert hp.settable() is False # still built not-settable
641+
642+
with device.state_transition():
643+
hp.set_settable(True)
644+
645+
# Subscribed AND wired: a topic that accepts commands and discards them is
646+
# the failure this field exists to avoid.
647+
assert [c for c in mock_paho.subscribe.call_args_list if c.args and str(c.args[0]).endswith("/control/limit/set")]
648+
assert hp.get_set_callback() is not None
649+
hp.get_set_callback()("42.0")
650+
assert received == ["42.0"]
651+
652+
653+
# --- a live model value reaches the wire (GH #77) ----------------------------
654+
655+
656+
def test_a_value_the_model_already_held_is_published(mock_paho):
657+
device = _device(mock_paho, "dev-live")
658+
model = _live_model(group="info") # holds SN-LIVE before any tree exists
659+
props = build_from_declarations(device, model, [PropertySpec("info", "serial-number", PropertyDatatype.STRING)])
660+
# The binding is on-change and the twin starts empty, so without an explicit
661+
# push the wire would show the declared default for the process lifetime.
662+
assert props[("info", "serial-number")].value() == "SN-LIVE"
663+
assert "SN-LIVE" in [c.args[1] for c in mock_paho.publish.call_args_list if len(c.args) > 1]
664+
665+
666+
def test_initial_value_seeds_but_does_not_clobber_a_live_value(mock_paho):
667+
device = _device(mock_paho, "dev-live-2")
668+
model = _live_model(group="info")
669+
build_from_declarations(
670+
device,
671+
model,
672+
[PropertySpec("info", "serial-number", PropertyDatatype.STRING, initial_value="DECLARED")],
673+
)
674+
assert model.value("info", "serial-number") == "SN-LIVE"
675+
676+
677+
def test_an_explicit_values_entry_still_wins_over_a_live_value(mock_paho):
678+
"""`values` is a statement about this run, so it is more specific than either."""
679+
device = _device(mock_paho, "dev-live-3")
680+
model = _live_model(group="info")
681+
build_from_declarations(
682+
device,
683+
model,
684+
[PropertySpec("info", "serial-number", PropertyDatatype.STRING)],
685+
values={("info", "serial-number"): "RUNTIME"},
686+
)
687+
assert model.value("info", "serial-number") == "RUNTIME"
688+
689+
690+
def test_the_tree_builder_publishes_live_values_too(mock_paho):
691+
root = _device(mock_paho, "enclosure-live")
692+
model = _live_model(group="dev-1")
693+
builder = DeviceTreeBuilder(root, model)
694+
spec = DeviceSpec("circuit", [PropertySpec("info", "serial-number", PropertyDatatype.STRING)], device_id="dev-1")
695+
builder.add(spec)
696+
assert builder.homie_properties(spec)[("info", "serial-number")].value() == "SN-LIVE"

0 commit comments

Comments
 (0)