Raise the coverage-gap Repair only from a live poll

A coverage gap is a claim about what the device currently reports, so
replaying a discovery snapshot shouldn't make it. Offline it would restate
the last live poll's conclusion while pointing the user at a diagnostics
download that stays empty until the appliance answers, and any drift in the
resolved device name between snapshot and live would churn the issue.

Not deduplication: HA already keys issues on (domain, issue_id), preserves
dismissed_version across async_get_or_create, and reloads non-persistent
issues with their dismissal intact -- one row per entry, and an "Ignore"
survives restarts.
This commit is contained in:
Marc Billow
2026-08-15 20:13:02 +00:00
parent e684146f61
commit edff7385c6
3 changed files with 45 additions and 3 deletions
+9 -3
View File
@@ -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)
+13
View File
@@ -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.
+23
View File
@@ -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: