You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,6 +6,8 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan
6
6
7
7
### Added
8
8
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
+
9
11
- `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))
10
12
11
13
- `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))
Copy file name to clipboardExpand all lines: README.md
+20-1Lines changed: 20 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -174,6 +174,25 @@ Children may have children of their own. A single Last Will registered on the ro
174
174
175
175
`$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)).
176
176
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**:
# -> 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
+
177
196
### Building a Proxy or Adapter
178
197
179
198
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
279
298
280
299
Core Homie convention implementation:
281
300
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
283
302
-**Node** - Groups related properties within a device
284
303
-**Property** - Individual data points (sensors, controls)
285
304
-**Controller** - Discovers and monitors Homie devices on a broker; navigates trees and computes effective state; `set_on_disconnect_callback` for push disconnect notification
0 commit comments