Skip to main content

ebus-sdk

PyPI Ruff

Python SDK for the Electrification Bus (eBus) integration framework, which adopts and supports the Homie Convention.

Installation

pip install ebus-sdk

Quick Start

Device Role

Create 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)

Clearing a value vs. an empty-string value

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-length retain=True payload, 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.) A None that 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. Inbound 0x00 payloads are decoded back to "" on the controller and on /set. Helpers encode_empty_string() / decode_empty_string() and the constant HOMIE_EMPTY_STRING_PAYLOAD are 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)

Device Trees (parent / child)

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 initready edge of an empty transition.

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.

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.

Controller Role

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.

Module Structure

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.

homie.py

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.

property.py

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

adapter.py

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)

declaration.py

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 mapping first, generic fallback for the rest) that turns source fields into specs and scaled values

topology.py

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 graph
  • connection_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 them
  • backed_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)

ha/

Home Assistant MQTT discovery interop, both directions (see doc/ha-mqtt-discovery.md and doc/ha-discovery-bridge.md):

  • Parse (HA -> eBus) - parse_device_config into the neutral HADevice / HAComponent model; derive_spec / unit_for map a component's device_class / unit to an eBus PropertySpec
  • Emit (eBus -> HA) - homie_description_to_ha / homie_device_to_ha / to_config serialize a Homie device into HA discovery config; ebus_default_override adds 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 permanent clear_all()
  • Loop avoidance - is_ebus_sdk_origin (origin self-echo) and the energy.ebus.imported extension + imported-from attribute (is_imported / imported_source) to prevent a HA <-> eBus round-trip echo

Examples

See examples/README.md for example scripts demonstrating device and controller usage.

Requirements

  • Python 3.10+
  • ebus-mqtt-client >= 0.1.7 (the MQTT transport layer; it pins paho-mqtt, so the SDK does not depend on paho directly)

Optional extras:

  • mdns (zeroconf) — mDNS broker discovery, used by the SPAN Panel controller example
  • validation (jsonschema) — $format JSONSchema validation for json-datatype properties; absent it, validation is gracefully skipped

Releases

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.

Contributing

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.

License

MIT License — Copyright (c) 2026 Clark Communications Corporation

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ebus_sdk-0.11.0.tar.gz (113.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ebus_sdk-0.11.0-py3-none-any.whl (76.9 kB view details)

Uploaded Python 3

File details

Details for the file ebus_sdk-0.11.0.tar.gz.

File metadata

  • Download URL: ebus_sdk-0.11.0.tar.gz
  • Upload date:
  • Size: 113.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for ebus_sdk-0.11.0.tar.gz
Algorithm Hash digest
SHA256 6305671c7440f3ae2aa7e1936e5b034f1fec43f0fb184783397cf6ef2f4f30fb
MD5 9baae22f2deecf544ede0dffbbd52fcd
BLAKE2b-256 a7ed18889c4d73589b9aec2f1a043bd4d9f47a962c3a1a73fba24c1505f18bec

See more details on using hashes here.

Provenance

The following attestation bundles were made for ebus_sdk-0.11.0.tar.gz:

Publisher: publish.yml on electrification-bus/python-sdk

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ebus_sdk-0.11.0-py3-none-any.whl.

File metadata

  • Download URL: ebus_sdk-0.11.0-py3-none-any.whl
  • Upload date:
  • Size: 76.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for ebus_sdk-0.11.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ad8d72ed5735acc44a21c8568a31e383c939218c764b53e6934324461ac1e9c3
MD5 38055b704f45346c65c9c37e539e6a2f
BLAKE2b-256 48fcb85a15b5f0729e6269c381b8d3526f86edc06d443811bd9ae7f0ed1af68b

See more details on using hashes here.

Provenance

The following attestation bundles were made for ebus_sdk-0.11.0-py3-none-any.whl:

Publisher: publish.yml on electrification-bus/python-sdk

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page