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 @@ -8,6 +8,10 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan

- Tooling: the ruff lint selection is now declared explicitly (`select = ["E4", "E7", "E9", "F"]`) rather than inherited from ruff's defaults, and CI moves from ruff 0.15.21 to 0.16.1. Ruff 0.16 widened its default selection (UP, LOG, BLE, I, RUF and more) and began formatting Python code blocks embedded in Markdown, so an unchanged codebase reported 0 or 381 violations depending only on which ruff you happened to run, and four docs files showed phantom format diffs locally that CI never saw. Pinning the set decouples "what this project lints for" from "what version of ruff is installed"; `extend-exclude = ["*.md"]` keeps prose out of both check and format. Verified clean on both 0.16.1 and 0.15.21, with no source changes. Widening the rule set (the 381) is now a deliberate act rather than an upgrade side-effect. ([#39](https://github.com/electrification-bus/python-sdk/issues/39))

### Fixed

- `Node.delete_property()` now republishes `$description`, as its mirror `Node.add_property()` always has. Deleting a property cleared the retained value topic but left the device in `ready` with a `$description` that still named the property, and nothing corrected it afterwards, so the broker held a self-contradicting device indefinitely. The two halves of the same API disagreed about whether mutating a node's property set is a structural change; it is. Deletions batch inside `device.state_transition()` exactly as additions do, so N deletions still collapse to one `$description` publish. ([#35](https://github.com/electrification-bus/python-sdk/issues/35))

## [0.18.1] — 2026-08-07

### Added
Expand Down
18 changes: 16 additions & 2 deletions src/ebus_sdk/homie.py
Original file line number Diff line number Diff line change
Expand Up @@ -1172,15 +1172,29 @@ def get_property(self, property_id: str) -> Optional[Property]:

def delete_property(self, property_id: str) -> bool:
"""
Remove property and clear its MQTT topic
Returns True if removed, False if not found
Remove property, clear its MQTT topic, and republish $description.

The mirror of add_property(): both mutate the node's property set, so
both must re-announce it. Without the republish the broker kept a device
in `ready` whose $description still named a property that no longer
existed, and nothing ever corrected it.

Batching several deletions inside `device.state_transition()` collapses
the republishes to one, exactly as it does for additions.

Returns True if removed, False if not found.
"""
if property_id not in self._properties:
logger.warning(f"reason=nodeDeletePropertyNotFound,nodeId={self._id},propertyId={property_id}")
return False
property = self._properties[property_id]
property.clear_value()
del self._properties[property_id]
# Delete from the dict BEFORE republishing, so the new $description
# reflects the removal (add_property() has the same ordering rule).
device = self.device()
if device:
device.publish_description()
logger.info(f"reason=nodeDeletedProperty,nodeId={self._id},propertyId={property_id}")
return True

Expand Down
44 changes: 44 additions & 0 deletions tests/test_homie_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,50 @@ def test_delete_missing_property(self):
n = Node(id="core")
assert n.delete_property("missing") is False

def test_delete_property_republishes_description(self, mock_paho):
"""The mirror of add_property() must also re-announce the property set.

Without the republish the broker keeps a device in `ready` whose
$description still names a property that no longer exists, with nothing
to correct it later.
"""
device, mock_client = _make_device(mock_paho, device_id="dev-1")
with device.state_transition():
node = device.add_node_from_dict({"id": "core", "type": "sensor"})
node.add_property_from_dict({"id": "temp", "datatype": PropertyDatatype.FLOAT})
node.add_property_from_dict({"id": "humidity", "datatype": PropertyDatatype.FLOAT})
mock_client.publish.reset_mock()

assert node.delete_property("temp") is True

descriptions = [c[0][1] for c in mock_client.publish.call_args_list if c[0][0].endswith("/dev-1/$description")]
assert descriptions, f"delete_property published no $description: {mock_client.publish.call_args_list}"
published = json.loads(descriptions[-1])
props = published["nodes"]["core"]["properties"]
assert "temp" not in props, f"deleted property still in published $description: {props}"
assert "humidity" in props, f"surviving property missing from $description: {props}"

def test_delete_property_batches_inside_state_transition(self, mock_paho):
"""N deletions in one transition collapse to one $description publish.

Same guarantee add_node/add_property already give, so the two halves of
the API stay symmetric under batching.
"""
device, mock_client = _make_device(mock_paho, device_id="dev-1")
with device.state_transition():
node = device.add_node_from_dict({"id": "core", "type": "sensor"})
for pid in ("a", "b", "c"):
node.add_property_from_dict({"id": pid, "datatype": PropertyDatatype.FLOAT})
mock_client.publish.reset_mock()

with device.state_transition():
for pid in ("a", "b", "c"):
node.delete_property(pid)

descriptions = [c for c in mock_client.publish.call_args_list if c[0][0].endswith("/dev-1/$description")]
assert len(descriptions) == 1, f"expected 1 consolidated $description, got {len(descriptions)}"
assert json.loads(descriptions[0][0][1])["nodes"]["core"]["properties"] == {}


class TestNodeDescription:
def test_description(self):
Expand Down