diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index e02124a..5682454 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -1122,7 +1122,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): resources = stored["resources"] try: - self._run_discovery(resources) + self._run_discovery(resources, from_snapshot=True) except Exception as e: # A snapshot written by an older release can outlive the registry # shape it was discovered against. Fall back to the pre-#295 @@ -1172,7 +1172,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.hass.config_entries.async_schedule_reload(self._entry.entry_id) - def _run_discovery(self, resources: dict[str, dict]) -> None: + def _run_discovery(self, resources: dict[str, dict], from_snapshot: bool = False) -> None: # Diagnostics only -- names the firmware generation (e.g. '7.0 Air # conditioner' is Tizen Lite); doesn't route, since every device # that reports it is already typed by modelNum. @@ -1288,7 +1288,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): model=model, ) self._persist_identity(serial, model, mfr, device_type_name) - self._update_coverage_gap_issue(device_type_name is None, unbound, name) + if not from_snapshot: + # A coverage gap is a claim about what the device reports, so only + # a live poll gets to make it. Replaying a snapshot would restate + # last run's conclusion while pointing the user at a diagnostics + # download that is empty until the appliance answers, and any + # drift in the device name between the two would churn the issue. + self._update_coverage_gap_issue(device_type_name is None, unbound, name) self._hot_hrefs = sorted(hot) self._warm_hrefs = sorted(warm) diff --git a/docs/investigations/offline-setup.md b/docs/investigations/offline-setup.md index 70c334c..95b812e 100644 --- a/docs/investigations/offline-setup.md +++ b/docs/investigations/offline-setup.md @@ -131,6 +131,19 @@ It also means `async_remove_config_entry_device` is no longer reachable with an empty `coordinator.subdevices`, so an offline load can't offer to delete a real-but-unreachable subdevice. +### The coverage-gap Repair stays live-only + +`_run_discovery(..., from_snapshot=True)` skips `_update_coverage_gap_issue`. +The Repair points the user at a diagnostics download, which is empty until +the appliance answers, and a device name that drifts between the snapshot and +the live poll would churn the issue for no reason. + +Not a de-duplication measure — HA already handles that. `async_create_issue` +is keyed on `(domain, issue_id)`, `dataclasses.replace` in +`async_get_or_create` leaves `dismissed_version` alone, and the registry +reloads non-persistent issues with their dismissal intact, so one row per +entry survives restarts and an "Ignore" sticks. + ## What this still won't do Entities will be present and `unavailable` — not showing their last values. diff --git a/tests/localthings/test_offline_setup.py b/tests/localthings/test_offline_setup.py index 37a659e..067038f 100644 --- a/tests/localthings/test_offline_setup.py +++ b/tests/localthings/test_offline_setup.py @@ -18,6 +18,7 @@ from unittest.mock import patch import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.util import dt as dt_util from pytest_homeassistant_custom_component.common import async_fire_time_changed @@ -327,6 +328,28 @@ async def test_reconcile_is_quiet_when_live_discovery_agrees( reload.assert_not_called() +def test_coverage_gap_repair_is_live_only(hass: HomeAssistant, mock_entry) -> None: + """A coverage gap is a claim about what the device reports, so replaying + a snapshot must not raise the Repair -- it would restate last run's + conclusion while the diagnostics download it points at is still empty.""" + gappy = { + "/information/vs/0": { + "x.com.samsung.da.modelNum": "TOTALLY_UNKNOWN_BOARD", + "x.com.samsung.da.serialNum": "TEST-SERIAL-0000", + }, + "/nothing/maps/this/vs/0": {"someField": 1}, + } + issue_id = f"device_gap_{mock_entry.entry_id}" + coordinator = LocalThingsCoordinator(hass, mock_entry) + + coordinator._run_discovery(gappy, from_snapshot=True) + assert coordinator._unbound_hrefs # the gap is real, it just stays quiet + assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None + + coordinator._run_discovery(gappy) + assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None + + async def test_live_load_never_reconciles( hass: HomeAssistant, mock_entry, mock_coordinator_session ) -> None: