feat: DeviceSpec and a tree-aware incremental builder - #60
Merged
Conversation
build_from_declarations materializes exactly one device and creates the
observable model itself, keyed by capability. That fits the single-device
proxy the SDK was first written for and cannot express what the eBus
framework actually describes: a root device whose circuits, lugs, MID and
DERs are child devices, each with its own id, $state, $description and
capability set. Three independent consumers had hand-rolled the same
layer on top of homie.Device(parent=...), which is evidence about the SDK
rather than about them.
DeviceSpec carries the device-level facts (class, id, parent, model
group) so they are not repeated on every property of the device.
device_type defaults to energy.ebus.device.{device_class}; that default
is load-bearing rather than convenient, because the SDK stores
Device.type verbatim and validates nothing against a registry, so a
hand-written type ships misspelled without complaint.
DeviceTreeBuilder covers the four things a tree needs that one device
does not:
external model Passed in, never created, and keyed per DEVICE. Keying
by capability collides the moment two children both
expose `info`, while they stay perfectly distinct on
the wire. A PropertySpec naming its own model_group
still wins, so a consumer with an existing model keeps
its keying.
late-bound ids device_id may be a callable returning None while an
asynchronous identifier has not arrived. add() returns
None and remembers the spec; resolve_deferred() builds
a whole generation, including children waiting behind
a deferred parent. Waiting beats guessing: a child
published under a wrong-but-stable id leaves retained
topics that outlive restarts and firmware updates.
idempotent add Incremental lifecycles re-fire, so a second add() of a
built spec returns the same Device and publishes
nothing.
depth-first rm remove() tears down grandchild before parent, derived
from the live tree rather than a caller-maintained
ordering, so nothing observes an orphaned child. It
also deletes the model entries it added and any group
it created that is now empty, leaving the caller's own
groups alone.
on_created carries per-child side effects so consumers do not have to
post-process the returned tree.
Extracts _materialize / _seed / _group_for from build_from_declarations
so both builders walk one path. Two copies of node creation, property
creation, binding, entity-setter wiring and seeding is exactly the drift
worth avoiding here; build_from_declarations is now three lines over the
shared helper and its tests are untouched.
Closes #57
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #57. Builds on #59 (the
model_group/source_idsplit, which acceptance criterion 1 needs).What this adds
DeviceSpeccarries the device-level facts, so they are not repeated on every property of the device:DeviceTreeBuildermaterializes them:add,resolve_deferred,remove,device_for, plushomie_properties(spec)anddeferred().homie_propertiesis beyond the API sketched in the issue, and necessary:build_from_declarationsreturns the{(capability, prop_id): homie.Property}map directly, and without an equivalent a tree caller has no way to reach the twins.Acceptance criteria
test_two_children_sharing_a_capability_do_not_collide_in_the_model,test_the_builder_never_creates_the_model,test_a_property_spec_model_group_still_winstest_a_spec_with_an_unresolved_id_defers_instead_of_publishing,test_a_deferred_parent_unblocks_its_deferred_children_in_one_calltest_remove_tears_down_grandchild_before_parentadd()is idempotenttest_add_is_idempotenttest_on_created_runs_once_with_the_live_deviceCriterion 3 gets the direct test the issue asked for. It asserts on publish order, that the grandchild's
$stateretraction precedes the parent's, because this is a transient that a settled-state comparison cannot catch.The refactor to look at first
_materialize/_seed/_group_forare extracted out ofbuild_from_declarations, and both builders now walk that one path. Otherwise there would be two copies of node creation, property creation, binding, entity-setter wiring and seeding, which is the drift #47 warns about when it says "reconcile rather than ship both".build_from_declarationsis three lines over the shared helper and its 24 tests are untouched.Two design calls
Model group is the device, not the device-and-capability.
DeviceSpec.model_groupdefaults to the resolved device id, so one device's properties share one group. That fixes the cross-device collision the issue names and not a within-device one (info/serial-numberandmeter/serial-numberon the same device would both key toserial-number). That case is rare, one device has one serial, andPropertySpec.source_idcovers it when it is not. The alternative, keyingf"{device}-{capability}", collides less at the cost of model keys a caller cannot guess.add()builds an unbuilt parent recursively rather than refusing. Deferring instead would be defensible, but it makes "add the leaf I care about" fail for a reason invisible to the caller.Note on the type-string guard
The issue observes that
Device.typeandNode.typeare stored verbatim with no registry validation, so a misspelling ships silently, and suggests making the derived default the well-lit path. That is whatdevice_type=Nonedoes, and the docs say so plainly rather than presenting it as a convenience.Verification
ruff checkandruff format --checkcleanCHANGELOG.md,README.md,doc/building-a-proxy.mddoc/building-a-proxy.mdgains "Declaring the tree instead of building it by hand" under the existing device-topology section🤖 Generated with Claude Code