The t22 review's O1, and the same bug class as t21's F1 — so it gets the same
structural treatment instead of a note.
WHAT WAS MEASURED. With a poller injected into an entry whose own option said
`adb_enabled: False`, the installed-apps sweep sent 4 real shells (the reviewer;
3 on this test's double, which yields one launchable package) and a label probe
sent 1 more. Both gated on `self._poller is None` — and the poller's absence is a
property of the SETUP code (`_build_poller_factory` returns None when ADB is off),
which is precisely the indirect guarantee the t21 repair had already shown to be
insufficient. Production was unreachable only through that setup path.
THE FIX. `_adb_disabled()` is now asked directly by every path in the manager
that can reach the device, and `_adb_active()` (option ON *and* a poller) is the
shared predicate for the ones that also need a transport. The `available` property
deliberately keeps its transport meaning (diagnostics reads it as "ADB is
configured"); a new docstring says so, because overloading it is how the gate got
lost in the first place.
Gated paths, and what each asks now:
* intent scan — the option (t21, unchanged) AND the private shell site itself
* installed-apps sweep — the option first, then poller+connected
* installed-apps refresh (public entry) — returns the last table, starts nothing
* app-label lookup / drain / the ONE probe site (`_dump_label`) — the option; a
lookup returns None and queues NOTHING (no request, no task, no cache write)
* ADB text entry, media keys, bring-to-foreground, digit and enter keys — `_adb_active()`
* the heartbeat `_refresh` — the option: no wake probe, no media dump, no
running-apps poll, no foreground fallback, no label drain, no logcat ensure
* the watchdog — the option BEFORE the poller factory, so not even a factory can
build a transport; and `_watchdog_cycle` refuses a direct call too (a TCP
connect is device traffic)
* the logcat stream — the option at the ensure gate, the option in
`_open_logcat_stream`, and the ladder exits WITHOUT counting a failure (a local
option must not auto-disable the option for the session)
* the public `adb_poller` accessor — None while the option is off, which closes
the shell paths that live OUTSIDE this module (the `adb_command` service, the
screen-capture image and the intent shell) without reaching into them: all
three already refuse on None with their own message
* `test_connect` (the pairing probe) stays ungated on purpose: it runs BEFORE an
entry exists, and the setup menu only offers that step when the ADB option is
on (config_flow.py). `async_stop`/`_note_failure` disconnect calls are teardown,
not traffic.
PROOF, BY TRAFFIC. tests/test_adb_off_gates_every_device_path.py drives every
audited path — as a parametrized AUDIT table plus one full cycle on one manager —
with `adb_enabled: False` and a double that WOULD answer everything, and asserts
the poller's complete traffic list (shells, polls, commands, connects) is EMPTY,
plus that nothing was cached (no intent answer, no app table, no stamp, no queue,
no store field, no wake reading). It also proves the watchdog never builds a
poller and never connects, that a direct cycle does not connect either, that the
option is a CHECK and not a latch (the same manager resumes the sweep, the scan,
the label queue, the media key, the digit key and the accessor when the option
flips back on), and that ADB-off and no-poller stay told apart.
PRE-FIX PROOF: the same file against `git archive HEAD` FAILS 16 of 23 tests, and
the failures print the measured traffic — the sweep's 3 shells, the label probe's
`pm dump … | grep …`, `input text hello`, `media:play`, `foreground:<pkg>`,
`digit:5`, `enter`, and 4 polls per heartbeat cycle.
HARNESS TRUTHFULNESS. The suites' managers were built with `entry=None`, which
means ADB is OFF (fail closed, matching `__init__.py`). Those harnesses now pass a
realistic entry whose option says ADB is on — they model production instead of the
one combination the gates block. No production behaviour changed for an ADB-on
entry: 923 passed / 5 skipped, up from 900 by this file.
Floors re-derived from the new walk (1661 blocks → 1627, same 2% band): raised,
because that is what the re-measurement says. Drift exit 0. Strings files
byte-identical.
NOT VERIFIED HERE: the device side is a scripted double, so what is proven is that
NOTHING WAS SENT, not what firmware would do; no real Shield was involved.
The reported Shield names no app: `pm dump com.netflix.ninja` is ~2241 lines of
dumpsys and grep for `application-label` over the whole capture finds nothing.
The probe shipped `| head -300`, which cut the dump BEFORE the package section,
so "no label" was a truncation artefact; the sweep then named every app after
its own package id, and the apps editor pre-filled the `pkg=pkg` row the user
read as a bug.
* the probe greps ON THE DEVICE for the three shapes Android prints
(`application-label`, `nonLocalizedLabel`, `labelRes`); a quoted value is a
name, `null` and a resource id are not. The dump is never truncated.
* after APP_LABEL_PROBE_LIMIT consecutive "ran, no label" answers the device
is taken at its word: the sweep stops spending a shell per package
(APP_LABEL_TTL expiry re-checks, and a package whose probe FAILED still owes
its retry — t57 UNIT 0 survives, driven by the app on screen).
* ONE precedence chain in `ha_adb_common/app_label.resolve_package_name`, used
by the media player, the Current App sensor, the discovery table, the source
list and the apps editor: user map > device label > curated map > package id
(the id shown ONCE, `name` empty when nothing names the app).
* the curated table covers the user's 21 apps (community-maintained, not
device truth) and the Configure row says when the device named nothing.
* the real device shape is a test fixture (trimmed, and marked as such in the
file: the raw capture is not in the repo) pinning that no label is
extracted, that the probe count is bounded, and that `Netflix` surfaces.
Also drops the dead `ATTR_MEDIA_DAG_NOW_PLAYING` re-exports left by the t65
flattening and updates the RESEARCH_SHIELD now-playing note to the flat names.
No real Shield was available: the fetch/burst behaviour is exercised against the
documented Android output and the trimmed reproduction of the reported capture.
t59 F1 (HIGH). `ShieldAdbPoller.shell` swallows its error and answers None
(adb_client.py:144-155), and `_fetch_app_label` read that as "the dump
ran and found no label" → `cache_app_label(pkg, None)` → `('', stamp,
resolved=True)`. That is a DEFINITIVE negative for APP_LABEL_TTL = 24 h,
so after ONE transient failure `app_label()` never re-enqueued, exactly
one `pm dump` was ever attempted, and a later good dump could not
recover the label — `sensor.native_value` and `media_player.app_name`
stayed on the package id for every later render.
The dump now carries an exit marker (`; echo __NSH_EXIT:$?`), so the
three outcomes are distinguishable:
* output with an `application-label` → cache the label;
* output that ran and carried none (the marker is still there) → cache
the empty string, the definitive negative that keeps t24 F1's
zero-round-trip rule for a genuinely label-less package;
* no output at all (`None`, or blank) → cache NOTHING and let the next
render re-enqueue, logged at debug.
The `resolved` flag goes with it: `cache_app_label` was its only writer
and always passed True, so an entry in `_app_labels` is now simply "a
fetch RAN" and the dead third field (`(label, stamp, True)`) is gone.
Tests drive the FakePoller-returns-None AND returns-'' shapes through
the sensor and through `media_player.app_name`, plus the label-less
package that must still be negatively cached. The stub poller now models
both real outcomes of a dump (an answer, or a transport that delivered
nothing) instead of conflating them — which is why the old tests could
only pin the raising-shell branch that already worked. 4 of the new
tests fail on the pre-fix tree, showing `('', stamp, True)` stamped.
Also in this commit:
- t59 F3: running_apps.py set `app["state"] = "paused"` twice — one line.
- t59 F4: no captured fixture contains state=6, so the "the Shield's own
sessions report 6 like stock Android" wording in README and the
media_session/media_player docstrings is reworded as INFERRED from the
`state=PlaybackState {state=N, …}` line shape. A `git notes` entry on
07682ff records the same correction against the t55 commit body.
The Current App sensor and media_player.app_name showed a package id in
the live run. Root cause was in the manifest-label tier: `_fetch_app_label`
returned early (no poller, ADB down) or logged and returned on a shell
error WITHOUT touching the cache, but `app_label` had already put the
package in `_app_label_requests`; the drain then threw the request away,
so a render between the failure and the next cycle re-enqueued — while
nothing distinguished "the device answered, this package has no label"
from "we never got an answer". Nothing was visibly retried, and the label
tier could stay silent indefinitely.
The cache is now (label, stamp, resolved) and records the FETCH, not the
wishful result:
* a fetch that RAN caches its outcome for APP_LABEL_TTL — the label, or
the empty string for a genuinely label-less package. Definitive: no
re-enqueue, so the t24 F1 per-heartbeat round-trip tax stays closed.
* a fetch that could NOT run stamps nothing, logs at debug (so a
package id in the UI is diagnosable), and the next render retries.
* a queued or in-flight fetch is not enqueued twice, so a burst of
renders still costs one `pm dump` per watchdog cycle.
`cache_app_label()` is the one public writer of the cache (tests and any
future out-of-band reader no longer poke the private tuple), and
`app_label_requests`/`app_label_inflight` expose the queue for tests.
The old 2-tuple seeds in the label tests move to `cache_app_label`.