Skip to content

feat: node_id callable so one device can carry two instances of a capability - #64

Merged
dcj merged 1 commit into
mainfrom
feat/node-id-callable
Aug 20, 2026
Merged

feat: node_id callable so one device can carry two instances of a capability#64
dcj merged 1 commit into
mainfrom
feat/node-id-callable

Conversation

@dcj

@dcj dcj commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Refs #47. The SDK half of reconciling panel-sim's wire/graph_builder with the DeviceTreeBuilder that shipped in 0.21.0.

The gap

_materialize hardcoded the Homie node id to the capability name:

device.add_node_from_dict(
    {"id": capability, "name": node_name(capability), "type": node_type(capability)}
)

node_type and node_name were already callables, so the id was the one part of a node a caller could not choose. That is fine 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.

The change

node_id: Callable[[str], str] on build_from_declarations and DeviceTreeBuilder, threaded through _materialize, defaulting to identity.

Renaming is all it does:

  • the declaration's vocabulary stays capability
  • the model group still comes from the spec
  • the returned map is still keyed by the declared capability

So a caller who ignores it sees no change, which the 653 pre-existing tests confirm by passing untouched.

It pairs with model_group

node_id separates two instances on the wire; PropertySpec.model_group (0.21.0) separates them in the model. Using one without the other moves the collision rather than removing it, so the docstrings and doc/building-a-proxy.md say that outright rather than leaving it to be discovered.

test_two_instances_of_one_capability_coexist_on_one_device is the shape reduced to essentials: two lugs on one enclosure, one build_from_declarations call per instance, distinct node_id and distinct model_group, then both published and read back independently.

Why this is the whole SDK-side change

The design pass over graph_builder found its two unique capabilities (node-on-parent placement, and per-instance node-id templating) reduce to this one missing seam. With it:

  • node-on-parent needs no placement enum in the SDK. It stops being a tree concern and becomes "add more nodes to a device that already exists", which build_from_declarations already does. The front end calls it against the parent with node_id=lambda cap: f"{prefix}-{cap}".
  • Templating and single-capability collapse stay in the front end, where the manifest vocabulary lives, so the SDK never learns what an instance_id is.

Everything else graph_builder does is either already covered (topological sort falls out of recursive add(); parent-by-class resolution happens while building specs) or better left where it is (root construction from mqtt_cfg= / mqttc= is connection policy, and belongs to the caller).

Verification

  • 658 tests pass (653 before, 5 new)
  • ruff check and ruff format --check clean
  • markdownlint clean on CHANGELOG.md and doc/building-a-proxy.md
  • No README change: node_type and node_name are not documented there either, and that section defers parameter-level detail to the proxy guide

🤖 Generated with Claude Code

…ability

The Homie node id was hardcoded to the capability name:

    device.add_node_from_dict(
        {"id": capability, "name": node_name(capability), ...}
    )

That 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.

Adds node_id to build_from_declarations and DeviceTreeBuilder, threaded
through _materialize, defaulting to identity.

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; the 653 tests that passed before still pass untouched.

It pairs with PropertySpec.model_group from 0.21.0: node_id separates two
instances on the wire, model_group separates them in the model. Using one
without the other moves the collision rather than removing it, which the
docstrings and the guide now say outright.

This is the SDK half of reconciling panel-sim's wire/graph_builder with
DeviceTreeBuilder. With it, `node-on-parent` placement needs no placement
enum in the SDK: it stops being a tree concern and becomes "add more
nodes to a device that already exists", which build_from_declarations
already does. Design pass recorded separately.

Refs #47

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dcj
dcj merged commit 4f469e8 into main Aug 20, 2026
5 checks passed
@dcj
dcj deleted the feat/node-id-callable branch August 20, 2026 17:32
dcj added a commit that referenced this pull request Aug 21, 2026
…n) (#71)

Bumps __version__ to 0.22.0 (single source of truth), promotes Unreleased
to [0.22.0], and adds the tag link definition.

  #64  node_id callable, so one device can carry two instances of a
       capability
  #65  DeviceTreeBuilder contract: model ownership, and the limit of
       add()'s ordering
  #66  the builder replaced properties in a model it does not own,
       discarding live values, callbacks and entity_setters. A producer
       that wired inbound control lost the actuator while $description
       kept advertising settable: true. Data loss in 0.21.0's headline
       feature.
  #67  a child could silently shadow an ancestor's id, or a sibling's,
       putting two devices on the same topics. Plus the feature it was
       filed for: add_root_capabilities(), so a root can carry its own
       capabilities rather than only parenting children.
  #68  extend(), so a device that already exists can grow a capability.
       The previous workaround dropped properties from $description while
       leaving their retained topics on the broker.

Minor rather than patch: three new public methods. #66's fix is the
reason not to sit on it, since 0.21.0 is live with that data loss.

Materialization is now idempotent at three levels (node, property, and
the state transition itself), which is what makes a re-fired incremental
lifecycle a genuine no-op. The third matters most: an empty
state_transition still emits init -> ready, and that edge forces every
controller on the bus to resync.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant