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
33 changes: 24 additions & 9 deletions custom_components/span_panel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

import asyncio
from dataclasses import dataclass
from enum import Enum, auto
import logging
from typing import Final
from typing import Final, Literal

from homeassistant.components.frontend import async_remove_panel as async_remove_panel
from homeassistant.components.panel_custom import async_register_panel as async_register_panel
Expand Down Expand Up @@ -172,6 +173,15 @@ class _DeferredReload:
"""Per entry, the reload waiting for Home Assistant to start; see `update_listener`."""

HARDWARE_READ_TIMEOUT_S: Final = 5.0


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

UNREAD = auto()


HARDWARE_UNREAD: Final = _HardwareRead.UNREAD
"""How long setup waits for the panel's status before going on without it."""


Expand Down Expand Up @@ -307,12 +317,14 @@ async def _async_pinned_ca(

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.
) -> str | None | Literal[_HardwareRead.UNREAD]:
"""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 the
verdict treats exactly as firmware that does not publish the field. This
the pin rejects and a body that cannot be read all answer `HARDWARE_UNREAD`,
which proceeds like firmware that does not publish the field but, unlike it,
leaves a standing refusal's Repair up: an unread status is no evidence the
hardware changed. This
read exists only to refuse unvalidated hardware, so it must never be the
reason a panel the connect path accepts is locked out; that path still owns
readiness, and reports every one of these failures in its own terms.
Expand All @@ -329,17 +341,17 @@ 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


@callback
def _async_enforce_hardware_verdict(
hass: HomeAssistant,
entry: SpanPanelConfigEntry,
hardware_version: str | None,
hardware_version: str | None | Literal[_HardwareRead.UNREAD],
model: str | None,
) -> None:
"""Refuse a panel this release has not been validated with, or clear a standing refusal.
Expand All @@ -348,8 +360,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 @@ -249,11 +249,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
11 changes: 8 additions & 3 deletions custom_components/span_panel/hardware_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,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 Down
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 @@ -216,7 +216,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 @@ -322,7 +323,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 @@ -914,11 +914,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 @@ -245,6 +245,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 @@ -302,13 +326,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
12 changes: 11 additions & 1 deletion tests/test_hardware_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,17 @@

@pytest.mark.parametrize(
("version", "verdict"),
[("1.2", P), ("2.0", P), (None, P), ("", P), ("UNKNOWN", C), ("9.9", R)],
[
("1.2", P),
("2.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
45 changes: 44 additions & 1 deletion tests/test_setup_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -854,8 +854,17 @@ async def test_validated_hardware_proceeds(
SpanPanelTLSVerificationError("certificate verify failed"),
SpanPanelAPIError("192.168.1.50 answered HTTP 200 with a body that is not JSON", 200),
httpx.ReadError("connection reset"),
httpx.InvalidURL("Invalid port: 'x'"),
],
ids=[
"timeout",
"library-timeout",
"connection",
"tls",
"unparseable-body",
"httpx",
"invalid-url",
],
ids=["timeout", "library-timeout", "connection", "tls", "unparseable-body", "httpx"],
)
async def test_a_status_that_cannot_be_read_proceeds(
hass: HomeAssistant, panel_status: AsyncMock, failure: Exception
Expand Down Expand Up @@ -1022,6 +1031,40 @@ async def test_a_later_successful_setup_clears_the_repair(hass: HomeAssistant, p
assert _unvalidated_hardware_issue(hass) is None


async def test_an_unread_status_leaves_the_repair_up(
hass: HomeAssistant, panel_status: AsyncMock
) -> None:
"""A read that fails says nothing about the hardware, so it neither refuses nor takes the Repair down."""
entry = _create_v2_entry()
entry.add_to_hass(hass)
panel_status.return_value = _status("9.9")
with pytest.raises(ConfigEntryError):
await _set_up(hass, entry, _panel_with_model("OTHER_MODEL"))
assert _unvalidated_hardware_issue(hass) is not None

panel_status.side_effect = TimeoutError()
assert await _set_up(hass, entry, _panel_with_model("OTHER_MODEL")) is True

assert _unvalidated_hardware_issue(hass) is not None


@pytest.mark.parametrize("version", ["unknown", " UNKNOWN "])
async def test_unknown_is_read_whatever_its_spelling(
hass: HomeAssistant, panel_status: AsyncMock, version: str
) -> None:
"""`unknown` is judged by the model like `UNKNOWN`, never refused on its spelling alone.

With no model published, `UNKNOWN` proceeds where an unlisted version is refused.
"""
entry = _create_v2_entry()
entry.add_to_hass(hass)
panel_status.return_value = _status(version)

assert await _set_up(hass, entry, _panel_with_model(None)) is True

assert _unvalidated_hardware_issue(hass) is None


async def test_removing_the_entry_clears_the_repair(hass: HomeAssistant, panel_status: AsyncMock) -> None:
"""Nothing else could clear it once the entry is gone."""
entry = _create_v2_entry()
Expand Down
Loading