Skip to content

Commit 0a581cd

Browse files
dcjclaude
andcommitted
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) <noreply@anthropic.com>
1 parent ab74fea commit 0a581cd

5 files changed

Lines changed: 296 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan
66

77
### Added
88

9+
- `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))
10+
911
- `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))
1012

1113
- `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))

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,25 @@ Children may have children of their own. A single Last Will registered on the ro
174174

175175
`$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)).
176176

177+
### Publishing under a different Homie domain
178+
179+
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.
180+
181+
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**:
182+
183+
```python
184+
lamp = Device('lamp-1', type='...', mqtt_cfg={...}, homie_domain='homie')
185+
lamp.start_mqtt_client()
186+
# -> homie/5/lamp-1/$state, homie/5/lamp-1/light/brightness, and a
187+
# Last Will on homie/5/lamp-1/$state
188+
```
189+
190+
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.
191+
192+
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.
193+
194+
The consumer side has always been configurable: `Controller(homie_domain=...)` monitors one domain, so watching both trees means two `Controller`s.
195+
177196
### Building a Proxy or Adapter
178197

179198
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
279298

280299
Core Homie convention implementation:
281300

282-
- **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
301+
- **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
283302
- **Node** - Groups related properties within a device
284303
- **Property** - Individual data points (sensors, controls)
285304
- **Controller** - Discovers and monitors Homie devices on a broker; navigates trees and computes effective state; `set_on_disconnect_callback` for push disconnect notification

src/ebus_sdk/homie.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,17 @@ def _transport_free(self) -> bool:
597597
device = node.device() if node is not None else None
598598
return device._transport_free() if device is not None else False
599599

600+
def _homie_domain(self) -> str:
601+
"""The domain of the tree this property belongs to.
602+
603+
Same node -> device walk as ``_transport_free``. A property not yet
604+
attached to a tree falls back to the eBus domain, which is what every
605+
topic here was hardcoded to before the domain was configurable.
606+
"""
607+
node = self.node()
608+
device = node.device() if node is not None else self._device
609+
return device.homie_domain() if device is not None else EBUS_HOMIE_DOMAIN
610+
600611
def get_node_id(self) -> str:
601612
"""
602613
Why is this needed?
@@ -876,7 +887,7 @@ def publish_value(self, *, force: bool = False) -> bool:
876887
if self._value is None and (not self._ever_published or self._skip_initial_publish):
877888
logger.debug(f"reason=propertySkipPublishNoneValue,propertyID={self._id}")
878889
return True
879-
topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}"
890+
topic = f"{self._homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}"
880891
if self._value is None:
881892
# Value was cleared after having been published. Emit the empty
882893
# retained message so the prior retained value is retracted from the
@@ -979,7 +990,7 @@ def clear_value(self) -> bool:
979990
f"reason=propertyClearValueInsufficientIDs,deviceID={device_id},nodeID={node_id},propertyID={self._id}"
980991
)
981992
return False
982-
topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}"
993+
topic = f"{self._homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}"
983994
try:
984995
# Publishing empty string clears retained message
985996
mqttc.publish(topic, "", retain=True, qos=self._qos)
@@ -1068,7 +1079,7 @@ def _settable_callback(self, topic: str, payload: Union[bytes, bytearray]) -> No
10681079
logger.warning(f"reason=nodeSetCallbackTopicParseException,e={e}")
10691080
return
10701081
if not (
1071-
(homie_domain == EBUS_HOMIE_DOMAIN)
1082+
(homie_domain == self._homie_domain())
10721083
and (homie_version == str(EBUS_HOMIE_VERSION_MAJOR))
10731084
and (property_id_set == "set")
10741085
):
@@ -1156,7 +1167,7 @@ def set_subscribe(self) -> None:
11561167
f"propertySetSubscribeInsufficientIDs,deviceID={device_id},nodeID={node_id},propertyID={self._id}"
11571168
)
11581169
return
1159-
topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}/set"
1170+
topic = f"{self._homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{device_id}/{node_id}/{self._id}/set"
11601171
try:
11611172
mqttc.subscribe(topic, param=partial(self._settable_callback), qos=self._qos)
11621173
except Exception as e:
@@ -1486,7 +1497,9 @@ class Device:
14861497
"username": "MyUserName",
14871498
"password": "SECRET"}}
14881499
1489-
homie_domains config for future use, not currently supported by this code
1500+
The ``homie_domains`` key in the broker config is not read by this class. To
1501+
publish a tree under a domain other than ``ebus``, pass ``homie_domain=`` to
1502+
the ROOT Device; see ``homie_domain()``.
14901503
14911504
mqtt_cfg={} connects using ebus-mqtt-client's defaults. mqtt_cfg=None opens no socket:
14921505
the tree still composes $description and resolves ids and topics, it just never
@@ -1520,6 +1533,7 @@ def __init__(
15201533
description_extras: Optional[dict] = None,
15211534
mqtt_cfg: Optional[dict] = None,
15221535
mqttc: Optional[MqttDeviceTransport] = None,
1536+
homie_domain: Optional[str] = None,
15231537
qos: int = EBUS_HOMIE_MQTT_QOS,
15241538
async_loop: Optional[asyncio.AbstractEventLoop] = None,
15251539
on_disconnect: Optional[Callable[[bool], None]] = None,
@@ -1535,6 +1549,15 @@ def __init__(
15351549
raise ValueError(
15361550
f"Device id={id}: cannot pass both parent= and mqttc=; children share the root's MQTT connection"
15371551
)
1552+
# The domain is a per-TREE property, like the connection and the QoS: one
1553+
# tree publishes under one prefix, and a child under a different domain
1554+
# would sit outside its own root's subtree. Refuse it on a child rather
1555+
# than silently ignoring it, matching the mqtt_cfg/mqttc rule above.
1556+
if parent is not None and homie_domain is not None:
1557+
raise ValueError(
1558+
f"Device id={id}: cannot pass both parent= and homie_domain=; a tree shares one domain, "
1559+
"set it on the root"
1560+
)
15381561
if mqtt_cfg is not None and mqttc is not None:
15391562
raise ValueError(
15401563
f"Device id={id}: cannot pass both mqtt_cfg= and mqttc=; pass mqtt_cfg to have the SDK "
@@ -1585,6 +1608,10 @@ def __init__(
15851608
# register disconnect handling on their own client.
15861609
logger.warning(f"reason=deviceInjectedClientOnDisconnectInert,id={id}")
15871610
self._id = id
1611+
# Only a root carries the domain; descendants read the root's via
1612+
# homie_domain(). Defaults to the eBus domain, so a publisher that never
1613+
# mentions it is unaffected.
1614+
self._homie_domain = (homie_domain or EBUS_HOMIE_DOMAIN) if parent is None else None
15881615
self._name = name if name else id
15891616
self._type = type
15901617
self._parent: Optional[Device] = parent
@@ -1732,6 +1759,20 @@ def extensions(self) -> List:
17321759
"""
17331760
return self._extensions
17341761

