You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: root capabilities and extend(), and make materialization idempotent
Two gaps reported against 0.21.0's DeviceTreeBuilder, plus the
idempotence both of them need.
root's own surfaces (an enclosure's aggregate metering, its state, its
controls) had no declarative expression. A producer had to hand-roll the
root beside the builder: one model, two construction styles, and the root
outside every guarantee the builder gives. The root already exists, so
this materializes onto it; the model group defaults to the root's device
id, matching how add() keys a child.
first published. add() short-circuits an already-built spec, so the
builder modeled devices appearing and disappearing but not growing. The
workaround was unsafe rather than absent: Device.add_node and
Node.add_property both replace wholesale, so re-declaring a live device
dropped the previous node's properties from $description while leaving
their retained topics on the broker, and only delete_node clears those.
Description and broker then disagree, across restarts.
Idempotence, at three levels, which both features need and which
build_from_declarations now gets too:
node reused rather than replaced (the mechanism behind the
description-versus-broker divergence above)
property reused rather than re-added (add_property replaces and
republishes with force=True)
the transition itself not opened at all when nothing would be
created. An empty state_transition still emits init -> ready,
and that edge forces every controller on the bus to resync,
so a re-declaration that changes nothing must not cost one.
The third was found by a test asserting extend() publishes nothing on a
re-declare: content was already idempotent, the state flap was not.
Closes#67Closes#68
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+8Lines changed: 8 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,8 +6,16 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan
6
6
7
7
### Added
8
8
9
+
-`DeviceTreeBuilder.add_root_capabilities()`: the tree's root can carry its own capabilities. `add()` only ever creates children, so a root's own surfaces (an enclosure's aggregate metering, its state, its controls) had no declarative expression and had to be hand-rolled beside the builder, which left one model with two construction styles and put the root outside every guarantee the builder gives (idempotence, ordered teardown, model cleanup). The root already exists, so this materializes onto it rather than constructing anything; the model group defaults to the root's device id, matching how `add()` keys a child. `root_capabilities()` reads back what has accumulated. ([#67](https://github.com/electrification-bus/python-sdk/issues/67))
10
+
11
+
- `DeviceTreeBuilder.extend()`: a device that already exists can grow a capability. A capability set is not always known when a device is first published, since a storage system commissioned at runtime gives an enclosure shed and forecast surfaces it did not have at boot, and `add()` short-circuits an already-built spec, so the builder modeled devices appearing and disappearing but not a device growing. The workaround was unsafe rather than merely absent: `Device.add_node` and `Node.add_property` both replace wholesale, so re-declaring a live device through `build_from_declarations` dropped the previous node's properties from `$description` while leaving their retained topics on the broker, and only `delete_node` clears those. The result was a tree whose description and whose broker state disagreed, persisting across restarts. `extend()` materializes inside one `state_transition()` and folds the new model keys into the same bookkeeping `remove()` uses. ([#68](https://github.com/electrification-bus/python-sdk/issues/68))
12
+
9
13
-`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))
10
14
15
+
### Changed
16
+
17
+
- Materializing declarations is now idempotent, at three levels. An existing Homie **node** is reused rather than replaced (`Device.add_node` is a wholesale `self._nodes.update(...)`, which is the mechanism behind the description-versus-broker divergence above); an existing Homie **property** is reused rather than re-added (`Node.add_property` replaces and republishes with `force=True`); and a materialization that would create nothing **does not open a state transition at all**. That last one matters most: an empty transition still emits `init` then `ready`, and that edge forces every controller on the bus to resync, so a re-declaration that changes nothing must not cost one. Together these make `build_from_declarations` safe to call twice, which is what lets a re-fired incremental lifecycle be a genuine no-op rather than a quieter republish.
18
+
11
19
### Fixed
12
20
13
21
- `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))
Copy file name to clipboardExpand all lines: doc/building-a-proxy.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -196,6 +196,8 @@ Four things the tree builder does that the single-device one has no need to:
196
196
-**`add()` is idempotent.** Incremental lifecycles re-fire, and a second `add()` of a built spec returns the same `Device` without republishing anything.
197
197
-**`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.
198
198
199
+
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).
200
+
199
201
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.
0 commit comments