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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan

## [Unreleased]

### Added

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

- `bind_property_to_homie` now binds a **non-retained** property on-set rather than on-change, so an event property can actually emit repeated events. The observable model fires on-change callbacks only when the value differs, and that gate sits *above* the Homie layer, so the publish-on-change exemption 0.20.0 gave non-retained properties was unreachable through the SDK's own recommended path: two identical consecutive events were swallowed by the model before the Homie property ever saw the second one. For a retained property nothing changes, and the model gate remains the cheap first line of defense; for an event property the repeat is the point, since the broker stores nothing and a subscriber learns of the event only by receiving it. A twin that does not answer `retained()` is treated as retained, which is what every twin got before the distinction existed. Found while testing the new `retained` field, which would otherwise have shipped as a declaration that looks like it enables event semantics and does not. ([#58](https://github.com/electrification-bus/python-sdk/issues/58))

### Documentation

- `PropertySpec.scale`'s docstring said the value is "metadata for a caller's mapping/resolver and is NOT applied by the builder", which is half the story and the half that misleads: `resolve()` **does** apply it, and `specs_and_values()` hands `build_from_declarations` values that have already been scaled, which is exactly why the builder must not scale them again. Stated positively in both docstrings and in [`doc/building-a-proxy.md`](doc/building-a-proxy.md), with a test that pins it, so the next reader of either call site learns the rule from the one they happen to open. A caller assembling a `values` map by hand passes values in the property's own unit.

- [`doc/building-a-proxy.md`](doc/building-a-proxy.md) gains a "Beyond the basic fields" section: one row per `PropertySpec` field beyond the common five, written as "use it when" rather than "it means", since the fields are individually obvious and it is knowing which problem each solves that is not.

## [0.20.1] — 2026-08-13

