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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file.
panel publishes them.
- **A battery the panel declares gets its device before its first reading**, reading unknown until then, and a battery fed by a circuit reports its connection.
- **Registration says when to retry** if the panel limits attempts, and setup asks only for the passphrase where the panel does not support proximity.
- **Diagnostics include the panel's device tree**, with identifiers and site details masked, so a report needs no separate capture.

### Changed

Expand Down Expand Up @@ -46,6 +47,7 @@ All notable changes to this project will be documented in this file.
- **Diagnostics redact the Wi-Fi network name.**
- **Each update from a panel on firmware r202633 or later costs Home Assistant's event loop the same small amount however many circuits and devices it
has**, instead of growing with each one ([span-panel-api#223](https://github.com/SpanPanel/span-panel-api/issues/223)).
- **The EV charger charge-limit number offers only the range the charger accepts**, where it offered values the charger refuses.

## [2.1.2] - 10/2026

Expand Down
47 changes: 45 additions & 2 deletions custom_components/span_panel/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@
from __future__ import annotations

from dataclasses import asdict
from typing import TYPE_CHECKING, Any, TypedDict
from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict

from homeassistant.components.diagnostics import REDACTED, async_redact_data
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from span_panel_api import SpanPanelSnapshot, ca_fingerprint, shared_meter_groups
from span_panel_api import (
MaskedTree,
SpanMqttClient,
SpanPanelSnapshot,
ca_fingerprint,
shared_meter_groups,
)
from span_panel_api.exceptions import SpanPanelValidationError

from .adoption import adopted_control_count, classify
Expand Down Expand Up @@ -161,6 +167,40 @@ def _discovery(findings: SchemaFindings | None) -> DiscoveryBlock:
}


class DeviceTreeBlock(TypedDict):
"""The `device_tree` section and, when there is no tree, why.

`device_tree_unavailable` is absent when the tree is present, so a reader
tells "this panel has no tree to give" from "the export was dropped from the
report" by the key rather than by guessing at a bare `None`.
"""

device_tree: MaskedTree | None
device_tree_unavailable: NotRequired[Literal["flat_data_model", "not_described"]]


def _device_tree(client: SpanMqttClient) -> DeviceTreeBlock:
"""Return the panel's device tree as the library masks it, or the reason there is none.

**Masked by the library, not here.** Serials, device ids and site details are
replaced where the tree is exported, so this function adds nothing to the
payload that the library has not already made safe: it forwards the export
whole and never reads a wire value itself.

The tree is the adapter's last view, so a panel that has gone offline still
reports it -- that is the report a maintainer most needs. `flat_data_model`
is a schema 0 panel, which publishes no device tree; `not_described` is a
parent/child panel whose root device has not described itself yet.
"""
tree = client.masked_tree()
if tree is not None:
return {"device_tree": tree}
reason: Literal["flat_data_model", "not_described"] = (
"flat_data_model" if client.schema_major == "schema_0" else "not_described"
)
return {"device_tree": None, "device_tree_unavailable": reason}


class AdoptedDeviceRow(TypedDict):
"""One adopted device, as it appears in the payload.

Expand Down Expand Up @@ -444,6 +484,9 @@ async def async_get_config_entry_diagnostics(
"last_update_success": coordinator.last_update_success,
},
"schema_discovery": _discovery(coordinator.schema_findings),
# Masked by the library and safe by construction, the rule `TO_REDACT`'s
# docstring states: nothing here is redacted again.
**_device_tree(coordinator.client),
"adopted_devices": _adoption(snapshot),
# Keys and enum values only -- no wire values, no user free text (names
# and icons live in Core's registry, not here). Same withholding rules
Expand Down
6 changes: 3 additions & 3 deletions custom_components/span_panel/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@
"quality_scale": "gold",
"requirements": [
"span-panel-api-schema-0==1.3.0b1",
"span-panel-api-schema-1==1.4.0b3",
"span-panel-api==3.8.0b1"
"span-panel-api-schema-1==1.4.0b4",
"span-panel-api==3.8.0b2"
],
"version": "2.2.0b4",
"version": "2.2.0b5",
"zeroconf": [
{
"type": "_span._tcp.local."
Expand Down
63 changes: 50 additions & 13 deletions custom_components/span_panel/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,18 @@

PARALLEL_UPDATES = 1

_FALLBACK_MINIMUM_A: Final = 0
"""The minimum for a charger that declares none, in amps."""


@dataclass(frozen=True)
class SpanEvseNumberRequiredKeysMixin(FieldPathDeclarationMixin):
"""Required keys mixin for EVSE number entities."""

value_fn: Callable[[SpanEvseSnapshot], int | None]
maximum_fn: Callable[[SpanEvseSnapshot], int | None]
minimum_fn: Callable[[SpanEvseSnapshot], int | None]
declared_maximum_fn: Callable[[SpanEvseSnapshot], int | None]
target_fn: Callable[[SpanEvseSnapshot], int | None]
settable_fn: Callable[[SpanEvseSnapshot], bool]
set_fn: Callable[[EvseControlProtocol, str, int], Awaitable[PublishOutcome]]
Expand All @@ -94,13 +99,14 @@ class SpanEvseNumberEntityDescription(NumberEntityDescription, SpanEvseNumberReq
translation_key="evse_charge_current_limit",
device_class=NumberDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
# Zero, because a charge-current ceiling has no meaning below it: this is a
# charge-only EVSE, so lowering the ceiling can stop charging and can never
# reverse it. Not read from the wire because there is nothing on the wire to
# read — Homie expresses a bounded numeric as a `min:max` `$format`, and
# neither the capture nor the `charge-limit` catalog declares one for this
# property. See `native_max_value`, which *is* published and is read.
native_min_value=0,
# Zero is the fallback, used only for a charger that declares no minimum for
# the limit: a charge-current ceiling has no meaning below it, since this is
# a charge-only EVSE. A charger that declares one (the capture's `6:32`
# `$format`) is bounded by it instead, because the library refuses a limit
# outside the declared range; `SpanEvseNumber._apply` reads it each update
# through `minimum_fn`, and the maximum through `maximum_fn` and
# `declared_maximum_fn`.
native_min_value=_FALLBACK_MINIMUM_A,
# The declared datatype is `integer`, and a step is the granularity of the
# quantity rather than a policy: a charger that accepts 16 A and 17 A does
# not accept 16.5. Asserted against the declaration in
Expand All @@ -113,6 +119,14 @@ class SpanEvseNumberEntityDescription(NumberEntityDescription, SpanEvseNumberReq
# what it enforces. Showing unknown there would hide a real setting.
value_fn=lambda evse: evse.effective_charge_current_limit_a,
maximum_fn=lambda evse: evse.charge_current_ceiling_a,
# The declared range applies only where the limit is settable; where it is
# not, the control keeps its fallback bounds.
minimum_fn=lambda evse: (
evse.charge_current_limit_minimum_a if evse.charge_current_limit_settable else None
),
declared_maximum_fn=lambda evse: (
evse.charge_current_limit_maximum_a if evse.charge_current_limit_settable else None
),
target_fn=lambda evse: evse.charge_current_limit_target_a,
settable_fn=lambda evse: evse.charge_current_limit_settable,
set_fn=lambda client, node_id, amps: client.set_evse_charge_limit(node_id, amps),
Expand Down Expand Up @@ -201,14 +215,37 @@ def _apply(self, evse: SpanEvseSnapshot) -> None:
The maximum moves with the panel because it can: an installer
recommissioning a charger republishes `installer-max`, and a control
still offering the old range would let a user ask for a current the
hardware is no longer rated for. Left at whatever was last published
when the value goes away, so the entity reports unavailable with its
last known bound rather than briefly widening.
hardware is no longer rated for. When neither the ceiling nor a declared
maximum is published the maximum is left at whatever it last was, and
the entity is unavailable rather than briefly widening.

The range the charger declares for its limit bounds the control too,
because the library refuses a value outside it: the minimum is the
declared one, else the description's fallback; the maximum is the lower
of the declared maximum and the ceiling, each where present, so a
declared maximum alone bounds it while the ceiling is unpublished (the
entity is unavailable then). An open side falls back for that side only,
and the range is applied only where the limit is settable.
"""
self._attr_native_value = self._description.value_fn(evse)
maximum = self._description.maximum_fn(evse)
if maximum is not None:
self._attr_native_max_value = maximum
declared_minimum = self._description.minimum_fn(evse)
self._attr_native_min_value = (
_FALLBACK_MINIMUM_A if declared_minimum is None else declared_minimum
)
ceilings = [
bound
for bound in (
self._description.maximum_fn(evse),
self._description.declared_maximum_fn(evse),
)
if bound is not None
]
if ceilings:
self._attr_native_max_value = min(ceilings)
# A charger commissioned below its declared minimum (ceiling 16 A, range
# 20:32) would otherwise report a minimum above its maximum, which Home
# Assistant cannot render; the ceiling is the hardware bound, so it wins.
self._attr_native_min_value = min(self._attr_native_min_value, self._attr_native_max_value)

@property
def available(self) -> bool:
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
[project]
name = "span"
version = "2.2.0b4"
version = "2.2.0b5"
description = "Span Panel Custom Integration for Home Assistant"
authors = [{name = "SpanPanel"}]
license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.9.4",
"span-panel-api==3.8.0b1",
"span-panel-api==3.8.0b2",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
# and that is the list Home Assistant actually reads.
"span-panel-api-schema-0==1.3.0b1",
"span-panel-api-schema-1==1.4.0b3",
"span-panel-api-schema-1==1.4.0b4",
]

[dependency-groups]
Expand Down
4 changes: 2 additions & 2 deletions requirements_test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.367
ebus-panel-sim==0.10.0b2
homeassistant==2026.9.4
span-panel-api==3.8.0b1
span-panel-api==3.8.0b2
span-panel-api-schema-0==1.3.0b1
span-panel-api-schema-1==1.4.0b3
span-panel-api-schema-1==1.4.0b4
33 changes: 31 additions & 2 deletions tests/captures_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@
from typing import Final

from ebus_panel_sim import Tree, load_reference_capture
from span_panel_api import ControlInterceptor, LeafNameMismatch, SpanPanelSnapshot
from ebus_sdk import encode_empty_string
from span_panel_api import (
ControlInterceptor,
LeafNameMismatch,
MaskedTree,
SpanPanelSnapshot,
TreeExportingAdapter,
)
from span_panel_api.adapters import resolve_adapter
from span_panel_api.dispatch import select_adapter_key
from span_panel_api.exceptions import SpanPanelError
Expand Down Expand Up @@ -177,12 +184,27 @@ def without_topics(tree: RetainedTree, device_id: str, *topics: str) -> Retained
}


def retained_payload(value: str) -> str:
"""The payload a tree's value is replayed as: `""` as `0x00`, anything else as itself.

A tree holds each value as the device published it, decoded, so `""` is an
empty value. On the wire a zero-length payload is not one: it deletes the
retained message, and the library reads it as a clear, the property
unpublished. Homie 5 spells an empty value as a single `0x00`, which the
library reads back as `""`, so that is what the broker would replay.
"""
return encode_empty_string(value)


