fix: a control target only where the device declares the control - #165
Merged
Conversation
Three refusal edges the #161 work left behind, all folding into the unreleased 3.1.0. `priority_is_settable` read an absent `$settable` as permission, which is right for a circuit that declares `load-shed/priority` and omits the attribute, and wrong for a device that declares no such property at all. The BESS, the MID, the lugs and the PV are all in that position, so `set_circuit_priority_target("bess")` resolved a topic for a control that device never published. `_declared_settable` now separates the two: an undeclared property is never settable, whatever the per-property default for a declared one says. The relay avoided this incidentally, because its default is already refusal. The flat adapter still built a target for a circuit id the panel never published: `get_prop` answers `""` for an id nothing published, `""` parses as `false`, and `not false` read as permission -- so any id was writable on any panel, the snapshot's own synthetic `unmapped_tab_*` keys included. It now answers from the node type in `$description`, which is the same question `schema_1` was already answering, and `SchemaAdapter` states the guarantee rather than leaving it to each implementation. An id no circuit answers to was refused with "declares its relay non-commandable" and audited as `relay not commandable`, which asserts something about a circuit that does not exist and sends whoever reads it to a panel's commissioning to explain a typo. The two cases now carry distinct messages and distinct audit details. `SchemaAdapter` gains `has_circuit` for it, consulted only once a target has already been refused -- so it can relabel a refusal but never cause one -- and both adapters implement it on circuit membership rather than on topology, since a BESS is not a circuit whose relay happens to be locked.
`pinned_release()` indexed straight into the lock document, so a `spec_lock.json` with no `peers` block, or no `ebus-panel-sim` inside it, came out as a bare `KeyError` where every other malformed input in this script exits with a sentence naming what was wrong.
`_derive_required_members` makes every public `SchemaAdapter` member mandatory of every adapter wheel, so adding one is a contract change and belongs beside the four `set_*_topic` renames rather than buried in a prose bullet. No new mismatch a consumer can hit -- a 1.0.0 adapter was already rejected by the renames -- but the rejection names this member too, and the changelog should say so before somebody meets it.
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.
Four findings from the review of #164, landing before 3.1.0 is published so they fold into the same unreleased version rather than costing a 3.1.1.
Three are the same family as the bug #161 fixed: the refusal was right for the case it was written for, and the edges either permitted when they should not have, or refused with the wrong reason.
A target only where the device declares the control
_declared_settablecollapsed two different absences onto one caller-chosen answer — a property the device never declared, and a property declared without$settable. Priority passedwhen_absent=Truefor the second (the documented firmware-omits-the-attribute case, and the catalog puts no condition on priority), which meant a device with noload-shednode at all was also treated as settable. The reference tree carries five: bess, bess-mid, pv, and both lugs.set_circuit_priority_target("bess")resolved a topic for a control that device never offered.The undeclared branch now refuses unconditionally, and the parameter is
when_unannotated, which is the only question it still answers. Relay behaviour is byte-identical — its default was already refusal — and only priority moves. A definition present but not a mapping counts as undeclared: a malformed declaration has not said the property is settable.Both adapters answer the same question the same way
schema-0 built targets for wholly unknown circuit ids, because its value lookup defaults to
""and that read as permission.relay_is_settable/priority_is_settablenow refuse a node that is not a circuit before reading any value, via a promotedis_circuit_node— so the command path, the snapshot's circuit set andhas_circuitall read one thing and cannot drift. That also refuses the snapshot's syntheticunmapped_tab_*keys, which were writable.A refusal that names the refusal it actually made
An unknown circuit id was refused with "declares its relay non-commandable" and audited as
relay not commandable. The refusal was right; the reason was not. That detail reachesafter_publishand the Home Assistant integration writes it into a security log where it is read as a fact about the panel.Unknown circuits now say
no such circuit; a genuinely locked relay still saysrelay not commandable, and a locked prioritypriority not settable. Both setters route through one_refuse_circuit_control, which consultshas_circuitonly after a target has already been refused — so an adapter answering it strangely can relabel a refusal but can never cause one. A test asserts the lookup is never made on a command that publishes.Breaking
SchemaAdaptergainshas_circuit. It is the third adapter-contract change in 3.1.0, alongside the fourset_*_topic→set_*_targetrenames, and is named as such in all three changelogs rather than left in prose. Finding 3 is not implementable without it: the client sees onlyNonefrom the target builder and has nothing else to tell the two cases apart.Verified rather than assumed — a class carrying every required member except
has_circuitis rejected at discovery, and the message names the member and states the remedy. It adds no mismatch anybody can newly hit: a 1.0.0 wheel was already rejected by the renames, and the rejection lists this alongside them with the same fix.ADAPTER_CONTRACT_VERSIONdoes not move, which is what its own docstring reserves it for.The protocol's documented guarantee changed with it:
Nonefrom the two circuit target builders now also means the panel carries no circuit under that id, which could not be stated before because only schema-1 honoured it.The two adapters differ, deliberately
schema-1's target builders do not gate on
has_circuit; schema-0's do gate on node type. schema-1 refuses on the declaration, which is the specification's rule and the thing that authorises the write — a type check in front of it would be a second, weaker rule sitting where the authorisation lives. schema-0 has no per-circuit declaration to read, so node type is its only lookup. On schema-1,has_circuitis purely diagnostic and answers on device type rather than tree membership: a BESS is not a circuit whose relay happens to be locked. Both files document this.Also
scripts/capture_parent_child_reference.pyexited with a bareKeyErrortraceback on a missing pin block where the rest of the script exits with prose. Two sites, not one.Tests
1061 → 1077, mypy clean across 43 source files, pylint 10.00/10, coverage 95.84%.
Four existing tests were asserting the buggy behaviour and are corrected rather than accommodated:
test_schema_zero_adapter.pybuilt targets against a panel that had published no$descriptionat all, and two setter tests commanded a circuit id that was never declared. They now describe the circuit they command.Not published
Folds into the unreleased 3.1.0 — no version bump, no pin changes.