Skip to content

Commit 23caa35

Browse files
dcjclaude
andcommitted
feat: extend PropertySpec to property-level parity
PropertySpec gains seven fields, each defaulting to what the spec did before it existed, so no existing declaration set changes: round_to decimal places applied on publish. The Homie property already supported this; the declaration could not reach it. Because the publish-on-change gate compares the final payload, rounding also decides whether two readings are the same value. initial_value a seed applied through the model at build time, 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. No Homie property, no $description entry, 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 truthful and no /set topic is opened on a property that would reject what arrives; the caller flips it with set_settable(True) inside a state_transition(). source_id, model_group split the observable-model identity from the wire identity. The 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: two child devices that both expose `info` collide in a shared model while remaining perfectly distinct on the wire. Separating them is the prerequisite for the tree-aware builder in #57. Two contradictions are now refused when the spec is constructed rather than when it publishes: settable with conditionally_settable, and internal_only with either. Carries one behavior fix the feature depends on: bind_property_to_homie now binds a non-retained property on-set rather than on-change. 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 documented path. Without it, retained=False would ship as a declaration that looks like it enables event semantics and does not. Also fixes a latent seeding bug: the values= seed was gated on presence in the returned Homie map, so an internal property could never have been seeded through it. Closes #58 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3a863e3 commit 23caa35

8 files changed

Lines changed: 518 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- `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))
10+
11+
### Fixed
12+
13+
- `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))
14+
15+
### Documentation
16+
17+
- `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.
18+
19+
- [`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.
20+
721
## [0.20.1] — 2026-08-13
822

923
### Changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,14 +302,14 @@ The observable application-state model used to build proxies and adapters (see [
302302

303303
Helpers that mirror the observable model onto the Homie tree, so you never hand-roll the bridge:
304304

305-
- **set_homie_property_from_python_property** - on-change callback that copies an observable property's value to its Homie twin
306-
- **bind_property_to_homie** - one-call convenience that registers that callback for a `(group, property_id)`
305+
- **set_homie_property_from_python_property** - callback that copies an observable property's value to its Homie twin
306+
- **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
307307

308308
### declaration.py
309309

310310
The declarative "schema" layer for proxies (see [`doc/building-a-proxy.md`](doc/building-a-proxy.md)):
311311

312-
- **PropertySpec** - declares one eBus property (capability/node, id, datatype, unit, scale, settable)
312+
- **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)
313313
- **build_from_declarations** - materializes a set of specs into Homie nodes/properties, the observable model, and their bindings in one call
314314
- **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
315315

doc/building-a-proxy.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ You can skip it for the trivial case: publishing a handful of static values once
4444

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

47-
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).
47+
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).
4848
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.
4949
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.
5050

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

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

69-
- `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).
69+
- `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).
7070
- `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.
7171
- `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.
7272
- `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).
73-
- `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.
73+
- `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.
7474

7575
## Declarative definitions in practice
7676

@@ -99,7 +99,23 @@ homie_props = build_from_declarations(device, model, SUBMETER)
9999
model.set_value("meter", "active-power", 1850.0)
100100
```
101101

102-
`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.
102+
`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.
103+
104+
## Beyond the basic fields
105+
106+
`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.
107+
108+
| Field | Use it when |
109+
| --- | --- |
110+
| `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. |
111+
| `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. |
112+
| `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. |
113+
| `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. |
114+
| `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. |
115+
| `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. |
116+
| `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. |
117+
118+
`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.
103119

104120
## Ingesting Home Assistant MQTT discovery
105121

src/ebus_sdk/adapter.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,28 @@ def bind_property_to_homie(
4545
"""Wire an observable model property to its Homie twin so changes mirror across.
4646
4747
Convenience wrapper over
48-
:func:`set_homie_property_from_python_property`: registers the on-change
49-
callback that republishes ``properties[group][property_id]`` onto
50-
``homie_property`` whenever the model value changes. Returns the callback id
51-
from ``GroupedPropertyDict.add_property_on_change_callback``.
48+
:func:`set_homie_property_from_python_property`: registers the callback that
49+
republishes ``properties[group][property_id]`` onto ``homie_property``.
50+
Returns the callback id from the ``GroupedPropertyDict`` registration.
51+
52+
Which callback depends on what the twin is, because the two kinds of Homie
53+
property disagree about what a repeated value means:
54+
55+
* A **retained** property (the default) binds to *on-change*. The broker
56+
holds its last payload, so re-setting the same value is a redundant write
57+
and the model drops it before it costs anything. The Homie layer's own
58+
publish-on-change gate is a second line of defense on the final payload.
59+
* A **non-retained** (event) property binds to *on-set*. The broker stores
60+
nothing for it, so an identical consecutive payload is a second real
61+
event, not a redundant write, and dropping it would lose an event. The
62+
Homie layer already exempts these from its gate; binding on-change would
63+
have made that exemption unreachable, since the model would have swallowed
64+
the repeat first.
65+
66+
A twin that does not answer ``retained()`` is treated as retained, which is
67+
what this function did for every twin before the distinction existed.
5268
"""
53-
return properties.add_property_on_change_callback(
54-
group, property_id, partial(set_homie_property_from_python_property, homie_property)
55-
)
69+
retained = getattr(homie_property, "retained", None)
70+
is_event = callable(retained) and retained() is False
71+
register = properties.add_property_on_set_callback if is_event else properties.add_property_on_change_callback
72+
return register(group, property_id, partial(set_homie_property_from_python_property, homie_property))

0 commit comments

Comments
 (0)