1762+
def homie_domain(self) -> str:
1763+
"""The Homie domain (topic prefix) this device's TREE publishes under.
1764+
1765+
Defaults to ``ebus``, which the eBus specification mandates for energy
1766+
devices. A publisher that also speaks for non-energy home-automation
1767+
devices can put a tree under the standard ``homie`` domain, or any
1768+
other, by passing ``homie_domain=`` to the ROOT device; every topic the
1769+
tree derives follows, including the Last Will.
1770+
1771+
Per-tree, never per-device: a child inherits its root's domain and is
1772+
refused its own, the same way it is refused its own connection.
1773+
"""
1774+
return self.root()._homie_domain or EBUS_HOMIE_DOMAIN
1775+
17351776
@property
17361777
def qos(self) -> int:
17371778
"""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
18391880
logger.info(f"reason=deviceStopSilent,id={root._id}")
18401881
elif mqttc.is_connected():
18411882
root._state = DeviceState.DISCONNECTED
1842-
state_topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state"
1883+
state_topic = f"{root.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state"
18431884
if root._owns_client and root._owned_client is not None:
18441885
flushed = root._owned_client.publish_and_flush(
18451886
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:
19131954
# `lost` long after recovery, which is worse than not sending it.
19141955
logger.info(f"reason=deviceDeclareLostBrokerUnreachable,id={root._id}")
19151956
return changed
1916-
state_topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state"
1957+
state_topic = f"{root.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state"
19171958
# Ownership decides, never isinstance: a caller may legitimately inject a real
19181959
# MqttClient (driven by asyncio_driver), and publish_and_flush/stop must not be
19191960
# called on a client the SDK does not own.
@@ -2087,7 +2128,7 @@ def delete_all_from_mqtt(self) -> None:
20872128
)
20882129
return
20892130

2090-
base_topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}"
2131+
base_topic = f"{self.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}"
20912132

20922133
# Step 1: Clear all property values that were actually published
20932134
for node_id, node in list(self._nodes.items()):
@@ -2168,7 +2209,7 @@ def delete(self) -> None:
21682209
# the retained $state and "the device will cease to exist", then clear
21692210
# its other retained topics). delete_all_from_mqtt only handles property
21702211
# values and $description, so $state is cleared here separately.
2171-
base_topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}"
2212+
base_topic = f"{self.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}"
21722213
self.clear_retained_topic(f"{base_topic}/$state")
21732214
self.delete_all_from_mqtt()
21742215
finally:
@@ -2354,7 +2395,7 @@ def publish(self, attribute: str = "", value: Optional[Any] = None) -> None:
23542395
logger.info("reason=devicePublishNoDeviceID")
23552396
return
23562397
try:
2357-
base_topic = f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}/"
2398+
base_topic = f"{self.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{self._id}/"
23582399
if attribute == "$state":
23592400
topic = base_topic + "$state"
23602401
if value:
@@ -2515,7 +2556,7 @@ def will(self) -> dict:
25152556
"""
25162557
root = self.root()
25172558
return {
2518-
"topic": f"{EBUS_HOMIE_DOMAIN}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state",
2559+
"topic": f"{root.homie_domain()}/{EBUS_HOMIE_VERSION_MAJOR}/{root._id}/$state",
25192560
"payload": DeviceState.LOST.value,
25202561
}
25212562

tests/test_homie_device.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def _make_wired_property(mock_client, device_id="dev1", node_id="node1", **prop_
5858
mock_device.id.return_value = device_id
5959
mock_device.get_mqtt_client.return_value = mock_client
6060
mock_device._qos = EBUS_HOMIE_MQTT_QOS
61+
mock_device.homie_domain.return_value = EBUS_HOMIE_DOMAIN
6162

6263
node = Node(id=node_id, device=mock_device)
6364
defaults = dict(id="temperature", value=72.5, datatype=PropertyDatatype.FLOAT)

0 commit comments

Comments
 (0)