Metadata-Version: 2.4
Name: ebus-sdk
Version: 0.10.0
Summary: Python SDK for Homie MQTT Convention (eBus)
Author: Clark Communications Corporation
License-Expression: MIT
Project-URL: Homepage, https://ebus.energy
Project-URL: Repository, https://github.com/electrification-bus/python-sdk
Project-URL: Issues, https://github.com/electrification-bus/python-sdk/issues
Keywords: mqtt,homie,iot,ebus
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Home Automation
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: ebus-mqtt-client>=0.1.7
Requires-Dist: paho-mqtt>=1.5.0
Provides-Extra: mdns
Requires-Dist: zeroconf>=0.131.0; extra == "mdns"
Provides-Extra: validation
Requires-Dist: jsonschema>=4.0; extra == "validation"
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: jsonschema>=4.0; extra == "dev"
Dynamic: license-file

# ebus-sdk

[![PyPI](https://img.shields.io/pypi/v/ebus-sdk)](https://pypi.org/project/ebus-sdk/)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)

Python SDK for the [Electrification Bus (eBus)](https://ebus.energy) integration framework, which adopts and supports the [Homie Convention](https://homieiot.github.io).

## Installation

```bash
pip install ebus-sdk
```

## Quick Start

### Device Role

Create a Homie device that publishes sensor data:

```python
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.

```python
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.

```python
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`](src/ebus_sdk/homie.py)).

`$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.

### 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.

```python
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`](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`](doc/ha-mqtt-discovery.md) parses HA MQTT discovery INTO eBus, and [`doc/ha-discovery-bridge.md`](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:

```python
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:

```python
# 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:

```python
# 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`](https://github.com/electrification-bus/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`](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`](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`](doc/ha-mqtt-discovery.md) and [`doc/ha-discovery-bridge.md`](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`](examples/README.md) for example scripts demonstrating device and controller usage.

## Requirements

- Python 3.10+
- paho-mqtt >= 1.6.1

## Releases

See [CHANGELOG.md](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](CONTRIBUTING.md) for how to file Discussions, Issues, and pull requests. Pure MQTT-transport changes (TLS, auth, paho upgrades) belong in [`ebus-mqtt-client`](https://github.com/electrification-bus/ebus-mqtt-client), not here. Normative behavior tracks the [Electrification Bus specification](https://github.com/electrification-bus/specification).

## License

[MIT License](LICENSE) — Copyright (c) 2026 Clark Communications Corporation
