Python SDK for the Electrification Bus (eBus) integration framework, which adopts and supports the Homie Convention.
pip install ebus-sdkCreate a Homie device that publishes sensor data:
from ebus_sdk import Device, Node, PropertyDatatype, Unit
# Create device
device = Device('my-device-id', name='My Sensor', mqtt_cfg={
'host': 'mqtt.example.com',
'port': 1883
})
# Add a node with properties
node = device.add_node_from_dict({
'id': 'sensors',
'name': 'Sensors',
'type': 'sensor'
})
# Add a temperature property
temp = node.add_property_from_dict({
'id': 'temperature',
'name': 'Temperature',
'datatype': PropertyDatatype.FLOAT,
'unit': Unit.DEGREE_CELSIUS
})
# Start and publish
device.start_mqtt_client()
temp.set_value(23.5)Homie 5 distinguishes two things that both look "empty" on the wire, and the SDK handles each automatically:
- Clearing (retracting) a retained value — set the property to
None. Once it has been published, this emits a zero-lengthretain=Truepayload, which MQTT/Homie treats as "delete the retained topic", so a subscriber that connects later sees no stale value. (clear_value()does the same explicitly;Node.delete_property()clears on removal.) ANonethat was never published is a silent no-op — no phantom topic is created. - An actual empty-string value — set a string property to
"". This is published as a single null byte (0x00), the Homie 5 encoding that keeps""distinct from a topic-clear. Inbound0x00payloads are decoded back to""on the controller and on/set. Helpersencode_empty_string()/decode_empty_string()and the constantHOMIE_EMPTY_STRING_PAYLOADare exported for consumers that need them directly.
temp.set_value(None) # retracts the retained topic (subscribers see nothing)
label.set_value("") # publishes an empty-string VALUE (0x00 on the wire)Build a tree of devices that share a single MQTT connection. The root device owns the connection (and the Last Will), every child borrows it via the parent= constructor arg, and $description root / parent / children fields are kept in sync automatically. The tree can be any depth.
panel = Device('panel-1', type='energy.ebus.device.electrical-panel', mqtt_cfg={...})
panel.start_mqtt_client()
# Add 32 circuit children inside one state transition — the broker sees
# exactly one INIT→READY cycle on the panel, not 32.
with panel.state_transition():
for cid in commissioned_circuits:
Device(id=cid, type='energy.ebus.device.circuit', parent=panel)
# Three-level tree: panel → BESS child → MID grandchild
bess = Device(id='bess-1', type='...battery-storage', parent=panel)
Device(id='mid-1', type='...metering', parent=bess)
# Remove a child at runtime (runs the Homie remove-child protocol)
panel.children()[0].delete()Children may have children of their own. A single Last Will registered on the root marks the entire tree lost if the publisher process dies — controllers compute effective state per the Homie 5 precedence table (see HOMIE_EFFECTIVE_STATE_TABLE).
$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.
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.
from ebus_sdk import (
Device, PropertyDatatype, Unit,
GroupedPropertyDict, ObservableProperty, bind_property_to_homie,
)
# Observable model (Homie-agnostic)
model = GroupedPropertyDict()
model.add_property('meter', ObservableProperty(id='active-power', type=float))
# Homie device + property
device = Device('my-meter', type='energy.ebus.device.submeter', mqtt_cfg={...})
device.start_mqtt_client()
with device.state_transition():
node = device.add_node_from_dict({'id': 'meter', 'type': 'energy.ebus.capability.meter'})
homie_prop = node.add_property_from_dict(
{'id': 'active-power', 'datatype': PropertyDatatype.FLOAT, 'unit': Unit.WATT})
# Bind: a model change now publishes to MQTT automatically
bind_property_to_homie(model, 'meter', 'active-power', homie_prop)
model.set_value('meter', 'active-power', 1850.0)If you are building a proxy, read doc/building-a-proxy.md first. It is the comprehensive guide: declarative property definitions, the bridge-root plus proxied-children topology, dynamic device shapes, settable/bidirectional properties, and the anti-pattern to avoid (driving homie.Device directly from your data path). examples/utility-meter is the fullest worked example.
Home Assistant interop is covered by two ebus_sdk.ha guides: doc/ha-mqtt-discovery.md parses HA MQTT discovery INTO eBus, and doc/ha-discovery-bridge.md emits eBus OUT to HA via HaDiscoveryBridge (per-device mapping, an eBus-aware customizer, and HA <-> eBus loop-avoidance guards). examples/ha-discovery-bridge is a live-broker, no-HASS-needed demo.
Discover and monitor Homie devices:
from ebus_sdk import Controller, DiscoveredDevice
def on_device_discovered(device: DiscoveredDevice):
print(f'Found: {device.device_id}')
def on_property_changed(device_id, node_id, prop_id, new_val, old_val):
print(f'{device_id}/{node_id}/{prop_id} = {new_val}')
controller = Controller(mqtt_cfg={'host': 'mqtt.example.com', 'port': 1883})
controller.set_on_device_discovered_callback(on_device_discovered)
controller.set_on_property_changed_callback(on_property_changed)
controller.start_discovery()Controllers can also navigate device hierarchies and compute effective state:
# Walk the tree
roots = controller.get_root_devices()
for root in roots:
for descendant in controller.get_descendants(root.device_id):
# When the root is lost/disconnected/sleeping/init, every descendant
# is effectively the same regardless of its own reported $state.
print(f'{descendant.device_id}: {controller.get_effective_state(descendant.device_id)}')Three controller discovery modes select what the controller listens for:
# Wildcard (default) — every device on the broker
Controller(mqtt_cfg=cfg)
# Single-device — subscribe to exactly one device, no children, no wildcards
Controller(mqtt_cfg=cfg, device_id='panel-1')
# Tree-rooted — subscribe to a root and auto-subscribe to its descendants
# as they're announced; subscription changes are gated on the parent's
# $state init→ready edge per the Homie 5 spec.
Controller(mqtt_cfg=cfg, root_device_id='panel-1')Tree-rooted mode is the right pick for consumers that want exactly one
device's tree on a multi-publisher broker — wildcard would re-introduce
multi-panel scope creep at the application layer, and single-device would
see the root and none of its children. As the publisher mutates the tree
(Device(parent=...) to add, child.delete() to remove), descendants are
subscribed or dropped on the parent's next init→ready transition.
src/ebus_sdk/
├── __init__.py # Package exports
├── homie.py # Homie convention implementation (Device, Node, Property, Controller, ...)
├── property.py # Observable application-state model (Property, GroupedPropertyDict)
├── adapter.py # Proxy/adapter helpers that mirror the model onto Homie
├── declaration.py # Declarative PropertySpec + build_from_declarations + resolve
├── topology.py # Consumer-side site-topology assembler (SiteTopology)
└── ha/ # Home Assistant MQTT discovery interop (parse in, emit out)
MQTT transport lives in the separate ebus-mqtt-client package; this SDK depends on it.
Core Homie convention implementation:
- Device - Represents a Homie device; pass
parent=to build a child in a tree - 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
- DiscoveredDevice - Represents a device found by the controller; exposes
root_id,parent_id,children_ids,is_root - DeviceState - Enum:
init,ready,disconnected,sleeping,lost - HOMIE_EFFECTIVE_STATE_TABLE - Homie 5 state-precedence table used by
Controller.get_effective_state() - PropertyDatatype - Enum:
STRING,INTEGER,FLOAT,BOOLEAN,ENUM,COLOR,DATETIME,DURATION,JSON - Unit - Common units:
DEGREE_CELSIUS,PERCENT,WATT,KILOWATT_HOUR, etc.
The observable application-state model used to build proxies and adapters (see doc/building-a-proxy.md):
- Property - Thread-safe observable property with change callbacks
- GroupedPropertyDict - Two-level dictionary organizing properties by group (one group per Homie node)
- PropertyDict - Simple property dictionary
- ChangeEvent - Enum for property change event types
Helpers that mirror the observable model onto the Homie tree, so you never hand-roll the bridge:
- set_homie_property_from_python_property - on-change callback that copies an observable property's value to its Homie twin
- bind_property_to_homie - one-call convenience that registers that callback for a
(group, property_id)
The declarative "schema" layer for proxies (see doc/building-a-proxy.md):
- PropertySpec - declares one eBus property (capability/node, id, datatype, unit, scale, settable)
- build_from_declarations - materializes a set of specs into Homie nodes/properties, the observable model, and their bindings in one call
- resolve / specs_and_values / ResolvedProperty - the two-tier mapping (hand-authored
mappingfirst, genericfallbackfor the rest) that turns source fields into specs and scaled values
Consumer-side site-topology assembler for the connection capability. eBus records site wiring as distributed per-device edges (feeds-* / fed-by-*) with no central authority; SiteTopology.assemble(devices) / SiteTopology.from_controller(controller) reconstructs the graph once so every consumer gets a resolved, queryable view:
root(),parents/children/what_feeds,ancestors/descendants(cycle-safe) - traverse the assembled graphconnection_points_feeding(id)+aggregate(id, value_fn)- the multi-source case (e.g. a multi-unit BESS on several circuits); sum a caller-supplied metric across thembacked_up_loads(),completeness()- which paths survive an outage; surveyed-vs-unknown coverage- Robust to partial data: dangling references become
undiscovered()placeholders, cycles terminate, and the graph is explicitly a view (never a source of truth)
Home Assistant MQTT discovery interop, both directions (see doc/ha-mqtt-discovery.md and doc/ha-discovery-bridge.md):
- Parse (HA -> eBus) -
parse_device_configinto the neutralHADevice/HAComponentmodel;derive_spec/unit_formap a component'sdevice_class/unitto an eBusPropertySpec - Emit (eBus -> HA) -
homie_description_to_ha/homie_device_to_ha/to_configserialize a Homie device into HA discovery config;ebus_default_overrideadds eBus-capability-aware metadata - HaDiscoveryBridge - controller-role runtime that discovers eBus devices and publishes/clears their HA discovery topics, with per-device mapping and graceful
stop()vs permanentclear_all() - Loop avoidance -
is_ebus_sdk_origin(origin self-echo) and theenergy.ebus.importedextension +imported-fromattribute (is_imported/imported_source) to prevent a HA <-> eBus round-trip echo
See examples/README.md for example scripts demonstrating device and controller usage.
- Python 3.10+
ebus-mqtt-client>= 0.1.7 (the MQTT transport layer; it pinspaho-mqtt, so the SDK does not depend on paho directly)
Optional extras:
mdns(zeroconf) — mDNS broker discovery, used by the SPAN Panel controller examplevalidation(jsonschema) —$formatJSONSchema validation forjson-datatype properties; absent it, validation is gracefully skipped
See CHANGELOG.md. 0.2.0 introduces parent/child device trees and contains breaking changes to the Device constructor — see the changelog entry before upgrading from 0.1.x.
The version has a single source of truth: __version__ in src/ebus_sdk/__init__.py. pyproject.toml reads it dynamically and the setup.py shim (Yocto/kirkstone path) parses the same literal, so they cannot drift. To cut a release:
- Bump
__version__insrc/ebus_sdk/__init__.py(the only place), and finalize theCHANGELOG.mdentry. - Commit it:
git commit -am "Release X.Y.Z". - Tag it to match,
v-prefixed:git tag vX.Y.Z. - Push the tag:
git push GitHub vX.Y.Z(a plaingit pushdoes not trigger a release).
Pushing a v* tag runs the publish workflow, which verifies the tag equals v + __version__ (a mismatch fails the run before anything is published), builds the sdist and wheel, and publishes to PyPI via Trusted Publishing. See the version single-source-of-truth convention.
See CONTRIBUTING.md for how to file Discussions, Issues, and pull requests. Pure MQTT-transport changes (TLS, auth, paho upgrades) belong in ebus-mqtt-client, not here. Normative behavior tracks the Electrification Bus specification.
MIT License — Copyright (c) 2026 Clark Communications Corporation