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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
63 changes: 52 additions & 11 deletions src/ebus_sdk/homie.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 "
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
}

Expand Down
1 change: 1 addition & 0 deletions tests/test_homie_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading