fix(discovery): a sweep that could not run is not an answer (t62 F1)

The installed-apps sweep stamped an EMPTY answer on every failure path, so a
Shield that was asleep at the entity's startup sweep was cached as "0 apps" for
APP_LIST_TTL (6 h): `homeassistant.update_entity` then issued zero shells and
the sensor kept reporting 0/"pending" until the TTL or the Configure row's
force. A genuinely empty device was indistinguishable from it, so "ok" was
unreachable for an empty list.

Only a sweep that RAN stamps the cache now. Every failure records the outcome
as `unknown` (`_apps_unavailable`), stores no table, and is retried by the next
non-forced refresh; a sweep that reached the device and failed is spaced by
APP_SWEEP_RETRY_SECONDS (30 s, force=True bypasses) so a silent socket is not
hammered once per poll, while an unavailable transport costs nothing and is
retried immediately. `installed_apps_status` exposes ok/unknown/pending, the
sensor surfaces it, and "ok" + an empty table is now the honest empty answer.
Docstrings, the README vocabulary and the docs on what a refresh actually
re-runs are corrected; the "update_entity re-runs only without a fresh table,
the Configure row forces one" wording stays true.

Red proof (pre-fix sources, current tests): the five new/updated tests fail on
HEAD, e.g. `installed_apps_stale` is False after the ADB-down startup sweep, and
the sensor reports 'pending' where it must say 'unknown'.

Same class already fixed for app labels: a fetch that could not run is not an
answer. Also records that the `-3`/resolve-activity/pm-dump output shapes are
documented, not observed.
This commit is contained in:
engineer committed 2026-10-12 08:19:10 +01:00
1 parent ef42682250
commit c05e6fa929
4 files changed
+217 -43

No files matched your search

+79 -5
View File
@@ -393,7 +393,11 @@ class TestDiscoverySweep:
class TestDiscoveryDegradesGracefully:
"""ADB off, down or mute: an empty list and a note, never a crash or a loop."""
"""ADB off, down or mute: an empty list and a note, never a crash or a loop.
t62 F1: "could not ask" and "asked, nothing there" are different answers.
Only the second one is cached; the first is retried.
"""
async def test_no_poller_is_an_empty_list(self):
manager = ShieldConnectionManager(hass=None, entry=None, poller=None)
@@ -416,19 +420,64 @@ class TestDiscoveryDegradesGracefully:
assert rows == []
assert poller.shell_calls == [] # nothing was even attempted
async def test_a_mute_transport_reports_empty_without_raising(self):
async def test_a_mute_transport_is_spaced_but_not_stamped(self):
"""t62 F1: a failed sweep is retried — but not once per state poll."""
now = [1000.0]
poller = _DiscoveryPoller(failure=True)
manager = _manager(poller)
manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=lambda: now[0]
)
rows = await manager.async_refresh_installed_apps()
assert rows == []
assert poller.shell_calls # it tried
# and a second call does NOT re-shell in a loop: the empty answer is
# stamped, and only an explicit force/TTL asks again
assert manager.installed_apps_status == "unknown" # NOT an empty answer
# the next poll inside the window does NOT re-shell (no hammering)..
calls = len(poller.shell_calls)
assert await manager.async_refresh_installed_apps() == []
assert len(poller.shell_calls) == calls
# ..but nothing was stamped as an answer, so once the window passes the
# sweep runs again. Pre-fix it waited out APP_LIST_TTL (6 h) instead.
now[0] += manager.APP_SWEEP_RETRY_SECONDS + 1
assert await manager.async_refresh_installed_apps() == []
assert len(poller.shell_calls) > calls
async def test_a_sweep_that_could_not_run_is_not_a_fresh_answer(self):
"""t62 F1 (red proof): startup with ADB down must not BE the answer.
The entity's one startup sweep runs with the poller down. Once ADB comes
back, ``homeassistant.update_entity`` (``sensor.async_update``) must
recover the table. Pre-fix the startup sweep stamped an empty answer, so
this call issued ZERO shells and kept reporting 0/"pending" for
APP_LIST_TTL = 6 h.
"""
poller = _DiscoveryPoller(dumps={"ar.tvplayer.tv": DUMP_TIVIMATE})
poller.connected = False
manager = _manager(poller)
assert await manager.async_refresh_installed_apps() == [] # the startup
assert poller.shell_calls == [] # it was not even attempted
assert manager.installed_apps_stale is True # ...so it is not an answer
assert manager.installed_apps_status == "unknown"
poller.connected = True # ADB is back
rows = await manager.async_refresh_installed_apps()
assert rows, "update_entity must recover the table once ADB returns"
assert "pm list packages -3" in poller.shell_calls
assert manager.installed_apps_status == "ok"
async def test_a_genuinely_empty_answer_is_ok(self):
"""t62 F1: 'ok' must be reachable for a really empty device."""
poller = _DiscoveryPoller(packages="", launcher="")
manager = _manager(poller)
assert await manager.async_refresh_installed_apps() == []
assert poller.shell_calls # the commands RAN and answered
assert manager.installed_apps_stale is False # stamped: a real answer
assert manager.installed_apps_status == "ok"
async def test_the_accessor_never_shells(self):
poller = _DiscoveryPoller()
@@ -534,6 +583,31 @@ class TestInstalledAppsSensor:
assert attrs["installed_apps"] == []
assert attrs["discovery"] in ("pending", "not_connected", "adb_disabled")
async def test_an_empty_discovery_is_ok_not_pending(self):
"""t62 F1: a sweep that RAN and found nothing is the answer 'ok'."""
poller = _DiscoveryPoller(packages="", launcher="")
manager = _manager(poller)
await manager.async_refresh_installed_apps()
sensor = self._sensor(manager, {"adb_enabled": True})
assert sensor.native_value == 0
assert sensor.extra_state_attributes == {
"installed_apps": [],
"discovery": "ok",
}
async def test_a_sweep_that_could_not_run_is_unknown_not_pending(self):
"""t62 F1: 'pending' used to cover both "running" and "could not run"."""
poller = _DiscoveryPoller(failure=True)
manager = _manager(poller)
await manager.async_refresh_installed_apps()
sensor = self._sensor(manager, {"adb_enabled": True})
assert sensor.extra_state_attributes == {
"installed_apps": [],
"discovery": "unknown",
}
def test_adb_disabled_is_reported_as_such(self):
poller = _DiscoveryPoller()
manager = _manager(poller)