diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a245ca..898c58a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/doc/building-a-proxy.md b/doc/building-a-proxy.md index dc74e84..b83e6cf 100644 --- a/doc/building-a-proxy.md +++ b/doc/building-a-proxy.md @@ -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.` (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.` (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 diff --git a/src/ebus_sdk/declaration.py b/src/ebus_sdk/declaration.py index 3814e45..fbe5e45 100644 --- a/src/ebus_sdk/declaration.py +++ b/src/ebus_sdk/declaration.py @@ -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`. @@ -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 @@ -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 @@ -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. @@ -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: @@ -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 @@ -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__( @@ -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 = {} @@ -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) diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 35eff22..a7cb75e 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -4,6 +4,8 @@ from ebus_sdk import ( Device, + DeviceSpec, + DeviceTreeBuilder, GroupedPropertyDict, PropertyDatatype, PropertySpec, @@ -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