### Changed
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,14 +302,14 @@ The observable application-state model used to build proxies and adapters (see [

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)`
- **set_homie_property_from_python_property** - 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)`, on-change for a retained twin and on-set for a non-retained (event) one

### 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)
- **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
- **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

Expand Down
24 changes: 20 additions & 4 deletions doc/building-a-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ You can skip it for the trivial case: publishing a handful of static values once

A proxy built this way has three clean layers. Keep them separate.

1. **Declarative definitions (the schema).** A list of `PropertySpec`s describing each property: its capability (Homie node), id, datatype, unit, scale, settable. This is the single source of truth for both the observable model and the Homie tree, and `build_from_declarations` materializes both from it. See [Declarative definitions](#declarative-definitions-in-practice).
1. **Declarative definitions (the schema).** A list of `PropertySpec`s describing each property: its capability (Homie node), id, datatype, unit, scale, settable, and optionally its rounding, retention, seed value, and observable-model identity. This is the single source of truth for both the observable model and the Homie tree, and `build_from_declarations` materializes both from it. See [Declarative definitions](#declarative-definitions-in-practice).
2. **The observable model (`GroupedPropertyDict`).** Homie-agnostic. Holds the device's live values as observable `Property` objects grouped by capability (one group per Homie node, conventionally). Your acquisition code calls `model.set_value(group, property_id, value)` and nothing else. It knows nothing about MQTT.
3. **The adapter.** Builds the Homie `Device` / `Node` / `Property` tree from the declarations, and wires each observable property to its Homie twin with an on-change callback. This is the only layer that touches both the model and Homie.

Expand All @@ -66,11 +66,11 @@ homie.Property.set_value(...) ──► MQTT (ebus/5/<device>/<node>/<property

`ebus_sdk` exports the whole layer so you never hand-roll it:

- `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).
- `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.
- `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` on-change callback. `build_from_declarations` calls it for you; use it directly when you build the tree yourself.
- `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.

## Declarative definitions in practice

Expand Down Expand Up @@ -99,7 +99,23 @@ homie_props = build_from_declarations(device, model, SUBMETER)
model.set_value("meter", "active-power", 1850.0)
```

`build_from_declarations` groups specs by `capability` (one Homie node each), defaulting each node's type to `energy.ebus.capability.<capability>` (override with `node_type=`). Pass `values={(capability, prop_id): value}` to seed initial values through the model. Note `PropertySpec.scale` is metadata for your own value mapping (unit conversion); the builder does not apply it, so scale the value before you `set_value` it.
`build_from_declarations` groups specs by `capability` (one Homie node each), defaulting each node's type to `energy.ebus.capability.<capability>` (override with `node_type=`). Pass `values={(capability, prop_id): value}` to seed initial values through the model, overriding any `initial_value` on the spec. Note `PropertySpec.scale` is applied by `resolve`, not by the builder: `specs_and_values` hands the builder values `resolve` has already scaled, and scaling them again would double-apply it. If you assemble a `values` map by hand, pass values already in the property's own unit.

## Beyond the basic fields

`capability`, `prop_id`, `datatype`, `unit` and `settable` cover most properties. The rest of `PropertySpec` exists for the ones they do not, and every field defaults to what the spec did before that field existed, so you can ignore all of them until you need one.

| Field | Use it when |
| --- | --- |
| `round_to` | The source gives you more precision than the property means. The Homie property rounds on publish, and because the publish-on-change gate compares the *final* payload, rounding also decides whether two consecutive readings count as the same value. A property rounded to 1 decimal publishes far less than the raw float behind it. |
| `initial_value` | The property has a known value at build time (a vendor name, a rating). Seeds through the model, so it publishes via the binding like any other value. The `values=` argument to the builder overrides it. |
| `retained=False` | The property is an *event*, not a state: a demand-response command, a fault pulse. The broker stores nothing for it, so a subscriber that connects later sees nothing, and two identical events in a row are two events. The SDK binds these on-set rather than on-change for exactly that reason. |
| `internal_only=True` | The model should track the value but the wire should never see it: an intermediate reading, a raw counter behind a derived property, a credential. No Homie property is created and nothing appears in `$description`. A capability whose specs are *all* internal gets no node at all. |
| `conditionally_settable=True` | Whether this property accepts commands depends on runtime state, per instance. The builder leaves it not settable, which keeps `$description` honest and avoids subscribing a `/set` topic that would reject what arrives; enable it with `homie_property.set_settable(True)` inside a `state_transition()` once you know. |
| `source_id` | Your model is populated under the source system's field name, but the wire must carry the eBus name. `source_id="RMS_Watts_Tot"` with `prop_id="active-power"` populates one and publishes the other. |
| `model_group` | Two devices in one tree expose the same capability. `capability` is the Homie node id and is fine to repeat across devices, but the model group is a flat key, so two children both exposing `info` would collide in a shared `GroupedPropertyDict`. `model_group` gives each its own. |

`settable` and `conditionally_settable` are mutually exclusive, and neither can combine with `internal_only`: a property that is never published has no `/set` topic to receive a command on. Both are rejected when you construct the spec, not when you publish.

## Ingesting Home Assistant MQTT discovery

Expand Down
31 changes: 24 additions & 7 deletions src/ebus_sdk/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,28 @@ def bind_property_to_homie(
"""Wire an observable model property to its Homie twin so changes mirror across.

Convenience wrapper over
:func:`set_homie_property_from_python_property`: registers the on-change
callback that republishes ``properties[group][property_id]`` onto
``homie_property`` whenever the model value changes. Returns the callback id
from ``GroupedPropertyDict.add_property_on_change_callback``.
:func:`set_homie_property_from_python_property`: registers the callback that
republishes ``properties[group][property_id]`` onto ``homie_property``.
Returns the callback id from the ``GroupedPropertyDict`` registration.

Which callback depends on what the twin is, because the two kinds of Homie
property disagree about what a repeated value means:

* A **retained** property (the default) binds to *on-change*. The broker
holds its last payload, so re-setting the same value is a redundant write
and the model drops it before it costs anything. The Homie layer's own
publish-on-change gate is a second line of defense on the final payload.
* A **non-retained** (event) property binds to *on-set*. The broker stores
nothing for it, so an identical consecutive payload is a second real
event, not a redundant write, and dropping it would lose an event. The
Homie layer already exempts these from its gate; binding on-change would
have made that exemption unreachable, since the model would have swallowed
the repeat first.

A twin that does not answer ``retained()`` is treated as retained, which is
what this function did for every twin before the distinction existed.
"""
return properties.add_property_on_change_callback(
group, property_id, partial(set_homie_property_from_python_property, homie_property)
)
retained = getattr(homie_property, "retained", None)
is_event = callable(retained) and retained() is False
register = properties.add_property_on_set_callback if is_event else properties.add_property_on_change_callback
return register(group, property_id, partial(set_homie_property_from_python_property, homie_property))
Loading