def replay(tree: RetainedTree) -> SchemaAdapter:
"""Deliver the tree to the adapter the library dispatches to, panel first.

The schema the adapter is built with stands in for `GET /api/v2/homie/schema`
and carries only what dispatch reads from it, the data-model version and the
firmware; neither parent/child adapter reads its `types`.

Every value goes through `retained_payload`, so a tree holding an empty value
replays it as one rather than as a clear.
"""
panel = panel_device_id(tree)
data_model_version = tree[panel].get("info/data-model-version")
Expand All @@ -203,7 +225,7 @@ def replay(tree: RetainedTree) -> SchemaAdapter:
adapter.handle_message(f"{prefix}/$state", topics["$state"])
for topic, payload in topics.items():
if not topic.startswith("$"):
adapter.handle_message(f"{prefix}/{topic}", payload)
adapter.handle_message(f"{prefix}/{topic}", retained_payload(payload))
assert adapter.is_ready(), f"the replay of {panel} never became ready"
return adapter

Expand Down Expand Up @@ -249,6 +271,13 @@ def schema_major(self) -> str:
def field_metadata(self) -> dict[str, FieldMetadata]:
return self._adapter.build_field_metadata()

def masked_tree(self) -> MaskedTree | None:
"""The library's masked export of the replayed tree, as `SpanMqttClient` offers it."""
adapter = self._adapter
if isinstance(adapter, TreeExportingAdapter):
return adapter.export_masked_tree()
return None

async def connect(self) -> None:
"""Connected already: the replay is the retained tree a connect receives."""

Expand Down
Loading
Loading