Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan

## [Unreleased]

### Added

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

## [0.21.0] — 2026-08-20

### Added
Expand Down
2 changes: 1 addition & 1 deletion doc/building-a-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ homie_props = build_from_declarations(device, model, SUBMETER)
model.set_value("meter", "active-power", 1850.0)
```

`build_from_declarations` groups specs by `capability` (one Homie node each), defaulting each node's type to `energy.ebus.capability.<capability>` (override with `node_type=`). Pass `values={(capability, prop_id): value}` to seed initial values through the model, overriding any `initial_value` on the spec. Note `PropertySpec.scale` is applied by `resolve`, not by the builder: `specs_and_values` hands the builder values `resolve` has already scaled, and scaling them again would double-apply it. If you assemble a `values` map by hand, pass values already in the property's own unit.
`build_from_declarations` groups specs by `capability` (one Homie node each), defaulting each node's type to `energy.ebus.capability.<capability>` (override with `node_type=`) and each node's id to the capability itself (override with `node_id=`, which is what lets one device carry two instances of a capability: call it once per instance with a distinct `node_id` and a distinct `PropertySpec.model_group`). Pass `values={(capability, prop_id): value}` to seed initial values through the model, overriding any `initial_value` on the spec. Note `PropertySpec.scale` is applied by `resolve`, not by the builder: `specs_and_values` hands the builder values `resolve` has already scaled, and scaling them again would double-apply it. If you assemble a `values` map by hand, pass values already in the property's own unit.

## Beyond the basic fields

Expand Down
31 changes: 29 additions & 2 deletions src/ebus_sdk/declaration.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ def build_from_declarations(
*,
node_type: Callable[[str], str] = _default_node_type,
node_name: Callable[[str], str] = lambda capability: capability,
node_id: Callable[[str], str] = lambda capability: capability,
values: Optional[dict] = None,
) -> dict:
"""Build bound Homie nodes/properties + observable properties from `specs`.
Expand All @@ -168,6 +169,13 @@ def build_from_declarations(
`values` map by hand therefore passes values in the property's own unit, not
raw source units.

`node_id` renames the Homie node a capability materializes onto, defaulting
to the capability itself. It exists for the case a single device carries two
instances of one capability: two meters, two lugs. `capability` stays the
declaration's vocabulary and `node_id` is the rendering, so call this once
per instance with a distinct `node_id` (and a distinct `PropertySpec.model_group`,
or the instances collide in the model instead of on the wire).

A spec may split its observable-model identity from its wire identity via
`source_id` / `model_group` (see `PropertySpec`). Everything on the model
side of the binding uses `spec.group_key` / `spec.model_key`; everything on
Expand All @@ -185,7 +193,7 @@ def build_from_declarations(
command, but its `entity_setter` is still registered, so `model.set_entity`
reaches it.
"""
built = _materialize(device, model, specs, node_type=node_type, node_name=node_name)
built = _materialize(device, model, specs, node_type=node_type, node_name=node_name, node_id=node_id)
_seed(model, built.declared, values)
return built.homie_props

Expand Down Expand Up @@ -220,6 +228,7 @@ def _materialize(
*,
node_type: Callable[[str], str],
node_name: Callable[[str], str],
node_id: Callable[[str], str] = lambda capability: capability,
default_group: Optional[str] = None,
) -> _Materialized:
"""Build one device's nodes, properties, model entries and bindings.
Expand All @@ -228,6 +237,11 @@ def _materialize(
device, model groups keyed by capability) and `DeviceTreeBuilder` (many
devices, model groups keyed per device). Runs inside one
`device.state_transition()`, so a device announces its structure once.

`node_id` maps a capability to the Homie node id it materializes onto,
defaulting to the capability itself. Only the id is renamed: the model group
still comes from the spec, and the returned map is still keyed by the
declared capability.
"""
grouped: dict[str, list[PropertySpec]] = {}
for spec in specs:
Expand All @@ -244,7 +258,11 @@ def _materialize(
published = [spec for spec in cap_specs if not spec.internal_only]
node = (
device.add_node_from_dict(
{"id": capability, "name": node_name(capability), "type": node_type(capability)}
{
"id": node_id(capability),
"name": node_name(capability),
"type": node_type(capability),
}
)
if published
else None
Expand Down Expand Up @@ -401,6 +419,12 @@ class DeviceTreeBuilder:
Batching: each `add()` announces its own device, and the parent republishes
its `$description` to name the new child. To collapse a burst of adds into
one parent announcement, wrap them in the parent's `state_transition()`.

`node_id` is passed through to every device this builder materializes, for a
publisher whose node ids are not simply their capability names. A tree whose
entities are devices does not need it, since each device has its own node
namespace; it is for the caller that also places several instances of one
capability onto a single device.
"""

def __init__(
Expand All @@ -410,11 +434,13 @@ def __init__(
*,
node_type: Callable[[str], str] = _default_node_type,
node_name: Callable[[str], str] = lambda capability: capability,
node_id: Callable[[str], str] = lambda capability: capability,
) -> None:
self._root = root
self._model = model
self._node_type = node_type
self._node_name = node_name
self._node_id = node_id
self._devices: dict = {}
self._homie_props: dict = {}
self._model_keys: dict = {}
Expand Down Expand Up @@ -460,6 +486,7 @@ def add(self, spec: DeviceSpec) -> Optional[Device]:
spec.specs,
node_type=self._node_type,
node_name=self._node_name,
node_id=self._node_id,
default_group=group,
)
_seed(self._model, built.declared, default_group=group)
Expand Down
94 changes: 94 additions & 0 deletions tests/test_declaration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from ebus_sdk import (
Device,
DeviceSpec,
DeviceTreeBuilder,
GroupedPropertyDict,
PropertyDatatype,
PropertySpec,
Expand Down Expand Up @@ -408,3 +410,95 @@ def test_build_does_not_apply_scale_to_seeded_values(mock_paho):
],
)
assert model2.value("meter", "imported-energy") == 22500.0


# --- node_id: renaming the node a capability materializes onto ---------------


def test_node_id_defaults_to_the_capability(mock_paho):
device = _device(mock_paho, "dev-nodeid-default")
model = GroupedPropertyDict()
build_from_declarations(device, model, [PropertySpec("meter", "active-power", PropertyDatatype.FLOAT)])
assert device.get_node("meter") is not None


def test_node_id_renames_the_node_without_moving_anything_else(mock_paho):
device = _device(mock_paho, "dev-nodeid")
model = GroupedPropertyDict()
homie_props = build_from_declarations(
device,
model,
[PropertySpec("meter", "active-power", PropertyDatatype.FLOAT, Unit.WATT)],
node_id=lambda capability: f"lugs-up-{capability}",
)
# The wire node is renamed.
assert device.get_node("lugs-up-meter") is not None
assert device.get_node("meter") is None
# The declaration's vocabulary is unchanged: model group and returned key
# both still say "meter".
assert "meter" in model.groups()
assert set(homie_props) == {("meter", "active-power")}
model.set_value("meter", "active-power", 240.0)
assert homie_props[("meter", "active-power")].value() == 240.0


def test_node_id_and_node_name_are_independent(mock_paho):
device = _device(mock_paho, "dev-nodeid-name")
model = GroupedPropertyDict()
build_from_declarations(
device,
model,
[PropertySpec("meter", "active-power", PropertyDatatype.FLOAT)],
node_id=lambda capability: f"a-{capability}",
node_name=lambda capability: "Upstream lugs",
node_type=lambda capability: "energy.ebus.capability.meter",
)
node = device.get_node("a-meter")
assert node.name() == "Upstream lugs"
assert node.type() == "energy.ebus.capability.meter"


def test_two_instances_of_one_capability_coexist_on_one_device(mock_paho):
"""The node-on-parent shape: several instances placed on a device that already exists."""
device = _device(mock_paho, "enclosure-1")
model = GroupedPropertyDict()

built = {}
for instance in ("lugs-up", "lugs-dn"):
built[instance] = build_from_declarations(
device,
model,
[
PropertySpec(
"meter",
"active-power",
PropertyDatatype.FLOAT,
Unit.WATT,
model_group=f"{instance}-meter",
)
],
node_id=lambda capability, i=instance: f"{i}-{capability}",
)

# Two nodes on one device, neither shadowing the other.
assert device.get_node("lugs-up-meter") is not None
assert device.get_node("lugs-dn-meter") is not None
assert set(device.description()["nodes"]) == {"lugs-up-meter", "lugs-dn-meter"}

# node_id separates them on the wire; model_group separates them in the
# model. Without the second they would collide even with distinct nodes.
model.set_value("lugs-up-meter", "active-power", 1000.0)
model.set_value("lugs-dn-meter", "active-power", -250.0)
assert built["lugs-up"][("meter", "active-power")].value() == 1000.0
assert built["lugs-dn"][("meter", "active-power")].value() == -250.0


def test_device_tree_builder_passes_node_id_through(mock_paho):
device = _device(mock_paho, "root-nodeid")
model = GroupedPropertyDict()
builder = DeviceTreeBuilder(device, model, node_id=lambda capability: f"x-{capability}")
child = builder.add(
DeviceSpec("bess", [PropertySpec("info", "serial-number", PropertyDatatype.STRING)], device_id="bess-1")
)
assert child.get_node("x-info") is not None
assert child.get_node("info") is None