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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 13 additions & 9 deletions src/ebus_sdk/declaration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
96 changes: 76 additions & 20 deletions src/ebus_sdk/homie.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)}"
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions tests/test_device_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading
Loading