Skip to content

Commit a2c9ad7

Browse files
dcjclaude
andcommitted
feat: key the builder on device id, and give extend() an inverse
#74: DeviceTreeBuilder keyed its bookkeeping on DeviceSpec object identity. A producer deriving its spec set from a manifest re-derives equal-but-distinct objects on every pass, so identity keying made each pass a new device. The alternative was an unstated obligation: hold a device_id -> DeviceSpec map for the process lifetime and never re-derive, which is exactly what a declarative API exists to avoid. Now keyed on the resolved device id. add(), remove(), extend(), device_for() and homie_properties() all answer for any spec naming the same device. Deferred specs stay keyed by identity, having no id yet by definition. One semantic decided explicitly: add() is idempotent on the DEVICE, not on the declaration. A differing capability set on an already-built id returns the existing device unchanged rather than applying the difference, because add() silently mutating a live tree is not what its name suggests. extend() is how a built device grows. The test that asserted the old contract (device_for on an equal spec returns None) is rewritten to assert the new one rather than deleted, so the change of contract is visible in the diff. #78: remove_capabilities(), the inverse of extend(). A capability that becomes relevant at runtime can stop being relevant, and its node otherwise stayed advertised with retained topics behind it. Device.delete_node already clears those and re-announces; the gap was the bookkeeping, since reaching around the builder left model_keys and created_groups describing properties that no longer existed and a later remove() working from that stale record. Bookkeeping now carries the capability, so a node's share of it is identifiable. Closes #74 Closes #78 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f459152 commit a2c9ad7

4 files changed

Lines changed: 224 additions & 30 deletions

File tree

CHANGELOG.md

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

55
## [Unreleased]
66

