Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/schema-1/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/schema-1/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down
7 changes: 5 additions & 2 deletions packages/schema-1/src/span_panel_api_schema_1/adoption.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 6 additions & 24 deletions packages/schema-1/src/span_panel_api_schema_1/charge_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,19 +30,18 @@
**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

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
Expand Down Expand Up @@ -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"
108 changes: 45 additions & 63 deletions packages/schema-1/src/span_panel_api_schema_1/circuits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions packages/schema-1/src/span_panel_api_schema_1/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
40 changes: 39 additions & 1 deletion packages/schema-1/src/span_panel_api_schema_1/description.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions packages/schema-1/src/span_panel_api_schema_1/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
Expand Down
11 changes: 6 additions & 5 deletions src/span_panel_api/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading