feat: extend PropertySpec to property-level parity - #59
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #58.
PropertySpecgains 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.round_tohomie.Propertyhas 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_valuevalues=argument overrides it, because a caller passing a runtime map is being more specific than the static declaration.retained=Falseinternal_only$descriptionentry, and a capability whose specs are all internal gets no node, since announcing an empty one describes nothing.conditionally_settablesource_id/model_groupThe split that matters
capabilitywas 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 exposeinfocollide in a shared model while remaining perfectly distinct on the wire.model_groupseparates 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 usescapability/prop_id. Unsplit they are the same strings, which is why nothing existing moved.A judgment call on
conditionally_settableThe obvious reading is "advertise
settable: truein$description, gate it at runtime." I did not do that, because advertising settable while not subscribing/setpublishes 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 astate_transition()once per-instance state is known.$descriptionis then truthful at every instant. This also matches how the one known consumer already behaves: it uses the flag for schema generation and callsset_settableper instance at runtime.The fix this feature depends on
bind_property_to_homienow binds a non-retained property on-set rather than on-change.The observable model's
Property.set_valuefires 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.scaleinconsistency 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 opensresolve. Now stated positively in both docstrings and indoc/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 aninternal_onlyproperty could never have been seeded), anddoc/building-a-proxy.mdgains 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
ruff checkandruff format --checkcleanCHANGELOG.md,README.md,doc/building-a-proxy.md🤖 Generated with Claude Code