7+
### Added
8+
9+
- `DeviceTreeBuilder.remove_capabilities()`: the inverse of `extend()`. A capability that becomes relevant at runtime can stop being relevant, and without this its node stayed advertised in `$description` with retained topics behind it. `Device.delete_node()` already clears those and re-announces, so what this closes is the bookkeeping: reaching around the builder to call it left `model_keys` and `created_groups` describing properties that no longer existed, and a later `remove()` working from that stale record. Idempotent like `extend()`, and named for capabilities rather than nodes because that is the declarative vocabulary. ([#78](https://github.com/electrification-bus/python-sdk/issues/78))
10+
11+
### Changed
12+
13+
- `DeviceTreeBuilder` keys its bookkeeping on the **resolved device id** rather than on `DeviceSpec` object identity. A producer deriving its spec set from a manifest re-derives equal-but-distinct objects on every pass, and identity keying made each pass a new device; the alternative was an unstated obligation to hold a `device_id -> DeviceSpec` map for the process lifetime and never re-derive, which is precisely what a declarative API exists to avoid. `add()`, `remove()`, `extend()`, `device_for()` and `homie_properties()` now all answer for any spec naming the same device. `add()` remains idempotent on the DEVICE rather than on the declaration: a differing capability set on an already-built id returns the existing device unchanged rather than applying the difference, since `add()` mutating a live tree is not what its name suggests; `extend()` is how a built device grows. Deferred specs stay keyed by identity, having no id yet by definition. ([#74](https://github.com/electrification-bus/python-sdk/issues/74))
14+
715
### Fixed
816

917
- `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))

doc/building-a-proxy.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ Four things the tree builder does that the single-device one has no need to:
196196
- **`add()` is idempotent.** Incremental lifecycles re-fire, and a second `add()` of a built spec returns the same `Device` without republishing anything.
197197
- **`remove()` is depth-first**, grandchild before parent, derived from the live tree rather than an ordering you maintain, so nothing ever observes an orphaned child. It also deletes the model entries it added, and any group it created that is now empty.
198198

199+
Specs are matched by the device id they resolve to, not by object identity, so you can re-derive your spec set from a manifest on every pass and `add()` / `extend()` / `remove()` keep answering for the same device. `add()` is idempotent on the device rather than the declaration: a differing capability set on a built id returns the existing device, and `extend(spec, specs)` / `remove_capabilities(spec, capabilities)` are how a built device grows and shrinks.
200+
199201
The root is not only a parent: `builder.add_root_capabilities(specs)` materializes capabilities onto the root device itself, keyed in the model by the root's device id, and `builder.extend(spec, specs)` gives an already-built device a capability it did not have at boot. Both are idempotent, and a call that would create nothing does not open a state transition at all, so a re-fired lifecycle costs no `init` to `ready` edge (an empty one still forces every controller on the bus to resync).
200202

201203
Each `add()` announces its own device and makes the parent republish its `$description`. To collapse a burst of adds into one parent announcement, wrap them in the parent's `state_transition()`. Use `on_created` for per-child side effects rather than post-processing the returned tree.

src/ebus_sdk/declaration.py

Lines changed: 89 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ class _Materialized:
210210

211211
homie_props: dict
212212
declared: dict
213-
model_keys: list
213+
model_keys: list # (capability, group, model_key), so a node's share is identifiable
214214
created_groups: list
215215

216216

@@ -297,7 +297,7 @@ def _materialize(
297297
model.add_property(group, ObservableProperty(id=spec.model_key, type=py_type))
298298
# Only what this call created, so a later remove() deletes what
299299
# it added and leaves anything the producer owned first.
300-
model_keys.append((group, spec.model_key))
300+
model_keys.append((capability, group, spec.model_key))
301301
elif existing.type() is not py_type:
302302
raise ValueError(
303303
f"{capability}/{spec.prop_id}: the model already holds "
@@ -561,19 +561,29 @@ def add(self, spec: DeviceSpec) -> Optional[Device]:
561561
the same `Device` without touching the tree, because incremental
562562
lifecycles re-fire and a second add must not republish or duplicate.
563563
"""
564-
existing = self._devices.get(spec)
565-
if existing is not None:
566-
return existing
564+
# Keyed on the RESOLVED DEVICE ID, not on this spec object. A producer
565+
# deriving its spec set from a manifest re-derives equal-but-distinct
566+
# objects on every pass, and identity keying silently made each pass a
567+
# new device; the alternative was an unstated obligation to hold a
568+
# device_id -> DeviceSpec map for the process lifetime and never
569+
# re-derive, which defeats the point of a declarative API. A spec whose
570+
# id is already built returns that device unchanged: to give a built
571+
# device more capabilities, use extend().
572+
device_id = spec.resolve_device_id()
573+
if device_id is not None:
574+
existing = self._devices.get(device_id)
575+
if existing is not None:
576+
return existing
567577

568578
if spec.parent is None:
569579
parent_device: Optional[Device] = self._root
570580
else:
571-
parent_device = self._devices.get(spec.parent) or self.add(spec.parent)
581+
parent_id = spec.parent.resolve_device_id()
582+
parent_device = (self._devices.get(parent_id) if parent_id else None) or self.add(spec.parent)
572583
if parent_device is None:
573584
self._defer(spec) # the parent is itself waiting on an id
574585
return None
575586

576-
device_id = spec.resolve_device_id()
577587
if device_id is None:
578588
self._defer(spec)
579589
return None
@@ -590,10 +600,10 @@ def add(self, spec: DeviceSpec) -> Optional[Device]:
590600
# dispatch synchronously) must not leave a live device the builder has no
591601
# record of: device_for() would return None and remove() would be a
592602
# 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] = []
603+
self._devices[device_id] = device
604+
self._homie_props[device_id] = {}
605+
self._model_keys[device_id] = []
606+
self._created_groups[device_id] = []
597607

598608
group = spec.resolve_model_group(device_id)
599609
built = _materialize(
@@ -607,9 +617,9 @@ def add(self, spec: DeviceSpec) -> Optional[Device]:
607617
)
608618
_seed(self._model, built.declared, default_group=group)
609619

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)
620+
self._homie_props[device_id].update(built.homie_props)
621+
self._model_keys[device_id].extend(built.model_keys)
622+
self._created_groups[device_id].extend(built.created_groups)
613623
if spec in self._deferred:
614624
self._deferred.remove(spec)
615625
if spec.on_created is not None:
@@ -649,12 +659,13 @@ def remove(self, spec: DeviceSpec) -> None:
649659
# deliberately torn down.
650660
self._deferred = [s for s in self._deferred if not _descends_from(s, spec)]
651661

652-
device = self._devices.get(spec)
662+
device_id = spec.resolve_device_id()
663+
device = self._devices.get(device_id) if device_id is not None else None
653664
if device is None:
654665
return # never built (or already removed); the queue is now clean
655666

656667
doomed = {id(d) for d in _descendants(device)}
657-
removed = [s for s, d in self._devices.items() if id(d) in doomed]
668+
removed = [k for k, d in self._devices.items() if id(d) in doomed]
658669
device.delete()
659670
for gone in removed:
660671
# Bookkeeping is dropped whatever the model does, so a teardown can
@@ -664,7 +675,7 @@ def remove(self, spec: DeviceSpec) -> None:
664675
# arrive after the group is gone, since delete_group removes it
665676
# before firing and dispatch is synchronous.
666677
try:
667-
for group, model_key in self._model_keys.get(gone, []):
678+
for _capability, group, model_key in self._model_keys.get(gone, []):
668679
if self._model.has_group(group) and self._model.get(group, model_key) is not None:
669680
self._model.delete_property(group, model_key)
670681
for group in self._created_groups.get(gone, []):
@@ -706,7 +717,7 @@ def add_root_capabilities(self, specs: Iterable[PropertySpec], *, model_group: O
706717
)
707718
_seed(self._model, built.declared, default_group=group)
708719
self._root_props.update(built.homie_props)
709-
self._root_model_keys.extend(built.model_keys)
720+
self._root_model_keys.extend(built.model_keys) # (capability, group, model_key)
710721
return dict(self._root_props)
711722

712723
def root_capabilities(self) -> dict:
@@ -730,7 +741,8 @@ def extend(self, spec: DeviceSpec, specs: Iterable[PropertySpec]) -> dict:
730741
Raises `KeyError` for a spec that is not built. Use `add()` first; a
731742
deferred device has no tree to extend.
732743
"""
733-
device = self._devices.get(spec)
744+
device_id = spec.resolve_device_id()
745+
device = self._devices.get(device_id) if device_id is not None else None
734746
if device is None:
735747
raise KeyError(
736748
f"{spec.device_class}: not built, so there is nothing to extend. "
@@ -747,22 +759,73 @@ def extend(self, spec: DeviceSpec, specs: Iterable[PropertySpec]) -> dict:
747759
default_group=group,
748760
)
749761
_seed(self._model, built.declared, default_group=group)
750-
self._homie_props[spec].update(built.homie_props)
751-
self._model_keys[spec].extend(built.model_keys)
752-
self._created_groups[spec].extend(built.created_groups)
753-
return dict(self._homie_props[spec])
762+
self._homie_props[device_id].update(built.homie_props)
763+
self._model_keys[device_id].extend(built.model_keys)
764+
self._created_groups[device_id].extend(built.created_groups)
765+
return dict(self._homie_props[device_id])
766+
767+
def remove_capabilities(self, spec: DeviceSpec, capabilities: Iterable[str]) -> None:
768+
"""Take capabilities away from a built device: the inverse of `extend()`.
769+
770+
A capability that becomes relevant at runtime can stop being relevant,
771+
and without this its node stayed advertised in `$description` with
772+
retained topics behind it. `Device.delete_node()` already clears those
773+
and re-announces, so the gap this closes is the bookkeeping: reaching
774+
around the builder to call it left `model_keys` and `created_groups`
775+
describing properties that no longer exist, and a later `remove()`
776+
working from that stale record.
777+
778+
Idempotent, like `extend()`: a capability the device does not have is
779+
skipped rather than an error, because incremental lifecycles re-fire.
780+
Named for capabilities rather than nodes because that is the declarative
781+
vocabulary; the node id is resolved through the builder's `node_id`.
782+
783+
Raises `KeyError` for a spec that is not built.
784+
"""
785+
device_id = spec.resolve_device_id()
786+
device = self._devices.get(device_id) if device_id is not None else None
787+
if device is None:
788+
raise KeyError(f"{spec.device_class}: not built, so there is nothing to remove from. add() it first.")
789+
790+
for capability in capabilities:
791+
if device.get_node(self._node_id(capability)) is None:
792+
continue # already gone, or never had it
793+
device.delete_node(self._node_id(capability))
794+
795+
doomed = [entry for entry in self._model_keys[device_id] if entry[0] == capability]
796+
for _capability, group, model_key in doomed:
797+
if self._model.has_group(group) and self._model.get(group, model_key) is not None:
798+
self._model.delete_property(group, model_key)
799+
self._model_keys[device_id] = [entry for entry in self._model_keys[device_id] if entry[0] != capability]
800+
self._homie_props[device_id] = {
801+
key: prop for key, prop in self._homie_props[device_id].items() if key[0] != capability
802+
}
803+
# A group this builder created and that is now empty goes with it.
804+
for group in {entry[1] for entry in doomed}:
805+
if (
806+
group in self._created_groups[device_id]
807+
and self._model.has_group(group)
808+
and not self._model.items(group)
809+
):
810+
self._model.delete_group(group)
811+
self._created_groups[device_id] = [g for g in self._created_groups[device_id] if g != group]
754812

755813
def device_for(self, spec: DeviceSpec) -> Optional[Device]:
756-
"""The live `Device` for `spec`, or None if it is deferred or removed."""
757-
return self._devices.get(spec)
814+
"""The live `Device` for `spec`, or None if it is deferred or removed.
815+
816+
Resolved by device id, so any spec naming the same device answers.
817+
"""
818+
device_id = spec.resolve_device_id()
819+
return self._devices.get(device_id) if device_id is not None else None
758820

759821
def homie_properties(self, spec: DeviceSpec) -> dict:
760822
"""`{(capability, prop_id): homie.Property}` for `spec`, as the single-device builder returns.
761823
762824
Empty for a spec that is not built. An `internal_only` property has no
763825
Homie twin and so is absent, exactly as in `build_from_declarations`.
764826
"""
765-
return self._homie_props.get(spec, {})
827+
device_id = spec.resolve_device_id()
828+
return dict(self._homie_props.get(device_id, {})) if device_id is not None else {}
766829

767830
def deferred(self) -> list:
768831
"""The specs waiting on an id, in the order they were first attempted."""

0 commit comments

Comments
 (0)