Skip to content

feat: extend PropertySpec to property-level parity - #59

Merged
dcj merged 1 commit into
mainfrom
feat/propertyspec-parity
Aug 20, 2026
Merged

feat: extend PropertySpec to property-level parity#59
dcj merged 1 commit into
mainfrom
feat/propertyspec-parity

Conversation

@dcj

@dcj dcj commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes #58.

PropertySpec gains seven fields, each defaulting to what the spec did before it existed, so no existing declaration set changes and the 600 tests that were passing before still pass untouched.

Field What it does
round_to Decimal places applied on publish. homie.Property has always supported this; the declaration simply could not reach it. Since 0.20.0's gate compares the final payload, rounding is also part of what decides whether two consecutive readings are the same value, so a declarative property was publishing more than an imperative one for the same data.
initial_value A seed applied through the model at build time. The builder's values= argument overrides it, because a caller passing a runtime map is being more specific than the static declaration.
retained=False An event property rather than a state. See the fix below, without which this would not have worked.
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, since announcing an empty one describes nothing.
conditionally_settable Settability decided per instance at runtime.
source_id / model_group Split the observable-model identity from the wire identity.

The split that matters

capability was simultaneously the Homie node id and the observable-model group key. Those are the same string only while one device is in play. On a tree, two child devices that both expose info collide in a shared model while remaining perfectly distinct on the wire.

model_group separates them, and this is the prerequisite for #57: its acceptance criterion 1 (the builder accepts an externally owned model, keyed per device rather than per capability) cannot be met while a spec's model group and its node id are the same field.

Everything on the model side of the binding now uses spec.group_key / spec.model_key; everything on the Homie side uses capability / prop_id. Unsplit they are the same strings, which is why nothing existing moved.

A judgment call on conditionally_settable

The obvious reading is "advertise settable: true in $description, gate it at runtime." I did not do that, because advertising settable while not subscribing /set publishes a lie: a controller sends a command and nothing is listening.

The builder materializes the property not settable, and the caller enables it with homie_property.set_settable(True) inside a state_transition() once per-instance state is known. $description is then truthful at every instant. This also matches how the one known consumer already behaves: it uses the flag for schema generation and calls set_settable per instance at runtime.

The fix this feature depends on

bind_property_to_homie now binds a non-retained property on-set rather than on-change.

The observable model's Property.set_value fires on-change callbacks only when the value differs. That gate sits above the Homie layer, so the publish-on-change exemption 0.20.0 deliberately gave non-retained properties was unreachable through the SDK's own documented adapter path: two identical consecutive events were swallowed by the model before the Homie property ever saw the second one. For an event, the repeat is the point, since the broker stores nothing and a subscriber learns of the event only by receiving it.

Retained properties are unaffected and keep the model gate as the cheap first line of defense. A twin that does not answer retained() is treated as retained, which is what every twin got before the distinction existed, so a duck-typed mirror keeps working.

This is a real behavior change for anyone who hand-built a non-retained property and bound it, which is why it is a separate CHANGELOG entry under Fixed rather than folded into the feature.

Also settled

The PropertySpec.scale inconsistency needed no code change: resolve() scales, specs_and_values() hands the builder already-scaled values, and the builder must not scale again. What was wrong was the docstring calling scale "metadata for a caller's mapping/resolver and is NOT applied by the builder", which is the half that misleads a reader who only opens resolve. Now stated positively in both docstrings and in doc/building-a-proxy.md, pinned by a test.

Two smaller things fell out: a latent seeding bug (the values= seed was gated on presence in the returned Homie map, so an internal_only property could never have been seeded), and doc/building-a-proxy.md gains a "Beyond the basic fields" section written as "use it when" rather than "it means", since the fields are individually obvious and knowing which problem each solves is not.

Verification

🤖 Generated with Claude Code

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>
@dcj
dcj merged commit 748a821 into main Aug 20, 2026
5 checks passed
@dcj
dcj deleted the feat/propertyspec-parity branch August 20, 2026 15:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extend PropertySpec to property-level parity for multi-device publishers

1 participant