From 0a581cd58fe2a52dcb5d02ab1a3eff885009f39f Mon Sep 17 00:00:00 2001 From: Donald Clark Jackson Date: Thu, 20 Aug 2026 08:32:56 -0700 Subject: [PATCH] feat: publish a device tree under any Homie 5 domain The SDK could CONSUME any Homie 5 tree and PRODUCE only an eBus one. Controller has always taken homie_domain=, uses it for subscriptions, set_property and $broadcast, and even parses the domain back out of a received topic. Device had no such parameter: every topic it derived came from the EBUS_HOMIE_DOMAIN constant at ten sites across Device, Node and Property, plus the Last Will. Its own docstring carried the stub "homie_domains config for future use, not currently supported by this code", which is now replaced by what to actually do. Nothing about eBus changes. Energy devices keep publishing under `ebus`, which the specification mandates and which remains the default, so a publisher that never mentions the parameter is byte-identical on the wire. What this buys is that the same SDK can also publish non-energy devices under the standard `homie` domain: the difference between an eBus library and a Homie 5 library that defaults to eBus. The domain covers everything a tree derives: property values, /set subscriptions, $state, $description, the retraction topics delete() and delete_all_from_mqtt() clear, and both will() and the LWT installed on an owned client. Inbound /set validation had to follow. Property._settable_callback compared the received domain against EBUS_HOMIE_DOMAIN, so a device published under `homie` would have subscribed to the right topic and then silently rejected every command that arrived. It is a property of the TREE, not of a device, exactly like the connection and the QoS: a child under a different domain would sit outside its own root's subtree, and the root's Last Will (one retained publish on the root's $state) could not cover it. Only a root stores it, descendants read it through the new Device.homie_domain(), and a child passing its own is refused with a ValueError as a child passing its own mqtt_cfg= already is. Refused even when the value would have matched: the rule is structural, and a silently-dropped domain surfaces as topics on the wrong prefix rather than as an error. One test-double fix: _make_wired_property builds a MagicMock device, which returned a MagicMock from homie_domain() and broke three settable-callback tests. The double now answers it, rather than the production code being made defensive about mocks. Closes #61 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + README.md | 21 +++- src/ebus_sdk/homie.py | 63 +++++++++-- tests/test_homie_device.py | 1 + tests/test_homie_domain.py | 221 +++++++++++++++++++++++++++++++++++++ 5 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 tests/test_homie_domain.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc5b4a..b8dd9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan ### Added +- `Device(homie_domain=...)`: a tree can publish under any Homie 5 domain, not only `ebus`. The consumer side was already configurable (`Controller(homie_domain=...)` and `DiscoveredDevice` both take one, and `_on_state_message` even parses the domain out of the topic), while the publisher side hardcoded the `EBUS_HOMIE_DOMAIN` constant at ten topic-construction sites across `Device`, `Node` and `Property`, plus the Last Will, so the SDK could consume any Homie 5 tree and produce only an eBus one. The default is unchanged and eBus energy devices keep publishing under `ebus`, which the specification mandates; what this buys is that the same SDK can also publish non-energy devices under the standard `homie` domain, which is the difference between an eBus library and a Homie 5 library that defaults to eBus. The domain covers everything a tree derives: property values, `/set` subscriptions, `$state`, `$description`, the retraction topics `delete()` clears, and the will. Inbound `/set` validation follows too: the topic check accepted only `ebus` and now accepts the tree's own domain, so a device under `homie` can actually be commanded. It is a property of the TREE rather than of a device, so only a root carries it, descendants read it through the new `Device.homie_domain()`, and a child passing its own is refused with a `ValueError` exactly as a child passing its own `mqtt_cfg` is; refused even when the value would have matched, because the rule is structural rather than a value check. The `Device` docstring's "homie_domains config for future use, not currently supported by this code" stub is replaced by what to actually do. ([#61](https://github.com/electrification-bus/python-sdk/issues/61)) + - `DeviceSpec` and `DeviceTreeBuilder`: a device-level declaration and a tree-aware, incremental builder, for publishers whose shape is a tree rather than one device. `build_from_declarations` materializes exactly one device and creates the observable model itself keyed by capability, which fits the single-device proxy the SDK was first written for and cannot express what the eBus framework actually describes: a root whose circuits, lugs, MID and DERs are child devices, each with its own id, `$state`, `$description` and capability set. Three independent consumers had hand-rolled the same layer on top of `homie.Device(parent=...)`, which is evidence about the SDK rather than about them. Device class, id and parent are device-level facts, so they live on `DeviceSpec` rather than being repeated on every property of the device; `device_type` defaults to `energy.ebus.device.{device_class}`, which matters more than a convenience default because the SDK stores `Device.type` verbatim and validates nothing against a registry, so the derived form is the main guard against a type that ships misspelled. The builder accepts a `GroupedPropertyDict` it does not own and keys each device's group by device rather than capability, since two children both exposing `info` otherwise collide in the model while remaining perfectly distinct on the wire; a `PropertySpec` naming its own `model_group` still wins, so a consumer with an existing model keeps its keying. Late-bound ids are first class: `device_id` may be a callable returning `None` while an asynchronous identifier has not arrived, `add()` returns `None` and remembers the spec, and `resolve_deferred()` resolves a whole generation including children waiting behind a deferred parent. That is worth the machinery because a child published under a wrong-but-stable id leaves retained topics that outlive restarts and firmware updates. `add()` is idempotent because incremental lifecycles re-fire; `remove()` is depth-first, grandchild before parent, derived from the live tree rather than a caller-maintained ordering, so nothing ever observes an orphaned child, and it also deletes the model entries the builder added plus any group it created that is now empty. `on_created` carries per-child side effects so consumers do not post-process the returned tree. ([#57](https://github.com/electrification-bus/python-sdk/issues/57)) - `PropertySpec` reaches property-level parity with the private declaration types that multi-device publishers were keeping instead of using it. Seven new fields, each defaulting to what the spec did before it existed, so no existing declaration set changes: `round_to` (decimal places applied on publish, which the property already supported and the declaration could not reach); `initial_value` (a seed applied through the model at build, overridden by the builder's `values=` argument); `retained=False` (an event property rather than a state); `internal_only` (the model tracks the value and the wire never sees it, so no Homie property is created and a capability whose specs are all internal gets no node); `conditionally_settable` (settability decided per instance at runtime, materialized not-settable so `$description` stays honest and no `/set` topic is opened on a property that would reject the command); and `source_id` / `model_group`, which split the observable-model identity from the wire identity. That last split is the load-bearing one: `capability` was simultaneously the Homie node id and the model group key, which is the same string only while one device is in play, and two child devices in a tree that both expose `info` collide in a shared model while remaining perfectly distinct on the wire. Two contradictions are now refused when the spec is constructed rather than when it publishes: `settable` with `conditionally_settable`, and `internal_only` with either. ([#58](https://github.com/electrification-bus/python-sdk/issues/58)) diff --git a/README.md b/README.md index 8ec6993..d23a028 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,25 @@ Children may have children of their own. A single Last Will registered on the ro `$description` republishes are minimized: structural changes made inside one `state_transition()` collapse to a single consolidated publish at exit (not one per `add_node`), and `publish_description()` is a no-op when the description content (ignoring its `version` timestamp) is unchanged — so a `state_transition()` that changes nothing structural does not re-emit the (potentially multi-KB) `$description`. A reconnect always republishes regardless, to restore retained state. Note this suppresses the redundant `$description` payload, not the `$state` `init`→`ready` edge of an empty transition. Property *values* are minimized the same way and with the same reconnect carve-out (see [Unchanged values are not republished](#unchanged-values-are-not-republished)). +### Publishing under a different Homie domain + +Every topic is prefixed by a *domain*: `ebus/5/...`. The eBus specification mandates `ebus` for energy devices, and that is the default, so an eBus publisher never has to think about this. + +A publisher that also speaks for non-energy devices can put a tree under the standard `homie` domain, or any other, by passing `homie_domain=` to the **root**: + +```python +lamp = Device('lamp-1', type='...', mqtt_cfg={...}, homie_domain='homie') +lamp.start_mqtt_client() +# -> homie/5/lamp-1/$state, homie/5/lamp-1/light/brightness, and a +# Last Will on homie/5/lamp-1/$state +``` + +The domain covers everything the tree derives: property values, `/set` subscriptions, `$state`, `$description`, the retraction topics `delete()` clears, and the Last Will. An inbound `/set` is accepted on the tree's own domain and ignored on any other. + +It is a property of the **tree**, not of a device. Children inherit the root's domain, and a child passing its own is refused the same way a child passing its own `mqtt_cfg` is, because a tree shares one connection and one prefix. Read it back with `device.homie_domain()` from any handle in the tree. + +The consumer side has always been configurable: `Controller(homie_domain=...)` monitors one domain, so watching both trees means two `Controller`s. + ### Building a Proxy or Adapter To publish a device whose state changes over time (a proxy for a non-eBus device, an adapter for a local device, a gateway/bridge), use the **observable-model pattern**: keep the device's live state in a `GroupedPropertyDict` of observable `Property` objects, and mirror each change onto the Homie tree with a per-property on-change callback. Your acquisition code only updates the model; publishing to MQTT is an automatic side-effect. @@ -279,7 +298,7 @@ MQTT transport lives in the separate [`ebus-mqtt-client`](https://github.com/ele Core Homie convention implementation: -- **Device** - Represents a Homie device; pass `parent=` to build a child in a tree, or `on_disconnect=` for a push disconnect hook (`clean: bool`); `declare_lost()` announces deliberate death and `stop(announce=False)` tears down without announcing +- **Device** - Represents a Homie device; pass `parent=` to build a child in a tree, `homie_domain=` on a root to publish under a domain other than `ebus`, or `on_disconnect=` for a push disconnect hook (`clean: bool`); `declare_lost()` announces deliberate death and `stop(announce=False)` tears down without announcing - **Node** - Groups related properties within a device - **Property** - Individual data points (sensors, controls) - **Controller** - Discovers and monitors Homie devices on a broker; navigates trees and computes effective state; `set_on_disconnect_callback` for push disconnect notification diff --git a/src/ebus_sdk/homie.py b/src/ebus_sdk/homie.py index aceef82..1267fe9 100644 --- a/src/ebus_sdk/homie.py +++ b/src/ebus_sdk/homie.py @@ -597,6 +597,17 @@ def _transport_free(self) -> bool: device = node.device() if node is not None else None return device._transport_free() if device is not None else False + def _homie_domain(self) -> str: + """The domain of the tree this property belongs to. + + Same node -> device walk as ``_transport_free``. A property not yet + attached to a tree falls back to the eBus domain, which is what every + topic here was hardcoded to before the domain was configurable. + """ + node = self.node() + device = node.device() if node is not None else self._device + return device.homie_domain() if device is not None else EBUS_HOMIE_DOMAIN + def get_node_id(self) -> str: """ Why is this needed? @@ -876,7 +887,7 @@ def publish_value(self, *, force: bool = False) -> bool: if self._value is None and (not self._ever_published or self._skip_initial_publish): logger.debug(f"reason=propertySkipPublishNoneValue,propertyID={self._id}") return True - topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}" + topic = f"{self._homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}" if self._value is None: # Value was cleared after having been published. Emit the empty # retained message so the prior retained value is retracted from the @@ -979,7 +990,7 @@ def clear_value(self) -> bool: f"reason=propertyClearValueInsufficientIDs,deviceID={device_id},nodeID={node_id},propertyID={self._id}" ) return False - topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}" + topic = f"{self._homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}" try: # Publishing empty string clears retained message mqttc.publish(topic, "", retain=True, qos=self._qos) @@ -1068,7 +1079,7 @@ def _settable_callback(self, topic: str, payload: Union[bytes, bytearray]) -> No logger.warning(f"reason=nodeSetCallbackTopicParseException,e={e}") return if not ( - (homie_domain == EBUS_HOMIE_DOMAIN) + (homie_domain == self._homie_domain()) and (homie_version == str(EBUS_HOMIE_VERSION_MAJOR)) and (property_id_set == "set") ): @@ -1156,7 +1167,7 @@ def set_subscribe(self) -> None: f"propertySetSubscribeInsufficientIDs,deviceID={device_id},nodeID={node_id},propertyID={self._id}" ) return - topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}/set" + topic = f"{self._homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}/set" try: mqttc.subscribe(topic, param=partial(self._settable_callback), qos=self._qos) except Exception as e: @@ -1486,7 +1497,9 @@ class Device: "username": "MyUserName", "password": "SECRET"}} - homie_domains config for future use, not currently supported by this code + The ``homie_domains`` key in the broker config is not read by this class. To + publish a tree under a domain other than ``ebus``, pass ``homie_domain=`` to + the ROOT Device; see ``homie_domain()``. mqtt_cfg={} connects using ebus-mqtt-client's defaults. mqtt_cfg=None opens no socket: the tree still composes $description and resolves ids and topics, it just never @@ -1520,6 +1533,7 @@ def __init__( description_extras: Optional[dict] = None, mqtt_cfg: Optional[dict] = None, mqttc: Optional[MqttDeviceTransport] = None, + homie_domain: Optional[str] = None, qos: int = EBUS_HOMIE_MQTT_QOS, async_loop: Optional[asyncio.AbstractEventLoop] = None, on_disconnect: Optional[Callable[[bool], None]] = None, @@ -1535,6 +1549,15 @@ def __init__( raise ValueError( f"Device id={id}: cannot pass both parent= and mqttc=; children share the root's MQTT connection" ) + # The domain is a per-TREE property, like the connection and the QoS: one + # tree publishes under one prefix, and a child under a different domain + # would sit outside its own root's subtree. Refuse it on a child rather + # than silently ignoring it, matching the mqtt_cfg/mqttc rule above. + if parent is not None and homie_domain is not None: + raise ValueError( + f"Device id={id}: cannot pass both parent= and homie_domain=; a tree shares one domain, " + "set it on the root" + ) if mqtt_cfg is not None and mqttc is not None: raise ValueError( f"Device id={id}: cannot pass both mqtt_cfg= and mqttc=; pass mqtt_cfg to have the SDK " @@ -1585,6 +1608,10 @@ def __init__( # register disconnect handling on their own client. logger.warning(f"reason=deviceInjectedClientOnDisconnectInert,id={id}") self._id = id + # Only a root carries the domain; descendants read the root's via + # homie_domain(). Defaults to the eBus domain, so a publisher that never + # mentions it is unaffected. + self._homie_domain = (homie_domain or EBUS_HOMIE_DOMAIN) if parent is None else None self._name = name if name else id self._type = type self._parent: Optional[Device] = parent @@ -1732,6 +1759,20 @@ def extensions(self) -> List: """ return self._extensions + def homie_domain(self) -> str: + """The Homie domain (topic prefix) this device's TREE publishes under. + + Defaults to ``ebus``, which the eBus specification mandates for energy + devices. A publisher that also speaks for non-energy home-automation + devices can put a tree under the standard ``homie`` domain, or any + other, by passing ``homie_domain=`` to the ROOT device; every topic the + tree derives follows, including the Last Will. + + Per-tree, never per-device: a child inherits its root's domain and is + refused its own, the same way it is refused its own connection. + """ + return self.root()._homie_domain or EBUS_HOMIE_DOMAIN + @property def qos(self) -> int: """Returns the MQTT QoS level for this device""" @@ -1839,7 +1880,7 @@ def stop(self, *, announce: bool = True, flush_timeout: float = 1.0, stop_timeou logger.info(f"reason=deviceStopSilent,id={root._id}") elif mqttc.is_connected(): root._state = DeviceState.DISCONNECTED - state_topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state" + state_topic = f"{root.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state" if root._owns_client and root._owned_client is not None: flushed = root._owned_client.publish_and_flush( state_topic, DeviceState.DISCONNECTED.value, qos=root._qos, retain=True, timeout=flush_timeout @@ -1913,7 +1954,7 @@ def declare_lost(self, *, flush_timeout: float = 1.0) -> bool: # `lost` long after recovery, which is worse than not sending it. logger.info(f"reason=deviceDeclareLostBrokerUnreachable,id={root._id}") return changed - state_topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state" + state_topic = f"{root.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state" # Ownership decides, never isinstance: a caller may legitimately inject a real # MqttClient (driven by asyncio_driver), and publish_and_flush/stop must not be # called on a client the SDK does not own. @@ -2087,7 +2128,7 @@ def delete_all_from_mqtt(self) -> None: ) return - base_topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}" + base_topic = f"{self.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}" # Step 1: Clear all property values that were actually published for node_id, node in list(self._nodes.items()): @@ -2168,7 +2209,7 @@ def delete(self) -> None: # the retained $state and "the device will cease to exist", then clear # its other retained topics). delete_all_from_mqtt only handles property # values and $description, so $state is cleared here separately. - base_topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}" + base_topic = f"{self.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}" self.clear_retained_topic(f"{base_topic}/$state") self.delete_all_from_mqtt() finally: @@ -2354,7 +2395,7 @@ def publish(self, attribute: str = "", value: Optional[Any] = None) -> None: logger.info("reason=devicePublishNoDeviceID") return try: - base_topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}/" + base_topic = f"{self.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}/" if attribute == "$state": topic = base_topic + "$state" if value: @@ -2515,7 +2556,7 @@ def will(self) -> dict: """ root = self.root() return { - "topic": f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state", + "topic": f"{root.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state", "payload": DeviceState.LOST.value, } diff --git a/tests/test_homie_device.py b/tests/test_homie_device.py index 70757c9..2e306b1 100644 --- a/tests/test_homie_device.py +++ b/tests/test_homie_device.py @@ -58,6 +58,7 @@ def _make_wired_property(mock_client, device_id="dev1", node_id="node1", **prop_ mock_device.id.return_value = device_id mock_device.get_mqtt_client.return_value = mock_client mock_device._qos = EBUS_HOMIE_MQTT_QOS + mock_device.homie_domain.return_value = EBUS_HOMIE_DOMAIN node = Node(id=node_id, device=mock_device) defaults = dict(id="temperature", value=72.5, datatype=PropertyDatatype.FLOAT) diff --git a/tests/test_homie_domain.py b/tests/test_homie_domain.py new file mode 100644 index 0000000..5acfd94 --- /dev/null +++ b/tests/test_homie_domain.py @@ -0,0 +1,221 @@ +"""Device-side Homie domain configurability. + +The Controller has always been domain-configurable; the Device was hardcoded to +`ebus`. These pin that a tree can publish under any Homie 5 domain, that the +default is unchanged, and that the domain is a property of the TREE. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from ebus_sdk import Device, DeviceState, PropertyDatatype +from ebus_sdk.homie import EBUS_HOMIE_DOMAIN + + +def _mock_client(): + client = MagicMock() + client.is_connected.return_value = True + client.is_running = True + client.publish.return_value = MagicMock(rc=0) + client.subscribe.return_value = (0, 1) + return client + + +def _device(device_id="dev-1", **kwargs): + with patch("ebus_sdk.homie.MqttClient.from_config") as from_config: + client = _mock_client() + from_config.return_value = client + device = Device(id=device_id, mqtt_cfg={"host": "localhost", "port": 1883}, **kwargs) + return device, client + + +def _topics(client): + return [str(c.args[0]) for c in client.publish.call_args_list if c.args] + + +def _subscribed(client): + return [str(c.args[0]) for c in client.subscribe.call_args_list if c.args] + + +def _flushed(client): + """Topics sent via publish_and_flush, the owned-client path stop()/declare_lost() take.""" + return [str(c.args[0]) for c in client.publish_and_flush.call_args_list if c.args] + + +# --- the default is unchanged ------------------------------------------------ + + +def test_default_domain_is_ebus(): + device, client = _device() + assert device.homie_domain() == EBUS_HOMIE_DOMAIN + device.set_state(DeviceState.READY) + device.publish("$state") + assert any(t.startswith("ebus/5/dev-1/") for t in _topics(client)) + + +def test_explicit_ebus_is_the_same_as_omitting_it(): + device, _ = _device(homie_domain="ebus") + assert device.homie_domain() == "ebus" + + +# --- publishing under another domain ---------------------------------------- + + +def test_a_root_can_publish_under_the_standard_homie_domain(): + device, client = _device(homie_domain="homie") + assert device.homie_domain() == "homie" + + node = device.add_node_from_dict({"id": "sensor", "name": "sensor", "type": "x"}) + node.add_property_from_dict({"id": "temp", "datatype": PropertyDatatype.FLOAT, "value": 21.5}) + device.set_state(DeviceState.READY) + device.publish("$state") + + published = _topics(client) + assert "homie/5/dev-1/sensor/temp" in published + assert "homie/5/dev-1/$state" in published + # Nothing leaked onto the eBus tree. + assert not [t for t in published if t.startswith("ebus/")] + + +def test_the_last_will_follows_the_domain(): + device, _ = _device(homie_domain="homie") + assert device.will()["topic"] == "homie/5/dev-1/$state" + assert device.will()["payload"] == DeviceState.LOST.value + + +def test_the_owned_client_lwt_still_matches_will_under_a_custom_domain(): + """The installed LWT and will() cannot drift, whatever the domain.""" + with patch("ebus_sdk.homie.MqttClient.from_config") as from_config: + from_config.return_value = _mock_client() + device = Device(id="dev-1", mqtt_cfg={"host": "localhost", "port": 1883}, homie_domain="homie") + assert from_config.call_args[1]["lwt"] == device.will() + assert from_config.call_args[1]["lwt"]["topic"].startswith("homie/5/") + + +def test_settable_properties_subscribe_under_the_domain(): + device, client = _device(homie_domain="homie") + node = device.add_node_from_dict({"id": "control", "name": "control", "type": "x"}) + node.add_property_from_dict({"id": "mode", "datatype": PropertyDatatype.STRING, "settable": True}) + assert "homie/5/dev-1/control/mode/set" in _subscribed(client) + + +def test_an_inbound_set_is_accepted_on_the_configured_domain(): + device, _ = _device(homie_domain="homie") + node = device.add_node_from_dict({"id": "control", "name": "control", "type": "x"}) + received = [] + prop = node.add_property_from_dict( + { + "id": "mode", + "datatype": PropertyDatatype.STRING, + "settable": True, + "set_callback": received.append, + } + ) + prop._settable_callback("homie/5/dev-1/control/mode/set", b"manual") + assert received == ["manual"] + + +def test_an_inbound_set_on_a_foreign_domain_is_rejected(): + """The topic check accepts this tree's domain, not merely 'ebus', and not anything.""" + device, _ = _device(homie_domain="homie") + node = device.add_node_from_dict({"id": "control", "name": "control", "type": "x"}) + received = [] + prop = node.add_property_from_dict( + { + "id": "mode", + "datatype": PropertyDatatype.STRING, + "settable": True, + "set_callback": received.append, + } + ) + prop._settable_callback("ebus/5/dev-1/control/mode/set", b"manual") + assert received == [] + + +def test_clearing_and_deleting_use_the_domain(): + device, client = _device(homie_domain="homie") + node = device.add_node_from_dict({"id": "sensor", "name": "sensor", "type": "x"}) + node.add_property_from_dict({"id": "temp", "datatype": PropertyDatatype.FLOAT, "value": 1.0}) + device.set_state(DeviceState.READY) + + before = len(client.publish.call_args_list) + device.delete() + after = _topics(client)[before:] + assert "homie/5/dev-1/$state" in after + assert not [t for t in after if t.startswith("ebus/")] + + +def test_declare_lost_uses_the_domain(): + device, client = _device(homie_domain="homie") + device.set_state(DeviceState.READY) + device.declare_lost() + # An owned client flushes, so the deliberate-death announcement goes out + # through publish_and_flush rather than publish. + assert "homie/5/dev-1/$state" in _flushed(client) + assert not [t for t in _flushed(client) if t.startswith("ebus/")] + + +def test_stop_announces_disconnected_under_the_domain(): + device, client = _device(homie_domain="homie") + device.set_state(DeviceState.READY) + device.stop() + assert "homie/5/dev-1/$state" in _flushed(client) + + +# --- the domain is a property of the TREE ------------------------------------ + + +def test_a_child_inherits_the_roots_domain(): + root, client = _device("root-1", homie_domain="homie") + child = Device(id="child-1", parent=root) + grandchild = Device(id="gc-1", parent=child) + + assert child.homie_domain() == "homie" + assert grandchild.homie_domain() == "homie" + + node = grandchild.add_node_from_dict({"id": "sensor", "name": "sensor", "type": "x"}) + node.add_property_from_dict({"id": "temp", "datatype": PropertyDatatype.FLOAT, "value": 1.0}) + assert "homie/5/gc-1/sensor/temp" in _topics(client) + + +def test_a_child_cannot_carry_its_own_domain(): + root, _ = _device("root-1", homie_domain="homie") + with pytest.raises(ValueError, match="a tree shares one domain"): + Device(id="child-1", parent=root, homie_domain="ebus") + + +def test_a_child_of_a_default_domain_root_is_also_refused(): + """Refused even when the value would have matched: the rule is structural.""" + root, _ = _device("root-1") + with pytest.raises(ValueError, match="a tree shares one domain"): + Device(id="child-1", parent=root, homie_domain="ebus") + + +def test_will_describes_the_roots_domain_from_a_child(): + root, _ = _device("root-1", homie_domain="homie") + child = Device(id="child-1", parent=root) + assert child.will()["topic"] == "homie/5/root-1/$state" + + +def test_two_trees_on_different_domains_do_not_interfere(): + ebus_tree, ebus_client = _device("energy-1") + homie_tree, homie_client = _device("lamp-1", homie_domain="homie") + + for device in (ebus_tree, homie_tree): + node = device.add_node_from_dict({"id": "info", "name": "info", "type": "x"}) + node.add_property_from_dict({"id": "vendor-name", "datatype": PropertyDatatype.STRING, "value": "Acme"}) + + assert "ebus/5/energy-1/info/vendor-name" in _topics(ebus_client) + assert "homie/5/lamp-1/info/vendor-name" in _topics(homie_client) + assert not [t for t in _topics(homie_client) if t.startswith("ebus/")] + assert not [t for t in _topics(ebus_client) if t.startswith("homie/")] + + +def test_a_transport_free_tree_still_resolves_its_domain(): + """Topic derivation is the point of a transport-free tree, so the domain must reach it.""" + device = Device(id="dev-1", homie_domain="homie") + child = Device(id="child-1", parent=device) + assert device.homie_domain() == "homie" + assert child.homie_domain() == "homie" + assert device.will()["topic"] == "homie/5/dev-1/$state"