From 32d59d6fb13b0e8544c94d8b03b4a21150e1f487 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:52:24 +0000 Subject: [PATCH 1/2] fix: address review findings on the 2.1.3b2 changes - Threshold services resolve an entity against the monitor's stored overrides as well as the live circuits, so the override of a circuit that has left the panel can still be cleared through its orphaned entity. Only this integration's entities are matched. - A hardware status that cannot be read proceeds without clearing a standing unvalidated-hardware Repair; httpx.InvalidURL is caught too. - The hardware verdict ignores surrounding whitespace and the case of UNKNOWN, so a spelling alone cannot make the terminal refusal. - A threshold call whose entity's own panel is not loaded says so instead of reporting monitoring as disabled. - Correct the battery power docstring: an unpublished value now reads unknown where 2.0.8 reported 0 W. - Circuit sensor name fallbacks use construct_circuit_identifier_from_tabs. --- custom_components/span_panel/__init__.py | 33 ++++++++--- .../span_panel/current_monitor.py | 10 +++- .../span_panel/hardware_guard.py | 11 +++- .../span_panel/sensor_circuit.py | 6 +- .../span_panel/sensor_definitions.py | 10 ++-- custom_components/span_panel/services.py | 30 +++++++++- tests/test_current_monitor.py | 58 +++++++++++++++++++ tests/test_hardware_guard.py | 12 +++- tests/test_setup_entry.py | 45 +++++++++++++- 9 files changed, 189 insertions(+), 26 deletions(-) diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index 03d288b7..037fa9a1 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -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 @@ -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.""" @@ -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. @@ -329,9 +341,9 @@ 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 @@ -339,7 +351,7 @@ async def _async_read_hardware_version( 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. @@ -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 diff --git a/custom_components/span_panel/current_monitor.py b/custom_components/span_panel/current_monitor.py index 3868576a..8a010259 100644 --- a/custom_components/span_panel/current_monitor.py +++ b/custom_components/span_panel/current_monitor.py @@ -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 diff --git a/custom_components/span_panel/hardware_guard.py b/custom_components/span_panel/hardware_guard.py index 23f8111a..27a8c0b9 100644 --- a/custom_components/span_panel/hardware_guard.py +++ b/custom_components/span_panel/hardware_guard.py @@ -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 diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index ffff1488..d4eca9d2 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -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'}" @@ -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}" diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index e04ca572..b595865d 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -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. diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index 99bcf898..7564371a 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -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: @@ -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, diff --git a/tests/test_current_monitor.py b/tests/test_current_monitor.py index c65c8a06..804cf066 100644 --- a/tests/test_current_monitor.py +++ b/tests/test_current_monitor.py @@ -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.""" @@ -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. diff --git a/tests/test_hardware_guard.py b/tests/test_hardware_guard.py index 08c32fce..50c653db 100644 --- a/tests/test_hardware_guard.py +++ b/tests/test_hardware_guard.py @@ -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 diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index d1e9f0f9..62bd7cd3 100644 --- a/tests/test_setup_entry.py +++ b/tests/test_setup_entry.py @@ -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 @@ -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() From 04b4e6cef0637cd38fcfb62ba7d7fdd361b8c73b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:56:12 -0700 Subject: [PATCH 2/2] docs(agents): no AI attribution in history or on GitHub --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 36cc115c..01e96c86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: