You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+14Lines changed: 14 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -4,6 +4,20 @@ All notable changes to `ebus-sdk` are recorded here. Format follows [Keep a Chan
4
4
5
5
## [Unreleased]
6
6
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.
Copy file name to clipboardExpand all lines: README.md
+3-3Lines changed: 3 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -302,14 +302,14 @@ The observable application-state model used to build proxies and adapters (see [
302
302
303
303
Helpers that mirror the observable model onto the Homie tree, so you never hand-roll the bridge:
304
304
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
307
307
308
308
### declaration.py
309
309
310
310
The declarative "schema" layer for proxies (see [`doc/building-a-proxy.md`](doc/building-a-proxy.md)):
311
311
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)
313
313
-**build_from_declarations** - materializes a set of specs into Homie nodes/properties, the observable model, and their bindings in one call
314
314
-**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
Copy file name to clipboardExpand all lines: doc/building-a-proxy.md
+20-4Lines changed: 20 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -44,7 +44,7 @@ You can skip it for the trivial case: publishing a handful of static values once
44
44
45
45
A proxy built this way has three clean layers. Keep them separate.
46
46
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).
48
48
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.
49
49
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.
`ebus_sdk` exports the whole layer so you never hand-roll it:
68
68
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).
70
70
-`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.
71
71
-`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.
72
72
-`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.
`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.
0 commit comments