Skip to content

electrification-bus/python-sdk

Repository files navigation

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.

Releasing

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:

  1. Bump __version__ in src/ebus_sdk/__init__.py (the only place), and finalize the CHANGELOG.md entry.
  2. Commit it: git commit -am "Release X.Y.Z".
  3. Tag it to match, v-prefixed: git tag vX.Y.Z.
  4. Push the tag: git push GitHub vX.Y.Z (a plain git push does 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.

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

About

Python SDK for eBus/Homie

Resources

License

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages