Skip to content

feat: DeviceSpec and a tree-aware incremental builder - #60

Merged
dcj merged 1 commit into
mainfrom
feat/device-spec-tree-builder
Aug 20, 2026
Merged

feat: DeviceSpec and a tree-aware incremental builder#60
dcj merged 1 commit into
mainfrom
feat/device-spec-tree-builder

Conversation

@dcj

@dcj dcj commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes #57. Builds on #59 (the model_group / source_id split, which acceptance criterion 1 needs).

What this adds

DeviceSpec carries the device-level facts, so they are not repeated on every property of the device:

DeviceSpec(
    device_class="bess",
    specs=BESS_SPECS,
    device_id=lambda: serial_when_known(),   # None while unresolved
    parent=enclosure_spec,
    model_group=None,                        # defaults to the resolved device id
    device_type=None,                        # defaults to energy.ebus.device.bess
    on_created=emit_acls,
)

DeviceTreeBuilder materializes them: add, resolve_deferred, remove, device_for, plus homie_properties(spec) and deferred().

homie_properties is beyond the API sketched in the issue, and necessary: build_from_declarations returns the {(capability, prop_id): homie.Property} map directly, and without an equivalent a tree caller has no way to reach the twins.

Acceptance criteria

# Criterion Test
1 Model is external, keyed per device 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_wins
2 Late-bound ids are first class test_a_spec_with_an_unresolved_id_defers_instead_of_publishing, test_a_deferred_parent_unblocks_its_deferred_children_in_one_call
3 Deletion is depth-first, grandchild first test_remove_tears_down_grandchild_before_parent
4 add() is idempotent test_add_is_idempotent
5 Per-child side effects test_on_created_runs_once_with_the_live_device

Criterion 3 gets the direct test the issue asked for. It asserts on publish order, that the grandchild's $state retraction 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_for are extracted out of build_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_declarations is 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_group defaults 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-number and meter/serial-number on the same device would both key to serial-number). That case is rare, one device has one serial, and PropertySpec.source_id covers it when it is not. The alternative, keying f"{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.type and Node.type are stored verbatim with no registry validation, so a misspelling ships silently, and suggests making the derived default the well-lit path. That is what device_type=None does, and the docs say so plainly rather than presenting it as a convenience.

Verification

  • 636 tests pass (618 before, 18 new)
  • ruff check and ruff format --check clean
  • markdownlint clean on CHANGELOG.md, README.md, doc/building-a-proxy.md
  • doc/building-a-proxy.md gains "Declaring the tree instead of building it by hand" under the existing device-topology section

🤖 Generated with Claude Code

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>
@dcj
dcj merged commit ab74fea into main Aug 20, 2026
5 checks passed
@dcj
dcj deleted the feat/device-spec-tree-builder branch August 20, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add DeviceSpec and a tree-aware incremental builder for multi-device publishers

1 participant