From 066acdde1afa4112a2e3fda1a2673b78c5cf5729 Mon Sep 17 00:00:00 2001 From: Donald Clark Jackson Date: Tue, 20 Oct 2026 21:47:59 -0400 Subject: [PATCH 1/2] Device: announce once, in the caller's first transition A Device built without nodes published init, an empty $description and ready at construction, so a caller populating it in its own transition put two cycles on the wire and announced ready before any value (convention.md:279). It now publishes nothing until its first state_transition() or add_node(). A child joins its parent's children, and the parent re-announces, after the child's first ready (:620-629). A child built inside an ancestor's open transition and never populated is announced when that transition closes. refresh_tree(), stop() and delete() skip a device that has not announced itself. A device built with nodes is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168KMogUF4KfBazQ7ZNaxpT --- CHANGELOG.md | 1 + src/ebus_sdk/homie.py | 96 +++++++++++++---- tests/test_homie_compliance.py | 190 +++++++++++++++++++++++++++++++-- tests/test_homie_device.py | 56 ++++++---- 4 files changed, 293 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b9a50..bb519cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan ### Changed +- A `Device` built without `nodes=` publishes nothing until its first `state_transition()` or `add_node()`, so that is its only `init`, values, `$description`, `ready` cycle. It published `init`, a `$description` with no nodes and `ready` at construction, so a caller populating it in its own transition produced two cycles, and `ready` was announced before any value (convention.md:279). A child joins its parent's `children`, and the parent publishes `init`, the new `$description` and `ready`, after the child's first `ready`; the parent did this at construction (:620-629). A child built inside an ancestor's open `state_transition()` and never populated is announced, empty, when that transition closes. Until its first announcement a device is skipped by `refresh_tree()` and the connect republish, gets no `disconnected` from `stop()`, and its `delete()` does not re-announce the parent. A device built with `nodes=` is unchanged. Code that relied on a bare `Device(...)` being `ready` must now open a `state_transition()` on it. - `utility-meter` adds `homie_domain`, `homie_version` and `homie_roles` to its `_ebus._tcp` advertisement, from the domain its Homie device publishes under. The `mdns` extra requires `ebus-service-discovery[zeroconf]>=0.5.1`, which added those keys (electrification-bus/python-service-discovery#8). ## [0.26.0] — 2026-10-09 diff --git a/src/ebus_sdk/homie.py b/src/ebus_sdk/homie.py index cb0bd80..f7966c9 100644 --- a/src/ebus_sdk/homie.py +++ b/src/ebus_sdk/homie.py @@ -2017,6 +2017,14 @@ class Device: Children share the root's single MQTT connection. Only the root registers a Last Will so the entire tree is marked lost on adapter death. + A device built without ``nodes=`` publishes nothing until its first + ``state_transition()`` (or its first ``add_node()``), which is then its only + init, values, ``$description``, ready cycle. A child joins its parent's + ``children`` after that first ready, and the parent re-announces then. A child + built inside an ancestor's open transition and never populated is announced, + empty, when that transition closes. A device built with ``nodes=`` announces + at construction. + mqtt_cfg is a dict, two examples: {"host": "127.0.0.1", @@ -2201,6 +2209,8 @@ def __init__( # entry/exit publishes INIT/READY). Init→ready transitions force every controller # in the wild to resync, so emitting only the minimum is a correctness concern. self._transition_depth = 0 + # True while the outermost open transition is this device's first. + self._first_announcement = False # Set by delete(): the device no longer exists on the broker, so nothing may # republish its $state (stop(), a reconnect's refresh_tree(), the will). self._deleted = False @@ -2221,15 +2231,22 @@ def __init__( self.connect_broker() else: parent._children.append(self) - # Child's own INIT → publish description+nodes → READY (Homie add-child steps 1-3). - with self.state_transition(): - for node in nodes or []: - self.add_node(node) - # Homie add-child steps 4-6: parent INIT → publish description (now includes self in - # `children`) → READY. Skipped if parent is mid-transition — in that case the parent's - # own state_transition will publish the updated description on exit (S1: 32 adds → 1 flap). - if parent is not None: - parent._notify_structural_change() + # A device built inside an ancestor's open state_transition() and never + # populated is announced, empty, when that ancestor's transition closes. + self._announce_with_parent = False + ancestor = parent + while ancestor is not None and not self._announce_with_parent: + self._announce_with_parent = ancestor._transition_depth > 0 + ancestor = ancestor.parent() + # Without nodes the device publishes nothing until the caller's first + # transition, so that transition is its only init -> values -> + # $description -> ready cycle (convention.md:279). With nodes it announces + # now, and on its first ready a child triggers the parent's add-child + # update (convention.md:620-629; see _end_state_transition). + if nodes: + with self.state_transition(): + for node in nodes: + self.add_node(node) def as_dict(self) -> dict: nodes = {} @@ -2410,7 +2427,8 @@ def stop(self, *, announce: bool = True, flush_timeout: float = 1.0, stop_timeou """Gracefully and promptly tear down this device tree's MQTT connection. Publishes a final ``$state=disconnected`` for EVERY device in the tree - (best-effort), descendants first and the root last, then stops the root's + that has announced itself (best-effort), descendants first and the root + last, then stops the root's MQTT client with a clean disconnect. Homie 5 requires a device to send ``disconnected`` before it cleanly disconnects (convention.md:280-281), and children share the root's connection, so each of them disconnects too; the @@ -2475,6 +2493,9 @@ def stop(self, *, announce: bool = True, flush_timeout: float = 1.0, stop_timeou # moves live inside this branch, so announce=False cannot overwrite a # $state a caller just declared. for device in root._tree_post_order(): + if device._state is None: + # Never announced: a `disconnected` would create it bare. + continue device._state = DeviceState.DISCONNECTED state_topic = f"{root.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{device._id}/$state" if device is root and owned: @@ -2604,7 +2625,8 @@ def description(self) -> dict: for node_id, node in nodes_snapshot.items(): nodes_descriptions[node_id] = node.description() description["nodes"] = nodes_descriptions - description["children"] = self.children_ids() + # A child joins `children` once it has announced itself (convention.md:620-629). + description["children"] = [child.id() for child in self._children if child._state is not None] if self._parent is not None: # Required if the device is NOT the root device, MUST be omitted otherwise. description["root"] = self.root().id() @@ -2842,7 +2864,8 @@ def delete(self) -> None: # The child keeps its _parent link until its topics are cleared: the # topic domain and the MQTT client are both resolved through the root. parent._children.remove(self) - parent._notify_structural_change() + if self._state is not None: + parent._notify_structural_change() # Step 2: clear the subtree leaves first, so no device is seen whose parent # is already gone. Each device's $state goes before its other topics, so it # ceases to exist first (convention.md:287-289). @@ -2905,6 +2928,7 @@ def _begin_state_transition(self) -> None: self._transition_depth += 1 logger.info(f"reason=deviceBeginStateTransition,deviceId={self._id},depth={self._transition_depth}") if self._transition_depth == 1: + self._first_announcement = self._state is None self.set_state(DeviceState.INIT) def _end_state_transition(self) -> None: @@ -2918,12 +2942,24 @@ def _end_state_transition(self) -> None: """ logger.info(f"reason=deviceEndStateTransition,deviceId={self._id},depth={self._transition_depth}") if self._transition_depth == 1: + # Still inside the scope, so each child's first ready does not + # re-announce this device; the description below names them. + for child in list(self._children): + if child._state is None and child._announce_with_parent and not child._deleted: + with child.state_transition(): + pass # Leave the transition scope BEFORE the consolidated publish so that # final, intended $description is not suppressed by the in-transition # defer guard in publish_description() (SDK-9ps). self._transition_depth = 0 self.publish_description() self.set_state(DeviceState.READY) + first, self._first_announcement = self._first_announcement, False + if first and self._parent is not None: + # Homie add-child steps 4-6 (convention.md:620-629): parent init, + # $description now naming this device, ready. Deferred to the + # parent's own exit when it is mid-transition. + self._parent._notify_structural_change() else: self._transition_depth -= 1 @@ -2935,15 +2971,18 @@ def _notify_structural_change(self) -> None: in which case the in-progress transition will publish on exit and we suppress the per-change flap (S1: many children, one parent cycle). - Safe to call before the device has been transitioned to READY at all - (state is None) — in that case we just publish the description with - no state flap. + On a device that has not announced itself yet (state None), the change + is its first announcement: init, $description, ready. """ if self._transition_depth > 0: logger.debug( f"reason=deviceStructuralChangeSuppressed,deviceId={self._id},transitionDepth={self._transition_depth}" ) return + if self._state is None and not self._deleted: + with self.state_transition(): + pass + return # publish_description() wraps the change in INIT when the device is ready # or sleeping, and publishes it directly otherwise. self.publish_description() @@ -2971,13 +3010,24 @@ def _reconfiguration(self): values it vouches for. A change that leaves the ``$description`` as last published (a node not attached to the device, an identical re-declaration) runs the body with no ``init``/``ready`` flap (convention.md:616). Inside an - open transition the change joins it. In any other state (not yet announced, - ``init``, ``disconnected``, ``lost``) the body and the new ``$description`` - are published directly. + open transition the change joins it. On a device not yet announced (state + None) a change that leaves it with nodes runs as its first + ``state_transition()``. In any other state (``init``, ``disconnected``, + ``lost``) the body and the new ``$description`` are published directly. """ if self._transition_depth > 0: yield return + if self._state is None and not self._deleted: + # Not announced yet: a change that gives the device nodes is its first + # announcement, and one that leaves it without nodes publishes nothing + # for it. + if self._nodes: + with self.state_transition(): + yield + else: + yield + return prior = self._state if prior not in (DeviceState.READY, DeviceState.SLEEPING) or not self._description_changed(): yield @@ -3034,6 +3084,11 @@ def refresh_tree(self, *, force: bool = True) -> None: # delete() removed this device; republishing would re-create it. logger.info(f"reason=deviceRefreshTreeDeletedSkipped,deviceId={self._id}") return + if self._state is None: + # Not announced yet: there is nothing of this device to restore, and a + # republish would put a $description with no $state on the broker. + logger.info(f"reason=deviceRefreshTreeUnannouncedSkipped,deviceId={self._id}") + return logger.info( f"reason=deviceRefreshTree,deviceId={self._id}," f"nodeCount={len(self._nodes)},childCount={len(self._children)}" @@ -3281,9 +3336,10 @@ def on_connect(self) -> None: The initial connect must be as complete as a reconnect because the broker connection is asynchronous (ebus-mqtt-client connect_async): a - root Device can be constructed while the broker is briefly unavailable, - in which case the construction-time state_transition publishes + root Device can be built and populated while the broker is briefly + unavailable, in which case its first state_transition's publishes ($state=init -> $description -> $state=ready) never reached the broker. + A tree that has not announced itself yet publishes nothing here. Publishing only node values here (the earlier behavior) would then leave a HALF-PUBLISHED device — nodes present but $state/$description missing — which a Homie consumer sees as broken. refresh_tree() also recurses to diff --git a/tests/test_homie_compliance.py b/tests/test_homie_compliance.py index 1ea5b42..9ae1ec7 100644 --- a/tests/test_homie_compliance.py +++ b/tests/test_homie_compliance.py @@ -42,9 +42,19 @@ def _make_root(device_id="root", **kwargs): client = _mock_mqtt_client() from_config.return_value = client device = Device(id=device_id, mqtt_cfg={"host": "localhost", "port": 1883}, **kwargs) + # A device built without nodes publishes nothing until its first transition. + with device.state_transition(): + pass return device, client +def _announced(device): + """Run the device's first transition, as a caller populating it does.""" + with device.state_transition(): + pass + return device + + def _publishes(client): """(topic, payload) for every plain publish, in order.""" return [(c.args[0], c.args[1]) for c in client.publish.call_args_list] @@ -58,8 +68,8 @@ class TestStopAnnouncesEveryDevice: def test_owned_stop_publishes_disconnected_descendants_first_root_last(self): root, client = _make_root() - child = Device(id="child", parent=root) - Device(id="grandchild", parent=child) + child = _announced(Device(id="child", parent=root)) + _announced(Device(id="grandchild", parent=child)) client.publish.reset_mock() root.stop() @@ -77,7 +87,7 @@ def test_owned_stop_publishes_disconnected_descendants_first_root_last(self): def test_stop_from_a_child_announces_the_whole_tree(self): root, client = _make_root() - child = Device(id="child", parent=root) + child = _announced(Device(id="child", parent=root)) client.publish.reset_mock() child.stop() @@ -87,8 +97,8 @@ def test_stop_from_a_child_announces_the_whole_tree(self): def test_injected_stop_publishes_disconnected_for_every_device(self): client = _mock_mqtt_client() - root = Device(id="root", mqttc=client) - Device(id="child", parent=root) + root = _announced(Device(id="root", mqttc=client)) + _announced(Device(id="child", parent=root)) client.reset_mock() root.stop() @@ -229,6 +239,172 @@ def test_a_reconnect_after_delete_republishes_nothing(self): assert client.publish.call_args_list == [] +def _populate(device, value=1.5): + node = device.new_node("n") + device.add_node(node) + node.add_property(Property(id="p", value=value, datatype=PropertyDatatype.FLOAT)) + return node + + +def _wire(client): + """Each publish as (topic, payload), with a $description reduced to its `children`.""" + out = [] + for topic, payload in _publishes(client): + topic = topic[len(BASE) + 1 :] + if topic.endswith("/$description") and payload: + payload = ("children", json.loads(payload)["children"]) + out.append((topic, payload)) + return out + + +class TestConstructionWithoutNodes: + """:279 and :620-629: a device announces itself once, in the caller's first transition, + and its parent's add-child update follows the child's first `ready`.""" + + def test_a_child_populated_in_one_transition_announces_once_then_the_parent(self): + client = _mock_mqtt_client() + root = _announced(Device(id="root", mqttc=client)) + client.reset_mock() + + child = Device(id="child", parent=root) + assert _publishes(client) == [] + assert root.description()["children"] == [] + + with child.state_transition(): + _populate(child) + + assert _wire(client) == [ + ("child/$state", "init"), + ("child/n/p", "1.5"), + ("child/$description", ("children", [])), + ("child/$state", "ready"), + ("root/$state", "init"), + ("root/$description", ("children", ["child"])), + ("root/$state", "ready"), + ] + + def test_a_root_built_without_nodes_publishes_nothing_until_populated(self): + client = _mock_mqtt_client() + root = Device(id="root", mqttc=client) + assert _publishes(client) == [] + assert root.state() is None + + with root.state_transition(): + _populate(root) + + assert _wire(client) == [ + ("root/$state", "init"), + ("root/n/p", "1.5"), + ("root/$description", ("children", [])), + ("root/$state", "ready"), + ] + + def test_an_owned_root_on_connect_before_populating_publishes_nothing(self): + with patch("ebus_sdk.homie.MqttClient.from_config") as from_config: + client = _mock_mqtt_client() + from_config.return_value = client + root = Device(id="root", mqtt_cfg={"host": "localhost", "port": 1883}) + + root.on_connect() + + assert _publishes(client) == [] + + def test_add_node_outside_a_transition_is_the_first_announcement(self): + client = _mock_mqtt_client() + root = Device(id="root", mqttc=client) + + root.add_node(root.new_node("n")) + + assert _wire(client) == [ + ("root/$state", "init"), + ("root/$description", ("children", [])), + ("root/$state", "ready"), + ] + + def test_a_device_built_with_nodes_announces_at_construction(self): + client = _mock_mqtt_client() + root = _announced(Device(id="root", mqttc=client)) + client.reset_mock() + prop = Property(id="p", value=1.5, datatype=PropertyDatatype.FLOAT) + node = Node(id="n", properties={"p": prop}) + prop.set_node(node) + + Device(id="child", parent=root, nodes=[node]) + + assert _wire(client) == [ + ("child/$state", "init"), + ("child/n/p", "1.5"), + ("child/$description", ("children", [])), + ("child/$state", "ready"), + ("root/$state", "init"), + ("root/$description", ("children", ["child"])), + ("root/$state", "ready"), + ] + + def test_refresh_tree_before_the_first_transition_publishes_nothing(self): + client = _mock_mqtt_client() + root = Device(id="root", mqttc=client) + Device(id="child", parent=root) + + root.refresh_tree() + + assert _publishes(client) == [] + + def test_refresh_tree_skips_a_child_not_yet_announced(self): + client = _mock_mqtt_client() + root = _announced(Device(id="root", mqttc=client)) + Device(id="child", parent=root) + client.reset_mock() + + root.refresh_tree() + + assert _wire(client) == [ + ("root/$description", ("children", [])), + ("root/$state", "ready"), + ] + + def test_a_childless_child_built_inside_the_parent_transition_is_announced_at_its_exit(self): + client = _mock_mqtt_client() + root = _announced(Device(id="root", mqttc=client)) + client.reset_mock() + + with root.state_transition(): + bess = Device(id="bess", parent=root) + Device(id="mid", parent=bess) + + assert _wire(client) == [ + ("root/$state", "init"), + ("bess/$state", "init"), + ("mid/$state", "init"), + ("mid/$description", ("children", [])), + ("mid/$state", "ready"), + ("bess/$description", ("children", ["mid"])), + ("bess/$state", "ready"), + ("root/$description", ("children", ["bess"])), + ("root/$state", "ready"), + ] + + def test_stop_announces_nothing_for_a_device_never_announced(self): + client = _mock_mqtt_client() + root = _announced(Device(id="root", mqttc=client)) + Device(id="child", parent=root) + client.reset_mock() + + root.stop() + + assert _wire(client) == [("root/$state", "disconnected")] + + def test_deleting_a_child_never_announced_leaves_the_parent_alone(self): + client = _mock_mqtt_client() + root = _announced(Device(id="root", mqttc=client)) + child = Device(id="child", parent=root) + client.reset_mock() + + child.delete() + + assert not [t for t, _ in _publishes(client) if t.startswith(f"{BASE}/root/")] + + # ── B. $description ────────────────────────────────────────────────────── @@ -297,7 +473,7 @@ def test_a_child_added_to_a_sleeping_parent_wraps_the_parent(self): root.set_state(DeviceState.SLEEPING) client.publish.reset_mock() - Device(id="child", parent=root) + _announced(Device(id="child", parent=root)) assert _states(client, "root") == ["init", "sleeping"] @@ -470,7 +646,7 @@ def test_refresh_after_invalidation_publishes_ready_once_and_last(self): node = root.new_node("n") root.add_node(node) node.add_property(Property(id="p", value=1.5, datatype=PropertyDatatype.FLOAT)) - Device(id="kid", parent=root) + _announced(Device(id="kid", parent=root)) root.clear_retained_topic(f"{BASE}/root/$description") client.publish.reset_mock() diff --git a/tests/test_homie_device.py b/tests/test_homie_device.py index 6e3ba09..a626e92 100644 --- a/tests/test_homie_device.py +++ b/tests/test_homie_device.py @@ -49,9 +49,19 @@ def _make_device(mock_paho, device_id="test-device", **kwargs): mqtt_cfg={"host": "localhost", "port": 1883}, **kwargs, ) + # A device built without nodes publishes nothing until its first transition. + with device.state_transition(): + pass return device, mock_client +def _announced(device): + """Run the device's first transition, as a caller populating it does.""" + with device.state_transition(): + pass + return device + + def _make_wired_property(mock_client, device_id="dev1", node_id="node1", **prop_kwargs): """Create a Property wired to a mock Node and Device for publish testing.""" mock_device = MagicMock() @@ -1340,7 +1350,7 @@ def test_single_add_runs_full_protocol(self, mock_paho): assert panel.state() == DeviceState.READY mock_client.publish.reset_mock() - Device(id="circuit-1", parent=panel) + _announced(Device(id="circuit-1", parent=panel)) # Child completed its own INIT→READY circuit_states = _state_payloads_for(mock_client, "circuit-1") @@ -1382,10 +1392,10 @@ def test_batched_adds_produce_one_parent_flap(self, mock_paho): def test_add_grandchild_flaps_only_immediate_parent(self, mock_paho): """A grandchild add changes the immediate parent's children list, not the root's.""" panel, mock_client = _make_device(mock_paho, device_id="panel-1") - bess = Device(id="bess-1", parent=panel) + bess = _announced(Device(id="bess-1", parent=panel)) mock_client.publish.reset_mock() - Device(id="mid-1", parent=bess) + _announced(Device(id="mid-1", parent=bess)) # bess flaps INIT→READY (its description gained mid-1) bess_states = _state_payloads_for(mock_client, "bess-1") @@ -1398,7 +1408,7 @@ def test_add_grandchild_flaps_only_immediate_parent(self, mock_paho): def test_delete_child_runs_remove_protocol(self, mock_paho): """Deleting a child clears its retained data and flaps the parent.""" panel, mock_client = _make_device(mock_paho, device_id="panel-1") - circuit = Device(id="circuit-1", parent=panel) + circuit = _announced(Device(id="circuit-1", parent=panel)) mock_client.publish.reset_mock() circuit.delete() @@ -1503,14 +1513,14 @@ def test_mixed_add_and_delete_in_one_transition(self, mock_paho): def test_sibling_subtree_transitions_are_independent(self, mock_paho): """A transition on one subtree must not flap an unrelated sibling subtree.""" panel, mock_client = _make_device(mock_paho, device_id="panel-1") - bess = Device(id="bess-1", parent=panel) - evse = Device(id="evse-1", parent=panel) + bess = _announced(Device(id="bess-1", parent=panel)) + evse = _announced(Device(id="evse-1", parent=panel)) mock_client.publish.reset_mock() # Adding a grandchild under bess should flap bess (its description changed) # but NOT evse and NOT panel (their children lists are unchanged). with bess.state_transition(): - Device(id="mid-1", parent=bess) + _announced(Device(id="mid-1", parent=bess)) bess_states = _state_payloads_for(mock_client, "bess-1") assert bess_states == [DeviceState.INIT, DeviceState.READY] @@ -1817,8 +1827,8 @@ def test_description_grandchild_root_vs_parent(self, mock_paho): def test_description_parent_lists_child(self, mock_paho): root, _ = _make_device(mock_paho, device_id="root-1") - Device(id="child-a", parent=root) - Device(id="child-b", parent=root) + _announced(Device(id="child-a", parent=root)) + _announced(Device(id="child-b", parent=root)) desc = root.description() assert desc["children"] == ["child-a", "child-b"] @@ -1935,12 +1945,12 @@ def test_reconnect_republishes_unchanged_property_values(self, mock_paho): assert value_payloads == ["72.5"] def test_initial_connection_cascades_to_children(self, mock_paho): - """A child constructed before the first connect (possible when the - broker was down at construction time) must also be fully published when + """A child announced before the first connect (possible when the + broker was down at that time) must also be fully published when that first connect finally arrives — the initial path cascades the whole tree exactly like a reconnect.""" root, mock_client = _make_device(mock_paho, device_id="panel-1") - Device(id="circuit-a", parent=root) + _announced(Device(id="circuit-a", parent=root)) root.initial_broker_connection = True mock_client.publish.reset_mock() @@ -2135,8 +2145,8 @@ def test_refresh_tree_forwards_force_to_children(self, mock_paho): def test_refresh_tree_cascades_to_children(self, mock_paho): """S6: reconnect republish must touch every device in the tree.""" root, mock_client = _make_device(mock_paho, device_id="panel-1") - Device(id="circuit-a", parent=root) - Device(id="circuit-b", parent=root) + _announced(Device(id="circuit-a", parent=root)) + _announced(Device(id="circuit-b", parent=root)) mock_client.publish.reset_mock() root.refresh_tree() @@ -2163,8 +2173,8 @@ def test_refresh_tree_publishes_own_state_after_its_children(self, mock_paho): ``test_refresh_tree_cascades_to_children`` already pins. """ root, mock_client = _make_device(mock_paho, device_id="panel-1") - Device(id="circuit-a", parent=root) - Device(id="circuit-b", parent=root) + _announced(Device(id="circuit-a", parent=root)) + _announced(Device(id="circuit-b", parent=root)) mock_client.publish.reset_mock() root.refresh_tree() @@ -2217,8 +2227,8 @@ def mutate_during_refresh(): def test_refresh_tree_three_levels(self, mock_paho): """S2 + S6: grandchildren also republish on reconnect.""" root, mock_client = _make_device(mock_paho, device_id="panel-1") - bess = Device(id="bess-1", parent=root) - Device(id="mid-1", parent=bess) + bess = _announced(Device(id="bess-1", parent=root)) + _announced(Device(id="mid-1", parent=bess)) mock_client.publish.reset_mock() root.refresh_tree() @@ -2242,9 +2252,9 @@ def test_refresh_tree_continues_past_a_raising_child(self, mock_paho): the cascade is best-effort: the failure is logged and the walk goes on. """ root, mock_client = _make_device(mock_paho, device_id="panel-1") - Device(id="circuit-a", parent=root) - bad = Device(id="circuit-bad", parent=root) - Device(id="circuit-z", parent=root) + _announced(Device(id="circuit-a", parent=root)) + bad = _announced(Device(id="circuit-bad", parent=root)) + _announced(Device(id="circuit-z", parent=root)) bad.refresh_tree = MagicMock(side_effect=RuntimeError("transport exploded")) mock_client.publish.reset_mock() @@ -2279,7 +2289,7 @@ def test_node_publish_continues_past_a_raising_property(self, mock_paho): def test_on_connect_reconnect_cascades(self, mock_paho): """On reconnect, root.on_connect() walks the whole tree.""" root, mock_client = _make_device(mock_paho, device_id="panel-1") - Device(id="circuit-a", parent=root) + _announced(Device(id="circuit-a", parent=root)) # Flip from initial to reconnect path root.initial_broker_connection = False mock_client.publish.reset_mock() @@ -2574,7 +2584,7 @@ def test_sdk_never_starts_an_injected_client(self, mock_paho): def test_stop_injected_publishes_disconnected_without_flush_or_close(self, mock_paho): client = _mock_mqtt_client() client.is_connected.return_value = True - device = Device(id="panel-1", mqttc=client) + device = _announced(Device(id="panel-1", mqttc=client)) client.reset_mock() # ignore construction-time publishes device.stop() From 4ca4a97727a1b8c98d8ab56779d32e09119a4357 Mon Sep 17 00:00:00 2001 From: Donald Clark Jackson Date: Tue, 20 Oct 2026 21:55:03 -0400 Subject: [PATCH 2/2] DeviceTreeBuilder: always announce a built device add() skipped the transition when the spec had nothing to create (all properties internal-only and already in the model, or no specs at all). Now that a Device built without nodes publishes nothing until its first transition, such a device never appeared. add() opens the transition regardless, so it announces empty, as it did before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168KMogUF4KfBazQ7ZNaxpT --- src/ebus_sdk/declaration.py | 22 +++++++++++++--------- tests/test_device_tree.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/ebus_sdk/declaration.py b/src/ebus_sdk/declaration.py index 7940fe8..1059646 100644 --- a/src/ebus_sdk/declaration.py +++ b/src/ebus_sdk/declaration.py @@ -641,15 +641,19 @@ def add(self, spec: DeviceSpec) -> Optional[Device]: self._created_groups[device_id] = [] group = spec.resolve_model_group(device_id) - built = _materialize( - device, - self._model, - spec.specs, - node_type=self._node_type, - node_name=self._node_name, - node_id=self._node_id, - default_group=group, - ) + # Always a transition: a device built without nodes publishes nothing until + # its first one, and a spec that creates no node (internal-only, or none at + # all) must still announce the device, empty. + with device.state_transition(): + built = _materialize( + device, + self._model, + 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) self._homie_props[device_id].update(built.homie_props) diff --git a/tests/test_device_tree.py b/tests/test_device_tree.py index a17b8f0..b51c28d 100644 --- a/tests/test_device_tree.py +++ b/tests/test_device_tree.py @@ -754,3 +754,38 @@ def test_the_latch_is_dropped_with_the_device(root): box["id"] = "bess-2" rebuilt = builder.add(spec) assert rebuilt.id() == "bess-2" + + +# --- A built device always announces ---------------------------------------- + + +def _states_of(mock_paho, device_id): + return [ + c.args[1] for c in mock_paho.publish.call_args_list if c.args and c.args[0].endswith(f"/{device_id}/$state") + ] + + +@pytest.mark.parametrize("seed_model", [False, True]) +def test_a_device_with_only_internal_properties_still_announces(root, mock_paho, seed_model): + model = GroupedPropertyDict() + if seed_model: + # The model already holds the property, so nothing is left to create. + model.create_group("bess-1") + model.add_property("bess-1", ObservableProperty("raw", 0, int)) + builder = DeviceTreeBuilder(root, model) + spec = PropertySpec("info", "raw", PropertyDatatype.INTEGER, internal_only=True) + + bess = builder.add(DeviceSpec("bess", [spec], device_id="bess-1")) + + assert _states_of(mock_paho, "bess-1") == ["init", "ready"] + assert bess.nodes() == {} + assert "bess-1" in root.description()["children"] + + +def test_a_device_with_no_specs_still_announces(root, mock_paho): + builder = DeviceTreeBuilder(root, GroupedPropertyDict()) + + builder.add(DeviceSpec("bess", [], device_id="bess-1")) + + assert _states_of(mock_paho, "bess-1") == ["init", "ready"] + assert "bess-1" in root.description()["children"]