From 511969478cdb22f33f41018340d82a56281d9d6c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:49:59 -0700 Subject: [PATCH 1/5] test(schema-1): a priority with no $settable is never-backup Homie 5 defaults the attribute to false and the eBus SDK's description builder emits it only when a property is settable, so a conforming publisher describes a never-backup circuit by omitting `$settable` on `load-shed/priority` rather than by publishing `false`. Both mutations fail against the current permissive default, which reads silence as permission and offers a priority control on exactly the circuits commissioned not to accept one. Two premise tests come with them, because both mutations edit an attribute the producer publishes: every circuit in the capture announces `settable: true` explicitly, so the permissive default was never the thing protecting this fixture, and a capture that stopped announcing it would make the mutations indistinguishable from the shipped state. --- tests/test_schema_one_circuits.py | 35 +++++++++-- tests/test_schema_one_control_refusal.py | 75 ++++++++++++++++-------- 2 files changed, 81 insertions(+), 29 deletions(-) diff --git a/tests/test_schema_one_circuits.py b/tests/test_schema_one_circuits.py index 8eb4484..b66914b 100644 --- a/tests/test_schema_one_circuits.py +++ b/tests/test_schema_one_circuits.py @@ -161,14 +161,41 @@ def test_a_locked_priority_means_never_backup(kitchen: DiscoveredDevice) -> None assert build_circuit(kitchen).is_never_backup is True -def test_an_unannounced_settable_means_settable(kitchen: DiscoveredDevice) -> None: - """Locking is what a panel announces. Treating silence as locked would mark - every circuit never-backup on firmware that omits the attribute.""" +def test_an_unannounced_settable_means_never_backup(kitchen: DiscoveredDevice) -> None: + """Omitting the attribute *is* how a panel announces the lock. + + Homie 5 defaults `$settable` to false, and the eBus SDK's description + builder writes the attribute only when the property is settable — so a + conforming publisher describes a never-backup circuit by leaving it out, and + never by publishing `false`. Reading silence as permission offered a + priority control on exactly the circuits commissioned not to have one. + """ description = json.loads(_TREE[KITCHEN_LIGHTS]["$description"]) del description["nodes"]["load-shed"]["properties"]["priority"]["settable"] kitchen.update_description(json.dumps(description)) - assert build_circuit(kitchen).is_never_backup is False + assert build_circuit(kitchen).is_never_backup is True + + +def test_every_captured_circuit_announces_its_priority_settable() -> None: + """The premise that makes the test above a mutation rather than the norm. + + A permissive default was justified as protecting firmware that publishes no + attribute at all. The capture publishes `settable: true` explicitly on every + circuit's `load-shed/priority`, so no circuit here ever needed the default — + and a capture that stopped doing so would be a producer change worth seeing + named rather than absorbed silently. + """ + circuits = { + device_id: topics + for device_id, topics in _TREE.items() + if json.loads(topics["$description"])["type"].endswith(".circuit") + } + assert circuits, "the capture carries no circuits" + + for device_id, topics in circuits.items(): + definition = json.loads(topics["$description"])["nodes"]["load-shed"]["properties"]["priority"] + assert definition.get("settable") is True, device_id def _catalogued_priorities() -> list[str]: diff --git a/tests/test_schema_one_control_refusal.py b/tests/test_schema_one_control_refusal.py index ec56549..14bc594 100644 --- a/tests/test_schema_one_control_refusal.py +++ b/tests/test_schema_one_control_refusal.py @@ -150,21 +150,23 @@ def test_the_catalog_still_states_the_condition_this_refusal_encodes() -> None: def test_the_catalog_puts_no_such_condition_on_the_shed_priority() -> None: - """Why the two properties read an absent `$settable` in opposite directions. - - `load-shed` 0.3 declares `priority` settable and states no condition, so - mutability is its ordinary state and a lock is an announcement. `switch` - conditions `relay`, so a publisher describing a locked relay correctly omits - the attribute. Neither default is a house style; each follows from its own - catalog entry, and this is what records that they were read separately. + """Why the relay reads a second signal and the priority reads one. + + Both properties answer an absent `$settable` the same way — Homie 5's + default, false — so the asymmetry is not in the default. It is in what else + there is to read: `switch` narrows `relay` by a *value* property, so + `relay_is_settable` reads `relay-controllable` alongside the declaration, + while `load-shed` states no condition on `priority` and there is no second + signal to consult. A condition appearing here would mean `priority_is_settable` + is now reading half of its rule. """ priority = _catalogued("load-shed", "priority") assert priority["settable"] is True assert "settable" not in str(priority["description"]).lower(), ( "`load-shed` has grown a condition on `priority`'s settability. " - "`circuits.priority_is_settable` treats an absent `$settable` as permission on the " - "strength of there being none; that is now a decision to re-take." + "`circuits.priority_is_settable` reads the declaration alone on the strength of " + "there being no condition to read; that is now a decision to re-take." ) @@ -282,21 +284,46 @@ def test_a_never_backup_circuit_yields_no_priority_target() -> None: assert adapter.set_circuit_relay_target(CONTROLLABLE_CIRCUIT) is not None -def test_an_unannounced_priority_settable_is_still_writable() -> None: - """Absence means settable here and locked on the relay, and the asymmetry is deliberate. +def test_an_unannounced_priority_settable_yields_no_target() -> None: + """Omission is the announcement, the same way it is on the relay. + + Homie 5 defaults `$settable` to false and the eBus SDK's description builder + emits the attribute only when the property is settable, so a conforming + publisher describes a never-backup circuit by omitting it. Reading silence as + permission resolved a write topic for precisely the circuits commissioned not + to accept one, and the panel would have refused the publish. - Never-backup is the exception a panel announces on top of an otherwise - mutable property, so defaulting silence to locked would refuse the priority - on every circuit of a firmware that publishes no attribute at all. + Only the priority: the relay is a separate commissioning flag and this + circuit's is untouched. """ adapter = _adapter(_redeclared(CONTROLLABLE_CIRCUIT, "load-shed", "priority", settable=None)) - assert adapter.set_circuit_priority_target(CONTROLLABLE_CIRCUIT) is not None + assert adapter.set_circuit_priority_target(CONTROLLABLE_CIRCUIT) is None + assert adapter.set_circuit_relay_target(CONTROLLABLE_CIRCUIT) is not None + + +def test_every_circuit_in_the_capture_announces_its_priority_settable(adapter: SchemaOneAdapter) -> None: + """The premise of the two mutations above, asserted rather than assumed. + + Both build a locked priority by editing a declaration that the producer + published as settable. A capture that stopped announcing the attribute would + make the mutations indistinguishable from the shipped state and quietly turn + `test_a_priority_target_exists_exactly_on_the_circuits` into an assertion + that no circuit has a priority target at all. + """ + tree = parent_child_tree() + + for device_id, topics in tree.items(): + description = json.loads(topics["$description"]) + if not description["type"].endswith(".circuit"): + continue + definition = description["nodes"]["load-shed"]["properties"]["priority"] + assert definition.get("settable") is True, device_id # --------------------------------------------------------------------------- -# An undeclared property is not an unannounced attribute +# A device that declares no such control at all # --------------------------------------------------------------------------- _NON_CIRCUITS = ("bess", "bess-mid", "pv", "lugs-upstream", "lugs-downstream") @@ -315,15 +342,13 @@ def test_the_capture_carries_devices_with_no_shed_priority(adapter: SchemaOneAda @pytest.mark.parametrize("device_id", _NON_CIRCUITS) def test_a_device_that_declares_no_priority_yields_no_priority_target(adapter: SchemaOneAdapter, device_id: str) -> None: - """The permissive default is for a *declared* property, and these declare none. - - `priority_is_settable` reads an absent `$settable` as permission, which is - right for the documented case where firmware declares `load-shed/priority` - and omits the attribute. A BESS, a MID or the lugs declare no `load-shed` - node at all — they have offered no shed priority for anything to be settable - on — and answering them with the same default resolved a write topic for a - control the device never published. The relay avoided this incidentally, - because its default is refusal. + """A BESS, a MID and the lugs declare no `load-shed` node at all. + + They have published no shed priority for anything to be settable *on*, and + `_target` is pure string formatting from a device id — so this is the case + that produced a write topic for a control the device never offered. It is + asserted separately from the settability tests because it does not depend on + them: an id in the tree is not a control, whatever any `$settable` says. """ assert adapter.set_circuit_priority_target(device_id) is None assert adapter.set_circuit_relay_target(device_id) is None From 3a8199e29b28b305e804255710cee23ce136dee2 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:53:05 -0700 Subject: [PATCH 2/5] fix(schema-1): a priority with no $settable is locked, not open `priority_is_settable` read a declared `load-shed/priority` carrying no `$settable` as settable, so a circuit commissioned never-backup reported `is_never_backup=False` and the adapter resolved a write topic for a priority the panel refuses. Homie 5 defaults the attribute to false and a conforming publisher emits it only where a write is accepted, so omission is how a lock is announced -- the same reading the relay already used, and the one the migration guide's `$settable = !never-backup` mapping implies. The justification for the permissive default was firmware that publishes no attribute at all; the vendored capture announces `settable: true` explicitly on every circuit's priority, so no producer here relied on it. With both callers answering absence the same way, `when_unannotated` is a knob nothing turns and goes. The rule it encoded moves to `description.declared_settable`, which is now the only reader of `$settable`: `charge_limit` was asking the same question with a second copy of the answer, and its stricter string handling -- only `"true"` authorises -- is the one kept, since a value that is neither the boolean nor that word has not said what it means. --- .../span_panel_api_schema_1/charge_limit.py | 30 +---- .../src/span_panel_api_schema_1/circuits.py | 108 ++++++++---------- .../src/span_panel_api_schema_1/const.py | 7 +- .../span_panel_api_schema_1/description.py | 40 ++++++- 4 files changed, 95 insertions(+), 90 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py b/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py index dc1a2e5..9a120c6 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py +++ b/packages/schema-1/src/span_panel_api_schema_1/charge_limit.py @@ -30,10 +30,10 @@ **Settability is read, never assumed.** The two properties of a spelling differ by exactly one Homie attribute — the ceiling declares no ``settable``, the limit declares ``settable: true`` — so a reader that treated an absent attribute as -"settable", the way :func:`circuits.priority_is_settable` correctly does for -``load-shed/priority``, would offer to write the installer's ceiling. The -defaults are opposite because the questions are: there, locking is the exception -a panel announces; here, writability is. +permission would offer to write the installer's commissioned ceiling. That is +Homie 5's default rather than a rule this module chose, and it is the same one +:func:`circuits.priority_is_settable` reads for a shed priority; both go through +:func:`description.declared_settable`. """ from __future__ import annotations @@ -41,8 +41,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from span_panel_api_schema_1.const import ATTR_SETTABLE -from span_panel_api_schema_1.description import nodes, optional_str, properties +from span_panel_api_schema_1.description import declared_settable, nodes, optional_str, properties if TYPE_CHECKING: from ebus_sdk.homie import DiscoveredDevice @@ -149,22 +148,5 @@ def _property(property_id: str, definition: dict[str, object] | None) -> ChargeL property_id=property_id, unit=optional_str(definition.get("unit")), datatype=str(definition.get("datatype") or "string"), - settable=_declared_settable(definition), + settable=declared_settable(definition), ) - - -def _declared_settable(definition: dict[str, object]) -> bool: - """Whether the declaration says this property may be written. - - Absent means **not** settable. See the module docstring: the ceiling and the - limit differ by this attribute alone, so a permissive default would make the - installer's commissioned maximum look writable. - - A string ``"true"`` counts, because Homie attributes travel as text and a - publisher that serialises the description by hand may not re-type the - booleans. - """ - settable = definition.get(ATTR_SETTABLE) - if isinstance(settable, bool): - return settable - return str(settable).strip().lower() == "true" diff --git a/packages/schema-1/src/span_panel_api_schema_1/circuits.py b/packages/schema-1/src/span_panel_api_schema_1/circuits.py index 1e10dd5..24a86eb 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/circuits.py +++ b/packages/schema-1/src/span_panel_api_schema_1/circuits.py @@ -27,7 +27,6 @@ from span_panel_api.models import SpanCircuitSnapshot from span_panel_api_schema_1.const import ( - ATTR_SETTABLE, NODE_BREAKER, NODE_INFO, NODE_LOAD_SHED, @@ -50,6 +49,7 @@ PROP_SPACES, UNKNOWN, ) +from span_panel_api_schema_1.description import declared_settable, node_properties if TYPE_CHECKING: from ebus_sdk.homie import DiscoveredDevice @@ -139,63 +139,49 @@ def _tabs(device: DiscoveredDevice) -> list[int]: return tabs -def _declared_settable(device: DiscoveredDevice, node: str, prop: str, *, when_unannotated: bool) -> bool: - """Read the Homie ``$settable`` attribute off one property's definition. +def _settable(device: DiscoveredDevice, node: str, prop: str) -> bool: + """Whether this device's declaration authorises writing one property. - **An undeclared property is never settable, whatever ``when_unannotated`` - says.** The two absences are different claims and were once collapsed onto - one answer: a device that declares ``load-shed/priority`` and omits - ``$settable`` has left a mutable property unannotated, while a device with no - ``load-shed`` node at all — a BESS, a MID, the lugs — has not declared the - property, and there is nothing on it to be settable. Answering the second - with the first's default produced a write target for a control the device - never offered, which is the defect the refusal exists to prevent. - - ``when_unannotated`` is therefore the narrower question it now names: what a - *declared* property means when it carries no ``$settable``. That is a - per-property judgement rather than one rule — which is why it is a parameter - instead of a default. See the two callers below for why they answer it - differently. - - A definition that is not a mapping is treated as undeclared for the same - reason: a malformed declaration has not said the property is settable. + The device-level lookup; `description.declared_settable` holds the rule, and + holds it once. `node_properties` drops a declaration that is not a mapping, + so a malformed one arrives here as undeclared — which is the right answer for + the same reason: it has not said the property is settable. """ - definition = device.get_node_properties(node).get(prop) - if not isinstance(definition, dict): - return False - settable = definition.get(ATTR_SETTABLE) - if settable is None: - return when_unannotated - if isinstance(settable, bool): - return settable - return str(settable).strip().lower() != "false" + return declared_settable(node_properties(device, node).get(prop)) def priority_is_settable(device: DiscoveredDevice) -> bool: """Whether ``load-shed/priority`` is user-settable on this circuit. - ``load-shed`` 0.3 declares ``priority`` settable and states no condition on - it, so mutability is the property's ordinary state and a lock is something a - panel announces. This is the successor to the flat ``never-backup`` boolean, - and it is read from the description rather than from a value topic — v1.0 - expresses never-backup as *mutability*, so the signal is the Homie - ``$settable`` attribute on the property definition. - - An unannounced ``$settable`` therefore means settable: treating an - unannounced circuit as locked would mark every circuit never-backup on a - panel that does not publish the attribute. - - **An unannounced attribute is not an undeclared property**, and only the - first of those means settable. A device carrying no ``load-shed`` node, or a - ``load-shed`` node with no ``priority`` on it, has not published a shed - priority for anything to be settable *on* — every non-circuit device in the - tree is in that position, the BESS and the MID and the lugs among them. The - permissive default is for the documented case where firmware declares the - property and omits the attribute, and reading it as permission for a device - that declared neither produced a write target for a control that device - never offered. `_declared_settable` separates the two. + This is the successor to the flat ``never-backup`` boolean, and it is read + from the description rather than from a value topic: v1.0 expresses + never-backup as *mutability*, and the migration guide maps the retired + property onto ``$settable = !never-backup``. So a circuit commissioned + never-backup is one whose priority the panel declares unwritable, and the + signal is the Homie attribute on the property definition. + + **A lock is announced by omission, not by ``settable: false``.** Homie 5 + defaults the attribute to false, and a conforming publisher emits it only + where it is true — the eBus SDK's description builder does exactly that, and + the vendored capture shows the same hand on ``switch/relay``, where + ``$settable`` is present on every controllable circuit and absent on the one + commissioned otherwise. `description.declared_settable` gives the rule and + the evidence for it. + + Reading omission the other way — as permission — is what this corrects. It + offered a priority control on precisely the circuits commissioned not to + accept one, and the panel refuses that write however the declaration last + read. Every circuit in the capture announces ``settable: true`` explicitly, + so no producer we have seen ever needed the permissive default that the + misreading existed to provide. + + Absence of the *property* answers the same way and for a plainer reason: a + device carrying no ``load-shed`` node, or one with no ``priority`` on it, has + published no shed priority for anything to be settable *on*. Every + non-circuit device in the tree is in that position — the BESS, the MID, the + lugs — and a write target for one of them names a control it never offered. """ - return _declared_settable(device, NODE_LOAD_SHED, PROP_PRIORITY, when_unannotated=True) + return _settable(device, NODE_LOAD_SHED, PROP_PRIORITY) def relay_is_settable(device: DiscoveredDevice) -> bool: @@ -226,24 +212,20 @@ def relay_is_settable(device: DiscoveredDevice) -> bool: an out-of-policy write regardless of what ``$settable`` last advertised, so the conjunction only ever refuses a write the panel would have refused. - **Absent ``$settable`` reads as locked here, the opposite of - `priority_is_settable`.** Homie 5 defaults the attribute to false, and the - catalog's condition means a locked relay is *not* settable, so a publisher - describing one correctly omits the attribute rather than publishing - ``false``. Absence is therefore the announcement. Priority answers the other - way because its catalog entry carries no condition at all. The two - properties are not making the same kind of claim. Both refuse a device that - declares no such property at all, which is a third case and not either - default. + **What differs from `priority_is_settable` is the second signal, not the + default.** Both read an absent ``$settable`` as locked, which is Homie 5's + own default and the rule `description.declared_settable` states once. The + relay reads a value property beside the declaration because ``switch`` + narrows it by one; ``load-shed`` states no condition on ``priority``, so + there is no second signal there to consult. Across the two production enclosures we hold captures from — 27 circuits — ``$settable`` is present on ``switch/relay`` exactly when ``relay-controllable`` is ``true``, without exception, which is the - specification's rule showing up in hardware. + specification's rule showing up in hardware. The vendored capture carries + the same pattern on its five. """ - return _declared_settable(device, NODE_SWITCH, PROP_RELAY, when_unannotated=False) and _flag( - device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=True - ) + return _settable(device, NODE_SWITCH, PROP_RELAY) and _flag(device, NODE_SWITCH, PROP_RELAY_CONTROLLABLE, default=True) def build_circuit( diff --git a/packages/schema-1/src/span_panel_api_schema_1/const.py b/packages/schema-1/src/span_panel_api_schema_1/const.py index 4a135aa..9698a1f 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/const.py +++ b/packages/schema-1/src/span_panel_api_schema_1/const.py @@ -199,6 +199,9 @@ # The Homie attribute that carries what the flat schema published as the # `never-backup` boolean. v1.0 retires the property and expresses it as -# mutability: a circuit commissioned never-backup has its priority locked, so -# the panel publishes `$settable = false` on `load-shed/priority`. +# mutability: a circuit commissioned never-backup has its priority locked, and +# the panel says so by *omitting* `$settable` from `load-shed/priority` — +# Homie 5 defaults it to false, so a conforming publisher emits the attribute +# only where a write is accepted. Read through `description.declared_settable`, +# which is the only thing that interprets it. ATTR_SETTABLE = "settable" diff --git a/packages/schema-1/src/span_panel_api_schema_1/description.py b/packages/schema-1/src/span_panel_api_schema_1/description.py index ac5e01d..b287327 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/description.py +++ b/packages/schema-1/src/span_panel_api_schema_1/description.py @@ -4,7 +4,12 @@ untyped mapping, so each reader has to narrow before it can index. Doing that once here keeps the narrowing identical everywhere and keeps ``Any`` out of the modules that read declarations — :mod:`field_metadata` for units and datatypes, -:mod:`charge_limit` for which spelling of a node a charger declares. +:mod:`charge_limit` for which spelling of a node a charger declares, +:mod:`circuits` for whether a control may be written. + +``$settable`` is read here too, and only here: it is the one declaration +attribute that authorises a write, and three modules were deciding what its +absence means. :func:`declared_settable` gives that answer once. These read the *declaration*, never a value. Property values come through :mod:`panel`'s ``text`` / ``number`` / ``integer`` readers. @@ -14,6 +19,8 @@ from typing import TYPE_CHECKING +from span_panel_api_schema_1.const import ATTR_SETTABLE + if TYPE_CHECKING: from ebus_sdk.homie import DiscoveredDevice @@ -58,6 +65,37 @@ def node_properties(device: DiscoveredDevice | None, node_id: str) -> dict[str, return properties(nodes(device.description or {}).get(node_id, {})) +def declared_settable(definition: dict[str, object] | None) -> bool: + """Whether one property's declaration authorises a write to it. + + **Absence is refusal, in both of the ways a declaration can be absent.** + Homie 5 defines ``$settable`` as defaulting to *false*, so a property + declared without the attribute has authorised nothing; and ``None`` — the + property, or the node carrying it, not declared at all — has not even said + there is something to write. Neither is permission, and this is the only + place in the adapter that decides so. + + That default is what a conforming publisher relies on. The eBus SDK builds a + ``$description`` from ``PropertySpec``, whose ``settable`` is ``False`` by + default and which emits the attribute *only* when it is true + (``ebus_sdk.declaration``) — so a producer describing a locked control omits + ``$settable`` and never publishes ``settable: false``. Reading omission as + permission would therefore mean offering a control on precisely the devices + commissioned not to have one. + + A string ``"true"`` counts and nothing else does, because Homie attributes + travel as text and a publisher that serialises the description by hand may + not re-type the booleans — while a value that is neither the boolean nor + that word has not said what it means, and a write is not the place to guess. + """ + if definition is None: + return False + settable = definition.get(ATTR_SETTABLE) + if isinstance(settable, bool): + return settable + return str(settable).strip().lower() == "true" + + def optional_str(value: object) -> str | None: """A declaration's string attribute, with empty and absent both meaning None.""" if value is None: From e97da72c93b3c9533dfef3fc2fe5474882544486 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:55:42 -0700 Subject: [PATCH 3/5] fix(schema-1): one reading of $settable, on every surface that authorises a write The sweep behind the priority fix. Adoption and extension answered the same question with a second expression -- `bool(definition.get( "settable", False))` -- which agrees on the default and disagrees on text: Homie attributes travel as strings, and `bool("false")` is True, so the declaration that most explicitly refuses a write was minting a set topic for an uncurated device. Both now read `description.declared_settable`. `SchemaAdapter.set_circuit_priority_target` documented the rule the adapter no longer follows, telling every implementer that an absent `$settable` on a declared `load-shed/priority` means settable. It says what the attribute's absence actually announces. The flat adapter needs nothing: it reads `never-backup` as a published value, not as mutability, and never consults `$settable`. --- .../src/span_panel_api_schema_1/adoption.py | 7 ++++-- .../src/span_panel_api_schema_1/extension.py | 4 ++-- src/span_panel_api/protocol.py | 11 +++++---- tests/test_adoption.py | 24 +++++++++++++++++++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/schema-1/src/span_panel_api_schema_1/adoption.py b/packages/schema-1/src/span_panel_api_schema_1/adoption.py index 234a61d..715990a 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/adoption.py +++ b/packages/schema-1/src/span_panel_api_schema_1/adoption.py @@ -34,7 +34,7 @@ TYPE_PANEL, TYPE_PV, ) -from span_panel_api_schema_1.description import device_type, nodes, optional_str, properties +from span_panel_api_schema_1.description import declared_settable, device_type, nodes, optional_str, properties if TYPE_CHECKING: from ebus_sdk.homie import DiscoveredDevice @@ -147,7 +147,10 @@ def _readings(device: DiscoveredDevice, declared_nodes: dict[str, dict[str, obje continue for property_id, definition in properties(node).items(): raw = device.get_property(node_id, property_id) - settable = bool(definition.get("settable", False)) + # The same reading the curated controls get: this one gates a set + # topic on an uncurated device, so a declaration that has not said + # `true` must not produce an address a consumer can write to. + settable = declared_settable(definition) readings.append( AdoptedProperty( node_id=node_id, diff --git a/packages/schema-1/src/span_panel_api_schema_1/extension.py b/packages/schema-1/src/span_panel_api_schema_1/extension.py index f427e7e..4a20358 100644 --- a/packages/schema-1/src/span_panel_api_schema_1/extension.py +++ b/packages/schema-1/src/span_panel_api_schema_1/extension.py @@ -33,7 +33,7 @@ from typing import TYPE_CHECKING from span_panel_api.models import ADOPTION_IDENTITY_NODE, ADOPTION_TOPOLOGY_NODE, ExtensionProperty, ExtensionSubject -from span_panel_api_schema_1.description import nodes, optional_str, properties +from span_panel_api_schema_1.description import declared_settable, nodes, optional_str, properties from span_panel_api_schema_1.field_metadata import is_addressed if TYPE_CHECKING: @@ -92,7 +92,7 @@ def build_extension_properties( datatype=str(definition.get("datatype") or "string"), unit=optional_str(definition.get("unit")), format=optional_str(definition.get("format")), - settable=bool(definition.get("settable", False)), + settable=declared_settable(definition), value=None if raw is None else str(raw), node_has_curated_siblings=has_curated_siblings, ) diff --git a/src/span_panel_api/protocol.py b/src/span_panel_api/protocol.py index 2e7406c..65ef293 100644 --- a/src/span_panel_api/protocol.py +++ b/src/span_panel_api/protocol.py @@ -275,11 +275,12 @@ def set_circuit_priority_target(self, circuit_id: str) -> ControlTarget | None: under the flat schema, `$settable` on `load-shed/priority` under v1.0 -- which is the same reading `SpanCircuitSnapshot.is_never_backup` reports. - None also where the panel carries no circuit under that id, and where - the device carries no shed priority to write: under v1.0 an absent - `$settable` on a *declared* `load-shed/priority` means settable, but a - device that declares no such property has offered no such control, and - the two are not the same absence. + Under v1.0 the lock is announced by *omitting* `$settable`, which is + Homie 5's default for the attribute and what a conforming publisher + emits for a control that accepts no write. A device that declares no + `load-shed/priority` at all answers None for the plainer reason that it + has offered no such control -- as does a panel carrying no circuit under + that id. """ def has_circuit(self, circuit_id: str) -> bool: diff --git a/tests/test_adoption.py b/tests/test_adoption.py index eba7c70..b797ddc 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -329,6 +329,30 @@ def test_a_property_the_device_does_not_declare_settable_carries_no_topic() -> N assert reading.set_topic is None +def test_a_settable_serialised_as_text_is_read_as_the_boolean_it_spells() -> None: + """Homie attributes travel as text, and `bool("false")` is True. + + A publisher that serialises its description by hand may not re-type the + booleans, so both spellings arrive here as strings. Reading them for + truthiness authorised a write on the declaration that most explicitly + refuses one; `description.declared_settable` reads the word instead, and + reads it the same way for a curated control and an adopted one. + """ + nodes = { + "generator": { + "properties": { + "mode": {"datatype": "enum", "format": "AUTO,OFF", "settable": "true"}, + "state": {"datatype": "enum", "format": "AUTO,OFF", "settable": "false"}, + } + } + } + tree = _with(_tree(), "generator-1", _device(UNMODELLED_TYPE, nodes=nodes)) + + topics = {prop.property_id: prop.set_topic for prop in _adopted(_snapshot(tree))["generator-1"].properties} + assert topics["mode"] == "ebus/5/generator-1/generator/mode/set" + assert topics["state"] is None + + def test_no_topic_reachable_this_way_can_name_a_modelled_device() -> None: """The property that keeps this from being a generic write. From 7f4567bac2238e04b4c6edd9ce50812859c915e0 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:57:48 -0700 Subject: [PATCH 4/5] chore: span-panel-api-schema-1 1.1.1 Only the adapter changed, so only the adapter is released -- RELEASE.md's rule, and the one the root changelog entry would have had to contradict in its own first sentence to justify a core bump for a docstring. `SchemaAdapter.set_circuit_priority_target`'s corrected docstring stays on the branch and rides out with the next release that has a reason of its own; a wrong sentence in the shipped 3.1.1 wheel is worth one release cycle of patience, not a release cycle of its own. Neither floor moves. The adapter asks nothing new of the bootstrap and the bootstrap's extras ask nothing new of the adapter, so every pairing that resolved before still resolves. --- packages/schema-1/CHANGELOG.md | 11 +++++++++++ packages/schema-1/pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 27bd1c2..7ab35d2 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -9,6 +9,17 @@ number. A release here means this parser changed, never that the panel did. Pre-releases are not listed separately. A beta is a step towards the next public version, so its changes are folded into that version's entry as they land and are described against the last public release, never against the beta before it. +## [1.1.1] + +A correctness fix to how this parser reads write authorisation off a `$description`. No API change, and the floor on `span-panel-api` stays at **3.1.0**: nothing here asks anything new of the bootstrap. + +### Fixed + +- **A circuit commissioned never-backup is reported as never-backup again, and no longer carries a shed-priority control the panel would refuse**, because a panel announces that lock by _omitting_ `$settable` from `load-shed/priority` — Homie 5's default + for the attribute, and what a conforming publisher emits — where this parser had read the omission as permission and returned `is_never_backup=False` with a working `set_circuit_priority_target`. +- **A property an adopted device declares settable as text now carries a write topic only where that text is `"true"`**, where the declaration had been read for truthiness — so `"false"` authorised a write, as did `"1"`, `"yes"` and `"on"`, none of which + the Homie attribute is specified to take. + ## [1.1.0] Requires `span-panel-api` **3.1.0 or newer**, and the two must be upgraded together in both directions: this wheel is rejected at discovery by a 3.0.x bootstrap, and a 1.0.0 wheel is rejected by 3.1.0. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 990027b..029d039 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "1.1.0" +version = "1.1.1" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} diff --git a/uv.lock b/uv.lock index 78b62aa..930116a 100644 --- a/uv.lock +++ b/uv.lock @@ -1043,7 +1043,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "1.1.0" +version = "1.1.1" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From 5093114c17253857379419a0fdb7fe67017ad1f0 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:07:33 -0700 Subject: [PATCH 5/5] test(schema-1): one premise test for the capture's announced priorities Both modules had grown the same assertion over the same capture -- every circuit declares `load-shed/priority` with `settable: true` -- written twice because each was justifying its own mutation. One of them can go, and it is the snapshot module's: the mutations that most need the premise are the adapter's, and a premise asserted beside the thing it is the premise of is the one that gets read when it fails. The survivor takes over the deleted test's two jobs. It carries the non-vacuity guard, now `len(circuits) == 5` in the spelling the relay invariant beside it already uses, so a capture whose circuits stopped matching the type suffix fails here instead of passing an empty loop. And it says what the deleted docstring said: this is the standing answer to the argument the permissive default rested on, firmware that publishes no attribute at all. --- tests/test_schema_one_circuits.py | 21 --------------------- tests/test_schema_one_control_refusal.py | 17 ++++++++++++----- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/tests/test_schema_one_circuits.py b/tests/test_schema_one_circuits.py index b66914b..d5113d8 100644 --- a/tests/test_schema_one_circuits.py +++ b/tests/test_schema_one_circuits.py @@ -177,27 +177,6 @@ def test_an_unannounced_settable_means_never_backup(kitchen: DiscoveredDevice) - assert build_circuit(kitchen).is_never_backup is True -def test_every_captured_circuit_announces_its_priority_settable() -> None: - """The premise that makes the test above a mutation rather than the norm. - - A permissive default was justified as protecting firmware that publishes no - attribute at all. The capture publishes `settable: true` explicitly on every - circuit's `load-shed/priority`, so no circuit here ever needed the default — - and a capture that stopped doing so would be a producer change worth seeing - named rather than absorbed silently. - """ - circuits = { - device_id: topics - for device_id, topics in _TREE.items() - if json.loads(topics["$description"])["type"].endswith(".circuit") - } - assert circuits, "the capture carries no circuits" - - for device_id, topics in circuits.items(): - definition = json.loads(topics["$description"])["nodes"]["load-shed"]["properties"]["priority"] - assert definition.get("settable") is True, device_id - - def _catalogued_priorities() -> list[str]: """Every value `load-shed` 0.3 declares for `priority`, in catalog order. diff --git a/tests/test_schema_one_control_refusal.py b/tests/test_schema_one_control_refusal.py index 14bc594..3f35169 100644 --- a/tests/test_schema_one_control_refusal.py +++ b/tests/test_schema_one_control_refusal.py @@ -311,14 +311,21 @@ def test_every_circuit_in_the_capture_announces_its_priority_settable(adapter: S make the mutations indistinguishable from the shipped state and quietly turn `test_a_priority_target_exists_exactly_on_the_circuits` into an assertion that no circuit has a priority target at all. + + It is also the standing answer to the argument the permissive default was + built on -- firmware that publishes no attribute at all. Every circuit here + announces one, so no producer we hold a capture from ever needed it. """ tree = parent_child_tree() + circuits = { + device_id: topics + for device_id, topics in tree.items() + if json.loads(topics["$description"])["type"].endswith(".circuit") + } + assert len(circuits) == 5, "the capture's circuit set has moved; this premise is no longer about what it was" - for device_id, topics in tree.items(): - description = json.loads(topics["$description"]) - if not description["type"].endswith(".circuit"): - continue - definition = description["nodes"]["load-shed"]["properties"]["priority"] + for device_id, topics in circuits.items(): + definition = json.loads(topics["$description"])["nodes"]["load-shed"]["properties"]["priority"] assert definition.get("settable") is True, device_id