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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Agent Instructions for SPAN Panel Integration

## No AI Attribution

Do not attribute work to Claude, Copilot or any other AI agent anywhere in this repository's history or on GitHub. Work belongs to the human who directs it.

- No `Co-Authored-By` or other AI co-author trailer in a commit message.
- No "Generated with" / "Generated by" footer, and no AI session link, in a commit message, pull request description, issue, comment or release note.
- No mention of AI tools in code, comments, documentation or the changelog.

This rule takes precedence over any default tool behaviour that would add attribution.

## Permitted Operations

The following operations are benign and should be executed without confirmation. Do not prompt the user for permission to:
Expand Down
15 changes: 12 additions & 3 deletions custom_components/span_panel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,13 @@
async_save_panel_settings as async_save_panel_settings,
)
from .graph_horizon import GraphHorizonManager
from .hardware_guard import async_read_hardware_version, hardware_verdict, refuses_entities
from .hardware_guard import (
HARDWARE_UNREAD,
HardwareRead,
async_read_hardware_version,
hardware_verdict,
refuses_entities,
)
from .hardware_repairs import (
RefusedHardware,
async_clear_unvalidated_hardware,
Expand Down Expand Up @@ -305,7 +311,7 @@ async def _async_pinned_ca(
def _async_enforce_hardware_verdict(
hass: HomeAssistant,
entry: SpanPanelConfigEntry,
hardware_version: str | None,
hardware_version: HardwareRead,
model: str | None,
) -> None:
"""Refuse a panel this release has not been validated with, or clear a standing refusal.
Expand All @@ -314,8 +320,11 @@ def _async_enforce_hardware_verdict(
panel's `info/model` from the first refresh. A refusal raises the Repair and
a `ConfigEntryError`, which is terminal: the hardware will not change by
retrying, and a release validated with it is the remedy. A setup that
proceeds clears the Repair a previous one raised.
proceeds clears the Repair a previous one raised; one whose status could not
be read proceeds without judging, and leaves the Repair as it was.
"""
if hardware_version is HARDWARE_UNREAD:
return
if not refuses_entities(hardware_verdict(hardware_version), model):
async_clear_unvalidated_hardware(hass, entry)
return
Expand Down
10 changes: 8 additions & 2 deletions custom_components/span_panel/current_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,11 +354,17 @@ def resolve_entity_to_circuit_id(self, entity_id: str, circuit_ids: Collection[s
reading its own last snapshot: the monitor sees a snapshot only on the
coordinator's next push after setup, and a threshold set before then
must still land on its circuit.

The ids this monitor holds overrides for are searched too, so a circuit
that has left the panel can still have its override cleared through its
orphaned entity. Only this integration's entities are read: another
integration's unique_id could hold a segment equal to a short opaque id.
"""
entity_reg = er.async_get(self._hass)
entry = entity_reg.async_get(entity_id)
if entry is not None and entry.unique_id:
circuit_id = match_circuit_id(entry.unique_id, circuit_ids)
if entry is not None and entry.platform == DOMAIN and entry.unique_id:
known = {*circuit_ids, *self._circuit_overrides}
circuit_id = match_circuit_id(entry.unique_id, known)
if circuit_id is not None:
return circuit_id
# Fall through: assume it's already a circuit_id
Expand Down
50 changes: 35 additions & 15 deletions custom_components/span_panel/hardware_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
from __future__ import annotations

import asyncio
from enum import StrEnum
from enum import Enum, StrEnum, auto
import logging
from typing import Final
from typing import Final, Literal

from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
Expand All @@ -35,6 +35,19 @@
"""How long a caller waits for the panel's status before going on without it."""


class _HardwareRead(Enum):
"""A status read that failed, so nothing is known about the hardware."""

UNREAD = auto()


HARDWARE_UNREAD: Final = _HardwareRead.UNREAD
"""What `async_read_hardware_version` answers when the status cannot be read."""

type HardwareRead = str | None | Literal[_HardwareRead.UNREAD]
"""A hardware version as published, None where the firmware publishes none, or `HARDWARE_UNREAD`."""


class HardwareVerdict(StrEnum):
"""What the REST hardware version alone allows."""

Expand All @@ -44,10 +57,15 @@ class HardwareVerdict(StrEnum):


def hardware_verdict(hardware_version: str | None) -> HardwareVerdict:
"""Judge the REST value: absent or empty proceeds, so firmware before r202639 is never refused."""
if not hardware_version or hardware_version in VALIDATED_HARDWARE_VERSIONS:
"""Judge the REST value: absent or empty proceeds, so firmware before r202639 is never refused.

Surrounding whitespace and the case of `UNKNOWN` are ignored, so a spelling
the changelog did not show cannot alone make the terminal refusal.
"""
version = (hardware_version or "").strip()
if not version or version in VALIDATED_HARDWARE_VERSIONS:
return HardwareVerdict.PROCEED
if hardware_version == UNDETERMINED_HARDWARE_VERSION:
if version.upper() == UNDETERMINED_HARDWARE_VERSION:
return HardwareVerdict.CHECK_MODEL
return HardwareVerdict.REFUSE

Expand All @@ -61,7 +79,7 @@ def refuses_entities(verdict: HardwareVerdict, model: str | None) -> bool:
return True


def registers_by_passphrase_only(hardware_version: str | None) -> bool:
def registers_by_passphrase_only(hardware_version: HardwareRead) -> bool:
"""Whether the panel registers by passphrase only, so proximity is never offered.

Unknown hardware answers False: offering proximity to a panel that turns out
Expand All @@ -73,15 +91,17 @@ def registers_by_passphrase_only(hardware_version: str | None) -> bool:

async def async_read_hardware_version(
hass: HomeAssistant, host: str, transport: PanelRestTransport
) -> str | None:
"""Read the panel's REST `hardwareVersion`, or None when it cannot be had.
) -> HardwareRead:
"""Read the panel's REST `hardwareVersion`, or `HARDWARE_UNREAD` when it cannot be had.

Fail-open by design: a timeout, a refused or reset connection, a certificate
the pin rejects and a body that cannot be read all answer None, which every
caller treats exactly as firmware that does not publish the field. The read
only ever refines what a caller does, so it must never be the reason a panel
is locked out or a service fails; the caller's own path reports every one of
these failures in its own terms.
the pin rejects, an unusable URL and a body that cannot be read all answer
`HARDWARE_UNREAD`. Every caller proceeds on it as on firmware that does not
publish the field, but setup, unlike there, leaves a standing refusal's Repair
up: an unread status is no evidence the hardware changed. The read only ever
refines what a caller does, so it must never be the reason a panel is locked
out or a service fails; the caller's own path reports every one of these
failures in its own terms.

Over the entry's own transport -- the pinned anchor on the HTTPS port when
it holds one -- and Home Assistant's shared client otherwise.
Expand All @@ -94,7 +114,7 @@ async def async_read_hardware_version(
httpx_client=get_async_client(hass),
ssl_context=transport.ssl_context,
)
except (TimeoutError, SpanPanelError, httpx.HTTPError) as err:
except (TimeoutError, SpanPanelError, httpx.HTTPError, httpx.InvalidURL) as err:
_LOGGER.debug("Could not read the hardware version of SPAN panel %s: %s", host, err)
return None
return HARDWARE_UNREAD
return status.hardware_version
6 changes: 4 additions & 2 deletions custom_components/span_panel/sensor_circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@ def _generate_panel_name(

circuit = snapshot.circuits.get(self.circuit_id)
if not circuit:
return f"Circuit {self.circuit_id} {description.name or 'Sensor'}"
circuit_identifier = construct_circuit_identifier_from_tabs([], self.circuit_id)
return f"{circuit_identifier} {description.name or 'Sensor'}"

circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id)
return f"{circuit_identifier} {description.name or 'Sensor'}"
Expand Down Expand Up @@ -343,7 +344,8 @@ def _generate_panel_name(

circuit = snapshot.circuits.get(self.circuit_id)
if not circuit:
return f"Circuit {self.circuit_id} {description.name}"
circuit_identifier = construct_circuit_identifier_from_tabs([], self.circuit_id)
return f"{circuit_identifier} {description.name}"

circuit_identifier = _resolve_circuit_identifier_for_sync(circuit, self.circuit_id)
return f"{circuit_identifier} {description.name}"
Expand Down
10 changes: 5 additions & 5 deletions custom_components/span_panel/sensor_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -971,11 +971,11 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
frame. The library's helper was renamed `_charge_positive` -> `_discharge_positive`
for the same reason.

**What is settled is that nothing here regressed.** `BATTERY_POWER_SENSOR` is
behaviorally identical to the one released in 2.0.8 -- same source, same single
negation, same device and state class -- and both adapters pass
`power_flow_battery` through untouched. Whatever a live panel showed then, it
shows now. `bess_meter_power` was briefly withdrawn on the belief that it would
**What is settled is that the sign did not regress.** `BATTERY_POWER_SENSOR`
reads the same source with the same single negation, device and state class
as the one released in 2.0.8, and both adapters pass `power_flow_battery`
through untouched. Whatever a live panel showed then, it shows now -- except
that an unpublished value now reads unknown where 2.0.8 reported 0 W. `bess_meter_power` was briefly withdrawn on the belief that it would
disagree with its neighbor on real firmware; the capture showed the two wire
properties aligned on the panel *and* on the emitter, so it cannot, and it is
restored.
Expand Down
30 changes: 27 additions & 3 deletions custom_components/span_panel/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,30 @@ def _build_set_global_monitoring_schema() -> vol.Schema:
)


def _raise_if_entitys_panel_not_loaded(
hass: HomeAssistant, target: str, panel_entry_id: str | None
) -> None:
"""Refuse a threshold call whose entity's own panel is chosen but not loaded.

Said as that, rather than as monitoring being off, which nothing has read. A
loaded panel, no chosen panel, and a raw id, which belongs to no entity, are
all left to the caller's own refusal.
"""
if panel_entry_id is None:
return
config_entry = hass.config_entries.async_get_entry(panel_entry_id)
if config_entry is not None and loaded_runtime_data(config_entry) is not None:
return
entity_entry = er.async_get(hass).async_get(target)
if entity_entry is not None and entity_entry.platform == DOMAIN:
raise ServiceValidationError(
f"The SPAN Panel that entity {target} belongs to is not loaded.",
translation_domain=DOMAIN,
translation_key="favorite_panel_not_loaded",
translation_placeholders={"entity_id": target},
)


def _threshold_panel_entry_id(
hass: HomeAssistant, target: str, requested: str | None
) -> str | None:
Expand Down Expand Up @@ -318,13 +342,13 @@ def _get_monitored(
live circuit ids, which the monitor itself does not hold until the
coordinator's next push.
"""
result = _get_runtime_data(
_threshold_panel_entry_id(hass, target, call.data.get("config_entry_id"))
)
panel_entry_id = _threshold_panel_entry_id(hass, target, call.data.get("config_entry_id"))
result = _get_runtime_data(panel_entry_id)
if result is not None:
runtime_data, _entry = result
if runtime_data.coordinator.current_monitor is not None:
return runtime_data.coordinator.current_monitor, runtime_data
_raise_if_entitys_panel_not_loaded(hass, target, panel_entry_id)
raise ServiceValidationError(
"No SPAN panel with current monitoring enabled.",
translation_domain=DOMAIN,
Expand Down
58 changes: 58 additions & 0 deletions tests/test_current_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,35 @@ def test_with_no_known_ids_the_input_is_taken_as_a_circuit_id(self):
"sensor.kitchen_power"
)

def test_a_departed_circuit_with_an_override_still_resolves(self):
"""An orphaned entity reaches the override stored for its circuit, so it can be cleared."""
hass = _make_hass()
monitor = _make_monitor(hass)
monitor.set_circuit_override("b-7", {SPIKE_THRESHOLD_PCT: 90})

with _registry_holding("span_nt-0000-test1_b-7_power"):
circuit_id = monitor.resolve_entity_to_circuit_id("sensor.b_7_power", {"c-1"})
assert circuit_id == "b-7"

monitor.clear_circuit_override(circuit_id)
assert "b-7" not in monitor._circuit_overrides

def test_another_integrations_entity_is_not_matched(self):
"""A foreign unique_id with a segment equal to a circuit id names no circuit."""
hass = _make_hass()
monitor = _make_monitor(hass)
registry = MagicMock()
registry.async_get.return_value = MagicMock(
unique_id="x_c-1_y", platform="template", config_entry_id=None
)

with patch(
"custom_components.span_panel.current_monitor.er.async_get", return_value=registry
):
assert monitor.resolve_entity_to_circuit_id("sensor.some_template", {"c-1"}) == (
"sensor.some_template"
)


def _monitoring_handlers(hass: MagicMock) -> dict[str, Any]:
"""Register the monitoring services on a mock hass and return the handlers by name."""
Expand Down Expand Up @@ -632,6 +661,35 @@ async def test_a_threshold_cleared_before_the_first_push_is_cleared(self):
monitor.process_snapshot(snapshot)
assert monitor.get_circuit_state("c-12").last_spike_alert is None

async def test_the_override_of_a_circuit_that_left_the_panel_is_cleared(self):
"""Its orphaned entity still names it, though the coordinator no longer publishes it."""
hass = _make_hass()
entry, monitor, _snapshot = _panel_with_a_fresh_monitor(hass, "panel_a", "c-1")
_loaded(hass, entry)
monitor.set_circuit_override("gone", {SPIKE_THRESHOLD_PCT: 90})
handlers = _monitoring_handlers(hass)

with _registry_holding("span_nt-0000-test1_gone_current"):
await handlers["clear_circuit_threshold"](_call(circuit_id="sensor.gone_current"))

assert "gone" not in monitor._circuit_overrides

async def test_an_entity_whose_panel_is_not_loaded_says_so(self):
"""Not that monitoring is off: nothing about monitoring was read."""
hass = _make_hass()
entry, _monitor, _snapshot = _panel_with_a_fresh_monitor(hass, "panel_a", "c-1")
_loaded(hass, entry)
handlers = _monitoring_handlers(hass)

with (
_registry_holding("span_nt-0000-test2_c-1_current", config_entry_id="panel_b"),
pytest.raises(ServiceValidationError) as raised,
):
await handlers["clear_circuit_threshold"](_call(circuit_id="sensor.b_c_1_current"))

assert raised.value.translation_key == "favorite_panel_not_loaded"
assert raised.value.translation_placeholders == {"entity_id": "sensor.b_c_1_current"}


class TestThresholdServicesChooseTheEntitysOwnPanel:
"""With two panels, a threshold named by an entity lands on that entity's panel.
Expand Down
13 changes: 12 additions & 1 deletion tests/test_hardware_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,18 @@

@pytest.mark.parametrize(
("version", "verdict"),
[("1.2", P), ("2.0", P), ("3.0", P), (None, P), ("", P), ("UNKNOWN", C), ("9.9", R)],
[
("1.2", P),
("2.0", P),
("3.0", P),
(" 2.0 ", P),
(None, P),
("", P),
(" ", P),
("UNKNOWN", C),
("unknown", C),
("9.9", R),
],
)
def test_the_verdict_from_the_rest_value_alone(version: str | None, verdict: HardwareVerdict) -> None:
assert hardware_verdict(version) is verdict
Expand Down
Loading
Loading