From 23caa357ef92ffcd37ea3aa785316f57b6ec9b94 Mon Sep 17 00:00:00 2001 From: Donald Clark Jackson Date: Thu, 20 Aug 2026 08:04:01 -0700 Subject: [PATCH] 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) --- CHANGELOG.md | 14 ++ README.md | 6 +- doc/building-a-proxy.md | 24 +++- src/ebus_sdk/adapter.py | 31 ++++- src/ebus_sdk/declaration.py | 155 +++++++++++++++++---- src/ebus_sdk/property.py | 7 +- tests/test_adapter.py | 56 ++++++++ tests/test_declaration.py | 266 ++++++++++++++++++++++++++++++++++++ 8 files changed, 518 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e91d060..24629a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index eb80363..1c3ce67 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/doc/building-a-proxy.md b/doc/building-a-proxy.md index 06a1107..14b05c2 100644 --- a/doc/building-a-proxy.md +++ b/doc/building-a-proxy.md @@ -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. @@ -66,11 +66,11 @@ homie.Property.set_value(...) ──► MQTT (ebus/5///` (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.` (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 diff --git a/src/ebus_sdk/adapter.py b/src/ebus_sdk/adapter.py index f6015be..353eb63 100644 --- a/src/ebus_sdk/adapter.py +++ b/src/ebus_sdk/adapter.py @@ -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)) diff --git a/src/ebus_sdk/declaration.py b/src/ebus_sdk/declaration.py index c52cb04..d745a27 100644 --- a/src/ebus_sdk/declaration.py +++ b/src/ebus_sdk/declaration.py @@ -2,16 +2,18 @@ A `PropertySpec` describes how a source field becomes an eBus property: which capability (Homie node) it lives on, its Homie datatype and unit, an optional -unit `scale`, and whether it is settable. It is the declarative "schema" layer of +unit `scale`, whether it is settable, how it is rounded and retained, whether it +is published at all, and where it lives in the observable model when that +differs from where it lives on the wire. It is the declarative "schema" layer of the proxy pattern (see `doc/building-a-proxy.md`). It is complementary to `property.py`: a `PropertySpec` is a static declaration, while a `property.py` `Property` is the live observable value built from it. `build_from_declarations` turns 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 a single -state transition. Acquisition code then only calls -`model.set_value(capability, prop_id, value)` and publishing follows. +property per spec, and the binding between them, all inside a single state +transition. Acquisition code then only calls +`model.set_value(group_key, model_key, value)` and publishing follows. """ from __future__ import annotations @@ -48,15 +50,43 @@ class PropertySpec: `capability` is the Homie node id (an eBus capability); `prop_id` is the Homie property id. `scale` multiplies a source value to reach `unit` (e.g. - kWh -> Wh is 1000); it is metadata for a caller's mapping/resolver and is NOT - applied by the builder. `python_type` overrides the observable-`Property` - type (otherwise derived from `datatype`). + kWh -> Wh is 1000); it is applied by `resolve`, NOT by + `build_from_declarations` (see both). `python_type` overrides the + observable-`Property` type (otherwise derived from `datatype`). `entity_setter` is the inbound-control translator for a settable property: a `callable(value)` invoked when a `/set` command arrives. When `settable=True` and `entity_setter` is given, `build_from_declarations` wires the whole inbound path automatically (see there); a settable spec without an `entity_setter` still gets a `/set` topic but no auto-wired handler. + + The remaining fields describe a property's wire and model behavior. Each + defaults to what the spec did before it existed, so an existing declaration + set is unaffected: + + * `round_to`: decimal places applied to a float on publish, by the Homie + property itself. Since the publish-on-change gate compares the FINAL + payload, rounding is part of what decides whether two consecutive readings + are the same value, so a rounded property also publishes less. + * `initial_value`: a seed value applied through the model at build time. The + `values` argument to `build_from_declarations` overrides it. + * `retained`: False declares an event property. The broker stores nothing + for it, so it is exempt from the publish-on-change gate and an identical + consecutive payload is a second real event. + * `internal_only`: the observable model tracks the value and no Homie + property is created, so it is never published and never appears in + `$description`. A capability whose specs are ALL internal gets no node. + * `conditionally_settable`: this property's settability is decided at + runtime, per instance. The builder materializes it NOT settable, which + keeps `$description` truthful and leaves no `/set` subscription open on a + property that would reject the command; the caller enables it with + `homie.Property.set_settable(True)` inside a `state_transition()`. It is + mutually exclusive with `settable`, which means "settable now". + * `source_id` / `model_group`: the observable-model identity, when it + differs from the wire identity. `source_id` defaults to `prop_id` and + `model_group` to `capability`, so they are fused unless split. Splitting + the group is what lets two child devices in one tree both expose an + `info` capability without colliding in a shared model. """ capability: str @@ -69,6 +99,38 @@ class PropertySpec: format: Optional[str] = None python_type: Any = None entity_setter: Optional[Callable] = None + round_to: Optional[int] = None + initial_value: Any = None + retained: bool = True + internal_only: bool = False + conditionally_settable: bool = False + source_id: Optional[str] = None + model_group: Optional[str] = None + + def __post_init__(self) -> None: + # Two contradictions are worth refusing at declaration time rather than + # producing a tree that misdescribes itself. + if self.settable and self.conditionally_settable: + raise ValueError( + f"{self.capability}/{self.prop_id}: settable and conditionally_settable are mutually " + "exclusive; settable means settable now, conditionally_settable means the caller " + "decides at runtime" + ) + if self.internal_only and (self.settable or self.conditionally_settable): + raise ValueError( + f"{self.capability}/{self.prop_id}: an internal_only property is never published, so it " + "has no /set topic and cannot be settable" + ) + + @property + def model_key(self) -> str: + """The observable-model property id: `source_id` if split, else `prop_id`.""" + return self.source_id or self.prop_id + + @property + def group_key(self) -> str: + """The observable-model group: `model_group` if split, else `capability`.""" + return self.model_group or self.capability def _default_node_type(capability: str) -> str: @@ -89,35 +151,71 @@ def build_from_declarations( Groups `specs` by capability (one Homie node each) and, for every spec, creates an observable `Property` in `model` and a Homie property on the node, wired together with `bind_property_to_homie` (the outbound/report path). Runs - inside one `device.state_transition()`. `values` (a `{(capability, prop_id): - value}` map) seeds initial values THROUGH the model after the structure is - built, so they publish via the bindings. Returns - `{(capability, prop_id): homie.Property}`. + inside one `device.state_transition()`. Returns + `{(capability, prop_id): homie.Property}`, keyed by WIRE identity; a spec + with `internal_only=True` creates no Homie property and so is absent from it. + + Values are seeded THROUGH the model after the structure is built, so they + publish via the bindings. Two sources, in precedence order: a spec's + `initial_value`, then the `values` argument (a `{(capability, prop_id): + value}` map), which wins because a caller passing a runtime map is being more + specific than the static declaration. Entries in `values` naming a property + that was not declared are ignored. + + `PropertySpec.scale` is NOT applied here. It is applied by `resolve`, and + `specs_and_values` hands this function values that `resolve` has ALREADY + scaled, so scaling again would double-apply it. A caller who assembles a + `values` map by hand therefore passes values in the property's own unit, not + raw source units. + + A spec may split its observable-model identity from its wire identity via + `source_id` / `model_group` (see `PropertySpec`). Everything on the model + side of the binding uses `spec.group_key` / `spec.model_key`; everything on + the Homie side uses `capability` / `prop_id`. Unsplit, they are the same + strings and this reads exactly as it did before. For a spec with `settable=True` AND an `entity_setter`, the inbound/control path is wired automatically: the observable `Property`'s `entity_setter` is registered on `model`, and the Homie property's `set_callback` is set to - `partial(model.set_entity, capability, prop_id)`, so an arriving `/set` + `partial(model.set_entity, group_key, model_key)`, so an arriving `/set` command routes `/set` payload -> `model.set_entity` -> the `entity_setter`. The `/set` subscription itself is already established when the property is added (`Node.add_property` -> `Property.set_subscribe`), so no `set_settable` - toggle is needed. + toggle is needed. An `internal_only` spec has no Homie property to receive a + command, but its `entity_setter` is still registered, so `model.set_entity` + reaches it. """ grouped: dict[str, list[PropertySpec]] = {} for spec in specs: grouped.setdefault(spec.capability, []).append(spec) homie_props: dict = {} + declared: dict[tuple, PropertySpec] = {} with device.state_transition(): for capability, cap_specs in grouped.items(): - node = device.add_node_from_dict( - {"id": capability, "name": node_name(capability), "type": node_type(capability)} + # A node exists to carry published properties. If every spec on this + # capability is internal, creating one would announce an empty node. + published = [spec for spec in cap_specs if not spec.internal_only] + node = ( + device.add_node_from_dict( + {"id": capability, "name": node_name(capability), "type": node_type(capability)} + ) + if published + else None ) - if not model.has_group(capability): - model.create_group(capability) for spec in cap_specs: + group = spec.group_key + if not model.has_group(group): + model.create_group(group) py_type = spec.python_type if spec.python_type is not None else python_type_for(spec.datatype) - model.add_property(capability, ObservableProperty(id=spec.prop_id, type=py_type)) + model.add_property(group, ObservableProperty(id=spec.model_key, type=py_type)) + declared[(capability, spec.prop_id)] = spec + # An entity_setter is the translator toward the entity, so it is + # registered whenever one is given and the model can reach it. + if spec.entity_setter is not None and (spec.settable or spec.internal_only): + model.set_entity_setter(group, spec.model_key, spec.entity_setter) + if spec.internal_only or node is None: + continue prop_dict: dict = {"id": spec.prop_id, "datatype": spec.datatype} if spec.name: prop_dict["name"] = spec.name @@ -127,20 +225,29 @@ def build_from_declarations( prop_dict["settable"] = True if spec.format: prop_dict["format"] = spec.format + if spec.round_to is not None: + prop_dict["round_to"] = spec.round_to + if not spec.retained: + prop_dict["retained"] = False homie_prop = node.add_property_from_dict(prop_dict) - bind_property_to_homie(model, capability, spec.prop_id, homie_prop) + bind_property_to_homie(model, group, spec.model_key, homie_prop) # Inbound/control path for a settable property with a translator: # /set payload -> model.set_entity -> entity_setter. The /set # subscription is already live from add_property -> set_subscribe. if spec.settable and spec.entity_setter is not None: - model.set_entity_setter(capability, spec.prop_id, spec.entity_setter) - homie_prop.set_set_callback(partial(model.set_entity, capability, spec.prop_id)) + homie_prop.set_set_callback(partial(model.set_entity, group, spec.model_key)) homie_props[(capability, spec.prop_id)] = homie_prop + # Seed declared initial values first, then let an explicit `values` entry + # override: the runtime map is the more specific statement of the two. + seed: dict[tuple, Any] = { + key: spec.initial_value for key, spec in declared.items() if spec.initial_value is not None + } if values: - for (capability, prop_id), value in values.items(): - if (capability, prop_id) in homie_props: - model.set_value(capability, prop_id, value) + seed.update({key: value for key, value in values.items() if key in declared}) + for key, value in seed.items(): + spec = declared[key] + model.set_value(spec.group_key, spec.model_key, value) return homie_props diff --git a/src/ebus_sdk/property.py b/src/ebus_sdk/property.py index ab14055..fab65ea 100644 --- a/src/ebus_sdk/property.py +++ b/src/ebus_sdk/property.py @@ -4,9 +4,10 @@ `GroupedPropertyDict` organizes many of them by group (typically one group per eBus capability / Homie node). This is the "observable model" layer of the recommended proxy pattern: keep a device's live state here, and mirror each -change onto a Homie `Device`/`Node`/`Property` tree via a per-property on-change -callback (`ebus_sdk.set_homie_property_from_python_property` / -`ebus_sdk.bind_property_to_homie`). Acquisition code updates this model; +change onto a Homie `Device`/`Node`/`Property` tree via a per-property callback +(`ebus_sdk.set_homie_property_from_python_property` / +`ebus_sdk.bind_property_to_homie`, which binds on-change for a retained twin and +on-set for an event one). Acquisition code updates this model; publishing to MQTT is a reactive side-effect. If you are building a proxy/adapter, start with `doc/building-a-proxy.md` rather diff --git a/tests/test_adapter.py b/tests/test_adapter.py index 601ed21..9336bb0 100644 --- a/tests/test_adapter.py +++ b/tests/test_adapter.py @@ -48,3 +48,59 @@ def test_bind_property_to_homie_does_not_fire_on_unchanged_value(): assert twin.value is None model.set_value("meter", "p", 200.0) # changed -> mirrored assert twin.value == 200.0 + + +class _FakeRetainedProperty(_FakeHomieProperty): + """A twin that answers `retained()`, as a real homie.Property does.""" + + def __init__(self, retained=True): + super().__init__() + self._retained = retained + self.writes = [] + + def retained(self): + return self._retained + + def set_value(self, v): + self.writes.append(v) + return super().set_value(v) + + +def test_bind_event_property_mirrors_every_set_including_repeats(): + """A non-retained twin binds on-set: the broker stores nothing, so a repeat is a second event.""" + model = GroupedPropertyDict() + model.create_group("dr") + model.add_property("dr", ObservableProperty(id="event", type=str)) + twin = _FakeRetainedProperty(retained=False) + + bind_property_to_homie(model, "dr", "event", twin) + model.set_value("dr", "event", "shed") + model.set_value("dr", "event", "shed") + assert twin.writes == ["shed", "shed"] + + +def test_bind_retained_property_still_drops_an_unchanged_repeat(): + model = GroupedPropertyDict() + model.create_group("meter") + model.add_property("meter", ObservableProperty(id="p", type=float)) + twin = _FakeRetainedProperty(retained=True) + + bind_property_to_homie(model, "meter", "p", twin) + model.set_value("meter", "p", 100.0) + model.set_value("meter", "p", 100.0) + assert twin.writes == [100.0] + + +def test_bind_twin_without_retained_is_treated_as_retained(): + """Backward compatibility: a duck-typed mirror that predates the distinction.""" + model = GroupedPropertyDict() + model.create_group("meter") + model.add_property("meter", ObservableProperty(id="p", type=float)) + twin = _FakeHomieProperty() # no retained() + + bind_property_to_homie(model, "meter", "p", twin) + model.set_value("meter", "p", 100.0) + assert twin.value == 100.0 + twin.value = None + model.set_value("meter", "p", 100.0) # unchanged -> still swallowed, as before + assert twin.value is None diff --git a/tests/test_declaration.py b/tests/test_declaration.py index f83ab49..35eff22 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -1,5 +1,7 @@ """Tests for the declarative PropertySpec + build_from_declarations builder.""" +import pytest + from ebus_sdk import ( Device, GroupedPropertyDict, @@ -142,3 +144,267 @@ def test_build_from_declarations_custom_node_type(mock_paho): node_type=lambda cap: "sensor", ) assert device.get_node("sensors").type() == "sensor" + + +# --- Property-level parity fields (GH #58) ----------------------------------- + + +def _device(mock_paho, device_id): + device = Device(device_id, mqtt_cfg={"host": "localhost", "port": 1883}) + device.start_mqtt_client() + return device + + +def _payloads_for(mock_paho, topic_suffix): + """Every payload published to a topic ending in `topic_suffix`.""" + return [c.args[1] for c in mock_paho.publish.call_args_list if c.args and str(c.args[0]).endswith(topic_suffix)] + + +def test_round_to_reaches_the_homie_property_and_the_publish_gate(mock_paho): + device = _device(mock_paho, "dev-round") + model = GroupedPropertyDict() + homie_props = build_from_declarations( + device, + model, + [PropertySpec("meter", "active-power", PropertyDatatype.FLOAT, Unit.WATT, round_to=1)], + ) + hp = homie_props[("meter", "active-power")] + assert hp.round() == 1 + + model.set_value("meter", "active-power", 0.14494210481643677) + assert hp.coerced_value() == "0.1" + before = len(_payloads_for(mock_paho, "/meter/active-power")) + # A genuinely different reading that rounds to the same payload is not a + # second publish: the gate compares the final payload, after rounding. + model.set_value("meter", "active-power", 0.14501120000000001) + assert len(_payloads_for(mock_paho, "/meter/active-power")) == before + + +def test_internal_only_populates_the_model_and_publishes_nothing(mock_paho): + device = _device(mock_paho, "dev-internal") + model = GroupedPropertyDict() + homie_props = build_from_declarations( + device, + model, + [ + PropertySpec("meter", "active-power", PropertyDatatype.FLOAT, Unit.WATT), + PropertySpec("meter", "raw-counter", PropertyDatatype.INTEGER, internal_only=True), + ], + ) + # Tracked in the model, absent from the wire and from the returned twins. + model.set_value("meter", "raw-counter", 42) + assert model.value("meter", "raw-counter") == 42 + assert ("meter", "raw-counter") not in homie_props + assert device.get_node("meter").get_property("raw-counter") is None + assert "raw-counter" not in device.get_node("meter").description()["properties"] + assert _payloads_for(mock_paho, "/meter/raw-counter") == [] + + +def test_capability_with_only_internal_specs_gets_no_node(mock_paho): + device = _device(mock_paho, "dev-internal-cap") + model = GroupedPropertyDict() + build_from_declarations( + device, + model, + [PropertySpec("scratch", "accumulator", PropertyDatatype.FLOAT, internal_only=True)], + ) + # No node, because announcing an empty one would describe nothing. + assert device.get_node("scratch") is None + assert "scratch" not in device.description()["nodes"] + assert model.value("scratch", "accumulator") is None # the group still exists + + +def test_internal_only_entity_setter_is_still_reachable_through_the_model(mock_paho): + device = _device(mock_paho, "dev-internal-set") + model = GroupedPropertyDict() + received = [] + build_from_declarations( + device, + model, + [ + PropertySpec( + "scratch", + "target", + PropertyDatatype.FLOAT, + internal_only=True, + entity_setter=received.append, + ) + ], + ) + model.set_entity("scratch", "target", 3.0) + assert received == [3.0] + + +def test_retained_false_declares_an_event_property_exempt_from_the_gate(mock_paho): + device = _device(mock_paho, "dev-event") + model = GroupedPropertyDict() + homie_props = build_from_declarations( + device, + model, + [PropertySpec("dr", "event", PropertyDatatype.STRING, retained=False)], + ) + hp = homie_props[("dr", "event")] + assert hp.retained() is False + assert hp.description()["retained"] is False + + model.set_value("dr", "event", "shed") + before = len(_payloads_for(mock_paho, "/dr/event")) + # The broker stores nothing for an event property, so an identical + # consecutive payload is a second real event rather than a redundant write. + model.set_value("dr", "event", "shed") + assert len(_payloads_for(mock_paho, "/dr/event")) == before + 1 + + +def test_retained_true_is_the_default_and_stays_gated(mock_paho): + device = _device(mock_paho, "dev-retained") + model = GroupedPropertyDict() + homie_props = build_from_declarations( + device, model, [PropertySpec("meter", "active-power", PropertyDatatype.FLOAT)] + ) + hp = homie_props[("meter", "active-power")] + assert hp.retained() is True + assert "retained" not in hp.description() # only emitted when False + + model.set_value("meter", "active-power", 12.0) + before = len(_payloads_for(mock_paho, "/meter/active-power")) + model.set_value("meter", "active-power", 12.0) + assert len(_payloads_for(mock_paho, "/meter/active-power")) == before + + +def test_conditionally_settable_builds_unsettable_and_the_caller_enables_it(mock_paho): + device = _device(mock_paho, "dev-cond") + model = GroupedPropertyDict() + homie_props = build_from_declarations( + device, + model, + [PropertySpec("control", "limit", PropertyDatatype.FLOAT, conditionally_settable=True)], + ) + hp = homie_props[("control", "limit")] + # Not settable at build: $description stays truthful and no /set topic is + # subscribed on a property that would reject the command. + assert hp.settable() is False + assert "settable" not in hp.description() + assert not [ + c for c in mock_paho.subscribe.call_args_list if c.args and str(c.args[0]).endswith("/control/limit/set") + ] + + # The caller gates it on per-instance state, inside a state transition. + with device.state_transition(): + hp.set_settable(True) + assert hp.settable() is True + assert [c for c in mock_paho.subscribe.call_args_list if c.args and str(c.args[0]).endswith("/control/limit/set")] + + +def test_contradictory_settability_is_refused_at_declaration_time(): + with pytest.raises(ValueError, match="mutually exclusive"): + PropertySpec("control", "limit", PropertyDatatype.FLOAT, settable=True, conditionally_settable=True) + with pytest.raises(ValueError, match="never published"): + PropertySpec("control", "limit", PropertyDatatype.FLOAT, settable=True, internal_only=True) + with pytest.raises(ValueError, match="never published"): + PropertySpec("control", "limit", PropertyDatatype.FLOAT, conditionally_settable=True, internal_only=True) + + +def test_identity_is_fused_by_default(): + spec = PropertySpec("meter", "active-power", PropertyDatatype.FLOAT) + assert spec.model_key == "active-power" + assert spec.group_key == "meter" + + +def test_source_id_splits_the_model_key_from_the_wire_id(mock_paho): + device = _device(mock_paho, "dev-srcid") + model = GroupedPropertyDict() + homie_props = build_from_declarations( + device, + model, + [PropertySpec("meter", "active-power", PropertyDatatype.FLOAT, source_id="RMS_Watts_Tot")], + ) + # Populated under the source name, published under the eBus name. + model.set_value("meter", "RMS_Watts_Tot", 719.0) + assert homie_props[("meter", "active-power")].value() == 719.0 + assert device.get_node("meter").get_property("active-power") is not None + assert device.get_node("meter").get_property("RMS_Watts_Tot") is None + + +def test_model_group_splits_the_model_group_from_the_capability_node(mock_paho): + device = _device(mock_paho, "dev-grp") + model = GroupedPropertyDict() + homie_props = build_from_declarations( + device, + model, + [PropertySpec("info", "serial-number", PropertyDatatype.STRING, model_group="bess-info")], + ) + model.set_value("bess-info", "serial-number", "TG-1") + assert homie_props[("info", "serial-number")].value() == "TG-1" + assert "bess-info" in model.groups() + assert "info" not in model.groups() + assert device.get_node("info") is not None # the wire is unchanged + + +def test_model_group_lets_two_capabilities_share_one_model_without_colliding(mock_paho): + device = _device(mock_paho, "dev-grp2") + model = GroupedPropertyDict() + homie_props = build_from_declarations( + device, + model, + [ + PropertySpec("info", "serial-number", PropertyDatatype.STRING, model_group="pv-info"), + PropertySpec("meter", "serial-number", PropertyDatatype.STRING, model_group="mid-info"), + ], + ) + model.set_value("pv-info", "serial-number", "PV-1") + model.set_value("mid-info", "serial-number", "MID-1") + assert homie_props[("info", "serial-number")].value() == "PV-1" + assert homie_props[("meter", "serial-number")].value() == "MID-1" + + +def test_initial_value_seeds_through_the_model(mock_paho): + device = _device(mock_paho, "dev-seed") + model = GroupedPropertyDict() + homie_props = build_from_declarations( + device, + model, + [ + PropertySpec("info", "vendor-name", PropertyDatatype.STRING, initial_value="Acme"), + PropertySpec("scratch", "counter", PropertyDatatype.INTEGER, internal_only=True, initial_value=7), + ], + ) + assert model.value("info", "vendor-name") == "Acme" + assert homie_props[("info", "vendor-name")].value() == "Acme" + # An internal property seeds too, even though it has no Homie twin. + assert model.value("scratch", "counter") == 7 + + +def test_values_argument_overrides_initial_value(mock_paho): + device = _device(mock_paho, "dev-seed2") + model = GroupedPropertyDict() + build_from_declarations( + device, + model, + [PropertySpec("info", "vendor-name", PropertyDatatype.STRING, initial_value="Acme")], + values={("info", "vendor-name"): "Runtime"}, + ) + assert model.value("info", "vendor-name") == "Runtime" + + +def test_build_does_not_apply_scale_to_seeded_values(mock_paho): + """`resolve` scales; the builder does not, or the pipeline would double-apply it.""" + device = _device(mock_paho, "dev-scale") + model = GroupedPropertyDict() + specs, values = specs_and_values(resolve(["kWh_Tot"], {"kWh_Tot": 22.5}, _MAP)) + build_from_declarations(device, model, specs, values=values) + # resolve() already turned 22.5 kWh into 22500.0 Wh. Scaling again here + # would publish 22_500_000.0. + assert model.value("meter", "imported-energy") == 22500.0 + + # Same via initial_value, which is likewise taken at face value. + model2 = GroupedPropertyDict() + build_from_declarations( + _device(mock_paho, "dev-scale2"), + model2, + [ + PropertySpec( + "meter", "imported-energy", PropertyDatatype.FLOAT, Unit.WATT_HOUR, scale=1000.0, initial_value=22500.0 + ) + ], + ) + assert model2.value("meter", "imported-energy") == 22500.0