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

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

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,9 @@ Helpers that mirror the observable model onto the Homie tree, so you never hand-
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, plus `round_to`, `initial_value`, `retained`, `internal_only`, `conditionally_settable`, and the `source_id` / `model_group` model-identity splits)
- **build_from_declarations** - materializes a set of specs into Homie nodes/properties, the observable model, and their bindings in one call
- **build_from_declarations** - materializes a set of specs into Homie nodes/properties, the observable model, and their bindings in one call (one device)
- **DeviceSpec** - declares one device in a tree (class, id or late-bound id resolver, parent, model group, `on_created` hook)
- **DeviceTreeBuilder** - materializes a set of `DeviceSpec`s into a parent/child tree over an externally-owned model: late-bound ids, idempotent `add()`, depth-first `remove()`
- **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
Expand Down
31 changes: 31 additions & 0 deletions doc/building-a-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ homie.Property.set_value(...) ──► MQTT (ebus/5/<device>/<node>/<property

- `PropertySpec`: the declaration for one property (its `capability`/node, `prop_id`, `datatype`, `unit`, `scale`, `settable`, and an optional `entity_setter` for the inbound/control path). The schema layer, complementary to the observable `Property` (which holds the live value). It also carries `round_to`, `initial_value`, `retained`, `internal_only`, `conditionally_settable`, and the `source_id` / `model_group` identity splits; see [Beyond the basic fields](#beyond-the-basic-fields).
- `build_from_declarations(device, model, specs, ...)`: materializes a set of `PropertySpec`s into a live device in one call: one Homie node per capability, an observable `Property` plus a Homie property per spec, and the on-change binding between them, all inside one `state_transition()`. Returns the `{(capability, prop_id): homie.Property}` map.
- `DeviceSpec` / `DeviceTreeBuilder`: the device-level declaration and the tree-aware, incremental builder over a model you own. See [Declaring the tree instead of building it by hand](#declaring-the-tree-instead-of-building-it-by-hand).
- `resolve(field_names, values, mapping, *, fallback=...)`: the two-tier mapping mechanism. Turns source fields into `PropertySpec`s (and scaled values) using a hand-authored `mapping` first, then a generic `fallback` for the rest (e.g. `ebus_sdk.ha.derive_spec` over discovered components). `specs_and_values(resolved)` splits the result straight into the `specs` and `values=` that `build_from_declarations` wants.
- `set_homie_property_from_python_property(homie_property, python_property)`: the low-level on-change mirror (copies an observable property's value onto its Homie twin).
- `bind_property_to_homie(properties, group, property_id, homie_property)`: registers that mirror as a `GroupedPropertyDict` callback. `build_from_declarations` calls it for you; use it directly when you build the tree yourself. A retained twin binds on-change, so a repeated value costs nothing; a non-retained (event) twin binds on-set, because for an event the repeat is the point.
Expand Down Expand Up @@ -165,6 +166,36 @@ A proxy is not one flat device. Per the eBus [`proxy.md`](https://github.com/ele

Children share the root's single MQTT connection automatically (that is what `parent=` does), and one Last Will on the root marks the whole tree `lost` if the process dies; `root.declare_lost()` publishes exactly the same thing deliberately, when the bridge knows it is dying rather than crashing. See [Device Trees](../README.md#device-trees-parent--child) in the README.

### Declaring the tree instead of building it by hand

`build_from_declarations` materializes exactly one device and creates the observable model itself, keyed by capability. For a tree, use `DeviceSpec` and `DeviceTreeBuilder`, which take a model you own and give each device its own group:

```python
from ebus_sdk import DeviceSpec, DeviceTreeBuilder, GroupedPropertyDict

model = GroupedPropertyDict() # yours, not the builder's
builder = DeviceTreeBuilder(root, model)

bess = DeviceSpec("bess", BESS_SPECS, device_id=lambda: serial_when_known())
mid = DeviceSpec("mid", MID_SPECS, device_id=lambda: f"{serial_when_known()}-mid", parent=bess)

builder.add(bess) # None while the serial is unknown
builder.add(mid) # deferred behind its parent
... # the serial arrives
builder.resolve_deferred() # both devices appear, parent first
```

Device class, id and parent are device-level facts, so they live on the `DeviceSpec` rather than being repeated on every `PropertySpec` of that device. `device_type` defaults to `energy.ebus.device.{device_class}`; prefer that default, because the SDK stores `Device.type` verbatim and validates nothing against a registry, so a hand-written type ships misspelled without complaint.

Four things the tree builder does that the single-device one has no need to:

- **The model group is the device**, not the capability. Two children that both expose `info` collide the moment they share a capability-keyed model. A `PropertySpec` that names its own `model_group` still wins, so an existing model keyed your way keeps that keying.
- **Ids can be late-bound.** `device_id` may be a callable returning `None` while an asynchronous identifier has not arrived. `add()` returns `None` and remembers the spec; `resolve_deferred()` retries and resolves a whole generation, including children waiting behind a deferred parent. Waiting beats guessing: a child published under a wrong-but-stable id leaves retained topics that survive restarts and firmware updates.
- **`add()` is idempotent.** Incremental lifecycles re-fire, and a second `add()` of a built spec returns the same `Device` without republishing anything.
- **`remove()` is depth-first**, grandchild before parent, derived from the live tree rather than an ordering you maintain, so nothing ever observes an orphaned child. It also deletes the model entries it added, and any group it created that is now empty.

Each `add()` announces its own device and makes the parent republish its `$description`. To collapse a burst of adds into one parent announcement, wrap them in the parent's `state_transition()`. Use `on_created` for per-child side effects rather than post-processing the returned tree.

## Lifecycle and state

- **Batch structural changes.** Adding N nodes/properties inside one `with device.state_transition():` collapses to a single `$description` publish and one `init` to `ready` edge, instead of N. Always build a device's structure inside a transition.
Expand Down
4 changes: 4 additions & 0 deletions src/ebus_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@

# Declarative property specs + builder + resolver (see doc/building-a-proxy.md)
from .declaration import (
DeviceSpec,
DeviceTreeBuilder,
PropertySpec,
ResolvedProperty,
build_from_declarations,
Expand Down Expand Up @@ -121,6 +123,8 @@
# Declarative specs + builder + resolver
"PropertySpec",
"ResolvedProperty",
"DeviceSpec",
"DeviceTreeBuilder",
"build_from_declarations",
"python_type_for",
"resolve",
Expand Down
Loading