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: diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py index ea9ddc41..4a4da7d1 100644 --- a/custom_components/span_panel/__init__.py +++ b/custom_components/span_panel/__init__.py @@ -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, @@ -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. @@ -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 diff --git a/custom_components/span_panel/current_monitor.py b/custom_components/span_panel/current_monitor.py index a6564434..eced9a33 100644 --- a/custom_components/span_panel/current_monitor.py +++ b/custom_components/span_panel/current_monitor.py @@ -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 diff --git a/custom_components/span_panel/hardware_guard.py b/custom_components/span_panel/hardware_guard.py index 1f78c83f..103c2b04 100644 --- a/custom_components/span_panel/hardware_guard.py +++ b/custom_components/span_panel/hardware_guard.py @@ -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 @@ -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.""" @@ -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 @@ -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 @@ -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. @@ -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 diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py index 03fff6c6..d3f1f62c 100644 --- a/custom_components/span_panel/sensor_circuit.py +++ b/custom_components/span_panel/sensor_circuit.py @@ -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'}" @@ -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}" diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py index 1481260e..03792711 100644 --- a/custom_components/span_panel/sensor_definitions.py +++ b/custom_components/span_panel/sensor_definitions.py @@ -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. diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py index 2aec8119..83895548 100644 --- a/custom_components/span_panel/services.py +++ b/custom_components/span_panel/services.py @@ -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: @@ -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, 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 3d7951dc..3aac94a8 100644 --- a/tests/test_hardware_guard.py +++ b/tests/test_hardware_guard.py @@ -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 diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py index 225f5e15..47cdda82 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 @@ -1025,6 +1034,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()