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
20 changes: 18 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ beta corrected in an earlier beta does not appear at all: from the point of view
## [3.1.0]

A security release. Three things a caller could not previously find out — whether a control command was delivered, whether the panel's bootstrap traffic was encrypted, and whether the CA behind the MQTT broker is still the one that was there yesterday —
now have answers. **Install the matching adapter**: this release replaces four `SchemaAdapter` members, so `span-panel-api-schema-0` / `-1` must move to 1.1.0 at the same time. The extras (`span-panel-api[schema-0]`) carry the floor; a direct install of
the adapter distribution does not, and a 1.0.0 adapter against this bootstrap is rejected at discovery with a named error rather than misbehaving.
now have answers. **Install the matching adapter**: this release replaces four `SchemaAdapter` members and adds one, so `span-panel-api-schema-0` / `-1` must move to 1.1.0 at the same time. The extras (`span-panel-api[schema-0]`) carry the floor; a direct
install of the adapter distribution does not, and a 1.0.0 adapter against this bootstrap is rejected at discovery with a named error rather than misbehaving.

### Fixed

Expand All @@ -32,6 +32,22 @@ the adapter distribution does not, and a 1.0.0 adapter against this bootstrap is

A locked relay keeps a settable priority, which is the combination real panels publish and which `switch` 0.3 and `load-shed` 0.3 scope separately.

**Both adapters also refuse a circuit id the panel never published**, where the flat one used to build a topic for it. Its lookup read an unpublished value as the empty string, which parses as "not always-on" and read as permission, so any id at all was
writable on any panel — including the synthetic `unmapped_tab_*` keys the snapshot itself invents. The two adapters answer the same question and now answer it the same way, and `SchemaAdapter` states the guarantee rather than leaving it to each
implementation.

**And a device that declares no such property is refused as well**, which is not the same absence as a declared property carrying no `$settable`. Under the parent/child schema an absent `$settable` on `load-shed/priority` means settable — that is the
documented case where firmware declares the property and omits the attribute — but a BESS, a MID or the lugs declare no `load-shed` node at all, and reading their silence as permission resolved a write topic for a control those devices never offered.

- **A refused circuit command names the refusal it actually made.** An id the panel carries no circuit under was refused with "declares its relay non-commandable" and audited as `relay not commandable`, which asserts something about a circuit that does not
exist: it sends whoever reads it to a panel's commissioning to explain a mistyped id. The two cases now carry distinct messages and distinct `detail` values (`no such circuit`), and the distinction matters most in the audit trail, because `detail`
reaches `after_publish` and the Home Assistant integration writes it into a security log where it is read as a fact about the panel. `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.

**`has_circuit` is a required protocol member**, and therefore the third adapter-contract change in this release alongside the four `set_*_topic` renames and the widened return types: `_derive_required_members` makes every public `SchemaAdapter` member
mandatory of every adapter wheel, so an adapter without it is rejected at discovery. That is not a new mismatch anyone can hit — a 1.0.0 adapter was already rejected by the renames — and the rejection names this member alongside them, with the same
remedy. `ADAPTER_CONTRACT_VERSION` still does not move: an added member is caught by name at discovery, which is what the constant's own docstring reserves it for.

- **A control the library refused before resolving an address is no longer invisible to `ControlInterceptor`.** `after_publish` is contracted to see every command, refusals included, but five refusals happened while resolving the target and therefore never
reached the publish path at all: a relay declared non-commandable, a priority declared locked, a charger with no settable limit, a panel with no islanding control, and an adopted property that is not settable. A consumer building a security audit on
`after_publish` — which is what the Home Assistant integration does — would have had a hole in it exactly where the interesting cases are, the highest-consequence control in the system among them.
Expand Down
10 changes: 10 additions & 0 deletions packages/schema-0/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ Requires `span-panel-api` **3.1.0 or newer**, and the two must be upgraded toget

A locked relay keeps a settable priority: always-on is not never-backup on either schema.

**And both refuse a circuit id this panel never published.** Absence-reads-as-permission is right for the two flags and wrong for the node itself: `get_prop` answers `""` for an id nothing published, `""` parses as `false`, and `not false` is permission
— so an unknown id produced a well-formed topic aimed at nothing, including for the synthetic `unmapped_tab_*` keys the snapshot invents. `schema_1` already refused an id its tree did not carry; two adapters answering the same question differently was
the defect, and the question is answered here from the node type in `$description`.

- **BREAKING: `SchemaZeroAdapter` gains `has_circuit`, a new required `SchemaAdapter` member.** 3.1.0 declares it so a transport can tell "no such circuit" apart from "this circuit's control is locked" when it reports a refusal. Every public member of that
protocol is mandatory of every adapter wheel — `_derive_required_members` enforces it by name — so this is the third contract change in the pair, alongside the four `set_*_topic` renames and the widened return types. It adds no new mismatch a consumer
can hit: a 1.0.0 wheel was already rejected by the renames, and the rejection names this member too, with the same remedy (upgrade both distributions together).

`HomieDeviceConsumer.is_circuit_node` is public for it, and `has_circuit` delegates there rather than re-deriving. One reading behind both answers: what counts as a circuit here decides the refusal and its stated reason together, so the two cannot drift.

## [1.0.0]

First release as a standalone distribution. Requires `span-panel-api` 3.0.0 or newer.
Expand Down
23 changes: 18 additions & 5 deletions packages/schema-0/src/span_panel_api_schema_0/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,11 @@ def find_node_by_type(self, type_str: str) -> str | None:
def set_circuit_relay_target(self, circuit_id: str) -> ControlTarget | None:
"""Where this circuit's relay is commanded, or None if it may not be.

None on an always-on circuit. `_target` is pure string formatting from a
node id, so without the lookup this aimed a write at a relay the panel
commissioned as permanently closed — and the refusal was already in the
values this adapter parses, as `is_user_controllable`.
None on an always-on circuit, and None on an id the panel published no
circuit under. `_target` is pure string formatting from a node id, so
without the lookup this aimed a write at a relay the panel commissioned
as permanently closed — and the refusal was already in the values this
adapter parses, as `is_user_controllable`.
"""
if not self._consumer.relay_is_settable(circuit_id):
return None
Expand All @@ -79,12 +80,24 @@ def set_circuit_priority_target(self, circuit_id: str) -> ControlTarget | None:
"""Where this circuit's shed priority is written, or None if it may not be.

None on a never-backup circuit, which is the flat spelling of the
`$settable` lock v1.0 publishes on `load-shed/priority`.
`$settable` lock v1.0 publishes on `load-shed/priority`, and None on an
id the panel published no circuit under.
"""
if not self._consumer.priority_is_settable(circuit_id):
return None
return self._target(circuit_id, "shed-priority")

def has_circuit(self, circuit_id: str) -> bool:
"""Whether the panel carries a circuit under this id.

The same lookup the two target builders make before they build anything,
asked separately so the transport can say which of the two refusals it
is reporting. One reading: a change to what counts as a circuit here
moves both answers together, and the pair cannot drift into a refusal
whose stated reason is the other one's.
"""
return self._consumer.is_circuit_node(circuit_id)

def set_dominant_power_source_target(self) -> ControlTarget | None:
core_node = self._consumer.find_node_by_type(TYPE_CORE)
if core_node is None:
Expand Down
31 changes: 27 additions & 4 deletions packages/schema-0/src/span_panel_api_schema_0/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,16 @@ def relay_is_settable(self, node_id: str) -> bool:
Absent reads as commandable, for the reason the flag exists — it marks
the exception, and defaulting to locked would refuse every relay on a
panel that omits it.

**That default is why the node has to exist first.** ``get_prop``
answers ``""`` for a node nothing ever published, which parses as
``always-on = false`` and reads as permission, so an id no circuit
answers to produced a write target — the same defect the parent/child
adapter refuses by finding no such device in its tree. Both adapters
answer this question, and they have to answer it the same way.
"""
if not self.is_circuit_node(node_id):
return False
return not _parse_bool(self._acc.get_prop(node_id, "always-on"))

def priority_is_settable(self, node_id: str) -> bool:
Expand All @@ -130,7 +139,13 @@ def priority_is_settable(self, node_id: str) -> bool:
``never-backup`` is the flat spelling of what v1.0 expresses as
mutability of ``load-shed/priority``, and it is already read into
``SpanCircuitSnapshot.is_never_backup``. Same reading, second surface.

Gated on the node existing for the reason ``relay_is_settable`` gives:
an unpublished property reads ``""``, which is indistinguishable from a
published ``false`` and would make every unknown id writable.
"""
if not self.is_circuit_node(node_id):
return False
return not _parse_bool(self._acc.get_prop(node_id, "never-backup"))

def circuit_nodes_missing_names(self) -> list[str]:
Expand Down Expand Up @@ -191,7 +206,7 @@ def _rebuild_dirty_circuits(self, dirty: frozenset[str]) -> SpanPanelSnapshot:
continue # drop old unmapped entries; will recompute below
updated_circuits[cid] = circ
for node_id in dirty:
if self._is_circuit_node(node_id):
if self.is_circuit_node(node_id):
meta = feed_metadata.get(node_id, {})
device_type = meta.get("device_type", "circuit")
relative_position = meta.get("relative_position", "")
Expand Down Expand Up @@ -245,8 +260,16 @@ def _find_lugs_node(self, direction: str) -> str | None:
TYPE_EVSE: "evse",
}

def _is_circuit_node(self, node_id: str) -> bool:
"""Check if node is a circuit device."""
def is_circuit_node(self, node_id: str) -> bool:
"""Whether the panel published a circuit under this node id.

False for an id nothing published and for a node of any other type, and
those are the same answer: the panel carries no circuit by that name.
Public because it is the flat schema's whole answer to "is there
anything here to command" — the command path asks it before building a
target, and the adapter asks it again to tell an unknown id apart from a
circuit that declares its control locked.
"""
return self._acc.get_node_type(node_id) in self._CIRCUIT_LIKE_TYPES

def _build_feed_metadata(self) -> dict[str, dict[str, str]]:
Expand Down Expand Up @@ -607,7 +630,7 @@ def _build_snapshot(self) -> SpanPanelSnapshot:
# Circuits
circuits: dict[str, SpanCircuitSnapshot] = {}
for node_id in self._acc.all_node_types():
if self._is_circuit_node(node_id):
if self.is_circuit_node(node_id):
meta = feed_metadata.get(node_id, {})
device_type = meta.get("device_type", "circuit")
relative_position = meta.get("relative_position", "")
Expand Down
12 changes: 12 additions & 0 deletions packages/schema-1/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ Requires `span-panel-api` **3.1.0 or newer**, and the two must be upgraded toget

Also `None` for a circuit id the tree does not carry, rather than a topic addressed to a device nobody published.

**An undeclared property is refused too, and it is not the same absence as an unannounced attribute.** `priority_is_settable` reads an absent `$settable` on `load-shed/priority` as permission, which is correct for the case it exists for — firmware that
declares the property and omits the attribute — but that default was also answering for devices with no `load-shed` node at all. The BESS, the MID, the lugs and the PV are all in that position in the reference tree, so
`set_circuit_priority_target("bess")` resolved a topic for a control that device never published. `_declared_settable` now distinguishes the two: a property the device does not declare is never settable, whatever the per-property default for a declared
one says. The relay avoided this incidentally, because its default is already refusal.

- **BREAKING: `SchemaOneAdapter` gains `has_circuit`, a new required `SchemaAdapter` member.** 3.1.0 declares it so a transport can tell "no such circuit" apart from "this circuit's control is locked" when it reports a refusal. Every public member of that
protocol is mandatory of every adapter wheel — `_derive_required_members` enforces it by name — so this is the third contract change in the pair, alongside the four `set_*_topic` renames and the widened return types. It adds no new mismatch a consumer
can hit: a 1.0.0 wheel was already rejected by the renames, and the rejection names this member too, with the same remedy (upgrade both distributions together).

It answers on the device _type_, not on membership of the tree: every device is addressable by id, and a BESS reported as a circuit would have its refusal read as a claim about a relay it does not have. It is deliberately not a gate on the two target
builders — those refuse on the declaration, which is the specification's rule and what authorises a write, and putting a type check in front of it would be a second, weaker rule where the authorisation lives.

- **`spec_lock.json`: `peer` becomes `peers`, and the eBus emitter is tracked as one.** `ebus-panel-sim` produced the reference tree and was recorded nowhere, which is the whole reason that capture went three releases stale without anything objecting. It
now carries a pin of the same shape panelbench has — repo, ref, role, commit, tag, released version, and the specification commit it implements — so a scheduled job can ask whether the producer has moved and a test can ask whether our checkout is the
release the bytes are attributed to.
Expand Down
23 changes: 23 additions & 0 deletions packages/schema-1/src/span_panel_api_schema_1/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
PROP_PRIORITY,
PROP_RELAY,
STATE_READY,
TYPE_CIRCUIT,
)
from span_panel_api_schema_1.description import device_type
from span_panel_api_schema_1.field_metadata import build_field_metadata
Expand Down Expand Up @@ -237,6 +238,28 @@ def set_circuit_priority_target(self, circuit_id: str) -> ControlTarget | None:
return None
return self._target(circuit_id, NODE_LOAD_SHED, PROP_PRIORITY)

def has_circuit(self, circuit_id: str) -> bool:
"""Whether the tree carries a circuit under this id.

The type is the question, not mere membership. Every device in the tree
is addressable by id — the BESS, the MID, the lugs, a charger — and none
of them is a circuit, so answering on membership alone would report a
BESS as a circuit whose relay the panel declares non-commandable. It
declares no relay at all.

A child that has not described itself yet reads as absent, because it
has declared nothing: type, nodes and properties all arrive together in
one `$description`, so there is no state in which it is known to be a
circuit and unknown whether its controls are settable.

This is a diagnostic, deliberately not a gate on the two target builders
above. Those refuse on the *declaration*, which is the specification's
rule and is what authorises a write; adding a type check in front of it
would put a second, weaker rule where the authorisation lives.
"""
device = self._child(circuit_id)
return device is not None and device_type(device) == TYPE_CIRCUIT

def set_dominant_power_source_target(self) -> ControlTarget | None:
"""The settable successor: `shed/asserted-islanding-state` on the panel.

Expand Down
Loading
Loading