125 Commits
Author SHA1 Message Date
James Mackay c588a7c66b docs(readme): record the MAC-less discovery contract (t37 D3, t39)
Release / release (push) Successful in 5s
Test / test (push) Successful in 22s
Test / ha-api-drift (push) Successful in 2m35s
t37 made MAC-less zeroconf discovery honour Home Assistant's own
`_async_handle_discovery_without_unique_id` contract — the helper is a coroutine
and its dedup (abort `already_configured` when ANY entry exists for this domain,
abort `already_in_progress` when another flow runs) only happens when it is
awaited. That is stricter than the accidental host-equality guard it replaced,
so it changes what a user can expect:

* with a MAC, discovery still aborts duplicates and updates a moved device's
  hosts;
* without a MAC, a second (or third) Shield must be added MANUALLY — discovery
  will abort as "already configured".

Someone with two Shields would otherwise read that abort as a bug. The README
now has a "Discovery (zeroconf)" section under Configuration stating the rule and
the manual route, and the drift sweep's docstring carries the same note next to
the D3 entry, so the decision is discoverable from wherever you meet it.

Verified: 289 passed, 2 skipped; ruff clean.
2026-10-10 21:29:46 +01:00
James Mackay fd62d850b7 fix(drift): F1+F2 — the sweep refuses false greens, and CI provisions the real floor (t39)
t38 found two holes in the drift gate itself.

F2 — the job could never run where it mattered. Home Assistant 2026.10.1
declares ``Requires-Python >=3.14.2`` (PyPI metadata), so the old guard let a
3.13 runner through and the ``homeassistant>=2026.10`` install then failed with
a resolver error. The job now provisions its own interpreter
(``actions/setup-python@v5`` with ``python-version: "3.14"``) and the guard runs
against the REAL floor, read from ``ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR`` —
one source of truth, pinned by a test against the installed distribution's own
``Requires-Python`` metadata. Verified: on 3.13.16 the guard refuses with
"Home Assistant needs Python >= 3.14.2, but this job runs 3.13.16 / Fix: raise
actions/setup-python…"; on 3.14.8 it reports "interpreter ok"; and
``uvx --python 3.14 --with 'homeassistant>=2026.10'`` resolves 2026.10.1.

F1 — the standalone entry point was a false green. It printed counters and
exited 0 even when it had walked nothing, which is exactly how the manual
red-proof tool gets misused. The non-vacuity floors now live in
``ha_api_drift.FLOORS`` and are enforced in ONE place, ``Report.floor_violations()``,
used by the pytest wrapper and by ``__main__`` (so the constants cannot drift
apart; a test asserts the wrapper holds no floor-like constants of its own).
On top of that the sweep can no longer lie in either direction:

* an empty or mis-targeted directory reports ``FLOOR:`` lines and
  ``drift sweep FAILED`` and exits 1 (a non-existent path is a ``bad-path``
  finding, not a traceback and not a silent zero-module sweep);
* with no homeassistant installed it returns a single ``no-homeassistant``
  finding with zero counters — never a wall of phantom "broken-import"s, never
  a green exit.

Verified in the CI-equivalent environment (Python 3.14.8 + homeassistant
2026.10.1): HEAD exits 0 with "no Home-Assistant API drift (13 modules, 60
imports, 35 call arguments, 7 awaited HA calls)"; the empty directory exits 1
with 4 FLOOR lines and no findings; a missing path exits 1 with the ``bad-path``
finding; and the full pre-fix tree (``git archive 5ede4fe``) exits 1 with
exactly the three t36 drifts (media_player.py:965 BrowseMediaSource,
diagnostics.py:93 homeassistant.util.diagnostic, config_flow.py:425
missing-await) and 0 floor violations.

The fast ``test`` job and ``release.yml`` are untouched; the workflow is
unchanged in shape except the new provisioning + guard steps, and parses as
valid YAML.
2026-10-10 21:29:39 +01:00
James Mackay 824d6192a1 test(ci): D1/D2/D3 — repo-owned HA API-drift sweep with a CI job (t37)
The unit suite validates production modules against hand-written stubs, so a
call site that no longer matches real Home Assistant stays green — that is how
D1, D2 and D3 reached master. This adds the check that would have caught them.

`tests/ha_api_drift.py` (stdlib-only, documented inline) parses every module of
custom_components/nvidia_shield and resolves each Home-Assistant dependency
against an INSTALLED homeassistant:

1. every `homeassistant.*` import — module present, attribute present (or a
   submodule of that name exists, which is what `from package import submodule`
   means) — catches D1/D2 and any future move;
2. every keyword argument passed to a known HA API (HA_CALL_OWNERS: the
   ConfigFlow/ConfigEntries APIs we call) must exist in the real signature —
   catches the `options_updates` class of bug;
3. every `self.<method>()` call in a class that inherits from an HA class is
   compared with the real definition: a coroutine must be awaited and an
   awaited call must be a coroutine — catches D3.

`tests/test_ha_api_drift.py` runs it against the production tree, asserts the
sweep is not vacuous (module/import/argument/await counters), and pins the red
proof with the four pre-fix forms, plus the green proof for the fixed forms. It
skips cleanly when homeassistant is absent, so the stdlib-only jobs are
unaffected.

`.gitea/workflows/test.yml` gains the `ha-api-drift` job — the one place Home
Assistant is installed — guarded by an explicit Python >= 3.13 check (Home
Assistant 2026.x requires it) and the same PEP 668 retry the test job uses.

Red proof against the real pre-fix revisions (svn-style checkout of
`git show HEAD:…` into a temp dir, and the pre-t35 config_flow.py for the kwarg):

  media_player.py:965: broken-import: from homeassistant.components.media_player
      import BrowseMediaSource: …BrowseMediaSource does not exist
  diagnostics.py:93: broken-import: from homeassistant.util.diagnostic import
      async_redact_data: module 'homeassistant.util.diagnostic' is not importable
  config_flow.py:425: missing-await: self._async_handle_discovery_without_unique_id()
      is a coroutine on homeassistant.config_entries.ConfigFlow
  config_flow.py:700: unknown-kwarg: async_update_reload_and_abort() has no
      'options_updates' parameter (exit 1 on that tree, exit 0 on this one)

Verified: variant 1 289 passed / 2 skipped; variant 2 (androidtvremote2) 290
passed / 1 skipped; ruff clean; `UV_PYTHON=3.13 uvx --with homeassistant --with
pytest-asyncio pytest tests/test_ha_api_drift.py -q` → 4 passed (the bare form of
that command needs the pin here only because this sandbox's default Python is
3.15 and pycares has no wheel for it / no CMake to build it; CI pins the
interpreter explicitly).
2026-10-10 21:20:28 +01:00
James Mackay 60fb4f7557 fix(config_flow): D3 — await the discovery-dedup coroutine (t37)
`ConfigFlow._async_handle_discovery_without_unique_id` is an `async def` in core
(verified: `inspect.iscoroutinefunction` is True) and core awaits it. t32 called
it without `await`, so the coroutine body never ran: no `already_configured`
abort when the handler already had entries, no
`DEFAULT_DISCOVERY_UNIQUE_ID` claim, no `already_in_progress` abort — HA's
discovery dedup was dead, Python warned "coroutine ... was never awaited", and
the host-equality loop that used to sit here was the only accidental dedup.

The call is awaited and the now-unreachable host loop is gone (the real helper
is stricter: any entry for the domain, or any in-flight flow). The harness
mirrors the real contract instead of a sync no-op:

* `entity_stublib`'s hub stub makes the helper a coroutine (a sync stub would
  turn the production `await` into a TypeError);
* `test_standalone_setup`'s stub models the real semantics — a coroutine raising
  `DiscoveryDedupAbortError` (core raises AbortFlow), driven by the shared
  holder's entry list and in-progress list — and its tests now assert all three
  outcomes: awaited-and-proceeds (counter + claimed discovery unique id),
  `already_configured` when entries exist, `already_in_progress` when another
  flow runs. A fourth test collects the un-awaited-coroutine warning, and the
  prefill test in the UX suite asserts the awaited counter.

Red proof: with the pre-fix config_flow.py the repo-owned sweep reports
`config_flow.py:425: missing-await: self._async_handle_discovery_without_unique_id()`.

Verified: 289 passed, 2 skipped (D3 stage); ruff clean.
2026-10-10 21:20:13 +01:00
James Mackay bd72ae3170 fix(diagnostics): D2 — redact through homeassistant.helpers.redact (t37)
`homeassistant.util.diagnostic` does not exist — verified against a real
install: `import homeassistant.util.diagnostic` raises ModuleNotFoundError, and
the helper has always lived in `homeassistant.helpers.redact`. The import sat
inside a `try/except ImportError` that set the helper to None, so the
documented G14 pass — the one carrying `_TO_REDACT`, hosts included — silently
did nothing; only the marker scan (pairing-/key-shaped names) masked anything,
which is why the existing test's pairing-code assertion stayed green.

The helper is now imported at MODULE scope from homeassistant.helpers.redact and
applied unconditionally after the marker-name pass. A missing module is a loud
import error at integration load instead of a silent downgrade.

* the diagnostics test now asserts `adb_host` and `protocol_host` really are
  masked in the emitted payload (options and a nested data value);
* TestFaithfulHaStubs asserts the module USES the helper from that path
  (`nsh.diagnostics.async_redact_data is homeassistant.helpers.redact.async_redact_data`),
  that no production module imports the non-existent module (AST), and that the
  try/except ImportError fallback is gone;
* `entity_stublib` gains a faithful `homeassistant.helpers.redact` stub (same
  masking, same nested mapping/list recursion, same None/empty-string skip) so
  the harness meets the import the way a real install does.

Verified: 287 passed, 2 skipped (D2 stage); ruff clean. Full gate re-run on the
completed branch: 289 passed / 2 skipped and 290 passed / 1 skipped.
2026-10-10 21:20:04 +01:00
James Mackay 37b2accfda fix(media_player): D1 — browse builds real BrowseMedia nodes, not BrowseMediaSource (t37)
`homeassistant.components.media_player` has not exported `BrowseMediaSource`
for years: the class lives in `homeassistant.components.media_source.models`,
where it exists to build `media_source://<domain>` ids and is the only place a
`domain` parameter belongs (t30 G18's note was about THAT class). The local
import therefore raised ImportError, and with it every real browse failed —
while the suite stayed green because the harness stubbed a `BrowseMediaSource`
that real HA does not expose from there.

`async_browse_media` now builds the standard tree exactly as core's own
androidtv_remote/media_player.py does: `BrowseMedia` imported from
homeassistant.components.media_player at module scope, every node carrying an
explicit media_content_id/media_content_type (MediaType.APP/APPS),
`children_media_class=MediaClass.APP` on the directory, and no `domain`.
Verified against a real install: the old import raises ImportError, the old
`domain=` kwarg raises TypeError, and the new node constructs.

The harness is now faithful so this cannot hide again:
* `entity_stublib` exposes a `BrowseMedia` with the real keyword-only
  signature, `as_dict()` and `calculate_children_class()`, plus `MediaType`,
  and deliberately NOT `BrowseMediaSource` — so both the drifted import and the
  drifted kwarg fail in-suite;
* the app-directory test asserts the standard shape (classes, explicit ids and
  types, children_media_class, the serialised payload) instead of the removed
  `domain` attribute;
* TestFaithfulHaStubs pins the harness contract on the AST (no production
  module imports BrowseMediaSource) rather than on prose.

Verified: 284 passed, 2 skipped (D1 stage); ruff clean. Full gate re-run on the
completed branch: 289 passed / 2 skipped and 290 passed / 1 skipped.
2026-10-10 21:19:53 +01:00
James Mackay 5ede4fe722 test(config_flow): prove a reconfigure entry really rebuilds the protocol transport (t35 F1)
The F1 test asserted the updated options; this closes the loop on the user
outcome — after the Reconfigure pairing, __init__._build_protocol_settings
returns settings (transport ON) pointing at the paired certificate under
.storage/nvidia_shield/ and at the address that just paired. That is why no
__init__.py change was needed: the reloaded entry already rebuilds the
transport from the persisted options + shared cert paths.

Verified: uvx --with pytest-asyncio pytest tests -q → 281 passed, 1 skipped;
uvx --with pytest-asyncio --with androidtvremote2 pytest tests -q → 282 passed;
uvx ruff check custom_components tests → clean.
2026-10-10 21:05:13 +01:00
James Mackay e11dccf2c8 fix(config_flow): F1 — a discoverable later pairing path (Reconfigure) and truthful skip copy (t35 F1/F1b)
"Continue without protocol pairing" switched the transport off for good: the
options flow carries no protocol fields, so nothing in the UI could ever turn
it back on, and the skip copy promised nothing about it (t34 F1). The entry now
answers Home Assistant's Reconfigure action:

* async_step_reconfigure (audit G5) seeds the flow from the entry (title, adb
  host/port/enabled, androidtv entity for the host fallback) and re-runs the
  pairing step — the address is editable, the Shield shows a PIN, and
  _finish_reconfigure persists protocol_enabled=True plus the address that just
  paired (so the transport no longer depends on the ADB fallback), adopts the
  paired MAC when it differs, and updates + reloads the entry. HA derives the
  "reconfigure_successful" abort from the flow source. A Reconfigure visit that
  clicks skip aborts honestly with "reconfigure_skipped" (entry untouched), and
  an entry that is gone aborts with "reconfigure_entry_missing".
* the skip data_description now states the truth — the transport stays off
  until you pair it — and names the later path (the integration's entry menu >
  Reconfigure).
* a host corrected on the pairing form now DROPS the session started against the
  old address and restarts pairing there, so the typed address is authoritative
  instead of being paired against the stale one.
* the success path no longer runs _abort_if_unique_id_configured for a flow
  that already owns an entry: core looks the domain up by unique_id, so
  re-pairing an entry aborted on itself as "already_configured" (this also
  unblocked reauth). The fresh-setup path is unchanged.
* adjacent fix while wiring the same call: _finish_reauth passed
  `options_updates`, which core's async_update_reload_and_abort does not accept
  (the kwarg is `options`, REPLACE) — the reauth path would have raised
  TypeError in production. Both paths now pass `options` and the test stubs
  mirror the real signature.

Tests: tests/test_config_flow_ux.py::TestProtocolReconfigure drives reconfigure
from a protocol-disabled entry through to updated options (transport on, host
persisted, other options kept, MAC adopted, reload recorded), a corrected host
reopening the PIN step against the new address, the skip abort, and the
missing-entry abort; TestCopyContract gained the F1b copy pin. Against HEAD's
config_flow.py all four reconfigure tests fail (no async_step_reconfigure).

Verified: uvx --with pytest-asyncio pytest tests -q → 281 passed, 1 skipped;
uvx --with pytest-asyncio --with androidtvremote2 pytest tests -q → 282 passed;
uvx ruff check custom_components tests → clean.
2026-10-10 21:04:47 +01:00
James Mackay 7a0152e6b5 fix(tests): F2 — the voluptuous mirror compares fields, not marker classes (t35)
real voluptuous 0.16.0 implements ``Marker.__eq__`` as ``self.schema ==
other`` — a field-name comparison with no class check, so
``Optional("x") == Required("x")`` is True (verified against the library
source, not just the audit note). The mirror compared class + field, which
kept two dict entries for one field: a ``Required(field, default=...)`` after
``Optional(field)`` LOOKED like it applied the default, while real voluptuous
keeps the first (defaultless) key object and silently drops it — the exact
t33 F-A failure mode the mirror exists to catch, i.e. the harness could mask
the bug it was built to see.

``Marker.__eq__`` is now the library's comparison and the contract tests gained
both pins: field-only equality (with equal hashes) and the mixed-marker
re-declaration that used to be masked. Reverting only ``__eq__`` makes those
two fail, which is the red proof recorded for t35.

Verified: uvx --with pytest-asyncio pytest tests -q → 276 passed, 1 skipped;
uvx ruff check custom_components tests → clean.
2026-10-10 21:04:34 +01:00
James Mackay 186e5c1935 test(config_flow): the t33 UX suite — schema-level prefill, skip paths, copy contract, strings parity (t33)
One home for the user-reported regressions, all driven end to end against the
scoped stub harness:

- TestVoluptuousMirrorContract pins tests/voluptuous_mirror.py itself (a
  default on the declaration is visible; a later re-declaration cannot add one)
  so the harness cannot silently go blind again.
- TestDiscoveryPrefill: the discovered address is the default of BOTH host
  keys, the port default is 5555, the step announces "Discovered Shield at
  <host>", a manual/seed flow claims no discovery, and the error re-render
  keeps the prefill. Reads the rendered schema, not the stored attribute.
- TestNoDeadEndPairing: skip protocol → entry created (passive), skip ADB →
  entry created (ADB intent kept for the Pair ADB button) with the real
  async_setup_entry accepting both entries (no ConfigEntryNotReady), skip ADB
  routes on to the protocol step, both normal pair paths still create the
  entry, a host typed on a pairing form is accepted in place, and a missing
  androidtvremote2 reports protocol_unavailable instead of raising.
- TestCopyContract: no copy points at "the previous step", the no_host errors
  name the field and the discovery prefill, every error/abort key raised by
  config_flow.py exists in both copy files, and every field rendered by the
  user/adb_pair/protocol_pair/options forms has a label.
- TestStringsTranslationsParity: strings.json and translations/en.json expose
  the same key tree.
- TestPassiveSetupNeverDemandsReadiness: async_setup_entry raises nothing.

Verified: uvx --with pytest-asyncio pytest tests -q → 274 passed, 1 skipped;
uvx --with pytest-asyncio --with androidtvremote2 pytest tests -q → 275 passed;
uvx ruff check custom_components tests → clean.
2026-10-10 20:50:38 +01:00
James Mackay 08b97fde73 fix(strings): F-C — plain, self-consistent setup copy for the live-run feedback (t33)
Every user/pairing-step label and data_description rewritten in plain language,
and the fields the new pairing forms render now have labels at all (adb_host,
adb_port, skip_pairing, protocol_host, pairing_code).

No error string sends the user to "the previous step" any more: adb_no_host and
protocol_no_host name the field on the same form AND say a discovered Shield is
pre-filled automatically. The confirmation messages for both pairing steps
describe what the form actually does (including the restart-on-empty-submit
path), and protocol_unavailable covers a missing androidtvremote2.

The options-flow copy was audited in the same pass so it matches the setup
step's vocabulary ("Read state over ADB", "Show title and artist", ...), and
translations/en.json stays byte-identical to strings.json.

Verified: uvx --with pytest-asyncio pytest tests -q → 242 passed, 1 skipped;
uvx ruff check custom_components tests → clean.
2026-10-10 20:50:28 +01:00
James Mackay a6cc56feb4 fix(config_flow): F-B — neither pairing step is a dead end (t33)
protocol_pair and adb_pair now each offer "continue without pairing" (the new
CONF_SKIP_PAIRING form key), so the entry still finishes — in passive mode —
instead of stranding the user on a form with no way forward.

adb_pair: the ADB host/port live ON this form (the user step is gone), a host
typed here is authoritative, the addr the error names is therefore fixable
where the error appears, and skip routes through _async_after_pairing. ADB
stays enabled on purpose: the manager already degrades to its passive,
background-retrying state without a key, and the Pair ADB button (registered
for adb-enabled entries) then pairs later without re-adding the integration.

protocol_pair: the protocol host lives on this form too and a corrected
address restarts the pairing session against it; submitting without a code asks
the Shield for a fresh code (_async_restart_protocol_pairing) instead of
erroring; skip switches the transport off, because an unpaired protocol client
fails auth on its first connect and would only bounce the user into the reauth
flow — a fresh dead end. On reauth the skip aborts with reauth_skipped instead
of faking a repair.

_start_protocol_pairing also returns None now when build_protocol_client does
(androidtvremote2 missing) so the step reports protocol_unavailable instead of
crashing on a None client in the default CI variant.

Verified: uvx --with pytest-asyncio pytest tests -q → 242 passed, 1 skipped;
uvx ruff check custom_components tests → clean. The end-to-end skip-path and
copy tests land with the t33 UX suite commit.
2026-10-10 20:50:22 +01:00
James Mackay 73e886dafe fix(config_flow): F-A/F-A2/F-D — the discovered host and the 5555 port ride the schema KEY defaults (t33)
F-A (HIGH): _user_schema_dict re-declared vol.Optional(CONF_ADB_HOST) /
CONF_PROTOCOL_HOST inside `if "host" in defaults:` — a no-op, because
voluptuous keys compare by field name and the first (defaultless) key object
survives. The discovered address therefore never reached the form, and the
user submitted a blank host. The host keys are now declared WITH default=host
(_host_field_key), so a discovery-driven user step arrives pre-filled.

F-A2: a discovery-driven user step says the address is known
("{discovery_note}Discovered Shield at <host> — its address is already filled
in below. ") through description_placeholders, and the no_state_source
re-render keeps the prefill instead of rebuilding a blank schema.

F-D: the ADB port is declared with default=DEFAULT_ADB_PORT (5555), and a YAML
seed's port hint is carried into the form instead of being flattened to 5555.
The seed host now feeds _seed_host (same prefill, no discovery claim).

Reproduce-through-test: tests/voluptuous_mirror.py mirrors the real
voluptuous.Marker key contract (hash/eq by field name, default=UNDEFINED when
omitted), because voluptuous is not installed in either CI variant and the old
bare-key stubs could not tell a schema WITH a default from one without. The
pre-t33 discovery assertion only checked the stored attribute and stayed green
while the form rendered blank; it now reads the schema keys and fails on the
pre-fix code (8 tests reproduced red locally).

Verified: uvx --with pytest-asyncio pytest tests -q → 242 passed, 1 skipped;
uvx ruff check custom_components tests → clean.
2026-10-10 20:50:16 +01:00
James Mackay bbd98ebc3f ci: survive PEP 668 on externally managed runners — retry the test-tooling install with --break-system-packages, pin the gate tool versions
Release / release (push) Successful in 6s
Test / test (push) Successful in 21s
2026-10-10 08:34:34 +01:00
James Mackay 60d426945b Merge branch 'master' of https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield
Release / release (push) Successful in 7s
Test / test (push) Failing after 5s
2026-10-10 08:31:15 +01:00
James Mackay c20146b08b fix(init,config_flow): F4 — seed blocks and the import prefill honor the block's shape: ADB only when the block carries an adb host, protocol-only blocks keep adb_enabled off; TestSeedBlockShapeF4 pins all three paths (t32) 2026-10-09 08:04:41 +01:00
James Mackay 6087566336 fix(config_flow): F2 — MAC-less zeroconf discovery calls the audited _async_handle_discovery_without_unique_id helper and guards against a same-host duplicate prefill, so re-discovery can't stack entries; the import guard reads entries via the base _async_current_entries helper; TestMacLessDiscoveryF2 pins helper call + one-entry outcome (t32) 2026-10-09 08:04:05 +01:00
James Mackay 8f40b6435c fix(poller): F1 — the poller factory re-reads entry.options at call time (audit-sanctioned option), so the next built poller targets the NEW address after a zeroconf IP change; the stale live-read comment in config_flow points at the actual mechanism; TestIpChangeSelfHealF1 drives an IP change and pins the next poller host (t32) 2026-10-09 08:03:28 +01:00
James Mackay c1a82a2fa5 fix(diagnostics): hosts join the explicit TO_REDACT list per the audit's G14 citation (t30 G14) 2026-10-09 07:47:12 +01:00
James Mackay 782d749b4a docs(readme): sensor names follow the translation_key entity naming (t30 G7) 2026-10-09 07:47:02 +01:00
James Mackay d004e73d99 fix(tests): drop the speculative config-entries install guard — the scoped-stub teardowns already isolate per file; full suite green both variants (completes t30 G6) 2026-10-09 07:46:53 +01:00
James Mackay e77bc12d81 test: cross-file stub-scoping repairs — hub ConfigFlow base carries the full plumbing, standalone rebuilds when its holder is gone, test_init scoped hygiene mirrors scoped_stubs (completes t30 G6) 2026-10-09 07:38:24 +01:00
James Mackay 80ddff313a test(init): tests/test_init.py lands — domain async_setup units + YAML seed parsing/normalization, flow-init seeding records, hostless drop, duplicate guards (UI host + virtual unique_id) and single-visit interactive routing; scoped stub hygiene mirrors scoped_stubs (t30 G6/U1) 2026-10-09 07:34:57 +01:00
James Mackay 297f8a21a7 docs(readme,strings): YAML seed-only semantics documented — interactive flow, duplicate guards, no implicit entry changes on block removal (t30 U1) 2026-10-09 07:24:52 +01:00
James Mackay a6eeae86e9 feat:init,config_flow: YAML seed-only setup — an optional nvidia_shield block starts interactive SOURCE_IMPORT flows (parsed in async_setup), the import step guards duplicates by host/MAC-virtual unique_id and never re-seeds UI-configured hosts, protocol fields are prefill hints only (t30 U1) 2026-10-09 07:24:39 +01:00
James Mackay 2e70587871 feat: standards polish — G7 sensor translation_key naming + entity strings, G10 protocol-push device identity on the registry card, G12 unrecorded poll-driven attributes (media player DAG internals + sensors), G14 async_redact_data with an explicit redaction list behind the marker scan (t30) 2026-10-09 07:22:23 +01:00
James Mackay 194a97ce34 feat(button,media_player): Pair ADB carries entity_category CONFIG (t30 G11) + BrowseMediaSource domain parameter at root and children (t30 G18, 2026-05-20 blog contract); stubs/tests pin both 2026-10-09 07:17:14 +01:00
James Mackay 7c12d96070 feat(icon): HACS-required 256x256 icon.png placeholder, generated (t30 G15) 2026-10-09 07:15:42 +01:00
James Mackay a5254b4740 feat(manifest): integration_type device + domain loggers adb_shell/androidtvremote2 (t30 G1) 2026-10-09 07:15:42 +01:00
James Mackay 383ec5ada5 feat(config_flow): commit the G2 + stub wiring lost in the amend cycle — device-MAC unique_id derivation in the flow, device-registry stubs across the flow suites 2026-10-09 07:11:11 +01:00
James Mackay e1a0fc3039 feat(config_flow): the G2 unique_id fix lands — the androidtv entity_id is never a unique_id; MAC derived from the device registry mac connection (empty placeholders skipped), else no unique_id; rename-stability + absence pinned by test (t29 G2) 2026-10-09 07:11:03 +01:00
James Mackay 086c089cc1 fix: commit G3a wiring lost in the amend cycle — listener removal, update-only host path, reauth via the current API (stub surface includes async_update_reload_and_abort) 2026-10-09 07:03:33 +01:00
James Mackay bcb8b962e7 fix: the deprecated listener+reload combo is gone — reauth uses async_update_reload_and_abort(_get_reauth_entry()), zeroconf host update is update-only, the __init__ options-reload listener is removed (grep-pinned); host-change tests assert update-without-reload (t29 G3a) 2026-10-09 07:02:21 +01:00
James Mackay 3e0208789a feat: adb_command is a conforming action — always registered in async_setup (option gates USE), services.yaml with required command/entity, strings services+exceptions, ServiceValidationError on empty/unknown-target/ADB-down; participant registry stays (t29 G8) 2026-10-09 06:59:44 +01:00
James Mackay 79faa32b68 docs: HA standards audit against current developer docs — deprecation gaps G3a/G3b/G8, YAML-import verdict, quality-scale notes (research)
Release / release (push) Successful in 6s
Test / test (push) Failing after 5s
2026-10-09 06:51:03 +01:00
James Mackay e51545262e fix(connection,adb_client): commit the F-NEW1..3 wiring lost in the amend cycle — no-idle-timeout transport budget with in-code rationale, config accessor consumption, wrapped-line buffer 2026-10-09 06:20:46 +01:00
James Mackay 9b3142874f fix(connection,adb_client): the logcat stream survives quiet windows — transport_timeout_s=None (bulk_read blocks quiet gaps; the ladder + async_stop remain recovery/cleanup), 3-line parse buffer for wrapped cmp records, public poller config accessor replaces the private reach-ins (t27 F-NEW1..3) 2026-10-09 06:20:38 +01:00
James Mackay 27715f6932 test(media_player): MAC-collision pre-check migration branches — occupied MAC falls back to the entry id, free MAC taken (t26 F4) 2026-10-09 06:13:06 +01:00
James Mackay 6c925c5241 chore(config_flow): replace the stranded self-answering comment in the options editor with a plain statement (t26 F5) 2026-10-09 06:11:58 +01:00
James Mackay 3587747c72 fix(media_player): unique_id migration pre-checks for an existing entry holding the target MAC — falls back to the config-entry id on collision (t26 F4) 2026-10-09 06:11:36 +01:00
James Mackay 2e63135745 test(media_player): spied sleep proves a non-default digit delay (0.3 s) reaches the channel sequence — option consumed per digit (t26 F3) 2026-10-09 06:11:28 +01:00
James Mackay 67174b8536 test(connection): drain beats before label-fetch asserts; label-less negative cache costs one pm dump ever + shlex-quote shape pinned (t26 F1,F2) 2026-10-09 06:10:27 +01:00
James Mackay b442440ccf fix(connection,app_labels): negative-cache label-less packages (same 24h TTL, no re-enqueue — kills the per-heartbeat pm dump tax) + shlex.quote the pm dump package argument (t26 F1,F2) 2026-10-09 06:08:33 +01:00
James Mackay 697e3ba307 feat(connection,const,strings): complete the P5-3 logcat consumer wiring lost in an earlier amend — dedicated stream, instant stamp, ladder + auto-disable, default-off toggle strings 2026-10-09 06:06:34 +01:00
James Mackay f863496ce5 test(media_session,connection): logcat START parsing (cmp=, wrapped records, null/non-START ignored, u-prefixes), instant stamp + notify, duplicate START suppression, 5-in-10-min auto-disable, OFF = no stream task, stream never touches the poller socket (P5-3) 2026-10-09 06:06:10 +01:00
James Mackay 07cb73fbb7 feat(config_flow,strings): CONF_LOGCAT_EVENTS options toggle (default off, stability-report wording) (P5-3) 2026-10-09 06:03:28 +01:00
James Mackay 0ecee66953 test(entity_stublib): ConfigFlow stub accepts the domain= __init_subclass__ kwarg and load_entity_modules drops config_flow (per-file loader ownership preserved) (completes P5-2 test scaffolding) 2026-10-09 06:00:20 +01:00
James Mackay a3b397920e test(config_flow,media_player,remote): digit-delay/send-enter options (store + window/error validation), IME-first text entry with ADB fallback tier, icon column parse/prefill/store (P5-2) 2026-10-09 06:00:17 +01:00
James Mackay 3d026ae60e feat(config_flow): digit-delay (0.05–1.0 s, invalid input error) + optional send-enter options UI consumed by the §7 channel path; strings/README updated (P5-2.1) 2026-10-09 05:55:07 +01:00
James Mackay 4661e52041 fix(ha_adb_common): application-label regex accepts double-quoted labels too (completes the P5-1 parser tolerance) 2026-10-09 05:52:21 +01:00
James Mackay c57a2e2ff2 test(ha_adb_common,connection): application-label tolerance (dual quotes, empty multi-label blocks, null, absent) + label cache reuse across cycles — no duplicate pm dump (P5-1) 2026-10-09 05:52:02 +01:00
James Mackay 2e6dfd8981 test(media_player): entity tier serves below map/manifest per the P5-1 resolution ruling (was map-beats-entity contradiction) 2026-10-09 05:49:18 +01:00
James Mackay cd0e634a76 feat(connection,media_player): on-demand manifest-label cache (≥24h TTL, one pm dump per watchdog cycle via the task pool) + app_name order map > label > entity > raw package (P5-1) 2026-10-09 05:48:39 +01:00
James Mackay 80e9a691bc feat(ha_adb_common): application-label parser — default-locale from pm dump, localized variants skipped, tolerant of empty/multi-label blocks (P5-1) 2026-10-09 05:47:30 +01:00
James Mackay 17648f43c9 fix(media_player): store the computed adb_command participant key on the entity — completes the F4 add/remove key symmetry in the lifecycle hooks 2026-10-08 11:22:23 +01:00
James Mackay f03ef1f531 fix(config_flow): per-host zeroconf updates — blank auto-detect hosts self-heal from the discovery address, a deliberately-different ADB host is never clobbered, same-IP with both hosts current is a no-op (F2) 2026-10-08 11:22:04 +01:00
James Mackay b37a14b920 fix(media_player): the adb_command participant registry key is computed once at registration and stored on the entity — add/remove always match (F4) 2026-10-08 11:21:09 +01:00
James Mackay c44c2c2170 feat(const,strings,readme): adb_command consts/service name, options toggle strings, curated DEFAULT_APP_DIRECTORY and README service notes (completes P4-2 unit 1) 2026-10-08 11:13:09 +01:00
James Mackay 326f02cb59 feat(media_player): BrowseMedia app directory — root lists the options apps map (curated TiviMate/Wholphin/Netflix/YouTube fallback), selection launches via play_media(app) (P4-2c) 2026-10-08 11:12:37 +01:00
James Mackay 034cb5c83d feat(media_player): option-gated adb_command service (default off; registered at setup, unregistered at last unload, ADB-down rejection) + redacted entry diagnostics (P4-2a,b) 2026-10-08 11:08:57 +01:00
James Mackay 4222027602 feat(const,config_flow): option-gated adb_command service switch (default off) + curated app directory consts + README service/security notes (P4-2) 2026-10-08 11:07:28 +01:00
James Mackay f4d750ff92 test(config_flow): zeroconf discovery (pre-fill, no-host abort, IP-change host update, same-IP skip) + apps map editor (store/parse/clear/prefill) + app_name map resolution tiers (P4-1) 2026-10-08 11:03:28 +01:00
James Mackay 61c7ac99a3 feat(config_flow,media_player): apps name/icon map editor in the options flow (package=name per line, invalid input error) + app_name resolves through CONF_APP_NAME_MAP after the entity/push tiers (P4-1b) 2026-10-08 10:58:04 +01:00
James Mackay 1dbb695cec feat(config_flow): zeroconf discovery step — pre-filled user step from the discovery address, duplicate-MAC abort, IP-change host update + reload for known entries (P4-1a) 2026-10-08 10:57:31 +01:00
James Mackay 8b4fea9a5c feat(const,manifest): zeroconf service type + CONF_APP_NAME_MAP; manifest declares the _androidtvremote2._tcp discovery dependency (P4-1) 2026-10-08 10:57:08 +01:00
James Mackay ce90f59f8e fix(config_flow): no_state_source fires only when none of the three sources is present — ADB-only standalone is valid; strings/docs name all three (F2) 2026-10-08 10:52:44 +01:00
James Mackay 3b8e97fe5b fix(media_player): privacy bare-app identity outranks the channel-title fallback in the foreground tier (F3) 2026-10-08 10:52:24 +01:00
James Mackay 392553ee26 fix(media_player): channel-title fallback gated to the channel app (TIVIMATE_PACKAGE or audio_playback membership) — launcher never shows a channel number (F1) 2026-10-08 10:52:08 +01:00
James Mackay f58be9ed05 docs: state ladder + fallback semantics in README; mediasession2mqtt retirement + universal-player glue appendix in RESEARCH_SHIELD; TiviMate as the documented no-session case (P3-3d) 2026-10-08 10:46:32 +01:00
James Mackay bc457baae7 feat(media_player): foreground tier in the state ladder with app-source now-playing (last-known → last_channel → audio hint), sensor reads the store's foreground priority chain (P3-3a,c) 2026-10-08 10:45:58 +01:00
James Mackay 1f0a4b2021 feat(running_apps): media_dag_running_apps enrichment — TTL-capped last_known_state/last_known_title for apps without a live session (P3-3b) 2026-10-08 10:42:41 +01:00
James Mackay aa4ee7ed62 feat(connection): TTL-filtered last_known_map snapshot of the per-package cache (P3-3b feed) 2026-10-08 10:42:24 +01:00
James Mackay bb2f787fcd test(ha_adb_common): fixture-driven foreground-parser tests — TiviMate capture, R4 launcher, wrap join, topResumed fallback ordering, duplicates tolerated (P3-2a) 2026-10-08 10:39:43 +01:00
James Mackay 8885ddd5b5 feat(connection): ADB foreground fallback on the watchdog cadence when the protocol push is silent (store priority protocol > ADB); audio-playback hint filtered through SYSTEM_MEDIA_PACKAGES (P3-2b,c) 2026-10-08 10:33:38 +01:00
James Mackay f1e49a661b feat(ha_adb_common): foreground parser — mResumedActivity primary with block-join for ActivityRecord wraps, topResumedActivity fallback (R4); no window-focus parsing 2026-10-08 10:32:39 +01:00
James Mackay 34d09e9f70 feat(media_player): app_id/app_name prefer the androidtv entity, fall back to the protocol current_app push when absent/unavailable; manifest androidtvremote2 floor aligned to >=0.2.0 (P3-1b,c) 2026-10-08 10:28:56 +01:00
James Mackay d35b61078d feat(config_flow): standalone setup — androidtv entity optional with no_state_source validation, entry data omits the field for standalone entries, sensor tolerates its absence; migration keeps v1 field / v2 absence valid (P3-1a,d) 2026-10-08 10:28:56 +01:00
James Mackay a65c893339 refactor(connection): public protocol_active accessor replaces the remote entity's private reach-in; media_player module docstring describes the Phase 2 architecture (F7) 2026-10-08 10:12:40 +01:00
James Mackay 5f8c3290cc fix: flow VERSION=2 for new entries; optional ENTER gains the ADB keyevent-66 fallback; CI installs androidtvremote2 for the real-library tests (F4, F5, F6) 2026-10-08 10:12:12 +01:00
James Mackay 8462ad494c fix(connection): ADB outage clears only ADB-derived store fields — protocol push truth survives by TTL (F2) 2026-10-08 10:03:27 +01:00
James Mackay b451638c65 test: command-routing and play_media(channel) orchestration — digit sequence with cancel guard, fallback ordering protocol→ADB→legacy, remote send_command holds/repeats, volume readout, is_on ladder 2026-10-08 09:54:23 +01:00
James Mackay f071d540bd feat(remote): protocol-backed RemoteEntity — SHORT/START_LONG/END_LONG prefixes, num_repeats/delay/hold, activity = current-app push, POWER keys honoring is_on 2026-10-08 09:45:06 +01:00
James Mackay 728b7ccbf9 feat(media_player): protocol-first command routing with ADB + legacy fallbacks, play_media(channel/app/url) per §7 with digit task-cancel guard, volume readout from protocol push, is_on tier in the power ladder 2026-10-08 09:43:34 +01:00
James Mackay 61aca9d413 feat: protocol key/launch command layer — RemoteKeyCode maps, C-LAUNCH app launch, ADB digit keyevent fallback, PlaybackIntent protocol path 2026-10-08 09:41:45 +01:00
James Mackay 906126b0da test(config_flow): protocol cert/PIN pairing, reauth-after-hard-reset, and v1→v2 entry migration (scoped stubs, deterministic PIN/MAC fake client) 2026-10-08 09:36:17 +01:00
James Mackay d57c4eaa40 feat: protocol_settings wiring + InvalidAuth reauth start + entry migration to v2; PLATFORMS gains REMOTE (placeholder platform until t10) 2026-10-08 09:33:19 +01:00
James Mackay b5fc0dacf4 feat(config_flow): remote-protocol cert/PIN pairing step + reauth after device hard reset (shared .storage cert paths; ADB step body byte-intact, routes onward when protocol enabled) 2026-10-08 09:32:27 +01:00
James Mackay e37a3be75f feat(const): protocol config keys + ports; connection manager generates missing certs before protocol start 2026-10-08 09:30:50 +01:00
James Mackay a4e7a285d4 fix(connection): let the library own keep_reconnecting's task — no double scheduling on protocol start/stop 2026-10-08 09:27:50 +01:00
James Mackay 9a1e1300dd test(connection): protocol-transport unit tests — push wiring, InvalidAuth reauth hook, lib-owned keep_reconnecting lifecycle, passive paths, volume normalization 2026-10-08 09:27:38 +01:00
James Mackay 49cf402bbe feat(pyproject): androidtvremote2 dev dependency; manager docstring covers both transports 2026-10-08 09:25:25 +01:00
James Mackay 27cc69b1d3 feat(connection): protocol transport alongside ADB — AndroidTVRemote client, keep_reconnecting ownership, push callbacks into StateStore, InvalidAuth→reauth hook, cert/PIN client factory 2026-10-08 09:25:11 +01:00
James Mackay d07820cde8 test: sound backoff-jitter assertions (per-delay band + non-overlapping growth) and scoped import-stub teardown in the pairing regression tests (F5 residual) 2026-10-08 09:19:20 +01:00
James Mackay 75fff11d04 fix(media_player): ADB-health-aware _attr_available; fetch_media_info dispatches through _track_task (F2, F4) 2026-10-08 09:13:51 +01:00
James Mackay 9c409b3805 fix(connection): reconnect decision closes the failed socket (health_check's documented role) + symmetric backoff-bound test assertion (F3, F5) 2026-10-08 09:13:51 +01:00
James Mackay 24981d84a8 fix(config_flow): pairing step passes test_connect's declared signature (host, port, key_path) — regression tests cover the adb_pair submit path (F1) 2026-10-08 09:13:51 +01:00
James Mackay 48c30463ec fix: await the async ADB poller-factory builder during entry setup (keeps setup non-blocking, no ConfigEntryNotReady) 2026-10-08 09:04:13 +01:00
James Mackay 0da84a5a6e refactor(sensor): Current App sensor falls back to the connection manager StateStore and refreshes on store updates 2026-10-08 09:03:42 +01:00
James Mackay 93b766ae97 refactor(media_player): entity becomes a thin view over the ShieldConnectionManager store; poll loop and socket removed from the entity 2026-10-08 09:03:18 +01:00
James Mackay 0a7dd887d7 refactor(config_flow): ADB pairing step reuses ShieldConnectionManager.test_connect (throwaway one-shot connect) 2026-10-08 08:59:36 +01:00
James Mackay 63f76f5328 feat: entry.runtime_data ShieldConnectionManager lifecycle — passive when ADB unavailable, close on HA stop + unload; adb_session adapter docstring 2026-10-08 08:59:07 +01:00
James Mackay ec2db2ae58 test(connection): manager unit tests with a fake poller — connect/reconnect backoff, TTL expiry, listeners, lifecycle 2026-10-08 08:58:00 +01:00
James Mackay 4779c20407 feat(connection): ShieldConnectionManager + StateStore — persistent socket, watchdog backoff reconnect, TTL expiry, listener API (Phase 1, B1/B5) 2026-10-08 08:54:54 +01:00
James Mackay 2ade5091c4 feat(const): persistent-connection timing constants — watchdog backoff/cap/jitter, StateStore TTL 2026-10-08 08:53:27 +01:00
James Mackay 4269e233b8 feat(adb_client): expose last_dump so consumers can parse the Audio playback section from the same query 2026-10-08 08:53:13 +01:00
James Mackay 37164b6f24 feat: re-export parse_audio_playback + parse_wakefulness from ha_adb_common (additive, keeps hisense_tv consumer parity) 2026-10-08 08:45:06 +01:00
James Mackay a72cf57494 test: poll_running_apps parsing against SYNTHETIC activity-recents fixture (A= and I= patterns; real R6 capture still outstanding) 2026-10-08 08:43:23 +01:00
James Mackay a1e671ac6a fix: track intent-retry tasks in the unload-cancelled task registry so removal can't trigger a shell() auto-reconnect (reviewer F2) 2026-10-08 08:43:23 +01:00
James Mackay b91a4242ba feat(adb_client): exact-value mWakefulness= parse (R5), health_check(), optional media-session truncation (B4); wakefulness fixtures + tests 2026-10-08 08:41:21 +01:00
James Mackay f6a37bdcd5 docs: fold research findings into plan — streaming_shell verified on pinned adb-shell 0.4.4 (C-STREAM), market://launch auto-prefix (C-LAUNCH), channel_up/down keys 2026-10-08 08:40:46 +01:00
James Mackay db8712db1a docs: add RESEARCH_IDEAS.md — community feature requests, androidtvremote2/adb_shell API audits, other-integration patterns, ranked ideas (research task t3) 2026-10-08 08:40:14 +01:00
James Mackay 8f27618e3d feat(media_session): parse PlaybackState position/speed/updated (uptime-ms) + parse_audio_playback section (MVP plan Phase 1, R9/R2) 2026-10-08 08:38:35 +01:00
James Mackay 4702c9534c docs: correct Phase 2 volume expectations — androidtvremote2 has no absolute volume set (core closed it not_planned); VOLUME_STEP|MUTE only, level from push 2026-10-08 08:36:11 +01:00
James Mackay ce82127a40 ci: add lint+test workflow; schedule package constants (TiviMate/Wholphin/system media); drop the stale sibling-checkout dev dependency
Release / release (push) Successful in 4s
Test / test (push) Failing after 6s
2026-10-08 07:49:07 +01:00
James Mackay 6b4227cb40 refactor: centralize ADB host/key resolution in helpers.py; drop the three duplicated resolvers 2026-10-08 07:48:30 +01:00
James Mackay d3afda0a25 fix: make ADB poll failures visible and stop stale-state cling; cancel tracked poll tasks on unload (B2, B5-prep); lint cleanups across daemon modules 2026-10-08 07:45:45 +01:00
James Mackay 359b96ef9c fix: retry playback intents over the command path that originally served them (ADB broadcasts annotate the intent, self-heal retry goes back over ADB) 2026-10-08 07:39:53 +01:00
James Mackay a2f1948a88 test: add firmware capture fixtures and stdlib-level unit tests for parsers, intents, running apps 2026-10-08 07:37:34 +01:00
James Mackay f829ef7a40 docs: README — drop mirror-job references after .gitea workflow rework
Release / release (push) Successful in 5s
2026-10-08 07:32:22 +01:00
James Mackay b706c031ea ci: streamline Gitea release workflow under .gitea/ (GITEA_TOKEN, concurrency, no HACS mirror job) 2026-10-08 07:32:08 +01:00
Levi 304e6dc3ec docs: rewrite README for the Shield-only fork
Release / gitea-release (push) Canceled after 0s
Release / hacs-github-mirror (push) Canceled after 0s
2026-10-08 07:26:13 +01:00
Levi 6622cc04b4 chore: adopt date-based versioning; point manifest metadata at the Gitea repo 2026-10-08 07:26:13 +01:00
Levi 9da7cb8b22 ci: release on every push to master with date-based versioning (vYYYY.MM.DD) 2026-10-08 07:26:13 +01:00
Levi 37df5732b7 ci: port release workflow to Gitea Actions (tag + release zip, optional HACS GitHub mirror) 2026-10-08 07:20:03 +01:00
Levi afc5f0a1d0 docs: add MVP plan and Shield firmware research findings 2026-10-08 07:18:27 +01:00
57 changed files with 13496 additions and 565 deletions

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
# Publish a date-based release every time master moves.
#
# Versioning is date-based: vYYYY.MM.DD. Priority rule — an unreleased `version`
# declared in custom_components/nvidia_shield/manifest.json wins (bump the
# manifest to force a specific release, e.g. after a meaningful change); otherwise
# the version is today's date (UTC), so a plain push to master publishes the
# day's release. Repeated pushes on the same day are deduped by tag existence.
#
# The Gitea release carries a nvidia_shield.zip asset (custom_components/
# nvidia_shield/) so the integration is installable manually from any release.
#
# No hand-made secrets are required: Gitea mints a token for every job and
# exposes it as secrets.GITEA_TOKEN.
name: Release
on:
push:
branches: [master]
# Manual runs, for the first release (no tags yet) or to re-publish after a
# partial failure.
workflow_dispatch:
# One release at a time. Two pushes in quick succession would otherwise race to
# tag the same commit; a later run dedupes safely instead of interleaving.
concurrency:
group: release-master
cancel-in-progress: false
permissions:
# The job token pushes the tag and creates the release.
contents: write
env:
ZIP_NAME: nvidia_shield.zip
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Tags drive versioning decisions, so the clone must carry them.
fetch-depth: 0
ref: master
token: ${{ secrets.GITEA_TOKEN }}
- name: Determine release version
id: version
run: |
set -euo pipefail
MANIFEST_VERSION="$(jq -r .version custom_components/nvidia_shield/manifest.json)"
DATE_VERSION="$(date -u +%Y.%m.%d)"
# An unreleased manifest version wins over today's date.
if git rev-parse -q --verify "refs/tags/v${MANIFEST_VERSION}" >/dev/null; then
VERSION="${DATE_VERSION}"
else
VERSION="${MANIFEST_VERSION}"
fi
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "v${VERSION} is already tagged — nothing to release"
echo "exists=yes" >> "$GITHUB_OUTPUT"
else
echo "releasing v${VERSION}"
echo "exists=no" >> "$GITHUB_OUTPUT"
fi
- name: Create tag
if: steps.version.outputs.exists == 'no'
run: |
set -euo pipefail
git config user.name "gitea-actions"
git config user.email "actions@notexpectedyet.com"
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
git push origin "refs/tags/${{ steps.version.outputs.tag }}"
- name: Build the release zip
if: steps.version.outputs.exists == 'no'
run: |
set -euo pipefail
cd custom_components
zip -r "../${ZIP_NAME}" nvidia_shield
- name: Publish the release
if: steps.version.outputs.exists == 'no'
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
# Notes: subject lines of the commits in the released range.
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
if [ -n "$PREV" ]; then
RANGE="${PREV}..${TAG}"
else
RANGE="${TAG}"
fi
{
echo "Release ${TAG}"
echo
git log --pretty=format:'- %s' "$RANGE"
} > /tmp/notes.md
# The body is built by an encoder rather than by string interpolation:
# release notes are multi-line markdown and quoting them into JSON by
# hand is a reliable way to publish a malformed release.
python3 - <<'PY' > /tmp/release.json
import json, os
with open('/tmp/notes.md', encoding='utf-8') as handle:
body = handle.read()
print(json.dumps({
'tag_name': 'v' + os.environ['VERSION'],
'name': 'v' + os.environ['VERSION'],
'body': body,
'draft': False,
'prerelease': False,
}))
PY
status="$(curl -sS -o /tmp/release-response.json -w '%{http_code}' \
-X POST "${SERVER}/api/v1/repos/${REPO}/releases" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
--data-binary @/tmp/release.json)"
RELEASE_ID="$(jq -r '.id // empty' /tmp/release-response.json)"
# 409 is "a release for this tag already exists": resolve the existing
# release instead of failing, so the zip still gets attached.
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID="$(curl -sS "${SERVER}/api/v1/repos/${REPO}/releases/tags/${TAG}" \
-H "Authorization: token ${TOKEN}" | jq -r '.id')"
else
if [ "$status" != "201" ] && [ "$status" != "200" ]; then
echo "release creation failed (HTTP ${status})" >&2
cat /tmp/release-response.json >&2
exit 1
fi
fi
case "$RELEASE_ID" in
''|*[!0-9]*)
echo "could not resolve a release id for ${TAG} (HTTP ${status})" >&2
cat /tmp/release-response.json >&2
exit 1
;;
esac
echo "release id: ${RELEASE_ID}"
curl -sS -o /tmp/asset-response.json \
-X POST "${SERVER}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ZIP_NAME}" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${ZIP_NAME}"
echo "published ${TAG} with ${ZIP_NAME}"
+114
View File
@@ -0,0 +1,114 @@
# Quality gate: lint + unit tests + the HA API-drift sweep.
#
# The test suite is stdlib-only (parsers, intents, running-apps) and runs the
# vendored ha_adb_common helpers without installing Home Assistant — see
# tests/conftest.py. androidtvremote2 is installed so the real-library
# volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real
# firmware captures live in tests/fixtures
# (how to capture more: docs/RESEARCH_SHIELD.md §8).
#
# The `ha-api-drift` job is the ONE place Home Assistant is installed: every
# other job validates our code against hand-written stubs, which stay green
# even when a call site no longer matches the real API. That is how three
# drifts reached master (t36 D1/D2/D3) — see tests/ha_api_drift.py.
name: Test
on:
push:
branches: [master]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install test tooling
run: |
set -euo pipefail
# Try the plain install first: it succeeds on images whose Python is
# not externally managed (and on pip < 23, which has no PEP 668
# enforcement at all). When the runner image DOES mark its Python as
# externally managed — Debian/Ubuntu based Gitea act images do — pip
# refuses with externally-managed-environment and we retry with the
# sanctioned override. That override is safe here specifically
# because the job runs in a disposable container, and because the
# flag only exists on pip >= 23.0.1, the same version that raises
# the refusal in the first place.
#
# Tools are pinned to the versions the suite was validated against so
# a newer ruff rule set cannot redden the gate on unrelated commits;
# androidtvremote2 tracks the manifest floor so CI exercises the same
# dependency users install.
PKGS="pytest~=9.1 pytest-asyncio~=1.4 ruff~=0.17.0 'androidtvremote2>=0.2.0'"
if ! eval python3 -m pip install --quiet $PKGS; then
echo "system Python is externally managed; retrying with --break-system-packages"
eval python3 -m pip install --quiet --break-system-packages $PKGS
fi
- name: Lint
run: ruff check custom_components tests
- name: Unit tests
run: python3 -m pytest tests -q
# Resolve every Home-Assistant API use in our production modules against a
# real install: imports, keyword arguments and await/sync mismatches. The
# sweep is repo-owned (tests/ha_api_drift.py) and skips cleanly when
# Home Assistant is absent, so it is inert in the job above and live here.
ha-api-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Provision the interpreter Home Assistant needs
uses: actions/setup-python@v5
with:
# Home Assistant 2026.10.1 declares Requires-Python >= 3.14.2 (PyPI
# metadata; tests/ha_api_drift.py holds our copy of that number and the
# drift suite pins it against the installed distribution), so the
# runner's own python3 cannot install the version this gate must run
# against. This resolves the newest 3.14.x patch; the guard below
# refuses anything still short of the floor rather than letting the
# install fail halfway through.
python-version: "3.14"
- name: Check the interpreter satisfies Home Assistant's floor
run: |
set -euo pipefail
# The floor comes from tests/ha_api_drift.py — one source of truth for
# this guard and the metadata pin in tests/test_ha_api_drift.py.
python3 - <<'PY'
import sys
from pathlib import Path
sys.path.insert(0, str(Path("tests").resolve()))
from ha_api_drift import HOMEASSISTANT_PYTHON_FLOOR as FLOOR # noqa: E402
needed = ".".join(str(part) for part in FLOOR)
if sys.version_info < FLOOR:
print(
f"Home Assistant needs Python >= {needed}, but this job runs "
f"{sys.version.split()[0]}."
)
print(f"Fix: raise actions/setup-python in this job to a >= {needed} build.")
raise SystemExit(1)
print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}")
PY
- name: Install Home Assistant + test tooling
run: |
set -euo pipefail
# Same PEP 668 dance as the test job (see the note there). The
# version floor tracks what we validate against; a resolver failure
# here is a real signal (Python too old), not something to swallow.
PKGS="pytest~=9.1 pytest-asyncio~=1.4 'homeassistant>=2026.10'"
if ! eval python3 -m pip install --quiet $PKGS; then
echo "system Python is externally managed; retrying with --break-system-packages"
eval python3 -m pip install --quiet --break-system-packages $PKGS
fi
- name: HA API-drift sweep
run: python3 -m pytest tests/test_ha_api_drift.py -q
-51
View File
@@ -1,51 +0,0 @@
name: Release
on:
push:
branches: [main]
paths:
- custom_components/nvidia_shield/manifest.json
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Read version from manifest.json
id: version
run: |
VERSION=$(jq -r .version custom_components/nvidia_shield/manifest.json)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if tag already exists
id: check
run: |
if git ls-remote --tags origin "refs/tags/${{ steps.version.outputs.tag }}" | grep -q .; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Create tag
if: steps.check.outputs.exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
git push origin "${{ steps.version.outputs.tag }}"
- name: Create GitHub Release
if: steps.check.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.version.outputs.tag }}" \
--title "${{ steps.version.outputs.tag }}" \
--generate-notes
+181 -53
View File
@@ -1,101 +1,229 @@
# NVIDIA Shield
[![hacs][hacs-badge]][hacs-url] [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
Home Assistant integration for the NVIDIA Shield TV — ADB media session tracking for
now-playing state from any app, plus Google remote-protocol commands, Jellyfin rich
metadata, and power monitoring.
Enhanced NVIDIA Shield TV integration for Home Assistant. ADB-based media session tracking, Jellyfin rich metadata, power monitoring, and receiver coordination.
This is a **Shield-only fork** of [ltomes/ha-nvidia-shield](https://github.com/ltomes/ha-nvidia-shield)
(early 0.1.x), rebuilt around a different architecture: one integration, one device,
ADB as the state source, and no dependency on separate `androidtv` / `androidtv_remote`
integrations.
> **Status:** Early access (0.1.x). Tested on the author's own setup; expect rough edges and occasional breaking changes until 1.0. Please file issues on GitHub if anything is broken.
> **Status:** early fork, actively rebuilt. Releases are date-based (`v2026.10.08`).
> Expect rough edges until the MVP phases land. The build-out plan is in
> [`docs/MVP_PLAN.md`](docs/MVP_PLAN.md); device research is in
> [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md).
```mermaid
graph TD
subgraph "NVIDIA Shield Integration"
ADB["ADB Media Session"] -->|now playing| Entity["media_player.nvidia_shield"]
JF["Jellyfin"] -->|rich metadata| Entity
ATV["Android TV Remote"] -->|power / commands| Entity
PWR["Smart Plug"] -->|power state| Entity
end
## What we're building (the fork's goals)
Entity -->|"media_dag_*"| MZ["Media Zone"]
The fork exists to make a single integration own the Shield end-to-end:
style Entity fill:#76b900,color:#fff
style MZ fill:#1a73e8,color:#fff
style ADB fill:#444,color:#fff
style JF fill:#00a4dc,color:#fff
style ATV fill:#444,color:#fff
style PWR fill:#444,color:#fff
1. **Event-driven ADB media session tracking as the primary playback state source** —
`dumpsys media_session` over a persistent connection, not screen capture, not
10-second polling.
2. **Fallback state for apps without media sessions** — e.g. TiviMate publishes
none at all; foreground-app detection (`mResumedActivity`) + last-known-state
+ the "audio playback" hint from the same session dump close that gap.
3. **All-in-one device** — absorb the Google remote protocol (`androidtvremote2`):
a `remote` entity alongside the media player, `play_media` with
`media_content_type: channel` for TiviMate channel-by-number, absolute volume,
focused-app push events. ADB stays the playback-state source; ADB pairing stays intact.
4. **Retire the surrounding glue** — the `mediasession2mqtt` bridge, universal
media player templates, and external remote entity reading all become native
integration behavior.
Phases 0–4 with files, risks, and definitions of done live in
[`docs/MVP_PLAN.md`](docs/MVP_PLAN.md).
## Current features (inherited from upstream 0.1.x)
- **ADB Media Session** — now-playing from any app that publishes a session
(Spotify, Netflix, Plex, Wholphin, …)
- **Jellyfin rich metadata** — title, series/episode, position, stream details
(resolution, codec, HDR, audio format) via the HA Jellyfin integration's cache
- **Power monitoring** — smart plug for reliable on/off with auto-discovered
sibling sensors (watts, voltage, current)
- **Intent tracking** — commands reflect in HA state immediately, self-heal
against actual device state
- **Privacy mode** — show only app name without media details for non-Jellyfin apps
- **Media DAG attributes** — `media_dag_*` attributes for media-zone style orchestration
Changes inherited from upstream still apply in 0.1.x code: polling is timer-based,
the ADB connection lives inside the media player entity, and fallback app state
arrives in Phase 3.
## State ladder and fallback semantics (Phase 3)
The media player state is decided top-down; each tier serves only when the
ones above it are silent. Both transports live in the entry's
`ShieldConnectionManager` (`connection.py`) — ADB is the state source, the
remote protocol is the fast push/command path:
1. **Plug off** — the power switch says `off` → `off`.
2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through.
3. **ADB wake** (exact `mWakefulness` parse) — asleep → `idle` (standby).
4. **Intent** — a just-sent command's target state, self-healing against
reality within 30 s (retry goes over the transport that served it).
5. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
6. **ADB media session** — `playing` / `paused` for apps that publish a
session (foreground app preferred).
7. **Foreground fallback** — an app is open (`current_app` protocol push,
then the ADB `mResumedActivity` parse) but publishes no media session →
`on`. Its identity comes from the TTL-capped last-known-state cache, the
last channel entered, or the zero-cost "audio playback" hint
(`media recently active`) — never resurrecting `playing` past the 60 s
state TTL.
8. **On** — powered, awake, nothing more specific.
TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 6–7 plus
the audio hint are what keep it visible.
**Standalone setup** accepts any one of the three state sources — the Android
TV Remote entity, the Nvidia Remote Protocol (PIN pairing), or ADB
media-session mode (ADB enabled + host). The `no_state_source` form error
fires only when none of the three is present; ADB-only setups are valid.
### Channel entry options
The options flow exposes the §7 channel behavior the integration replicates:
the inter-digit **delay** (0.05–1.0 s, default 0.1) and the optional
**ENTER press** after the digits (off by default — TiviMate's validated
behavior). Both are consumed by `play_media` with
`media_content_type: channel`.
### YAML seeding (optional, seed-only)
An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an
interactive setup flow — nothing is created without the user walking the
standard flow, and pairing stays interactive exactly as with UI setup:
```yaml
nvidia_shield:
- name: Living Room
adb_host: 10.0.0.31 # or protocol_host for the PIN-pairing hint
adb_port: 5555
```
## Features
`protocol_host` is a prefill hint only (the PIN pairing still happens in
the flow). Blocks without protocol/adb hosts are ignored with a warning.
Duplicate guards: a block matching an existing entry's host (or its
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
host. Removing the YAML block creates or deletes nothing — entries live
in the storage registry and require user consent to remove. Deleting/re-
adding is the only way to move an imported entry between hosts; the UI is
the contractual path (YAML is the seed, the flow is the truth).
- **ADB Media Session** -- Now-playing from any app (PipePipe, Spotify, VLC, Plex, etc.), not just Jellyfin
- **Jellyfin Rich Metadata** -- Title, series, season/episode, duration, position, stream details (resolution, codec, HDR, audio format)
- **Power Monitoring** -- Smart plug for reliable on/off detection with auto-discovered sibling sensors (watts, voltage, current)
- **Receiver Coordination** -- Links to a receiver entity and input name
- **Intent Tracking** -- Commands reflect in HA state immediately, self-heal against actual device state
- **Privacy Mode** -- Show only app name without media details for non-Jellyfin apps
- **Controlled ADB Pairing** -- Dedicated pairing step in config flow + re-pair button entity
- **Media DAG Attributes** -- Full signal chain data for [Media Zone](https://github.com/ltomes/ha-media-zone) orchestration
### Raw `adb_command` service (opt-in)
When enabled in the integration's options (`adb_command_enabled`, **default
off**), the integration registers the `nvidia_shield.adb_command` service:
send a raw ADB shell command to the entry's persistent connection
(`{"command": "your-command", "entity_id": "media_player.<yours>"}` — the
entity is optional when exactly one entry provides the service). This is the
same security posture as the Home Assistant core `androidtv` integration: a
documented, opt-in escape hatch for debugging and automation, registered at
setup and unregistered when the last participating entity unloads. It
rejects (logs a warning, sends nothing) when the ADB connection is down.
### Migration from mediasession2mqtt + universal-player glue
The `mediasession2mqtt` bridge sensors and the universal media player YAML
templates the fork replaces are retired by the ladder above: the media player
entity already carries the DAG attributes (`media_dag_running_apps` with
foreground/last-known enrichment, `media_dag_now_playing` with its source
tag) that media-zone orchestration consumed from those sensors. See the
appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) for the
retirement mapping and the universal-player glue it replaces.
## Releases
- **Date-based versioning**: `vYYYY.MM.DD` (e.g. `v2026.10.08`).
- Pushes to `master` on [Gitea](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield)
run the release workflow (`.github/workflows/release.yml`):
- A plain push publishes the current day's release (same-day pushes are deduped).
- Bumping `version` in `custom_components/nvidia_shield/manifest.json` forces that
specific version's release before the date fallback kicks in.
- Releases land on Gitea with the tag and a `nvidia_shield.zip` asset for manual
installs. (No automatic mirroring — see the HACS note below.)
## Installation
### HACS (recommended)
### HACS
HACS only works with public GitHub repositories, so the Gitea repo cannot be
added to HACS directly. If you set up a public GitHub mirror of the releases,
add it as a custom repository:
1. In HACS, open the overflow menu (⋮) → **Custom repositories**.
2. Paste `https://github.com/ltomes/ha-nvidia-shield`, pick type **Integration**, click **Add**.
2. Paste `https://github.com/notexpectedyet/ha-nvidia-shield`, pick type
**Integration**, click **Add**.
3. Find **NVIDIA Shield** in the HACS list and click **Download**.
4. **Restart Home Assistant.**
5. Go to **Settings → Devices & Services → Add Integration**, search for **NVIDIA Shield**, and set up the integration (see [Configuration](#configuration)).
### Manual
Copy `custom_components/nvidia_shield` to your HA `config/custom_components/` directory, restart, then add the integration from **Settings → Devices & Services**.
Copy `custom_components/nvidia_shield` to your HA `config/custom_components/`
directory, restart, then add the integration from **Settings → Devices & Services**.
## Configuration
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
2. Select the Android TV Remote entity for your Shield
3. Enable ADB if you want now-playing from all apps
4. If ADB is enabled, the next step will pair -- approve the prompt on the Shield
4. If ADB is enabled, the next step will pair — approve the prompt on the Shield
5. Optionally configure Jellyfin, receiver, power switch
### ADB Pairing
ADB is paired during setup via a dedicated config flow step. If you need to re-pair later (e.g., after factory reset), use the **"Pair ADB"** button on the device page in HA.
ADB is paired during setup via a dedicated config flow step. If you need to
re-pair later (e.g. after factory reset), use the **"Pair ADB"** button on the
device page in HA.
Requirements: Settings > Developer options > Network debugging enabled on the Shield.
### Discovery (zeroconf)
Home Assistant can discover a Shield through the `_androidtvremote2._tcp`
service, and the discovered address is pre-filled on the setup form. The dedup
rules are Home Assistant's own, and they differ with what the discovery carries:
- **with** the device MAC, a duplicate aborts as "already configured", and an
existing entry's hosts are updated when the address changed;
- **without** a MAC, the flow follows HA's stricter
`_async_handle_discovery_without_unique_id` contract: **any** existing entry
for this integration aborts the discovery, and only one discovery flow runs at
a time.
So a second (or third) Shield that advertises no MAC has to be added **manually**
— Settings > Devices & Services > Add Integration > "NVIDIA Shield" — rather than
by discovery. That is a deliberate, tested contract (t37 D3), not a bug: the
helper the flow calls is a coroutine and its dedup only runs when awaited.
### Options
- **Jellyfin Media Player** -- Auto-detected or manually selected
- **Receiver + Input** -- For Media Zone coordination
- **Power Switch** -- Smart plug entity
- **ADB Host/Port** -- Override auto-detection if needed
- **Jellyfin Media Player** — auto-detected or manually selected
- **Receiver + Input** — for media-zone coordination
- **Power Switch** — smart plug entity
- **ADB Host/Port** — override auto-detection if needed
## Entities
| Entity | Type | Description |
|--------|------|-------------|
| Media Player | `media_player` | Main entity with playback controls and metadata |
| Current App | `sensor` | Currently running app |
| Now Playing | `sensor` | Formatted now-playing string |
| Current app (`sensor.current_app`) | `sensor` | Currently running app |
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string |
| Pair ADB | `button` | Initiate or re-pair ADB connection |
## Development
Shared ADB helpers live in [ha-adb-common](https://github.com/ltomes/ha-adb-common) and are also used by [ha-hisense-tv](https://github.com/ltomes/ha-hisense-tv). For HACS installs, a copy is **vendored** at `custom_components/nvidia_shield/ha_adb_common/` so end users need nothing extra. We plan to publish `ha-adb-common` to PyPI so it can be declared as a normal `manifest.json` requirement; until then, the vendored copy is the source consumed at runtime.
To hack on the shared code locally, clone [ha-adb-common](https://github.com/ltomes/ha-adb-common) as a sibling directory and run `uv sync --extra dev` — the `[tool.uv.sources]` section in `pyproject.toml` wires it up editable.
## Part of the Media Zone Ecosystem
This integration works standalone, but pairs with [ha-media-zone](https://github.com/ltomes/ha-media-zone) to create a unified zone controller:
- [ha-media-zone](https://github.com/ltomes/ha-media-zone) -- Orchestrates receiver + display + sources into one entity
- [ha-emotiva-mc1](https://github.com/ltomes/ha-emotiva-mc1) -- RS232 control for Emotiva MC1 receiver
- [ha-hisense-tv](https://github.com/ltomes/ha-hisense-tv) -- Hisense Google TV control via ADB + AirPlay
[hacs-badge]: https://img.shields.io/badge/HACS-Custom-41BDF5.svg
[hacs-url]: https://github.com/hacs/integration
- **Repository:** [Gitea — git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield), branch `master`
- **CI/CD:** Gitea Actions; every push to `master` releases (see
`.gitea/workflows/release.yml`). Auth uses the job-provided `GITEA_TOKEN` —
no extra secrets required.
- **Shared ADB helpers** are vendored at `custom_components/nvidia_shield/ha_adb_common/`
(trimmed in this fork); dev extras in `pyproject.toml` (`uv sync --extra dev`).
## License
+210 -8
View File
@@ -1,16 +1,150 @@
"""The Nvidia Shield integration."""
"""The Nvidia Shield integration.
Phase 1 architecture (docs/MVP_PLAN.md §2): the entry owns a single
ShieldConnectionManager stored in ``entry.runtime_data`` — the persistent ADB
socket and all ADB-derived state live there, not on the entities. Entities are
thin callback-views over the manager's StateStore.
ADB is optional: a failed/absent ADB connection never blocks setup (no
ConfigEntryNotReady) — the manager retries in the background, or stays
passive until the user pairs via the Pair ADB button.
"""
import logging
import os
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .adb_session import AdbMediaSessionPoller
from .connection import ShieldConnectionManager
from .const import (
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
DEFAULT_ADB_PORT,
DEFAULT_NAME,
DOMAIN,
SERVICE_ADB_COMMAND,
)
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.BUTTON]
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON]
async def async_setup(hass: HomeAssistant, config) -> bool:
"""Domain setup: actions (always-on) + optional YAML seeding (t30 U1).
YAML is SEED-ONLY (user ruling): every ``nvidia_shield`` block starts an
interactive SOURCE_IMPORT config flow — nothing is created without the
user walking the flow, and pairing stays interactive exactly as for UI
setup. Removing the YAML block creates OR deletes nothing. UI-configured
entries are never re-seeded (host/MAC duplicate guards).
"""
domain_config = (config or {}).get(DOMAIN)
for block in _yaml_seed_blocks(domain_config or []):
if not block.get(CONF_ADB_HOST) and not block.get(CONF_PROTOCOL_HOST):
_LOGGER.warning(
"nvidia_shield YAML block without protocol/adb host ignored: %s",
block.get("name", "<unnamed>"),
)
continue
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=block,
)
)
# t29 G8 (kept here across the t30 splice): the action registers ALWAYS
from .media_player import _ADB_COMMAND_SCHEMA, _async_adb_command_service
hass.services.async_register(
DOMAIN,
SERVICE_ADB_COMMAND,
_async_adb_command_service,
schema=_ADB_COMMAND_SCHEMA,
)
return True
def _yaml_seed_blocks(domain_config) -> list[dict]:
"""Normalize the optional YAML domain key into seed blocks (t30 U1)."""
blocks: list[dict] = []
raw_list = domain_config if isinstance(domain_config, list) else [domain_config]
for block in raw_list:
if not isinstance(block, dict):
continue
name = block.get("name") or DEFAULT_NAME
# t32 F4 (review rider): the seed honors the block's shape — a
# protocol-only block does NOT force ADB on; ADB comes only when the
# block carries an adb host (interactive cosmetics stay in the flow).
seed = {
"name": name,
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)),
CONF_ADB_HOST: str(block.get(CONF_ADB_HOST) or ""),
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
# protocol fields are prefill hints ONLY — pairing stays interactive
CONF_PROTOCOL_HOST: str(block.get(CONF_PROTOCOL_HOST) or ""),
}
if block.get("unique_id"):
seed["unique_id"] = str(block["unique_id"])
blocks.append(seed)
return blocks
async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
"""Poller factory when ADB is configured AND usable today, else None.
Returning None leaves the manager passive: the entry still sets up, the
entities simply see no ADB state. The Pair ADB button / reconfigure
triggers a reload that re-evaluates this.
"""
if not entry.options.get(CONF_ADB_ENABLED, False):
return None
adb = resolve_adb_config(hass, entry)
if adb is None:
_LOGGER.warning(
"ADB enabled but no host resolvable for entry %s — continuing "
"without ADB state.",
entry.title,
)
return None
# Only auto-connect when a key already exists — an ADB handshake against
# an unpaired device is pointless noise (pairing happens via the Pair ADB
# button / config flow, untouched).
key_path_ready = await hass.async_add_executor_job(
os.path.exists, adb.key_path
)
if not key_path_ready:
_LOGGER.info(
"ADB key not found for entry %s — skipping auto-connect. "
"Use the Pair ADB button or reconfigure the integration.",
entry.title,
)
return None
# t32 F1 (review repair): the audit-sanctioned FACTORY RE-READ — the
# host/port resolve from entry.options AT CALL TIME, so the poller built
# after a zeroconf IP change targets the NEW address without a reload
# (the setup-time closure pinned the old address). A key-path change
# still needs the Pair ADB button / reconfigure reload, by design.
def _factory():
adb_now = resolve_adb_config(hass, entry)
if adb_now is None:
return None
return AdbMediaSessionPoller(adb_now.host, adb_now.port, adb_now.key_path)
return _factory
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -18,10 +152,77 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry
manager = ShieldConnectionManager(
hass,
entry,
poller_factory=await _build_poller_factory(hass, entry),
protocol_settings=await _build_protocol_settings(hass, entry),
invalid_auth_callback=lambda: entry.async_start_reauth(hass),
)
entry.runtime_data = manager
await manager.async_start()
# Close the persistent socket on Home Assistant stop as well as unload.
async def _close_on_hass_stop(_event) -> None:
await manager.async_stop()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close_on_hass_stop)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
"""Manager protocol_settings when the transport is enabled & resolvable.
Falls back to the androidtv-remote host discovery (the ADB host fallback)
until the user pairs, after which the explicit protocol host persists.
"""
if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
return None
host = entry.options.get(CONF_PROTOCOL_HOST) or discover_adb_host(
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
)
if not host:
_LOGGER.info(
"Protocol enabled for entry %s but no host resolvable — continuing "
"without the remote-protocol transport. Pair via the flow.",
entry.title,
)
return None
certfile, keyfile = await hass.async_add_executor_job(protocol_cert_paths, hass)
return {
"host": host,
"client_name": entry.title or "Home Assistant",
"certfile": certfile,
"keyfile": keyfile,
}
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate v1 entries to v2 (adds the remote-protocol fields).
ADB options carry over untouched; the ADB-host discovery fallback keeps
serving as the protocol host until pairing populates it. Downgrades
(version > 2) are rejected so HA offers a rollback.
"""
if config_entry.version > 2:
return False
if config_entry.version < 2:
options = {**config_entry.options}
options.setdefault(CONF_PROTOCOL_ENABLED, False)
options.setdefault(CONF_PROTOCOL_HOST, "")
hass.config_entries.async_update_entry(
config_entry, options=options, version=2
)
_LOGGER.info(
"Migrated %s config entry to version 2", config_entry.title
)
return True
@@ -30,11 +231,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
manager = getattr(entry, "runtime_data", None)
if manager is not None:
await manager.async_stop()
entry.runtime_data = None
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
+24 -5
View File
@@ -1,15 +1,24 @@
"""ADB media session reader for NVIDIA Shield.
"""ADB session adapter for NVIDIA Shield.
Thin wrapper around ha_adb_common.AdbClientBase — adds the Shield-specific
`poll()` method that calls wake check + media session in sequence.
Since Phase 1 this module is a thin adapter on top of ha_adb_common:
- ``ensure_adb_key`` (re-exported here for the config flow / Pair ADB button)
does the one-time key-material setup — the pairing flow stays untouched.
- ``AdbMediaSessionPoller`` remains the protocol-level ADB client used by the
ShieldConnectionManager (connection.py) and one-shot pairing connects. The
polling loop itself lives in the connection manager, not here.
Re-exports MediaSessionInfo and ensure_adb_key for existing importers.
"""
import logging
from .ha_adb_common import AdbClientBase, ensure_adb_key as _ensure_adb_key
from .ha_adb_common import MediaSessionInfo # noqa: F401 (re-export)
from .ha_adb_common import (
AdbClientBase,
MediaSessionInfo, # noqa: F401 (re-export)
parse_foreground,
)
from .ha_adb_common import ensure_adb_key as _ensure_adb_key
_LOGGER = logging.getLogger(__name__)
@@ -35,3 +44,13 @@ class AdbMediaSessionPoller(AdbClientBase):
prefer its media session over stale sessions from other apps.
"""
return await self.poll_media_sessions(foreground_package)
async def poll_foreground(self) -> str | None:
"""Foreground package via `dumpsys activity activities` (RESEARCH R4).
The grep keeps the dump tiny; the parser handles the R4 line wrap.
"""
dump = await self.shell("dumpsys activity activities 2>/dev/null | grep -iE 'resumed'")
if not dump:
return None
return parse_foreground(dump)
+11 -24
View File
@@ -5,8 +5,7 @@ import logging
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
@@ -18,6 +17,7 @@ from .const import (
DEFAULT_ADB_PORT,
DOMAIN,
)
from .helpers import discover_adb_host
_LOGGER = logging.getLogger(__name__)
@@ -36,6 +36,7 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
_attr_has_entity_name = True
_attr_name = "Pair ADB"
_attr_entity_category = EntityCategory.CONFIG
_attr_icon = "mdi:link-variant"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -52,29 +53,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
model="Shield TV",
)
def _resolve_host(self) -> str | None:
"""Resolve ADB host from config or Android TV entity."""
host = self._entry.options.get(CONF_ADB_HOST)
if host:
return host
atv_entity = self._entry.data.get(CONF_ANDROIDTV_ENTITY)
if not atv_entity:
return None
registry = er.async_get(self.hass)
entity_entry = registry.async_get(atv_entity)
if not entity_entry or not entity_entry.config_entry_id:
return None
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if config_entry:
return config_entry.data.get("host")
return None
async def async_press(self) -> None:
"""Attempt ADB connection. The Shield will show a pairing prompt."""
host = self._resolve_host()
host = self._entry.options.get(CONF_ADB_HOST) or discover_adb_host(
self.hass, self._entry.data.get(CONF_ANDROIDTV_ENTITY)
)
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
if not host:
@@ -85,7 +68,11 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
ensure_adb_key, self.hass.config.config_dir
)
_LOGGER.info("Attempting ADB pairing with %s:%d — approve the prompt on the Shield", host, port)
_LOGGER.info(
"Attempting ADB pairing with %s:%d — approve the prompt on the Shield",
host,
port,
)
poller = AdbMediaSessionPoller(host, port, key_path)
if await poller.connect():
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+112 -1
View File
@@ -22,17 +22,128 @@ CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
CONF_ADB_HOST = "adb_host"
CONF_ADB_PORT = "adb_port"
# Remote-protocol transport (androidtvremote2) — Phase 2
CONF_PROTOCOL_ENABLED = "protocol_enabled"
CONF_PROTOCOL_HOST = "protocol_host"
CONF_PAIRING_CODE = "pairing_code"
# t33 F-B: the "continue without pairing" escape offered on BOTH pairing
# steps — the entry still finishes (passive mode) instead of stranding the
# user on a form with no way forward.
CONF_SKIP_PAIRING = "skip_pairing"
DEFAULT_PROTOCOL_API_PORT = 6466
DEFAULT_PROTOCOL_PAIR_PORT = 6467
ADB_POLL_INTERVAL = 10 # seconds
ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
DEFAULT_ADB_PORT = 5555
# Persistent-connection manager timing (Phase 1, docs/MVP_PLAN.md §2):
# watchdog reconnects with exponential backoff + jitter; ADB-derived store
# fields expire (B5) instead of clinging to stale values.
ADB_RECONNECT_BACKOFF_START = 5 # seconds before the first reconnect retry
ADB_RECONNECT_BACKOFF_CAP = 300 # seconds; backoff never sleeps longer
ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms
STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
DEFAULT_NAME = "Nvidia Shield"
# Emotiva DAG attribute we read to get available inputs
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
# Known Jellyfin app package IDs
# Known Jellyfin app package IDs (used to route Jellyfin-family clients
# — including Wholphin, a Jellyfin client — to the rich-metadata path).
JELLYFIN_APP_IDS = [
"org.jellyfin.androidtv",
"org.jellyfin.mobile",
"com.github.damontecres.wholphin",
]
# Remote-protocol key names (RemoteKeyCode enum in remotemessage.proto).
# HA service kind → protocol keycode string.
PROTOCOL_MEDIA_KEYS = {
"media_play": "KEYCODE_MEDIA_PLAY",
"media_pause": "KEYCODE_MEDIA_PAUSE",
"media_stop": "KEYCODE_MEDIA_STOP",
"media_next_track": "KEYCODE_MEDIA_NEXT",
"media_previous_track": "KEYCODE_MEDIA_PREVIOUS",
}
PROTOCOL_VOLUME_KEYS = {
"volume_up": "KEYCODE_VOLUME_UP",
"volume_down": "KEYCODE_VOLUME_DOWN",
"volume_mute": "KEYCODE_VOLUME_MUTE",
}
PROTOCOL_REMOTE_KEYS = {
**PROTOCOL_VOLUME_KEYS,
"play": "KEYCODE_MEDIA_PLAY",
"pause": "KEYCODE_MEDIA_PAUSE",
"stop": "KEYCODE_MEDIA_STOP",
"up": "KEYCODE_DPAD_UP",
"down": "KEYCODE_DPAD_DOWN",
"left": "KEYCODE_DPAD_LEFT",
"right": "KEYCODE_DPAD_RIGHT",
"enter": "KEYCODE_DPAD_CENTER",
"home": "KEYCODE_HOME",
"back": "KEYCODE_BACK",
"menu": "KEYCODE_MENU",
"search": "KEYCODE_SEARCH",
"power": "KEYCODE_POWER",
"channel_up": "KEYCODE_CHANNEL_UP",
"channel_down": "KEYCODE_CHANNEL_DOWN",
}
# §7 channel selection: digits only, ~0.1 s apart, no ENTER by default
# (replicates HA core androidtv_remote's verified behavior).
# Zeroconf discovery (Phase 4): the Android TV remote service type.
ZEROCONF_SERVICE_TYPE = "_androidtvremote2._tcp.local."
# P4-1 apps name/icon map (options flow editor): package → friendly name,
# consumed by app_name after the androidtv-entity and protocol-push tiers.
CONF_APP_NAME_MAP = "app_name_map"
# P5-2: optional icon column (package|icon) — package → icon URL, feeding
# entity_picture when no Jellyfin session picture is live.
CONF_APP_ICON_MAP = "app_icon_map"
# P5-1: device manifest label cache TTL (long — labels change with app
# updates, not per poll).
APP_LABEL_TTL = 24 * 3600
# P4-2a adb_command service: rawADB shell on this entry — OFF by default
# (same security posture as the core androidtv integration).
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
SERVICE_ADB_COMMAND = "adb_command"
# P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer
# (foreground AppState fast-path). Default OFF; the user flips it on and
# reports stability on their device.
CONF_LOGCAT_EVENTS = "logcat_events"
# P5-3: duplicate START for the same package inside this window is not
# re-stamped (repeat suppression).
LOGCAT_SUPPRESS_WINDOW = 15.0
# P4-2c BrowseMedia app directory fallback when the options map is empty
# (curated; the packages are the plan's reference apps).
DEFAULT_APP_DIRECTORY = {
"ar.tvplayer.tv": "TiviMate",
"com.github.damontecres.wholphin": "Wholphin",
"com.netflix.ninja": "Netflix",
"com.google.android.youtube.tv": "YouTube",
}
CHANNEL_DIGIT_DELAY = 0.1
CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay"
CONF_CHANNEL_SEND_ENTER = "channel_send_enter"
# Verified on-device package names (docs/RESEARCH_SHIELD.md): TiviMate does
# not publish a MediaSession on Shield Android 11 — it is the headline
# Phase-3 fallback app.
TIVIMATE_PACKAGE = "ar.tvplayer.tv"
# Packages to ignore when parsing foreground/audio-playback identities
SYSTEM_MEDIA_PACKAGES = [
"com.google.android.apps.mediashell", # Chromecast receiver process
"com.android.systemui",
"com.android.tv.settings",
"com.nvidia.diagtools",
]
@@ -0,0 +1,100 @@
"""Entry diagnostics for Nvidia Shield (P4-2b, t21 F1 platform-module move).
HA discovers ``async_get_config_entry_diagnostics`` through the integration's
``diagnostics`` platform module — a placement inside media_player.py never
serves in a real install (t20 finding F1).
The payload: redacted entry config (data + options), the manager's StateStore
snapshot via the public ``store_snapshot`` accessor, and both transports'
health. Cert paths are kept readable; key material / pairing secrets never
exist in stored state, and any key carrying a secret-shaped name is redacted
defensively.
"""
import time
from dataclasses import asdict, is_dataclass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.redact import async_redact_data
from .connection import ShieldConnectionManager
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
_REDACTED = "**REDACTED**"
# G14: the documented explicit redaction field list — the audit calls out
# config fields like the hosts; pairing/key-shaped fields are masked by
# async_redact_data and the marker scan adds a second layer.
_TO_REDACT = {
"pairing_code",
"adb_pairing_code",
"protocol_pairing_code",
"serial_number",
"uuid",
"adb_host",
"protocol_host",
"host",
}
def _redact(obj):
"""Marker-based defensive pass (kept behind the async_redact_data helper)."""
"""Redact sensitive keys from a JSON-ish diagnostics payload."""
if isinstance(obj, dict):
return {
key: (
_REDACTED
if any(m in str(key).lower() for m in _REDACT_KEY_MARKERS)
else _redact(value)
)
for key, value in obj.items()
}
if isinstance(obj, list):
return [_redact(item) for item in obj]
return obj
def _store_snapshot(manager) -> dict[str, dict]:
"""TTL-stamped store snapshot; dataclass values expand to dicts."""
snapshot = {}
for name, (stamp, value) in manager.store.values_stamped().items():
if is_dataclass(value) and not isinstance(value, type):
value = asdict(value)
snapshot[name] = {"age": round(time.monotonic() - stamp, 3), "value": value}
return snapshot
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict:
"""Entry diagnostics: redacted config + store snapshot + transport health."""
manager = getattr(entry, "runtime_data", None)
payload: dict = {
"entry": {
"data": dict(getattr(entry, "data", {}) or {}),
"options": dict(getattr(entry, "options", {}) or {}),
},
"store": {},
"transport": {},
}
if isinstance(manager, ShieldConnectionManager):
payload["store"] = manager.store_snapshot()
payload["transport"] = {
"adb": {"connected": manager.connected, "awake": manager.awake},
"protocol": {
"active": manager.protocol_active,
"available": manager.is_available,
"is_on": manager.is_on,
},
}
# G14 (t30) + t37 D2: the documented helper is the primary redaction path
# with an explicit field list (the hosts included), and the marker scan
# above runs defensively after it. It is imported at MODULE scope from
# homeassistant.helpers.redact — the real module; the old
# `homeassistant.util.diagnostic` never existed, and the swallowed
# ImportError turned the whole pass into a silent no-op.
return _redact(async_redact_data(payload, _TO_REDACT))
__all__ = ["async_get_config_entry_diagnostics"]
@@ -1,6 +1,7 @@
"""Shared ADB utilities for Home Assistant custom integrations."""
from .adb_client import AdbClientBase
from .adb_client import AdbClientBase, parse_wakefulness
from .app_label import parse_application_label
from .const import (
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING,
@@ -9,9 +10,16 @@ from .const import (
ATTR_MEDIA_DAG_ROLE,
ATTR_MEDIA_DAG_RUNNING_APPS,
)
from .foreground import parse_foreground
from .intent import PlaybackIntent
from .keys import ensure_adb_key
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
from .logcat_events import foreground_from_logcat
from .media_session import (
MediaSessionInfo,
parse_all_media_sessions,
parse_audio_playback,
parse_media_session,
)
from .running_apps import running_apps_from_sessions, running_apps_from_tasks
__all__ = [
@@ -26,6 +34,11 @@ __all__ = [
"PlaybackIntent",
"ensure_adb_key",
"parse_all_media_sessions",
"parse_application_label",
"parse_audio_playback",
"parse_foreground",
"foreground_from_logcat",
"parse_wakefulness",
"parse_media_session",
"running_apps_from_sessions",
"running_apps_from_tasks",
@@ -3,7 +3,9 @@
Subclasses add device-specific polling (media sessions, wake state, etc.).
"""
import contextlib
import logging
import re
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
@@ -30,6 +32,33 @@ DEVICE_KEYCODES = {
ALL_KEYCODES = {**MEDIA_KEYCODES, **DEVICE_KEYCODES}
# Recognized non-awake mWakefulness= values (see Android PowerManagerInternal).
# Anything else is "unknown" — never silently reported as awake or asleep.
_WAKEFULNESS_ASLEEP_VALUES = {"Asleep", "Dozing", "Dozing-suspend"}
def parse_wakefulness(text: str | None) -> bool | None:
"""Parse `dumpsys power` output for the exact ``mWakefulness=`` value (R5).
Exact-value parse, no substring matching: only the ``mWakefulness=``
assignment counts — ``mWakefulnessChanging=false`` and
``mWakefulnessSetting=...`` are ignored. Unknown values (e.g. a future
firmware's ``mWakefulness=Dozing-ext``) return None so callers treat it
as "unknown", never as awake or asleep.
"""
if not text:
return None
for line in text.splitlines():
match = re.search(r"(?<![A-Za-z])mWakefulness=([^\s,}]+)", line)
if match:
value = match.group(1)
if value == "Awake":
return True
if value in _WAKEFULNESS_ASLEEP_VALUES:
return False
return None
return None
class AdbClientBase:
"""Base ADB client with connection management and common commands."""
@@ -43,12 +72,28 @@ class AdbClientBase:
# Media session state
self._last_info: MediaSessionInfo | None = None
self._last_dump: str | None = None
self._all_sessions: list[MediaSessionInfo] = []
@property
def connected(self) -> bool:
return self._connected
@property
def config(self) -> dict:
"""Public connection config: {host, port, key_path} (t27 F-NEW3).
Replaces the private-attr reach-ins for secondary streams — callers
(the dedicated logcat stream) share the signer material without
touching the poller's socket or its private attributes.
"""
return {
"host": self._host,
"port": self._port,
"key_path": self._key_path,
}
@property
def last_info(self) -> MediaSessionInfo | None:
return self._last_info
@@ -60,6 +105,7 @@ class AdbClientBase:
def clear_info(self) -> None:
"""Clear the cached media session info."""
self._last_info = None
self._last_dump = None
self._all_sessions = []
async def connect(self) -> bool:
@@ -90,18 +136,15 @@ class AdbClientBase:
async def disconnect(self) -> None:
"""Disconnect from the device."""
if self._device:
try:
with contextlib.suppress(Exception):
await self._device.close()
except Exception:
pass
self._device = None
self._connected = False
async def shell(self, cmd: str) -> str | None:
"""Run a shell command, reconnecting if needed."""
if not self._connected or not self._device:
if not await self.connect():
return None
if (not self._connected or not self._device) and not await self.connect():
return None
try:
return await self._device.shell(cmd)
except Exception as err:
@@ -135,6 +178,24 @@ class AdbClientBase:
return True
return False
async def send_digit_keyevent(self, digit: str) -> bool:
"""Send a channel digit via `input keyevent` (KEYCODE_0..9 = 7..16)."""
if not digit.isdigit() or len(digit) != 1:
_LOGGER.warning("Not a single digit: %s", digit)
return False
result = await self.shell(f"input keyevent {7 + int(digit)}")
if result is not None:
_LOGGER.debug("ADB digit keyevent %d (%s)", 7 + int(digit), digit)
return result is not None
async def send_enter_keyevent(self) -> bool:
"""Send ENTER via `input keyevent` (KEYCODE_ENTER = 66) — the ENTER
fallback for the §7 channel sequence."""
result = await self.shell("input keyevent 66")
if result is not None:
_LOGGER.debug("ADB ENTER keyevent (66)")
return result is not None
async def bring_to_foreground(self, package: str) -> bool:
"""Bring an app to the foreground using monkey command."""
result = await self.shell(
@@ -145,27 +206,60 @@ class AdbClientBase:
return result is not None
async def is_awake(self) -> bool | None:
"""Check if the device screen is on / awake via ADB."""
"""Check if the device screen is on / awake via ADB.
Exact-value mWakefulness= parse (R5); None means "unknown".
"""
result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness")
if result:
return "Awake" in result
return None
return parse_wakefulness(result)
# -- Media session polling --
async def poll_media_sessions(
self, foreground_package: str | None = None, head_lines: int = 200
self, foreground_package: str | None = None, head_lines: int | None = 200
) -> MediaSessionInfo | None:
"""Query media sessions. Stores all sessions and returns the best match."""
dump = await self.shell(
f"dumpsys media_session 2>/dev/null | head -{head_lines}"
)
"""Query media sessions. Stores all sessions and returns the best match.
Args:
foreground_package: Prefer the session of the foreground app.
head_lines: Truncate the dump to N lines (the historical default
of 200). Pass None for the full dump — the ShieldConnection
manager does so, since the cut silently drops sessions below
it (plan blocker B4).
"""
command = "dumpsys media_session 2>/dev/null"
if head_lines is not None:
command += f" | head -{head_lines}"
dump = await self.shell(command)
self._last_dump = dump if dump is not None else None
if dump is None:
return None
self._all_sessions = parse_all_media_sessions(dump)
self._last_info = parse_media_session(dump, foreground_package)
return self._last_info
@property
def last_dump(self) -> str | None:
"""Raw output of the last media-session query (or None if it failed).
Shared consumers (the ShieldConnectionManager) parse the "Audio
playback" section from this same dump — no extra round trip.
"""
return self._last_dump
# -- Health check --
async def health_check(self) -> bool:
"""Verify the connection is actually usable with a cheap shell round trip.
Returns True only when the device answers the trivial ``echo ok`` with
the expected body — a stronger signal than the ``connected`` flag,
which can lag behind reality (the watchdog uses this to fire a
reconnect instead of trusting a dead socket).
"""
result = await self.shell("echo ok")
return result is not None and result.strip() == "ok"
# -- Running apps (recent visible tasks) --
async def poll_running_apps(self) -> list[dict]:
@@ -182,12 +276,11 @@ class AdbClientBase:
if not result:
return []
import re
apps = []
seen = set()
# System packages to exclude
_EXCLUDED = {
excluded = {
"com.android.systemui",
"com.android.tv.settings",
"com.nvidia.diagtools",
@@ -206,7 +299,7 @@ class AdbClientBase:
match = re.search(r"I=(\S+)/", stripped)
if match:
pkg = match.group(1)
if pkg not in seen and pkg not in _EXCLUDED and not pkg.startswith("."):
if pkg not in seen and pkg not in excluded and not pkg.startswith("."):
seen.add(pkg)
apps.append({"package": pkg})
@@ -0,0 +1,30 @@
"""App friendly-name resolution from the package manifest (P5-1).
Parses `pm dump <package>` output for the ``application-label`` line — the
default-locale variant; localized suffixed variants
(``application-label-<lang>:``) are skipped, and multi-label blocks across
locales are tolerated (first default-locale label wins), mirroring the
tolerant pattern of the androidtv-adb core helper.
"""
import re
_APPLICATION_LABEL_RE = re.compile(
r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'"
r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"'
)
def parse_application_label(dump: str) -> str | None:
"""The package's default-locale application label, or None.
Returns None on empty/failed dumps too — callers fall through to the
next name tier.
"""
if not dump:
return None
for match in _APPLICATION_LABEL_RE.finditer(dump):
label = (match.group(1) or match.group(2) or "").strip()
if label and label not in ("null", ""):
return label
return None
@@ -0,0 +1,91 @@
"""Foreground-app parsing from `dumpsys activity activities` (RESEARCH_SHIELD R4).
Primary pattern: ``mResumedActivity: ActivityRecord{... u0 <pkg>/<activity> ...}``
(Shield Android 11). ``topResumedActivity`` (newer firmware) is the fallback,
checked only after the primary — per R4 finding 1. ``dumpsys window`` focus is
NOT parsed (R4 finding 2: the first ``mCurrentFocus`` can be literally null).
Multi-line ActivityRecord wraps are handled by joining the block until the
record closes or a state line arrives (R4 finding 3: match on the joined
block, not per-line). ``ResumedActivity``/``Resumed`` duplicates are tolerated.
"""
import re
_RESUMED_MARKERS = (
("mResumedActivity", "mResumedActivity:"),
("topResumedActivity", "topResumedActivity:"),
("ResumedActivity", "ResumedActivity:"),
("Resumed", "Resumed:"),
)
# `u0 <pkg>/<activity>` — the userId prefix tolerates u0..uN; the package is
# anything up to the first `/` (or closing brace when the activity is bare).
_PACKAGE_RE = re.compile(r"u\d+\s+(\S+?)\s*/|u\d+\s+(\S+?)\s*}")
def _marker_kind(stripped: str) -> str | None:
for kind, marker in _RESUMED_MARKERS:
if stripped.startswith(marker):
return kind
return None
def _resumed_blocks(dump: str) -> list[tuple[str, str]]:
"""(kind, joined-text) blocks for each resumed-activity line, in order."""
blocks: list[tuple[str, str]] = []
kind: str | None = None
buf = ""
for raw in dump.split("\n"):
stripped = raw.strip()
marker = _marker_kind(stripped)
if marker is not None:
if buf:
blocks.append((kind, buf))
kind, buf = marker, stripped
continue
if kind is None:
continue
# Continuation of a wrapped ActivityRecord: join until the record
# closes (brace) or the state line completes the block (R4 finding 3).
buf += " " + stripped
if "}" in stripped or "state=" in stripped:
blocks.append((kind, buf))
kind, buf = None, ""
if buf:
blocks.append((kind, buf))
return blocks
def _extract_package(text: str) -> str | None:
match = _PACKAGE_RE.search(text)
if match is None:
return None
package = match.group(1) or match.group(2)
if not package or package in ("null", "Window{"):
return None
return package
def parse_foreground(dump: str) -> str | None:
"""Foreground package: mResumedActivity primary, topResumed fallback.
Returns None when neither pattern matches (e.g. pre-parse wrappers) —
callers must not fall back to window-focus parsing (R4 finding 2).
"""
if not dump:
return None
blocks = _resumed_blocks(dump)
for kind, text in blocks:
if kind == "topResumedActivity":
continue
package = _extract_package(text)
if package:
return package
for kind, text in blocks:
if kind != "topResumedActivity":
continue
package = _extract_package(text)
if package:
return package
return None
@@ -13,20 +13,34 @@ class PlaybackIntent:
Attributes:
target_state: The MediaPlayerState we expect after the command.
command: The service/command name (for retries and logging).
command: The command/service name (for retries and logging).
created_at: Monotonic timestamp when the intent was created.
prior_state: The source state before the intent (for detecting external overrides).
retry_count: Number of retries attempted so far.
via_adb: True when the command was successfully served by the ADB
keyevent path — the retry must then go over ADB too, not back to
the remote that did not serve it.
adb_command: The ADB keycode command name matching the intent
(play/pause/stop/next/previous); set together with via_adb.
via_protocol: True when the command was served by the remote-protocol
key push (Phase 2) — the retry then goes over the protocol key
again instead of the other transports.
protocol_key: The protocol keycode string served with via_protocol.
"""
target_state: str # MediaPlayerState value (e.g. "playing", "paused")
command: str
created_at: float
prior_state: str | None = None
retry_count: int = 0
via_adb: bool = False
adb_command: str | None = None
via_protocol: bool = False
protocol_key: str | None = None
@classmethod
def create(cls, target_state: str, command: str, prior_state: str | None = None) -> "PlaybackIntent":
def create(
cls, target_state: str, command: str, prior_state: str | None = None
) -> "PlaybackIntent":
return cls(
target_state=target_state,
command=command,
@@ -45,4 +59,6 @@ class PlaybackIntent:
"command": self.command,
"retries": self.retry_count,
"age_s": self.age_seconds,
"via_adb": self.via_adb,
"adb_command": self.adb_command,
}
@@ -0,0 +1,79 @@
"""Live events push (P5-3 / C-STREAM): logcat foreground trigger.
Parses Android logcat's ActivityTaskManager ``START`` records and extracts
the ``cmp=package`` part so the connection manager can stamp the foreground
AppState instantly (sub-heartbeat latency). Logcat carries lifecycle only —
no PlaybackState — so the hybrid split (ADB stays the playback/scene state
source) is untouched: the stream never reads playback state.
"""
import re
_LOG_RECORD_RE = re.compile(r"^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+")
_CMP_RE = re.compile(r"cmp=([\w.]+)(?:/[^\s,}]+)?")
_START_HINT_RE = re.compile(r"ActivityTaskManager:.*?START\b")
def start_records(lines: list[str]) -> list[str]:
"""Split raw logcat lines into START-record buffers (wrapped lines joined).
A record opens at a line that matches a log timestamp and mentions
ActivityTaskManager + START; subsequent lines that do NOT look like a
fresh log record are treated as continuations (the intent JSON can wrap
across lines). Anything else closes/open the next record.
"""
records: list[str] = []
current: list[str] = []
for line in lines:
if _LOG_RECORD_RE.match(line) and _START_HINT_RE.search(line):
if current:
records.append("\n".join(current))
current = [line]
elif current and not _LOG_RECORD_RE.match(line):
current.append(line) # wrapped intent text
else:
if current:
records.append("\n".join(current))
current = []
if current:
records.append("\n".join(current))
return records
def foreground_from_logcat(lines: list[str]) -> str | None:
"""The foreground package from the LAST valid START record, or None.
Non-START lines are ignored entirely; records whose intent carries no
``cmp=`` (e.g. service starts) yield nothing; a explicit ``null`` cmp
is skipped too. Multi-user prefixes (``START u0``/``u10``...) are
tolerated — the user prefix is never part of the cmp value.
"""
package: str | None = None
for record in start_records(lines):
match = _CMP_RE.search(record)
if not match:
continue
candidate = match.group(1)
if candidate and candidate != "null":
package = candidate # last valid START wins
return package
def foreground_packages(lines: list[str]) -> list[str]:
"""Every distinct valid START package in order (test seam)."""
seen: list[str] = []
for record in start_records(lines):
match = _CMP_RE.search(record)
if not match:
continue
candidate = match.group(1)
if candidate and candidate != "null" and candidate not in seen:
seen.append(candidate)
return seen
__all__ = [
"foreground_from_logcat",
"foreground_packages",
"start_records",
]
@@ -4,12 +4,20 @@ Parses `dumpsys media_session` output from ADB to extract now-playing
information from any app using Android's MediaSession API.
"""
import re
from dataclasses import dataclass
@dataclass
class MediaSessionInfo:
"""Parsed media session data from Android."""
"""Parsed media session data from Android.
Playback position fields come from the session's PlaybackState line:
``position`` is the playback offset in ms as of ``updated`` — which is a
**device uptime-ms** timestamp (verified on Shield Android 11, R9), not a
wallclock value. Use :meth:`position_at_uptime` with the device's current
uptime to extrapolate the live position.
"""
title: str | None = None
artist: str | None = None
@@ -17,6 +25,56 @@ class MediaSessionInfo:
package: str | None = None
playing: bool = False
paused: bool = False
position: float | None = None
speed: float | None = None
updated: int | None = None
def position_at_uptime(self, now_uptime_ms: float) -> float | None:
"""Extrapolate the live playback position at device uptime ``now_uptime_ms``.
Mirrors HA core's convention: ``position + (now - updated) * speed``.
Returns None when the session carries no position sample.
"""
if self.position is None or self.updated is None:
return None
speed = self.speed if self.speed is not None else 1.0
return self.position + (now_uptime_ms - self.updated) * speed
_PLAYBACK_STATE_RE = re.compile(r"PlaybackState\s*\{([^}]*)\}")
def _parse_playback_state(content: str) -> dict[str, str]:
"""Parse the ``key=value`` pairs inside a PlaybackState block.
Splitting on ", " (rather than searching for ``position=`` anywhere in
the line) keeps the ``buffered position`` field from being mistaken for
the real ``position``.
"""
fields: dict[str, str] = {}
for part in content.split(", "):
key, _, value = part.partition("=")
if value:
fields[key.strip()] = value.strip()
return fields
def _parse_float(value: str | None) -> float | None:
if value is None:
return None
try:
return float(value)
except ValueError:
return None
def _parse_int(value: str | None) -> int | None:
if value is None:
return None
try:
return int(value)
except ValueError:
return None
def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
@@ -32,6 +90,9 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
current_pkg: str | None = None
in_active = False
current_state = 0 # 0=unknown, 2=paused, 3=playing
current_position: float | None = None
current_speed: float | None = None
current_updated: int | None = None
current_meta: MediaSessionInfo | None = None
for line in dump.split("\n"):
@@ -47,16 +108,25 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
current_pkg = pkg[1].strip()
in_active = False
current_state = 0
current_position = None
current_speed = None
current_updated = None
current_meta = None
if "active=true" in stripped:
in_active = True
if in_active and "state=PlaybackState" in stripped:
if "state=3" in stripped:
current_state = 3
elif "state=2" in stripped:
current_state = 2
match = _PLAYBACK_STATE_RE.search(stripped)
if match:
fields = _parse_playback_state(match.group(1))
try:
current_state = int(fields.get("state", 0))
except ValueError:
current_state = 0
current_position = _parse_float(fields.get("position"))
current_speed = _parse_float(fields.get("speed"))
current_updated = _parse_int(fields.get("updated"))
if in_active and "metadata: size=" in stripped and "description=" in stripped:
desc_start = stripped.find("description=")
@@ -69,6 +139,9 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
info.package = current_pkg
info.playing = current_state == 3
info.paused = current_state == 2
info.position = current_position
info.speed = current_speed
info.updated = current_updated
if len(parts) >= 1 and parts[0] not in ("null", ""):
info.title = parts[0]
@@ -97,6 +170,34 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
return sessions
def parse_audio_playback(dump: str) -> list[str]:
"""Parse the "Audio playback (lastly played comes first)" section.
Returns the package names that recently played audio, in the dump's own
order (most recently played first). This is a zero-cost hint from the same
``dumpsys media_session`` call: an app shows up here even when it publishes
no MediaSession (e.g. TiviMate, R2). Callers may filter system processes
such as ``com.google.android.apps.mediashell`` (the Chromecast receiver).
"""
if not dump:
return []
packages: list[str] = []
in_audio = False
for line in dump.split("\n"):
stripped = line.strip()
if not in_audio:
in_audio = "Audio playback" in stripped
continue
marker = "packages="
if marker not in stripped:
continue
pkg_part = stripped.split(marker, 1)[1]
for pkg in pkg_part.replace(",", " ").split():
if pkg and pkg not in packages:
packages.append(pkg)
return packages
def parse_media_session(
dump: str, foreground_package: str | None = None
) -> MediaSessionInfo | None:
@@ -12,6 +12,7 @@ def running_apps_from_tasks(
recent_tasks: list[dict],
media_sessions: list[MediaSessionInfo] | None = None,
foreground_package: str | None = None,
last_known: dict[str, MediaSessionInfo] | None = None,
) -> list[dict] | None:
"""Build a list of running app dicts from recent tasks + media sessions.
@@ -19,6 +20,9 @@ def running_apps_from_tasks(
recent_tasks: List of {"package": str} from ADB recent tasks.
media_sessions: Optional media sessions for playback state enrichment.
foreground_package: The current foreground app package.
last_known: Optional per-package TTL-filtered last-known-state cache
(P3-3b) — apps without a live session carry their last known
playback state as ``last_known_state``/``last_known_title``.
Returns None if there are fewer than 2 apps (no point showing a switcher
with only one option).
@@ -57,6 +61,15 @@ def running_apps_from_tasks(
app["artist"] = session.artist
else:
app["state"] = "running"
# P3-3b: no live session — surface the TTL-capped last-known state
cached = (last_known or {}).get(pkg)
if cached is not None:
if cached.playing:
app["last_known_state"] = "playing"
elif cached.paused:
app["last_known_state"] = "paused"
if cached.title:
app["last_known_title"] = cached.title
apps.append(app)
@@ -0,0 +1,73 @@
"""Shared config helpers for the NVIDIA Shield integration."""
import os
from dataclasses import dataclass
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .const import (
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY,
DEFAULT_ADB_PORT,
DOMAIN,
)
@dataclass
class AdbConfig:
"""Resolved ADB connection settings for a config entry."""
host: str
port: int
key_path: str
def adb_key_path(hass: HomeAssistant) -> str:
"""Path of the ADB private key for this integration (may not exist yet)."""
return os.path.join(hass.config.config_dir, ".storage", DOMAIN, "adbkey")
def protocol_cert_paths(hass: HomeAssistant) -> tuple[str, str]:
"""Local (certfile, keyfile) paths for the remote protocol client.
Mirrors core androidtv_remote's storage pattern: private material under
``.storage/<domain>/``. Shared between config-flow pairing and runtime —
an entry aborts duplicate setup by unique_id (= device MAC), so one pair
of files suffices per install.
"""
base = os.path.join(hass.config.config_dir, ".storage", DOMAIN)
os.makedirs(base, exist_ok=True)
return os.path.join(base, "client.pem"), os.path.join(base, "client.key")
def discover_adb_host(
hass: HomeAssistant, androidtv_entity_id: str | None
) -> str | None:
"""Best-effort host for an AndroidTV entity, from its owning config entry."""
if not androidtv_entity_id:
return None
registry = er.async_get(hass)
entity_entry = registry.async_get(androidtv_entity_id)
if not entity_entry or not entity_entry.config_entry_id:
return None
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if config_entry:
return config_entry.data.get("host")
return None
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
"""Resolve host/port/key_path for the entry's ADB connection, or None.
Priority: explicit ADB host/port options > host from the AndroidTV
entity's own config entry.
"""
host = entry.options.get(CONF_ADB_HOST) or discover_adb_host(
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
)
if not host:
return None
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
return AdbConfig(host=host, port=port, key_path=adb_key_path(hass))
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+23 -14
View File
@@ -1,16 +1,25 @@
{
"domain": "nvidia_shield",
"name": "NVIDIA Shield",
"codeowners": [
"@ltomes"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/ltomes/ha-nvidia-shield",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/ltomes/ha-nvidia-shield/issues",
"requirements": [
"adb-shell>=0.4.0"
],
"version": "0.1.1"
"domain": "nvidia_shield",
"name": "NVIDIA Shield",
"codeowners": [
"@notexpectedyet"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield",
"iot_class": "local_push",
"issue_tracker": "https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield/issues",
"requirements": [
"adb-shell>=0.4.0",
"androidtvremote2>=0.2.0"
],
"version": "2026.10.08",
"zeroconf": [
"_androidtvremote2._tcp.local."
],
"integration_type": "device",
"loggers": [
"adb_shell",
"androidtvremote2"
]
}
File diff suppressed because it is too large. Load diff
+190
View File
@@ -0,0 +1,190 @@
"""Remote entities for NVIDIA Shield — protocol-backed (Phase 2, t10).
Port of HA core androidtv_remote/remote.py semantics onto the absorbed
remote-protocol transport (androidtvremote2) owned by ShieldConnectionManager:
- ``send_command`` accepts SHORT/START_LONG/END_LONG direction prefixes
(``"START_LONG:<key>"``; no prefix = SHORT) and core-style service keys;
the core port's hold_secs+direction validation quirks are not carried over —
long presses are expressed as the START_LONG/END_LONG pair instead;
- ``num_repeats`` / ``delay_secs`` are honored; long-press duration uses the
``hold_secs`` parameter;
- ``activity`` = the protocol current-app push (package; name resolution is
deferred app-map work);
- turn on/off send the POWER key and honor the is_on push (no superfluous
toggling when the state already matches).
"""
import asyncio
from homeassistant.components.remote import RemoteEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .connection import ShieldConnectionManager
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS
_START_PREFIX = "START_LONG:"
_END_PREFIX = "END_LONG:"
_SHORT_PREFIX = "SHORT:"
_POWER_KEY = "KEYCODE_POWER"
def _split_command(command: str) -> tuple[str, str]:
"""Key + direction from a command string (default SHORT)."""
lowered = command.strip()
if lowered.startswith(_START_PREFIX):
return lowered[len(_START_PREFIX):], "START_LONG"
if lowered.startswith(_END_PREFIX):
return lowered[len(_END_PREFIX):], "END_LONG"
if lowered.startswith(_SHORT_PREFIX):
return lowered[len(_SHORT_PREFIX):], "SHORT"
return lowered, "SHORT"
def _key_code_name(command: str) -> str:
"""Command kind → protocol keycode (raw KEYCODE_ names pass through)."""
mapped = PROTOCOL_REMOTE_KEYS.get(command.lower())
if mapped is not None:
return mapped
upper = command.upper()
if upper.startswith("KEYCODE_"):
return upper
return upper # unknown names: the library reports/validates them
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add the protocol-backed remote entity for this entry."""
async_add_entities([NvidiaShieldRemoteEntity(hass, entry)])
class NvidiaShieldRemoteEntity(RemoteEntity):
"""Remote entity over the manager's androidtvremote2 transport."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
self._entry = entry
self._manager: ShieldConnectionManager | None = getattr(
entry, "runtime_data", None
)
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_remote"
def _manager_of(self) -> ShieldConnectionManager | None:
return getattr(self._entry, "runtime_data", None)
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="NVIDIA",
model="Shield TV",
)
@property
def available(self) -> bool:
"""Unavailable while the transport is active but unpaired/down.
A passive manager (no protocol transport) keeps the entity available:
nothing to transport is not a broken one.
"""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return True
if not manager.protocol_active:
return True
return manager.is_available is not False
@property
def activity(self) -> str | None:
"""Current foreground app reported by the protocol push."""
manager = self._manager_of()
if isinstance(manager, ShieldConnectionManager):
return manager.current_app
return None
@callback
def _refresh_state(self) -> None:
"""Manager store changed — re-evaluate state (activity/available)."""
self._manager = self._manager_of()
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
manager = self._manager_of()
if isinstance(manager, ShieldConnectionManager):
self._manager_unregistered = manager.register_listener(
self._refresh_state
)
async def async_will_remove_from_hass(self) -> None:
unregistered = getattr(self, "_manager_unregistered", None)
if unregistered is not None:
unregistered()
self._manager_unregistered = None
async def async_turn_on(self, **kwargs) -> None:
"""Wake the device with the POWER key when it reports off."""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return
if manager.is_on is not False:
# Unknown counts as "maybe on" — no superfluous toggle.
return
await manager.send_protocol_key(_POWER_KEY, "SHORT")
async def async_turn_off(self, **kwargs) -> None:
"""Sleep the device with the POWER key when it reports on."""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return
if manager.is_on is not True:
return
await manager.send_protocol_key(_POWER_KEY, "SHORT")
async def async_send_command(
self,
command: list[str],
num_repeats: int = 1,
delay_secs: float = 0.0,
hold_secs: float = 0.0,
) -> None:
"""Send protocol keys: repeats × (key, or START_LONG/hold/END_LONG)."""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return
for _ in range(max(1, num_repeats)):
for cmd in command:
key_part, direction = _split_command(cmd)
key_name = _key_code_name(key_part)
if hold_secs > 0 and direction == "SHORT":
if await manager.send_protocol_key(key_name, "START_LONG"):
await asyncio.sleep(max(0.0, hold_secs))
await manager.send_protocol_key(key_name, "END_LONG")
else:
await manager.send_protocol_key(key_name, direction)
if delay_secs > 0:
await asyncio.sleep(delay_secs)
async def async_send_text(self, text: str) -> bool:
"""Text entry (P5-2) via the protocol send_text (remote surface).
Falls back to ADB `input text` only when the protocol transport is
down or the send failed (the Shield may swallow IME text while its
on-screen keyboard is up, RESEARCH_IDEAS §2).
"""
manager = self._manager_of()
if manager is None or not text:
return False
if await manager.send_text_ime(text):
return True
return await manager.send_adb_input_text(text)
+51 -15
View File
@@ -4,12 +4,13 @@ import logging
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_PLAYING, STATE_PAUSED
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .connection import ShieldConnectionManager
from .const import (
CONF_ANDROIDTV_ENTITY,
CONF_JELLYFIN_ENTITY,
@@ -38,6 +39,8 @@ class NvidiaShieldSensorBase(SensorEntity):
_attr_has_entity_name = True
_attr_should_poll = False
# G12 (t30): poll-driven attributes never belong in the database
_unrecorded_attributes = frozenset({"app_id", "app_name", "playback_state"})
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
@@ -60,49 +63,82 @@ class NvidiaShieldSensorBase(SensorEntity):
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
"""Current running app on the Shield."""
"""Current running app on the Shield.
_attr_name = "Current App"
Primary source: the androidtv entity's own state. When that entity is
unavailable, falls back to the ShieldConnectionManager's StateStore
(most recent visible ADB recent-task app).
"""
_attr_translation_key = "current_app"
_attr_icon = "mdi:application"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
super().__init__(hass, entry)
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY]
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
def _manager(self) -> ShieldConnectionManager | None:
manager = getattr(self._entry, "runtime_data", None)
return manager if isinstance(manager, ShieldConnectionManager) else None
async def async_added_to_hass(self) -> None:
self._unsub_listeners.append(
async_track_state_change_event(
self.hass, [self._androidtv_entity], self._handle_state_change
)
)
manager = self._manager()
if manager is not None:
# Refresh when the connection manager updates its store.
self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
@callback
def _handle_state_change(self, event: Event) -> None:
self.async_write_ha_state()
@callback
def _handle_store_update(self) -> None:
self.async_write_ha_state()
@property
def native_value(self) -> str | None:
state = self.hass.states.get(self._androidtv_entity)
if not state:
return None
return state.attributes.get("app_name") or state.attributes.get("app_id")
if state:
return state.attributes.get("app_name") or state.attributes.get("app_id")
# androidtv entity unavailable — StateStore priority chain (P3-3c):
# protocol current_app push → ADB mResumedActivity → ADB recents
manager = self._manager()
if manager is not None:
foreground = manager.foreground_app
if foreground:
return foreground
running = manager.running_apps
if running:
return running[0].get("package")
return None
@property
def extra_state_attributes(self) -> dict:
state = self.hass.states.get(self._androidtv_entity)
if not state:
return {}
return {
"app_id": state.attributes.get("app_id"),
"app_name": state.attributes.get("app_name"),
}
if state:
return {
"app_id": state.attributes.get("app_id"),
"app_name": state.attributes.get("app_name"),
}
manager = self._manager()
if manager is not None:
running = manager.running_apps
if running:
return {"app_id": running[0].get("package")}
return {}
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
"""What's currently playing via Jellyfin."""
_attr_name = "Now Playing"
_attr_translation_key = "now_playing"
_attr_icon = "mdi:play-circle"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -0,0 +1,24 @@
adb_command:
# Both fields required — integrating targeting must not be optional
# (docs/dev_101_services: no default target when none specified).
name: ADB command
description: Send a raw ADB shell command to the persistent connection of
an Nvidia Shield entry (the entry must have the adb_command_enabled
option on; the integration gates USE, not registration).
fields:
command:
name: Command
description: The shell command to run on the device, e.g.
"dumpsys power | head -5".
required: true
example: "dumpsys power | head -5"
selector:
text:
entity_id:
name: Entity
description: The Nvidia Shield media player entity to target.
required: true
selector:
entity:
integration: nvidia_shield
domain: media_player
+140 -41
View File
@@ -3,70 +3,169 @@
"step": {
"user": {
"title": "NVIDIA Shield",
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, the Nvidia Remote Protocol, or ADB. Link the devices and services you want to use.",
"data": {
"name": "Device Name",
"androidtv_entity": "Android TV Remote",
"jellyfin_entity": "Jellyfin Media Player",
"name": "Name",
"androidtv_entity": "Android TV Remote entity",
"jellyfin_entity": "Jellyfin media player",
"receiver_entity": "Receiver",
"receiver_input": "Receiver Input",
"power_entity": "Power Switch",
"adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host",
"adb_port": "ADB Port"
"receiver_input": "Receiver input",
"power_entity": "Power switch",
"adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show title and artist",
"adb_host": "ADB host (IP address)",
"adb_port": "ADB port",
"protocol_enabled": "Add the Nvidia Remote Protocol",
"protocol_host": "Protocol host (IP address)"
},
"data_description": {
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
"receiver_entity": "The receiver this device is connected to.",
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
"power_entity": "Smart plug switch for reliable on/off detection.",
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
"adb_port": "ADB port number. Default is 5555."
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.",
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
"receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \u2014 change it only if you need a different address.",
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step.",
"protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host."
}
},
"adb_pair": {
"title": "Pair ADB",
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
"title": "Allow ADB access",
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
"data": {
"adb_host": "ADB host (IP address)",
"adb_port": "ADB port",
"skip_pairing": "Continue without ADB pairing"
},
"data_description": {
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
"adb_port": "The standard ADB port is 5555.",
"skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later."
}
},
"protocol_pair": {
"title": "Pair the remote protocol",
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
"data": {
"pairing_code": "Pairing code shown on the Shield",
"protocol_host": "Protocol host (IP address)",
"skip_pairing": "Continue without protocol pairing"
},
"data_description": {
"pairing_code": "The code the Shield displays while this step is open.",
"protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
}
},
"reauth_confirm": {
"title": "Re-pair Nvidia Shield",
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again \u2014 the Shield then shows a code."
}
},
"error": {
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step."
"adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
"adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
"protocol_no_host": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
"no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, 'Add the Nvidia Remote Protocol', or 'Read state over ADB'.",
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.",
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
},
"abort": {
"already_configured": "This Shield entity is already configured."
"already_configured": "This Shield is already set up.",
"reauth_successful": "Re-paired successfully.",
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
"reauth_entry_missing": "This entry no longer exists.",
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
"zeroconf_no_host": "The discovery event carried no usable address.",
"yaml_seed_only": {},
"yaml_seed": {
"title": "YAML succeeded earlier",
"message": "The configuration.yaml nvidia_shield block is seed-only: it starts the standard interactive flow. Removing the YAML block creates or deletes nothing."
}
}
},
"options": {
"step": {
"init": {
"title": "NVIDIA Shield Settings",
"description": "Configure linked devices and monitoring.",
"title": "NVIDIA Shield settings",
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
"data": {
"jellyfin_entity": "Jellyfin Media Player",
"jellyfin_entity": "Jellyfin media player",
"receiver_entity": "Receiver",
"receiver_input": "Receiver Input",
"power_entity": "Power Switch",
"adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host",
"adb_port": "ADB Port"
"receiver_input": "Receiver input",
"power_entity": "Power switch",
"adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show title and artist",
"adb_host": "ADB host (IP address)",
"adb_port": "ADB port",
"app_name_map": "App names and icons",
"adb_command_enabled": "Enable the adb_command service",
"channel_digit_delay": "Channel digit delay (seconds)",
"channel_send_enter": "Send ENTER after channel digits",
"logcat_events": "Instant app detection (logcat stream)"
},
"data_description": {
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
"receiver_entity": "The receiver this device is connected to.",
"receiver_input": "Which input on the receiver.",
"power_entity": "Smart plug switch for reliable on/off detection.",
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
"adb_port": "ADB port (default 5555)."
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
"receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.",
"adb_port": "The ADB port. The standard value is 5555.",
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.",
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).",
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing."
}
}
}
},
"services": {
"adb_command": {
"name": "ADB command",
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
"fields": {
"command": {
"name": "Command",
"description": "The shell command to run on the device."
},
"entity_id": {
"name": "Entity",
"description": "The Nvidia Shield media player to target."
}
}
}
},
"exceptions": {
"invalid_command": {
"title": "Empty command",
"message": "The command field is empty \u2014 nothing was sent."
},
"unsupported_target": {
"title": "Unsupported entity for adb_command",
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
},
"adb_unavailable": {
"title": "ADB unavailable",
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
}
},
"entity": {
"sensor": {
"current_app": {
"name": "Current app"
},
"now_playing": {
"name": "Now playing"
}
}
}
}
@@ -3,70 +3,169 @@
"step": {
"user": {
"title": "NVIDIA Shield",
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, the Nvidia Remote Protocol, or ADB. Link the devices and services you want to use.",
"data": {
"name": "Device Name",
"androidtv_entity": "Android TV Remote",
"jellyfin_entity": "Jellyfin Media Player",
"name": "Name",
"androidtv_entity": "Android TV Remote entity",
"jellyfin_entity": "Jellyfin media player",
"receiver_entity": "Receiver",
"receiver_input": "Receiver Input",
"power_entity": "Power Switch",
"adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host",
"adb_port": "ADB Port"
"receiver_input": "Receiver input",
"power_entity": "Power switch",
"adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show title and artist",
"adb_host": "ADB host (IP address)",
"adb_port": "ADB port",
"protocol_enabled": "Add the Nvidia Remote Protocol",
"protocol_host": "Protocol host (IP address)"
},
"data_description": {
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
"receiver_entity": "The receiver this device is connected to.",
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
"power_entity": "Smart plug switch for reliable on/off detection.",
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
"adb_port": "ADB port number. Default is 5555."
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.",
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
"receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \u2014 change it only if you need a different address.",
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step.",
"protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host."
}
},
"adb_pair": {
"title": "Pair ADB",
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
"title": "Allow ADB access",
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
"data": {
"adb_host": "ADB host (IP address)",
"adb_port": "ADB port",
"skip_pairing": "Continue without ADB pairing"
},
"data_description": {
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
"adb_port": "The standard ADB port is 5555.",
"skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later."
}
},
"protocol_pair": {
"title": "Pair the remote protocol",
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
"data": {
"pairing_code": "Pairing code shown on the Shield",
"protocol_host": "Protocol host (IP address)",
"skip_pairing": "Continue without protocol pairing"
},
"data_description": {
"pairing_code": "The code the Shield displays while this step is open.",
"protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
}
},
"reauth_confirm": {
"title": "Re-pair Nvidia Shield",
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again \u2014 the Shield then shows a code."
}
},
"error": {
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step."
"adb_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
"adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
"protocol_no_host": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).",
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
"no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, 'Add the Nvidia Remote Protocol', or 'Read state over ADB'.",
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.",
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
},
"abort": {
"already_configured": "This Shield entity is already configured."
"already_configured": "This Shield is already set up.",
"reauth_successful": "Re-paired successfully.",
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
"reauth_entry_missing": "This entry no longer exists.",
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
"zeroconf_no_host": "The discovery event carried no usable address.",
"yaml_seed_only": {},
"yaml_seed": {
"title": "YAML succeeded earlier",
"message": "The configuration.yaml nvidia_shield block is seed-only: it starts the standard interactive flow. Removing the YAML block creates or deletes nothing."
}
}
},
"options": {
"step": {
"init": {
"title": "NVIDIA Shield Settings",
"description": "Configure linked devices and monitoring.",
"title": "NVIDIA Shield settings",
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
"data": {
"jellyfin_entity": "Jellyfin Media Player",
"jellyfin_entity": "Jellyfin media player",
"receiver_entity": "Receiver",
"receiver_input": "Receiver Input",
"power_entity": "Power Switch",
"adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host",
"adb_port": "ADB Port"
"receiver_input": "Receiver input",
"power_entity": "Power switch",
"adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show title and artist",
"adb_host": "ADB host (IP address)",
"adb_port": "ADB port",
"app_name_map": "App names and icons",
"adb_command_enabled": "Enable the adb_command service",
"channel_digit_delay": "Channel digit delay (seconds)",
"channel_send_enter": "Send ENTER after channel digits",
"logcat_events": "Instant app detection (logcat stream)"
},
"data_description": {
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
"receiver_entity": "The receiver this device is connected to.",
"receiver_input": "Which input on the receiver.",
"power_entity": "Smart plug switch for reliable on/off detection.",
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
"adb_port": "ADB port (default 5555)."
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
"receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.",
"adb_port": "The ADB port. The standard value is 5555.",
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.",
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).",
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.",
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing."
}
}
}
},
"services": {
"adb_command": {
"name": "ADB command",
"description": "Send a raw ADB shell command to the persistent connection of an Nvidia Shield entry (option-gated use).",
"fields": {
"command": {
"name": "Command",
"description": "The shell command to run on the device."
},
"entity_id": {
"name": "Entity",
"description": "The Nvidia Shield media player to target."
}
}
}
},
"exceptions": {
"invalid_command": {
"title": "Empty command",
"message": "The command field is empty \u2014 nothing was sent."
},
"unsupported_target": {
"title": "Unsupported entity for adb_command",
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
},
"adb_unavailable": {
"title": "ADB unavailable",
"message": "The ADB connection of {entity_id} is down \u2014 command not sent."
}
},
"entity": {
"sensor": {
"current_app": {
"name": "Current app"
},
"now_playing": {
"name": "Now playing"
}
}
}
}
+238
View File
@@ -0,0 +1,238 @@
# HA integration-standards audit — custom_components/nvidia_shield
Status: **COMPLETE.** Every claim is cited to a developer-docs page fetched during
this audit (October 2026; the docs site shows "Last updated" stamps of Jun–Oct 2026
on the pages cited). Companion docs: `MVP_PLAN.md`, `RESEARCH_SHIELD.md`,
`RESEARCH_IDEAS.md`.
Sources audited (all fetched live):
[Manifest](https://developers.home-assistant.io/docs/creating_integration_manifest/) (updated 2026-10-06) ·
[Config flow](https://developers.home-assistant.io/docs/core/integration/config_flow/) ·
[Options flow](https://developers.home-assistant.io/docs/core/integration/options_flow/) (updated 2026-09-17) ·
[YAML configuration](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/) (updated 2026-09-17) ·
[Config entries](https://developers.home-assistant.io/docs/config_entries_index/) (updated 2026-06-19) ·
[Entity](https://developers.home-assistant.io/docs/core/entity/) ·
[Internationalization](https://developers.home-assistant.io/docs/internationalization/core/) ·
[Quality scale](https://developers.home-assistant.io/docs/core/integration-quality-scale/) + [rules](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/) ·
[Custom actions](https://developers.home-assistant.io/docs/dev_101_services/) ·
[Diagnostics](https://developers.home-assistant.io/docs/core/integration/diagnostics/) ·
[Tests file structure](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/) ·
[Deprecating (incl. YAML-drop pattern)](https://developers.home-assistant.io/docs/deprecating/) ·
[ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md) ·
[Probatio blog](https://developers.home-assistant.io/blog/2026/09/30/probatio-validation-engine/) ·
[Listener+reload deprecation blog](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/) ·
[BrowseMediaSource domain blog](https://developers.home-assistant.io/blog/2026/05/20/browse-media-source-root-class/) ·
[Central config-flow abort reasons](https://developers.home-assistant.io/blog/2026/09/28/central-config-flow-abort-reasons/) ·
[Always reload after reauth](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/) ·
[HACS publish requirements](https://hacs.xyz/docs/publish/integration/)
Code audited: `manifest.json`, `strings.json`, `translations/en.json`,
`__init__.py`, `config_flow.py`, `connection.py`, `media_player.py`,
`remote.py`, `sensor.py`, `button.py`, `diagnostics.py`, `helpers.py`, tests/,
repo root (`hacs.json`, README).
Legend: ✅ compliant · ⚠️ partial / should improve · ❌ gap (concrete fix in §3).
---
## Part 1 — Compliance checklist
### 1.1 Manifest ([docs](https://developers.home-assistant.io/docs/creating_integration_manifest/))
| Requirement | Us | Notes |
|---|---|---|
| `domain`, `name` naming rules | ✅ | `nvidia_shield` / "NVIDIA Shield" — local-only product, no suffix, per manifest doc's name rules |
| `version` required for custom integrations (AwesomeVersion-compatible) | ✅ | `2026.10.08` (CalVer) — "The version of the integration is required for custom integrations" |
| `documentation` URL | ✅ | points to the repo; for core submission it must be home-assistant.io (not applicable yet) |
| `issue_tracker` | ✅ | present (HACS also lists it as required: [HACS publish](https://hacs.xyz/docs/publish/integration/)) |
| `codeowners` | ✅ | `@notexpectedyet` |
| `iot_class` from allowed set | ✅ | `local_push` — valid enum value; correct now that both the protocol push and the persistent ADB manager are the primary paths |
| `config_flow: true` + `config_flow.py` exists | ✅ | |
| `integration_type` | ⚠️ | **Not set.** Required for core integrations with a config flow; for custom it defaults to `hub` — but we are a single-device-per-entry integration, so it "should generally" be `device`. Explicit is recommended ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)). Gap G1 |
| `requirements` | ✅ | `adb-shell>=0.4.0`, `androidtvremote2>=0.2.0`; "Custom integrations should only include requirements that are not required by Core" — both are non-core deps. Range-style (`>=`) is accepted for custom integrations (hassfest pins core integrations to `==`; range bounds are the pragmatic custom-integration choice) |
| `dependencies` | ✅ | `[]` — we read Jellyfin state via the *already-configured* jellyfin integration and discover via the user's zeroconf service; neither is required to be a `dependencies` entry (manifest doc: dependencies guarantee the integration loads before setup, not that entries exist). G4 below covers the Jellyfin soft-dependency behavior if we ever import its API |
| `zeroconf` discovery key | ✅ | `_androidtvremote2._tcp.local.` — exact-service-type entry, not in the "too generic" list |
| `single_config_entry` | n/a | Multiple Shields are legitimate — do NOT set ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
| `loggers` | ⚠️ | Not set. Recommended so users can raise `adb_shell`/`androidtvremote2` logging under our domain view; `adb_shell` (we wrap) and `androidtvremote2` are exactly the `getLogger` namespaces of our requirements. Gap G1 |
| `quality_scale` | n/a | Only meaningful for core integrations; custom integrations can still self-audit (§1.9) but shouldn't fake the key ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
| `import_executor` | ✅ | default (true) is right for us |
| `disabled` | n/a | |
### 1.2 Config flow ([docs](https://developers.home-assistant.io/docs/core/integration/config_flow/))
| Requirement | Us | Notes |
|---|---|---|
| Extends `ConfigFlow, domain=DOMAIN` in `config_flow.py` | ✅ | |
| `VERSION` (+ migration for old entries) | ✅ | `VERSION = 2` with `async_migrate_entry` handling v1→v2 and rejecting downgrades (`config_entry.version > 2 → False`) — matches the migration contract ("return False → migration_error") |
| Unique IDs: stable, string, not user-changeable; `_abort_if_unique_id_configured` | ⚠️ | Protocol path uses the **MAC from the server certificate** (accepted source, [docs list](https://developers.home-assistant.io/docs/core/integration/config_flow/)). But the fallback unique_id is the **`androidtv_entity` entity_id** — entity IDs are user-renameable, which makes them a weak unique_id source (docs list "Device Name / hostname that can be changed" as unacceptable; a renameable entity reference is the same class of problem). Gap G2 |
| Discovery (zeroconf): set unique ID, abort `already_configured`, update host on IP change | ✅ | `async_step_zeroconf` sets MAC unique_id, aborts duplicates, and updates `protocol_host`/blank `adb_host` on IP change — this is the [gold `discovery-update-info`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/discovery-update-info) behavior already implemented |
| Discovery steps must never finish without user confirmation | ✅ | first-time discovery chains into the user step |
| Discovery without unique ID → `_async_handle_discovery_without_unique_id()` | ❌ | not used on the no-MAC path; gap G2 (combine with the unique-id fallback fix) |
| `is_matching` / `async_has_matching_flow` for ambiguous discovery | ✅-ish | MAC unique_id set in the flow aborts concurrent flows for the same device (docs: "the flow will immediately abort if another flow is in progress for this unique ID"); the MAC-less path is the only exposure (G2) |
| Errors/aborts via strings keys (`errors["base"]`, `async_abort(reason=...)`) | ✅ | all keys exist in `strings.json` config.error/abort |
| Interactive pairing (PIN) via flow steps | ✅ | `protocol_pair` uses `async_show_progress`-free two-step pattern (PIN prompt); acceptable — `async_show_progress`/`async_show_progress_done` are for long-running background work |
| Reauth flow | ⚠️ | Steps exist (`async_step_reauth`/`reauth_confirm`), triggered via `entry.async_start_reauth(hass)` from the manager's invalid-auth callback ([config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/): reauth must update the entry and abort, not create). But `_finish_reauth` uses `async_update_entry` + `async_schedule_reload` — see G3 (deprecated combo) and [always-reload-after-reauth blog](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/); prefer `async_update_reload_and_abort(self._get_reauth_entry(), ...)`, and reauth flows need to be linked to a config entry ([2024-11-04 blog](https://developers.home-assistant.io/blog/2024/11/04/reauth-reconfigure-entry-id/) — ours gets it via `self.context["entry_id"]`, the mechanism the blog's helper replaces) |
| Reconfigure step | ❌ | No `async_step_reconfigure`. The [config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/) says a reconfigure step lets users change **data** that is "not meant to be optional" without an OptionsFlow, and it is a [gold rule](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow). Our hosts live in options, so we are not broken — but G5 documents what a reconfigure step should own (device name + host moves + source toggles) |
| Config-entry mutation only via `async_update_entry` | ✅ | everywhere ([config entries doc](https://developers.home-assistant.io/docs/config_entries_index/)) |
| Full test coverage of config_flow.py ([docs](https://developers.home-assistant.io/docs/core/integration/config_flow/): "Integrations with a config flow require full test coverage of all code in config_flow.py") | ⚠️ | tests exist (`test_config_flow_pairing.py`, `test_config_flow_protocol.py`) but "full coverage" (also [bronze rule `config-flow-test-coverage`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow-test-coverage)) needs a coverage run to confirm every step/branch; G6 |
### 1.3 Options flow ([docs](https://developers.home-assistant.io/docs/core/integration/options_flow/))
| Requirement | Us | Notes |
|---|---|---|
| `async_get_options_flow(config_entry) -> OptionsFlow()` **without constructor args**; entry via `self.config_entry` | ❌ | Ours: `NvidiaShieldOptionsFlow(config_entry)` stores `self._config_entry` — the old pattern. Current docs' handler takes **no arguments** and reads `self.config_entry`. Gap G3 |
| Prefill via `self.add_suggested_values_to_schema(SCHEMA, self.config_entry.options)` | ⚠️ | We hand-roll `vol.Optional(key, default=current)` per field — works, but the documented helper is the single schema + suggested-values pattern; also lets us keep ONE static schema instead of conditionally building keys (ours rebuilds the schema dict each render, which also means fields disappear/reappear depending on state — legal but harder to test). G3 |
| Update listener: `entry.async_on_unload(entry.add_update_listener(...))` | ✅ | registered in `async_setup_entry` ([options doc](https://developers.home-assistant.io/docs/core/integration/options_flow/)) |
| Reload-on-options: `OptionsFlowWithReload` OR update listener — **not both** | ❌ | We use the update listener (full reload on every options change, including pure-UI tweaks like app map); the current docs offer `OptionsFlowWithReload` for exactly this. Combined with G3 (the flow's own `async_schedule_reload` calls in reauth/zeroconf) this is the deprecated [listener+reloading-methods combo (error from 2026.12)](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/). Gap G3 |
### 1.4 strings.json / translations ([docs](https://developers.home-assistant.io/docs/internationalization/core/))
| Requirement | Us | Notes |
|---|---|---|
| `strings.json` adjacent to integration; `translations/en.json` for custom | ✅ | both present, same content |
| `config` (steps/errors/aborts), `options` sections | ✅ | complete for every key we return |
| Common-string references (`[%key:common::...%]`) | ⚠️ | e.g. `already_configured`, `reauth_successful` are common abort reasons — now [translated centrally](https://developers.home-assistant.io/blog/2026/09/28/central-config-flow-abort-reasons/) for shared reasons; harmless duplicated, minor cleanup |
| `title` key | ⚠️ | optional ("fallback to manifest name"); omitted is fine |
| `entity` section with `translation_key` on named entities | ⚠️ | Sensors use hard-coded English names (`_attr_name = "Current App"`, `"Now Playing"`). Entity doc: "Avoid hard coding a natural language name, use a translated name instead"; [gold rule `entity-translations`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-translations). Gap G7 |
| `exceptions` section for `HomeAssistantError` translations | ❌ | we raise/log instead of raising translated exceptions in the action handler (see G8; also [raising exceptions](https://developers.home-assistant.io/docs/core/platform/raising_exceptions/) pattern) |
| `services` section + `services.yaml` | ❌ | `nvidia_shield.adb_command` is registered dynamically with a schema but has **no `services.yaml` and no `services` strings** — see G8. Note [2025-10-24 blog](https://developers.home-assistant.io/blog/2025/10/24/service-web-api-changes/): service translations were removed from the WebSocket/REST service list — descriptions now live only in `services.yaml`/strings, making them required for frontend display |
| `issues` section (repairs) | n/a | no repair issues yet (gold [`repair-issues`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/repair-issues) — G9 suggestion for the ADB-unpaired state) |
### 1.5 Entity standards ([Entity doc](https://developers.home-assistant.io/docs/core/entity/))
| Requirement | Us | Notes |
|---|---|---|
| `has_entity_name = True` ([mandatory for new integrations](https://developers.home-assistant.io/docs/core/entity/)) | ✅ | all entities |
| Main-feature entity: `_attr_name = None` (friendly_name = device name) | ✅ | media_player and remote |
| Sub-entities named without device/type prefix | ✅-ish | "Current App", "Now Playing", "Pair ADB" — right shape, but hard-coded (G7) |
| `unique_id` on every entity ([bronze `entity-unique-id`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-unique-id)) | ✅ | `{DOMAIN}_{entry.entry_id}_{key}` — stable, not user-configurable |
| `device_info` on all entities (automatic device registration) | ✅ | every platform; one device per entry keyed by `entry.entry_id`. ⚠️ model is hard-coded "Shield TV" — the protocol's `device_info` (model/vendor/sw_version) is available and populating `model`/`model_id`/`sw_version` from it is a cheap upgrade ([`model_id` added 2024-07](https://developers.home-assistant.io/blog/2024/07/16/model-id-device-info/)). G10 |
| `should_poll` — push entities notify via `async_schedule_update_ha_state` / write | ✅ | `_attr_should_poll = False` everywhere + explicit `async_write_ha_state` on store events ([entity doc, subscribing pattern](https://developers.home-assistant.io/docs/core/entity/)) |
| `available` ([silver `entity-unavailable`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-unavailable)) | ✅ | media_player/remote/sensors derive from manager health |
| Feature flags via `MediaPlayerEntityFeature` enum (no magic numbers — [deprecation](https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation/)) | ✅ | TURN_ON|TURN_OFF|VOLUME_STEP|VOLUME_MUTE|PLAY_MEDIA|BROWSE_MEDIA + playback features; **no `VOLUME_SET`** — correct per the protocol's capability ([RESEARCH_IDEAS §2 C-VOL](RESEARCH_IDEAS.md)) |
| States via current `MediaPlayerState` enum | ✅ | imports the component enum; `STANDBY` not used ([deprecated 2025-07](https://developers.home-assistant.io/blog/2025/07/16/media-player-standby-state-deprecation/)) — our asleep→IDLE mapping is the compliant choice |
| `entity_category` ([gold `entity-category`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-category)) | ⚠️ | "Pair ADB" button is a device-configuration action → `EntityCategory.CONFIG` is the documented fit; sensors are primary. G11 |
| `entity_registry_enabled_default` for noisy entities ([gold](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-disabled-by-default)) | ⚠️ | Now Playing is chatty (per-track state changes); consider `False` for the least-used mirror sensors only — judgment call, low priority |
| `_unrecorded_attributes` for frequently-changing attributes ([entity doc](https://developers.home-assistant.io/docs/core/entity/)) | ⚠️ | `extra_state_attributes` (media_player.py:804) carries session/transport details; media_player already unrecords its domain attributes, but our custom attributes that change with every poll should be listed. G12 |
| Icon property discouraged vs icon translations ([entity doc](https://developers.home-assistant.io/docs/core/entity/)) | ⚠️ | static `_attr_icon` is the discouraged-but-legal form; app-art uses `entity_picture` (fine). Icon translations only matter for gold |
### 1.6 Setup / unload / lifecycle ([config entries doc](https://developers.home-assistant.io/docs/config_entries_index/))
| Requirement | Us | Notes |
|---|---|---|
| `entry.runtime_data` for runtime objects ([bronze `runtime-data`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/runtime-data)) | ✅ | `ShieldConnectionManager` on `entry.runtime_data` |
| `async_forward_entry_setups` + platform `async_setup_entry` | ✅ | |
| `async_unload_entry` forwards unload + cleans up listeners/connections | ✅ | manager stopped, hass.data popped; entities unsubscribe in `async_will_remove_from_hass` |
| `EVENT_HOMEASSISTANT_STOP` closes connections | ✅ | via `entry.async_on_unload(bus.async_listen_once(...))` |
| `hass.data[DOMAIN][entry_id]` usage | ⚠️ | we still store the entry there for cross-module lookups; fine, but the manager on `entry.runtime_data` makes most consumers redundant — trim opportunistically |
| Test-before-setup ([bronze](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup)) | ⚠️ deliberate | We intentionally never raise `ConfigEntryNotReady` (ADB may be unpaired; manager retries in background). This is a justified deviation — the rule's goal (avoid "unknown error" states) is met by the passive-start design + Pair ADB button + protocol `keep_reconnecting` — but it should be **documented** (e.g. a `quality_scale.yaml`-style note or README paragraph). G13 |
### 1.7 Actions (services) ([docs](https://developers.home-assistant.io/docs/dev_101_services/))
| Requirement | Us | Notes |
|---|---|---|
| Register actions in `async_setup` — "Service actions should always be registered" so they exist even with no loaded entries | ❌ | `adb_command` is registered lazily from the media_player platform when the first entry opts in, and removed when the last unloads. That violates the documented posture (always registered; the doc's rationale: editable/validatable automations + informative errors). Gap G8 |
| `services.yaml` describing fields/target | ❌ | missing entirely (G8) |
| Names/descriptions from translations ([i18n doc](https://developers.home-assistant.io/docs/internationalization/core/), `services` key) | ❌ | none (G8) |
| Target the right level; **target must not be optional** ("Do not implement a default target when none is specified") | ⚠️ | our schema has `Optional("entity_id")` with implicit all-entries fallback — non-compliant targeting. G8 |
| Errors raised as exceptions with translations ([silver `action-exceptions`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/action-exceptions); [exceptions i18n](https://developers.home-assistant.io/docs/internationalization/core/)) | ❌ | logs warnings on failure instead of raising `ServiceValidationError(translation_domain=...)`. G8 |
### 1.8 Diagnostics ([docs](https://developers.home-assistant.io/docs/core/integration/diagnostics/))
| Requirement | Us | Notes |
|---|---|---|
| `async_get_config_entry_diagnostics` | ✅ | correct module-level function (diagnostics.py — previously misplaced inside media_player.py and fixed per t20/t21 finding) |
| Redaction of sensitive data via `async_redact_data` | ⚠️ | we ship a hand-rolled `_redact` with marker matching; the doc points at the `async_redact_data` helper with an explicit TO_REDACT list — the helper is the convention (defensive marker-redaction is a reasonable extra on top, but config fields like host should use the helper list). G14 |
| Device diagnostics ([gold `diagnostics`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/diagnostics)) | ⚠️ | config-entry only; device diagnostics is the gold-tier add (single-device integration → mostly redundant, note why if skipped) |
### 1.9 Tests ([tests file structure](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/))
| Requirement | Us | Notes |
|---|---|---|
| Tests per domain under `tests/`, `conftest.py`, `test_init.py` for setup/reload/unload | ⚠️ | `tests/` exists with focused files; there is no `test_init.py` covering setup/reload/unload/migration of the entry (the structure doc names `test_init.py` for exactly that). G6 |
| Config-flow full coverage | ⚠️ | see G6 |
| Coverage bar for silver: >95% ([`test-coverage`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-coverage)) | ⚠️ | run pytest --cov to baseline; vendored `ha_adb_common` counts toward it |
### 1.10 Documentation / quality scale ([quality scale](https://developers.home-assistant.io/docs/core/integration-quality-scale/), [rules](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/))
**Bronze** (baseline for all new integrations): config-flow ✅ · entity-unique-id ✅ · has-entity-name ✅ · runtime-data ✅ · test-before-configure ✅ (both pairing steps test the connection) · unique-config-entry ✅ (MAC path) · action-setup ❌ (G8) · config-flow-test-coverage ⚠️ (G6) · brands ⚠️ (G15: no `brand/` icon in repo; HACS requires brand assets) · docs rules (G16: README has setup docs; needs explicit removal instructions + action descriptions to satisfy `docs-high-level-description`, `docs-installation-instructions`, `docs-removal-instructions`, `docs-actions`).
**Silver** adds: config-entry-unloading ✅ · reauthentication-flow ✅ (with G3 fix) · entity-unavailable ✅ · log-when-unavailable ⚠️ (verify we log once per transition, not every retry — the manager's backoff looks right; add a test) · parallel-updates ⚠️ (entities are push; declare/verify `PARALLEL_UPDATES` where the platform defaults matter — G12 note) · action-exceptions ❌ (G8) · test-coverage ⚠️ (G6) · docs-configuration-parameters ⚠️ (README should describe every option; strings.json is the source of truth today) · integration-owner ✅ (codeowner declared).
**Gold** adds (for reference, not required): discovery ✅ (+update-info ✅) · devices ✅ · diagnostics ✅ · entity-category/entity-translations/icon-translations ❌ (G7/G11) · reconfiguration-flow ❌ (G5) · repair-issues ❌ (G9) · extensive docs ❌ (G16).
## Part 2 — Optional YAML configuration alongside the config flow: current doctrine and canonical pattern
### 2.1 The rule
- [ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md) (binding): *"Integrations that communicate with devices and/or services are only configured via the UI. In rare cases, we can make an exception."*
- Current [YAML configuration doc](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/): same rule restated — *"Integrations that communicate with devices and/or services are configured via a config flow. In rare cases, we can make an exception. Existing integrations that should not have a YAML configuration are allowed and encouraged to implement a configuration flow and remove YAML support. **Changes to existing YAML configuration for these same existing integrations will no longer be accepted.**"* Also: *new integrations must not use platform-key YAML* (ADR-0007); any YAML must live under the integration's own domain key.
- So: for our device integration, YAML is allowed **only** as an *import source* that ends in a real config entry (`source: import`), never as a parallel runtime configuration path. The `import` step name is [reserved for exactly this](https://developers.home-assistant.io/docs/core/integration/config_flow/).
### 2.2 The as-documented pattern ([deprecating doc](https://developers.home-assistant.io/docs/deprecating/), "Dropping YAML support for an integration")
The current docs' canonical wiring (shown there in the context of deprecating YAML, and it is the same seeding mechanism an *optional* import uses):
1. **`__init__.py`: `async_setup(hass, config)` reads the domain key and seeds the import flow.** The exact documented snippet:
```python
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the integration."""
if DOMAIN not in config:
return True
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
...
```
(`SOURCE_IMPORT` from `homeassistant.config_entries`; this is the current documented form — the deprecated `discovery` step and platform-key imports are gone.)
2. **`config_flow.py`: `async_step_import` (step name `import`) validates the YAML dict, reuses the same entry-creation path as `async_step_user` (unique_id, `_abort_if_unique_id_configured`, `_create_entry`).** Resulting entries carry `source: import` and behave exactly like UI entries thereafter — options flow, reload, unload all apply. The [config-flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/) reserves the step name; [config entries doc](https://developers.home-assistant.io/docs/config_entries_index/) treats imported entries as normal config entries.
3. **Add a `CONFIG_SCHEMA`** for the domain-key YAML ([YAML configuration doc](https://developers.home-assistant.io/docs/core/integration/yaml_configuration/), CONFIG_SCHEMA section) using probatio/vol schema, and re-use the config-flow validation so YAML and UI stay consistent.
4. If YAML support is ever *dropped* (not our case), the documented procedure is: import step + the core `deprecated_yaml` repair issue with `HOMEASSISTANT_DOMAIN`, `breaks_in_ha_version`, then leave `cv.config_entry_only_config_schema(DOMAIN)` behind ([deprecating doc](https://developers.home-assistant.io/docs/deprecating/)).
### 2.3 What cannot be imported (honest limits)
- **Interactive pairing cannot happen during import.** An import step produces a config entry from static YAML data — it cannot wait for a human to approve an ADB prompt or type a PIN shown on the Shield. Our design already anticipates this: **ADB key approval is intentionally decoupled from setup** (the manager auto-connects only when a key exists; the Pair ADB button + flow step handle approval), so an imported entry can start unpaired and become functional after the user pairs. For the **protocol (PIN) pairing** there is currently no post-setup trigger — an imported entry with `protocol_enabled` cannot complete pairing without re-running the config flow. Options: keep protocol pairing out of the YAML surface (recommended), or add a repair issue/button that starts a reauth-style protocol-pair flow for unpaired entries (G9).
- **Discovery-derived values (MAC unique_id) cannot come from YAML.** The import can set a *user-chosen* unique_id (e.g. the entity reference) or none; a MAC-less entry weakens duplicate-protection (see G2) — the import should require the `androidtv_entity` reference or an explicit `unique_id`-grade value.
- **`source: import` entries show in the UI** and can be migrated to `source: user` only by deletion + re-add; document that YAML edits require deleting/re-importing (or honoring `async_update_entry` on re-import with `reason: options_updated` style updates — simplest is abort `already_configured` on re-import, matching most core integrations).
### 2.4 HACS / custom-integration policy view
- [HACS publish requirements](https://hacs.xyz/docs/publish/integration/) do not mention YAML at all — they require repo structure (one integration under `custom_components/`), the six manifest keys (we have all: domain, documentation, issue_tracker, codeowners, name, version), and **brand assets** (an `icon.png` under `brand/` or the brands repo — we currently lack it, G15). HACS never *forbids* an import path; it is simply orthogonal.
- The HA project position ([ADR-0010](https://github.com/home-assistant/architecture/blob/master/adr/0010-integration-configuration.md)) is that contributors may drop YAML to reduce burden "power to our contributors" — i.e. no project-level pressure to provide YAML for device integrations, and the quality scale contains no YAML rule. **Recommendation: treat YAML import as optional sugar; do not build it for the MVP** — the UI flow already covers discovery + pairing, and the import path buys only static-foreignKey convenience at the cost of the G2/G9 caveats.
## Part 3 — Gap list (concrete, file-anchored)
Ordered by severity; each item names the file(s) and the change.
| # | Severity | Gap → Fix |
|---|---|---|
| G3a | **High (breaks 2026.12)** | Deprecated listener+reloading combo: `config_flow.py` `_finish_reauth` (`async_update_entry` + `async_schedule_reload`) and the zeroconf host-update path (`async_update_entry` + `async_schedule_reload`) both double-reload because `__init__.py` registers `entry.add_update_listener(_async_update_listener)` (full reload). Per the [2026-05-07 blog](https://developers.home-assistant.io/blog/2026/05/07/config-entry-listener-together-with-reloading-methods/): in `config_flow.py` replace reauth's pair with `self.async_update_reload_and_abort(self._get_reauth_entry(), data_updates={...})`; for zeroconf use `async_update_entry` only and let the listener reload (or `reload_on_update=False` on `_abort_if_unique_id_configured`). Also see [always-reload-after-reauth](https://developers.home-assistant.io/blog/2024/04/25/always-reload-after-successful-reauth-flow/) |
| G3b | **High (deprecation)** | `config_flow.py` `NvidiaShieldOptionsFlow(config_entry)`: constructor must take no args — `async_get_options_flow(config_entry) -> NvidiaShieldOptionsFlow()` reading `self.config_entry` ([options flow doc](https://developers.home-assistant.io/docs/core/integration/options_flow/)); convert hand-rolled defaults to `add_suggested_values_to_schema`; consider `OptionsFlowWithReload` and dropping the update listener (or keep listener for partial reloads of non-structural options — but then never schedule reloads inside the flow) |
| G8 | High (silver) | `media_player.py` `_adb_command_service_ensure/_maybe_remove` + missing files: move registration to `__init__.py` `async_setup` (always registered, with `option-dependent` handler that no-ops/logs when no entry opted in); add `services.yaml` (fields: `command` required, `entity_id` **required** target — [targeting rules](https://developers.home-assistant.io/docs/dev_101_services/): target must not be optional; consider `device_id`/config_entry targeting instead of entity); add `strings.json` `services` section (name/description/fields) + `icons.json` service icon; replace warning-logs with `ServiceValidationError(translation_domain=DOMAIN, translation_key=...)` + `exceptions` section ([action-exceptions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/action-exceptions), [exceptions i18n](https://developers.home-assistant.io/docs/internationalization/core/)) |
| G2 | High | Unique-ID source fallback: `config_flow.py` uses `androidtv_entity` (entity_id) as unique_id when no MAC exists — renameable/user-changeable → weak. Fix: on the protocol path always MAC; on ADB-only setups without protocol, use `await self._async_handle_discovery_without_unique_id()`-grade handling for discovery, and for manual setup keep the entity-based ID but document the rename caveat, or derive from the device's MAC via the entity's device registry entry (device registry holds `CONNECTION_NETWORK_MAC`) |
| G1 | Medium | `manifest.json`: add `"integration_type": "device"`, `"loggers": ["adb_shell", "androidtvremote2"]` ([manifest doc](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
| G6 | Medium | Tests: add `tests/test_init.py` (setup/reload/unload/migration) per the [tests structure doc](https://developers.home-assistant.io/docs/creating_integration_tests_file_structure/); run coverage against `config_flow.py` to satisfy [config-flow-test-coverage](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/config-flow-test-coverage) |
| G7 | Medium | Entity naming: sensors `sensor.py` "Current App"/"Now Playing" — replace `_attr_name` with `translation_key` + `entity.sensor.*` names in `strings.json` ([entity naming](https://developers.home-assistant.io/docs/core/entity/), [entity-translations](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-translations)); button "Pair ADB" likewise |
| G9 | Medium | Unpaired/needs-pairing UX: raise a repair issue (gold [`repair-issues`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/repair-issues)) with an `issues` strings section when the protocol transport is enabled but unpaired, linking to the reauth/pair flow — this also gives YAML-imported entries a pairing path (see §2.3) |
| G10 | Low | `media_player.py` `device_info`: populate `model`/`model_id`/`sw_version` from the protocol `device_info` push (available post-pairing; [model_id blog](https://developers.home-assistant.io/blog/2024/07/16/model-id-device-info/)) |
| G14 | Low | `diagnostics.py`: switch `_redact` to `async_redact_data` with an explicit TO_REDACT list for `entry.data/options` ([diagnostics doc](https://developers.home-assistant.io/docs/core/integration/diagnostics/)), keeping the marker-based defense as a second layer |
| G11 | Low | `button.py`: `_attr_entity_category = EntityCategory.CONFIG` on Pair ADB ([entity-category rule](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/entity-category)) |
| G12 | Low | `media_player.py`: declare `_unrecorded_attributes` (class attribute) for fast-changing custom attributes; verify silver `log-when-unavailable` once-per-transition behavior in `connection.py` with a test ([entity doc](https://developers.home-assistant.io/docs/core/entity/), [log-when-unavailable](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/log-when-unavailable)) |
| G16 | Low | Docs for bronze: README needs a removal section + per-action documentation ([docs-removal-instructions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/docs-removal-instructions), [docs-actions](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/docs-actions)) |
| G15 | Low | Brand assets: add `brand/icon.png` to the repo (HACS requirement, [HACS publish](https://hacs.xyz/docs/publish/integration/)) and add the integration to the [brands repo](https://developers.home-assistant.io/docs/creating_integration_brand/) |
| G17 | Info | Schema library: `config_flow.py`/`media_player.py` import `voluptuous` — **no change required** ([Probatio blog](https://developers.home-assistant.io/blog/2026/09/30/probatio-validation-engine/): HA aliases the import; voluptuous is no longer installed but the alias resolves; the ban is core-only). New code may import `probatio` directly; opportunistically migrate |
| G18 | Info | BrowseMedia: `media_player.py` `async_browse_media` constructs `BrowseMediaSource` without `domain` — `domain` is now a **required** `str` on `BrowseMediaSource.__init__` ([2026-05-20 blog](https://developers.home-assistant.io/blog/2026/05/20/browse-media-source-root-class/)). Verify the media_player-component helper we import and pass `domain=DOMAIN` (children) / the integration domain (root); our `media_content_type="apps"` root is not a `RootBrowseMediaSource` case |
| G5 | Info | Reconfigure step (gold [`reconfiguration-flow`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow)): a future `async_step_reconfigure` should own device name + host overrides + source toggles, using `async_update_reload_and_abort(self._get_reconfigure_entry(), ...)` and `_abort_if_unique_id_mismatch` ([config flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/)) |
| G4 | Low | Jellyfin auto-detect silently fails when the Jellyfin integration is not yet configured (`config_flow.py` `_find_jellyfin_entity` finds no entities). We deliberately do not list `jellyfin` in `dependencies` (nothing breaks without it); document the ordering caveat in the README instead ([manifest doc, dependencies semantics](https://developers.home-assistant.io/docs/creating_integration_manifest/)) |
| G13 | Low | Document the deliberate deviation from [test-before-setup](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-before-setup): a README paragraph (or an exempted-rule note) explaining that entries start passively without `ConfigEntryNotReady` because ADB may be unpaired, with the Pair ADB button + protocol `keep_reconnecting` + reauth as the recovery UX |
| G5 | Info | Reconfigure step (gold [`reconfiguration-flow`](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/reconfiguration-flow)): a future `async_step_reconfigure` should own device name + host overrides + source toggles, using `async_update_reload_and_abort(self._get_reconfigure_entry(), ...)` and `_abort_if_unique_id_mismatch` ([config flow doc](https://developers.home-assistant.io/docs/core/integration/config_flow/)) |
### Explicitly OK as-is (no action)
- `iot_class: local_push`, requirement ranges, zeroconf entry, `version` (CalVer), `config_flow`, repo structure for HACS (one integration dir), `hacs.json`, devices per entry, feature-flag enums, `MediaPlayerState` usage, `entry.runtime_data`, unload/stop lifecycle, migration versioning shape, entity availability model, and the deliberate no-`ConfigEntryNotReady` design (document it, G13).
- Voluptuous imports remain valid through the probatio alias (G17).
+529
View File
@@ -0,0 +1,529 @@
# ha-nvidia-shield MVP Plan (Shield-only fork)
Scope: Shield-only. Primary state = ADB media session tracking (`dumpsys media_session`),
persistent event-driven connection, with a fallback path for apps that don't publish
media sessions. ADB pairing flow stays intact. Companion doc: `docs/RESEARCH_SHIELD.md`
(firmware research results, fixtures, and the user's current YAML glue).
**Decisions locked:**
- Option 1 adopted: absorb the remote protocol (`androidtvremote2`) — all-in-one device.
- TiviMate (`ar.tvplayer.tv`) publishes no media session → headline fallback app.
- `play_media(channel)` replicates core androidtv_remote behavior exactly
(digits, 0.1 s delay, no ENTER).
---
## 1. Current state assessment
### Architecture as found
```
config_flow.py ── user step → adb_pair step (one-shot connect test)
__init__.py ── no runtime object; hass.data[DOMAIN][entry_id] = entry (nothing!)
adb_session.py ── AdbMediaSessionPoller = AdbClientBase + poll() (thin)
media_player.py ── the real "coordinator": entity owns ADB socket, polling loop,
intent tracking, state tiering, Jellyfin/media-info, power logic
sensor.py ── 2 read-only mirror sensors
button.py ── Pair ADB (throwaway poller, connect→disconnect→reload)
jellyfin.py ── piggybacks on HA core Jellyfin coordinator cache (zero extra HTTP)
power.py ── sibling-sensor discovery for the smart plug
ha_adb_common/ ── vendored: adb_client (connect/shell/keyevents/media_session poll),
media_session parser, running_apps, intent dataclass, keys manager
```
There is **no DataUpdateCoordinator and no shared runtime object**. The entity
(`NvidiaShieldMediaPlayer`) *is* the coordinator: it creates the poller in
`async_added_to_hass`, runs `async_call_later` polling, and writes state itself.
### What already works (keep)
| Area | File | Notes |
|---|---|---|
| ADB key lifecycle: generate, migrate from legacy paths, namespace per domain | `ha_adb_common/keys.py` | Solid; pairing-flow compatible. Keep untouched. |
| Pairing UX: config-flow step + Pair ADB button + "approve on Shield" guidance | `config_flow.py`, `button.py` | Works; keep intact per constraint. |
| ADB connect via `adb_shell` (`AdbDeviceTcpAsync` + RSA signer in executor) | `adb_client.py` | Correct executor usage; blocking I/O off the loop. |
| Media session parsing: package/active/state/metadata from `dumpsys media_session`; foreground-app preference; playing>paused ordering | `media_session.py` | Works on Shield Android 11; version-drift risk (see fragility). |
| Optimistic UI: `PlaybackIntent` + self-heal + one retry + external-override detection | `intent.py`, `media_player.py` | Good design; survives rewrite. |
| State tiering: power → intent → Jellyfin → ADB session → ON | `media_player.py` `state` property | Sound priority model. |
| Jellyfin metadata piggyback (resolution/HDR/audio labels from core coordinator cache) | `jellyfin.py` | Zero-cost; keep. |
| Power: ADB wake as authority, plug "off" as hard off, sibling sensor discovery | `power.py`, `media_player.py` | Keep. |
| Running apps from `dumpsys activity recents` | `adb_client.py`, `running_apps.py` | Useful for Media DAG switcher. |
| Media commands: ADB keyevent when connected & Jellyfin inactive, else androidtv_remote | `media_player.py` `_media_cmd` | Sensible dual path; superseded by protocol-first routing in Phase 2. |
### What is fragile
1. **Connection ownership is in the entity.** The ADB socket, its poll timer and all
state live on `NvidiaShieldMediaPlayer`. Reload (options change triggers full
entry reload via `_async_update_listener`) tears down and re-handshakes the ADB
connection. Sensors have no access to ADB data. A second entity is impossible.
2. **Untracked in-flight poll tasks.** `_adb_poll` is dispatched via
`async_create_task` and never cancelled on unload; an in-flight `shell()` after
`disconnect()` hits `shell()`'s auto-reconnect → surprise reconnect after removal.
3. **Silent failure mode.** `shell()` swallows errors at DEBUG, returns `None`, marks
disconnected, and the next poll is 300 s later. Meanwhile `self._adb_awake` and
`last_info` keep their last values — the entity can report `playing` for minutes
while the device is asleep/dead. **No staleness/expiry on media session info.**
4. **Wake detection is a substring.** `"Awake" in dumpsys power result` — works on
this firmware (`mWakefulness=Awake/Asleep`, verified R5) but fragile against
future values like `Dozing`; switch to exact value match.
5. **Parser depends on exact dumpsys text** (`package=`, `active=true`,
`state=PlaybackState` `state=3/2`, `description=` positional split) and `head -200`
truncates the dump — sessions below the cut are lost. No position parsed from
`PlaybackState` despite it being present (verified R9: position/ms + uptime-ms
`updated`).
6. **Intent retry sends the wrong path.** `_on_intent_timeout` retries via
`_cmd()` (androidtv_remote) even when the original command succeeded via ADB.
7. **Dead/duplicated logic.** `jf_active` computed and unused in `_adb_poll`;
`_resolve_adb_host` duplicated in 3 places; `JELLYFIN_APP_IDS` defined but never
used (Wholphin routing); Current App sensor merely mirrors androidtv attributes.
8. **State writes gated on change.** `_adb_poll` writes HA state only when wake state
flipped or `last_info` exists — `running_apps` changes and info *clears* can be
missed; stale attribute windows.
9. **`power is False → IDLE` not OFF.** ADB-asleep maps to IDLE (standby) by design,
but the plug-off check happens before ADB, so semantics are split across two
places and easy to break.
10. **No tests, no CI lint gate** (ruff/pytest configured in `pyproject.toml` but
zero test files). A rewrite will be blind.
11. `iot_class: local_polling` + per-entity 10 s polling of **three** dumpsys
commands (power, media_session, activity recents) — wasted round trips when
nothing changed.
### What's missing for MVP
- Persistent connection manager with watchdog + backoff (nothing tracks connection
health; the only "watchdog" is the next poll).
- Event-driven trigger source (foreground-app change events) — today everything is
timer-driven.
- Fallback foreground/state path for apps without media sessions (TiviMate is the
headline case — verified no session, R2).
- Playback position from media sessions (+ uptime-anchored math, R9).
- Media session staleness handling (session gone → transition to idle).
- Tests + CI (lint + unit) to make the rewrite safe.
---
## 2. Event-driven rewrite: keep or replace the polling layer?
**Decision: replace the per-entity poll loop with an entry-scoped
`ShieldConnectionManager` that runs a hybrid event/heartbeat loop. Do not try to
make logcat a true push stream over `adb_shell` for MVP.**
### Why not pure logcat push (yet)
CORRECTION (C-STREAM, spike-verified on our pinned range): `adb_shell` 0.4.4
**does** ship `streaming_shell` — a generator yielding each line of output — on
both the sync `AdbDevice` and async `AdbDeviceAsync` (async needs the tiny
`aiofiles` + `async_timeout` extras, both already HA-ecosystem staples). So a
true logcat push stream needs neither internals hacks nor a new major
dependency. What keeps the hybrid design honest is *device behavior*, not the
library: logcat does not reliably carry *playback state* for third-party apps —
it reliably carries **foreground/app lifecycle** events (verified R8:
`ActivityTaskManager: START u0 {… cmp=<pkg>/<activity>}` on every app open,
including home-button → launcher). Streaming stability over multi-hour
connections still needs a device spike before we make it the sole trigger.
### The hybrid design (target architecture)
```
entry.runtime_data = ShieldConnectionManager
├─ transport A: androidtvremote2 protocol client (Phase 2)
│ push callbacks: is_on, is_available, current_app, volume_info
│ commands: send_key_command, send_launch_app_command, IME
├─ transport B: ADB (adb_shell) — persistent socket, no re-handshake per command
├─ watchdog task: health ping + exponential backoff reconnect (5s→…→300s cap)
├─ trigger listener: foreground-change events
├─ trigger listener: foreground-change events
│ Phase 1: logcat delta snapshots ~2–5 s cadence (safe default)
│ Phase 2+: streaming_shell logcat stream on a dedicated connection
│ (library support verified on 0.4.4; pending device stability spike)
├─ on trigger → cheap `dumpsys activity activities` (mResumedActivity confirm)
│ → if changed → `dumpsys media_session` fetch
├─ heartbeat: `dumpsys media_session` every ~15–30 s while awake (safety net)
├─ parsed StateStore: {is_on, foreground_pkg, sessions[], audio_playback[],
│ last_updated ts per field}
└─ event API: listener registration so entities are thin views
```
Net effect vs today: one persistent socket instead of reconnect-on-failure;
wake-ups driven by device events within seconds; heartbeat as fallback; wake check
folded into the foreground query instead of a separate grep; per-cycle cost drops
from 3 dumpsys commands × every 10 s to ~1 command × on-event + 1 × per heartbeat.
### Blocking issues to fix before/during the rewrite (flagged)
| # | Blocker | Where | Fix |
|---|---|---|---|
| B1 | Connection owned by entity; no shared runtime object | `media_player.py` `_setup_adb`, `__init__.py` | Move connection+state into `ShieldConnectionManager` stored in `entry.runtime_data`; entities subscribe. Mirror HA core androidtv's `entry.runtime_data` dataclass pattern. |
| B2 | Untracked `async_create_task` polls escape unload | `media_player.py` `_adb_poll_callback` | Manager owns its tasks; cancel on `async_stop` + `EVENT_HOMEASSISTANT_STOP`; guard `shell()` reconnect behind explicit lifecycle calls. |
| B3 | `shell()` auto-reconnect hides failure from callers | `adb_client.py` `shell` | Explicit error propagation; reconnect handled only by watchdog. Adopt androidtv core's `adb_decorator` shape: catch known ADB exceptions → log once → close → mark unavailable. |
| B4 | `head -200` truncation + parser text-coupling | `adb_client.py`, `media_session.py` | Remove `head`; tolerant parser w/ real firmware fixtures (captured — `docs/RESEARCH_SHIELD.md`). |
| B5 | Stale-state window (no TTL on `last_info`/`_adb_awake`) | `media_player.py` | StateStore carries timestamps; info older than TTL (e.g. 2×heartbeat) is expired. |
| B6 | Options change reloads entry → full reconnect churn | `__init__.py` update listener | Adopt core androidtv's pattern: only reload for structural options (`RELOAD_OPTIONS`), otherwise re-read options via `async_dispatcher_send`. |
---
## 3. Fallback mechanism for apps without media sessions
Options considered:
| Option | Reliability | Cost | Notes |
|---|---|---|---|
| **A. Foreground app via `dumpsys activity activities` (`mResumedActivity` / `topResumedActivity`)** | High on Shield (Android 11/12 line) | 1 cheap command, on-event only | Deterministic current app; works for *every* app. Verified on this Shield (R4): `mResumedActivity` is the Android-11 pattern; `topResumedActivity` kept as newer-firmware fallback. |
| B. Intent tracking (Android usage/intent logs) | Low–medium | Higher parse cost | Requires AccessibilityService/app instrumentation on modern Android — not viable via ADB shell. Reject. |
| C. Last-known-state cache (retain last session per package with timestamp) | Medium (supplementary) | Memory only | No extra polling; answers "what was this app doing". |
| D. logcat foreground listener | High for events | Part of the listener work | Covered by the hybrid loop above; R8 verified `ActivityTaskManager: START` lines. |
| **E. "Audio playback" section of `dumpsys media_session`** | High as a *hint* | Zero (same command) | NEW from R2: lists recently-playing audio packages even with no session (`ar.tvplayer.tv` shows there). Confidence check that a foreground app is actually playing media. |
**Recommendation: build A + C — with the Option-1 decision (§9), the absorbed
protocol client's `current_app` push is the *primary* foreground signal.** A
(`mResumedActivity` dumpsys) is the fallback when the protocol client is down
or silent; C (last-known-state cache) turns "foreground but no session" from
UNKNOWN into "in <app> (idle / last known paused)"; E enriches the fallback tier
with a "media recently active" hint at zero round-trip cost. D lands as the
ADB-side trigger in Phase 1.
Resulting state ladder for MVP: protocol is_on → intent → Jellyfin → media
session → foreground fallback (`current_app` push, then ADB `mResumedActivity`;
state = "on, in app X", with last-known metadata if any) → ON. Audio-playback
identity (E) enriches the fallback tier with a "media recently active" hint.
---
## 4. Phased MVP plan
### Phase 0 — Stabilize & trim (foundation; ~1–2 days)
Goal: make the codebase safe to rewrite. No behavior change.
Files:
- `custom_components/nvidia_shield/__init__.py` — introduce `entry.runtime_data`
placeholder (shared per-entry context object, initially just config mirror).
- `custom_components/nvidia_shield/const.py` — add TTL/staleness constants,
backoff constants; wire the currently-unused `JELLYFIN_APP_IDS` and add
`com.github.damontecres.wholphin` + `ar.tvplayer.tv` package constants.
- `custom_components/nvidia_shield/adb_session.py` + `ha_adb_common/adb_client.py`
— de-duplicate host resolution into one helper; make `shell()` propagate errors
(B3) while keeping current callers working; stop swallowing poll errors silently.
- `custom_components/nvidia_shield/media_player.py` — remove dead `jf_active`;
fix intent retry to use the path that originally succeeded; track+cancel poll
tasks on unload (B2); write state whenever any ADB-derived value changes.
- `tests/` (new) — parser fixtures (real `dumpsys media_session`, `activity
recents` outputs), intent-logic unit tests, config-flow smoke tests. Real
firmware fixtures are already captured — see the fixtures table in
`docs/RESEARCH_SHIELD.md`.
- `pyproject.toml`/CI — run ruff + pytest on PR.
Risks: parser fixture drift across Shield firmware (mitigate: fixtures from 2+
firmware versions, tolerant regexes). Fixing `shell()` error propagation can expose
previously hidden reconnect bugs — expected, ship together with the task-tracking
fix.
Done when: all tests green; no in-flight task survives entity unload (test
proves it); intent retry honors command path; ruff clean.
### Phase 1 — Core playback state + connection reliability (~3–5 days)
Goal: reliable, fast, persistent connection; media session is the source of truth.
Files:
- `custom_components/nvidia_shield/connection.py` (new) — `ShieldConnectionManager`:
persistent socket, watchdog + backoff reconnect, on-demand fetch API, StateStore
with timestamps, listener registration. API designed to host a second transport
(the remote protocol joins in Phase 2).
- `custom_components/nvidia_shield/adb_session.py` — becomes thin adapter over the
manager (keep `ensure_adb_key` re-export for config flow/button).
- `ha_adb_common/adb_client.py` — extract protocol-level commands (no loop
scheduling); add explicit `health_check()`.
- `ha_adb_common/media_session.py` — parse `PlaybackState` position (ms, with
uptime-anchored math — `updated` is uptime-ms per R9); add session-expiry at
store level.
- `media_player.py`, `sensor.py` — entities become views over StateStore;
state tiering unchanged; availability reflects manager connection health
(`_attr_available`).
- `manifest.json` — `iot_class: local_polling` stays until the protocol client
(push) lands in Phase 2, then becomes `local_push`.
- `config_flow.py` — pairing step reuses manager's test-connect (no behavior
change to flow).
Risks: `adb_shell` async stability over long-lived connections (mitigate:
watchdog pings + teardown/rebuild on protocol errors); reconnect storms during
device reboots (backoff + jitter); intent logic regression (covered by Phase-0
tests).
Done when: device reboot recovers automatically within backoff window; play/pause
reflected in ≤1 heartbeat cycle (≤30 s) worst case, foreground-triggered changes
faster; unavailable/available transitions correct; no state staleness beyond TTL;
tests for manager (connect/reconnect/expiry) pass.
### Phase 2 — All-in-one: absorb the remote protocol (androidtvremote2) (~4–6 days)
Goal: one integration, one device. We own the Google remote protocol for
commands, power and availability; ADB remains the playback-state source. The
external androidtv / androidtv_remote integrations are no longer required.
Files:
- `manifest.json` — add `androidtvremote2` requirement (`adb-shell` stays).
- `config_flow.py` — protocol pairing ported from core androidtv_remote: generate
cert if missing, fetch name + MAC (unique_id), PIN pair step (code shown on the
Shield), reauth flow on InvalidAuth (device hard reset). The existing ADB
pairing step stays intact — both pairings coexist in one flow.
- `connection.py` — manager now hosts both transports: protocol client
(`keep_reconnecting`, callbacks: is_on, is_available, current_app, volume_info)
+ ADB client; one StateStore fed by both.
- `remote.py` (new platform) — protocol-backed remote entity (port of core:
SHORT/START_LONG/END_LONG command prefixes, num_repeats/delay/hold, activity =
current app).
- `media_player.py` — command routing switches to protocol keys; volume gains a
push-based level readout + real mute state (NOTE: androidtvremote2 has NO
absolute volume set — HA core closed volume_set for androidtv_remote as
not_planned; only VOLUME_STEP | VOLUME_MUTE are claimed, level comes from
volume_info push); availability and is_on come
from protocol callbacks; `_is_power_on` ladder: protocol is_on → ADB wake →
plug off.
plug off.
- `media_player.py` `async_play_media` — §7 channel port via protocol
`send_key_command` digits (0.1 s delay) + task-cancel guard; ADB keyevent
fallback when the protocol is down.
- `__init__.py` — PLATFORMS gains REMOTE; entry migration (VERSION 2); close
both clients on HA stop / unload.
Risks: two pairing experiences (ADB + PIN) need clear flow copy;
androidtvremote2's own reconnect task and our ADB watchdog must not fight each
other — one owner per transport; existing installs that wrap the external
androidtv_remote entity need a migration path (transitional fallback while it's
still configured); IME option defaults off.
Done when: entry sets up standalone (no androidtv/androidtv_remote entries
needed); remote entity sends keys and launches apps; real volume level/mute
shown; channel selection works via protocol with ADB fallback; device hard reset
triggers reauth; ADB pairing unchanged; migration covered by tests.
### Phase 3 — Fallback app state (~2–3 days, smaller now)
Goal: every app gets a sensible state even without a media session. The protocol
client's `current_app` push is the primary foreground signal; ADB fills gaps.
Files:
- `ha_adb_common/adb_client.py` (or new `foreground.py`) — `mResumedActivity`
parser (Android 11 primary pattern; `topResumedActivity` kept as a
newer-firmware fallback) — used only when protocol current_app is
None/unknown/stale. Do not parse `dumpsys window` focus first (multiple
`mCurrentFocus` lines incl. literal `null` — R4).
- `connection.py` — foreground priority in StateStore: protocol current_app push
> ADB `mResumedActivity`; last-known-state cache per package (option C);
audio-playback identity parsed from the same media_session dump (E).
- `ha_adb_common/media_session.py` — parse the "Audio playback (lastly played
comes first)" section into `{package: last_played_hint}`; ignore-list
`com.google.android.apps.mediashell` (Chromecast receiver process).
- `media_player.py` — state ladder gains the foreground tier; running_apps
enrichment with foreground/last-known info.
- `sensor.py` — Current App sensor reads StateStore (protocol push makes it
accurate without androidtv attribute reads).
- README — document the state ladder and fallback semantics; document the
migration away from mediasession2mqtt sensors + the universal-player YAML
glue (appendix in `docs/RESEARCH_SHIELD.md`).
Risks: dumpsys format differences across Shield Experience releases (fixtures
from real devices; R4); apps that keep background sessions while another is in
the foreground (session-vs-foreground preference already handles); last-known
cache must never resurrect stale "playing".
Done when: launching an app with no media session shows correct app + "on/idle"
within one update cycle; returning to an app with a session shows its state;
state tracks `current_app` push with no attribute polling; stale cache never
reports playing after >TTL; **`play_media` (`media_content_type: "channel"`)
selects TiviMate channels by number (protocol path, ADB fallback) per §7**;
tests cover all ladder branches.
### Phase 4 — QoL (~ongoing)
Files:
- Deep links — now cheap: `media_type: app/url` → protocol
`send_launch_app_command` (port of core pattern; the client auto-prefixes
`market://launch?id=` for bare package ids — C-LAUNCH, verified in the client
source, so no extra branch for app-id vs URI) + BrowseMedia app directory;
per-app `am start` extras via ADB only for apps the protocol can't launch
(data-driven map in const; discovery recipe via
`adb shell cmd package dump <pkg>` in RESEARCH_IDEAS.md).
- Channel keys: add `channel_up`/`channel_down` (proto keycodes 166/167 —
trivial, no UI needed) alongside the §7 digit path.
- Options: apps name/icon map editor (core androidtv_remote pattern), digit
delay, IME toggle, privacy scope; finish config entry migration if needed.
- Zeroconf discovery (`_androidtvremote2._tcp`) — auto-fill host, update entry on
IP change; `manifest.json` gains the `zeroconf` integration dependency.
- `adb_command` service + diagnostics (port androidtv patterns).
- Optional: streaming_shell logcat stream as the trigger source (library
support verified on adb-shell 0.4.4, spike at goal round 9; pending a
multi-hour device stability spike) behind an option flag; default stays
delta snapshots.
- Trim: fold Current App sensor if StateStore covers it; drop
`running_apps_from_sessions` legacy; remove transitional external-entity code.
Risks: deep-link semantics differ per app (intent extras) — keep the app map
data-driven; options migration bugs (test entry migration).
Done when: deep links work for 2–3 reference apps; apps editor works; discovery
updates host on IP change; docs updated.
---
## 5. Dependency & compatibility notes
- Runtime deps: `adb-shell>=0.4.0` + `androidtvremote2` (adopted in Phase 2 —
small, HA-core-blessed); `zeroconf` integration dependency joins in Phase 4
for discovery. Logcat *streaming*
is the only thing that might eventually
need internals or a new dep — explicitly deferred and flagged.
- Vendored `ha_adb_common` stays for HACS; keep the hisense_tv consumer in mind
when changing its API (add, don't break; the fork can diverge later but Phase 0–2
changes should remain additive where possible).
- Pairing flow: ADB pairing (`async_step_adb_pair`, Pair ADB button,
`ensure_adb_key`, `.storage/nvidia_shield/adbkey` layout) is untouched in all
phases. The remote-protocol cert/PIN pairing (`.storage` certs) is *added*
alongside it in Phase 2.
## 6. Sequencing summary
| Phase | Theme | Primary outcome |
|---|---|---|
| 0 | Stabilize/trim, tests | Safe rewrite base; no behavior change |
| 1 | Persistent connection + session truth | Reliable fast playback state, auto-recovery |
| 2 | Absorb remote protocol (androidtvremote2) | All-in-one device; channel select; focused-app push |
| 3 | Foreground fallback + last-known state | Full-app coverage without sessions |
| 4 | Deep links, apps editor, discovery, diagnostics | Polish |
---
## 7. TiviMate channel selection (`play_media` port)
**Requirement:** retain `media_player.play_media` with `media_content_type: "channel"`
so TiviMate channels can be selected by number (e.g. `media_id: "1"`). This logic
lives today in the Android TV Remote integration; with Option 1 adopted we port
it onto our own protocol client (primary path) with ADB keyevents as fallback.
### Port design
- Add `MediaPlayerEntityFeature.PLAY_MEDIA` and implement `async_play_media(media_type, media_id)`.
- When `media_type == "channel"`: extract digits from `media_id`, then send each
digit as an ADB keyevent — `KEYCODE_0` = 7 … `KEYCODE_9` = 16 (digit n → keycode 7+n) —
with a configurable delay between digits (default ~0.5 s), then a confirm
keycode. Reference (verified from HA core `androidtv_remote/media_player.py`):
core sends digits ONLY — `media_id.isnumeric()`, cancels any prior in-flight
channel task (concurrency guard), `delay_secs=0.1`, and never sends a confirm
key: the device's channel overlay auto-selects. Port: digit n → keycode 7+n;
make ENTER an optional boolean (default off — replicates the androidtv_remote
behavior the user confirms works today).
- Other `media_type` values (`app`, `url`): launch via the protocol client's
`send_launch_app_command` once Phase 2 lands; until then reject unknown types
with a log warning.
- **Fallback chain:** protocol client down → ADB `input keyevent` digits (keycode
7+n, slower but socket-independent) → transitional only: delegate to the
still-configured external androidtv_remote entity (migration path, removed in
Phase 4). Order: protocol first, ADB second, legacy third.
- Command path: protocol `send_key_command` (push-based, fast, no extra shell
round trip); ADB keyevents only as the fallback (works whenever the socket is
up, regardless of the remote protocol's state).
- Timing risk: too-fast digits can be dropped or the overlay can close early —
make the inter-digit delay a `keyvalue` option with a safe default (0.1 s,
core-verified); the live check (R7) remains optional.
- Service-call note: current HA syntax passes `data.media.media_content_id` /
`media_content_type`; `async_play_media` receives `"channel"` / `"<n>"` the
same as before.
### Metadata implication
Channel *metadata* (EPG name, title) for TiviMate is a separate question from
channel *selection*:
- TiviMate: **no media session** (R2 verdict) → now-playing = Phase-3 foreground
fallback: state = "on, in TiviMate", title = `last_channel` cache when
play_media fires (cheap, honest, no EPG claim). Full EPG/EPG-logo data is out
of scope for the ADB-only MVP.
- Jellyfin-family clients: **Wholphin (`com.github.damontecres.wholphin`)**
publishes a proper session with usable metadata (R9) — routes through
`JELLYFIN_APP_IDS` to the rich-metadata path.
- Netflix publishes a stale inactive session (`active=false`, `state=null`) —
the parser must keep ignoring it; a fixture pins this behavior.
### Where it lands
- Phase 2 (the port target is the protocol client absorbed in Phase 2; TiviMate
state *display* comes from Phase 3's fallback).
---
## 8. Local research on the Shield — STATUS
**STATUS: DONE.** R1–R5, R8, R9 executed; R6 optional-not-run; R7 skipped by
decision. Full results, raw captures, and the user's current YAML glue are in
`docs/RESEARCH_SHIELD.md`.
| Step | Status | Key result |
|---|---|---|
| Setup | ✅ | Shield `mdarcy`, Android 11, `RQ1A.210105.003`; custom launcher `com.spocky.projengmenu` |
| R1 | ✅ | TiviMate package = **`ar.tvplayer.tv`** (plan's `iv.android.tv` guess was wrong) |
| R2 | ✅ | **No media session** for TiviMate (branch 3). Netflix stale inactive session only. NEW: "Audio playback" section lists `ar.tvplayer.tv` |
| R3 | ⏳ partial | Wholphin + Netflix captured; YouTube/Spotify/Plex optional |
| R4 | ✅ | `mResumedActivity` is the Android-11 pattern; no `topResumedActivity`; `mCurrentFocus=null` trap |
| R5 | ✅ | `mWakefulness=Awake/Asleep` — switch to exact value match |
| R6 | ⏳ | Not run (optional; low risk) |
| R7 | ⏭ skipped | Replicate core behavior (digits, 0.1 s, no ENTER) — user's existing flow works |
| R8 | ✅ | `ActivityTaskManager: START` lines reliable incl. home button; user has `be.digitalia.mediasession2mqtt` installed |
| R9 | ✅ | `PlaybackState{state=3, position (ms), speed, updated (uptime ms)}` parseable; Wholphin session good (`description=Twirlywoos`) |
---
## 9. HA core research: androidtv + androidtv_remote (what's worth taking)
Sources reviewed on `dev`:
[androidtv](https://github.com/home-assistant/core/tree/dev/homeassistant/components/androidtv) ·
[androidtv_remote](https://github.com/home-assistant/core/tree/dev/homeassistant/components/androidtv_remote).
Key findings first:
1. **`androidtv` (ADB component) has NO `async_play_media`** — channel-by-number
logic is not there. It lives in **`androidtv_remote/media_player.py`**, so §7's
port source is confirmed to be androidtv_remote (not androidtv).
2. **Both components ship `PLATFORMS = [MEDIA_PLAYER, REMOTE]`** — HA core's own
precedent for one device exposing a media_player + remote entity pair. That is
exactly the all-in-one shape requested.
3. `androidtv`'s remote entity is pure ADB (`input keyevent`) — a basic remote
entity needs no remote-protocol dependency (superseded by the Option-1
decision; its logic survives as our command fallback path).
### Worth taking from `androidtv` (ADB component)
| Pattern | File | Fold into |
|---|---|---|
| `entry.runtime_data` dataclass: client + options snapshot | `__init__.py` | Phase 1 manager (B1) — canonical HA pattern to mirror |
| `ConfigEntryNotReady` on first connect failure → HA itself retries setup | `__init__.py` | Phase 1 — better than our in-entity 300 s retry |
| Close connection on `EVENT_HOMEASSISTANT_STOP` | `__init__.py` | Phase 1 manager lifecycle |
| Options change **without full reload**: only reload for `RELOAD_OPTIONS`; otherwise re-read options via `async_dispatcher_send` | `__init__.py` update_listener | Proper fix for B6 (reload churn) |
| `adb_decorator`: uniform exception handling → log once, `adb_close()`, mark entity unavailable | `entity.py` | Phase 1 — replaces swallow-at-DEBUG `shell()` (B3) |
| Reconnect-on-update + `_failed_connect_count` (log first failure only) | `media_player.py` `async_update` | Phase 1 watchdog detail |
| `select_source` = launch app; `!name` prefix = close app; `source_list` built from running apps | `media_player.py` | Phase 3/4 QoL — natural fit with our existing `running_apps` |
| `adb_command` service, diagnostics | `services.py`, `diagnostics.py` | Phase 4 QoL |
### Worth taking from `androidtv_remote` (remote protocol)
| Pattern | File | Fold into |
|---|---|---|
| **Channel logic (the §7 port source)**: `media_type == CHANNEL`, `media_id.isnumeric()`, cancel previous in-flight channel `asyncio.Task`, `_send_key_commands(list(media_id), delay_secs=0.1)` — digits only, **no ENTER** | `media_player.py` | §7 |
| `app`/`url` media types → launch app; BrowseMedia app directory | `media_player.py` | Phase 4 deep links + browse |
| Entities as thin callback-views over one runtime API object (available / is_on / current_app / volume callbacks) | `entity.py` | Phase 1 manager → entity design |
| `keep_reconnecting()` + trigger reauth when the device hard-resets (cert invalid) | `__init__.py` | Phase 1/2 watchdog: distinguish "unreachable" (backoff) from "unpaired" (reauth) |
| Zeroconf discovery (`_androidtvremote2._tcp`, MAC as unique_id, host auto-update on IP change) | `config_flow.py` | Phase 4 QoL |
| Cert+PIN pairing, certs stored in `.storage` | `config_flow.py`, `helpers.py` | Phase 2 |
### All-in-one: the remote as an entity alongside the media_player — DECIDED
Today we *wrap* an external `androidtv_remote` entity (resolving its host from
its config entry). **Decision: Option 1 adopted** — androidtvremote2 is excellent
for *commands* but weak on receiving playback state; our ADB session tracking
fills exactly that gap, making the two transports complementary. Phase 2 absorbs
the protocol: we own the connection, one device provides `media_player` +
`remote`, and the `current_app` push becomes the primary foreground signal —
fewer dumpsys round trips and a more reliable Phase-3 fallback. ADB remains the
state source; the ADB pairing flow stays intact. The ADB-only remote entity
(Option 2) is not built as a separate platform; its `input keyevent` logic
survives as the command fallback path inside `async_play_media`/command routing.
+180
View File
@@ -0,0 +1,180 @@
# Research ideas for ha-nvidia-shield (features, patterns, TiviMate)
Status: **COMPLETE.** Companion docs: `MVP_PLAN.md`, `RESEARCH_SHIELD.md`.
Method: primary sources first — library/integration source code (androidtvremote2,
adb_shell, HA core), HA core GitHub issues/PRs, home-assistant.io integration docs,
home-assistant.io action docs, and community threads. Citations are inline markdown
links. Note: HA's Action *classes* (`MediaPlayerEntityFeature.*`) are what we set in
code; HA's *Actions* (`media_player.play_media` etc.) are what users call.
---
## 1. What users actually request (from HA core issues, community threads, library issues)
| # | Request | Source | Take for our MVP |
|---|---|---|---|
| U1 | Absolute volume set on Android TV players — `media_player.volume_set` repeatedly requested; HA core androidtv_remote closed it **not_planned** | [core#176505](https://github.com/home-assistant/core/issues/176505) | High demand, but the protocol v2 proto has **no usable volume-set primitive shipped** (it reports volume; see C-VOL). Our Phase 2 now ships `VOLUME_STEP|MUTE` + level readout (captain accepted; plan already fixed). If demand ever recurs, the honest fallback is an ADB `input keyevent KEYCODE_VOLUME_*` step loop behind a helper script — **not** a fake supported feature |
| U2 | Launching *any* installed app (not just deep-linkable ones) and retrieving the installed-app list | [androidtvremote2#17](https://github.com/tronikos/androidtvremote2/issues/17) | The official NVIDIA Shield app *does* retrieve the app list over another service; androidtvremote2 accepted `market://launch?id=<pkg>` as the app-launch workaround (now built into `send_launch_app_command`, see C-LAUNCH). App-list *retrieval* is not implementable through this client's protocol surface — a data-driven, options-defined app map is the right MVP substitute (already in plan) |
| U3 | Text/IME entry (typing into search boxes); behavior **depends on the app and on whether the on-screen keyboard is open** (one-at-a-time chars get replaced in Spotify; whole-string send works) | [androidtvremote2#32](https://github.com/tronikos/androidtvremote2/issues/32) | Library supports `send_text` but behavior is app-dependent. Keep the IME option **default off** (as planned) and document the qualifier rather than trying to be clever |
| U4 | Voice search / voice commands | [androidtvremote2#28](https://github.com/tronikos/androidtvremote2/issues/28) — closed; the library now ships `start_voice()`/`VoiceStream`, gated by `enable_voice` and device feature support | Implemented in the lib (PCM 16-bit mono 8 kHz streamed as `RemoteVoiceBegin/Payload/End`). Post-MVP only; note exists so Phase 4 does not re-research |
| U5 | Deep-link launching of apps into specific sections (community guide table with ~60 known app URLs, plus how to discover one via `adb shell cmd package dump PACKAGE` and manifest string resolvers) | [deep-link guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921/) | Directly validates Phase-4 deep links; `send_launch_app_command` accepts either a deep link or a bare package id (see C-LAUNCH). The guide's discovery recipe (`cmd package dump <pkg>`, grep `<data`, resolve `@string/deeplink_schema`) is worth copying into our docs/contributor workflow |
| U6 | BrowseMedia containing installed apps / activity list so users can launch apps from the media panel instead of scripts | [core#117126](https://github.com/home-assistant/core/pull/117126) (androidtv_remote: "Implement Android TV Remote browse media with apps and activity list") | Validates Phase-4 BrowseMedia app directory; can be built from our own running_apps data or an options-defined list (no protocol app-list needed) |
| U7 | Channel-by-number play_media + channel up/down keys on streaming players (Roku, Bravia) | [Roku docs](https://www.home-assistant.io/integrations/roku/), [Bravia docs](https://www.home-assistant.io/integrations/braviatv/) | `play_media(media_content_type: channel)` is the canonical way every TV integration does tuning (Roku, Bravia); our §7 digit path matches. Bonus Roku/Bravia idea: expose `channel_up`/`channel_down` as remote commands (protocol `KEYCODE_CHANNEL_UP=166` / `KEYCODE_CHANNEL_DOWN=167` — in the proto, no new work) |
| U8 | TiviMate: start it and switch to a specific channel from an HA automation | [community thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344) (2025, active) | **Exactly our §7 play_media(channel) requirement.** The working recipe there: `remote.turn_on` with `activity: ar.tvplayer.tv` → delay ~7 s (app cold-start) → `media_player.play_media` with `media_content_type: channel` / `media_content_id: "1"` — note the wait; our play_media should not promise cold-start-to-channel in one shot (we don't own app launch speed). Follow-up request in the thread shows demand for tuning VOD/series pages too (unresolved anywhere) |
| U9 | Shield app-launcher cards for wall-panel remotes (Firemote adds TiviMate/SmartTube shortcuts); Harmony-style activity switching | [HA-Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48), [Shield launcher blueprint](https://community.home-assistant.io/t/shield-app-launcher-for-harmony-remote/858225) | QoL signal for Phase-4 apps editor + `source_list`; also shows app-name→icon mapping is what users iterate on (supports data-driven app map + icon support) |
| U10 | Firemote / universal-remote-card users must hand-define every app shortcut because the Shield integration exposes no app directory | [universal-remote-card discussion](https://github.com/Nerwyn/universal-remote-card/discussions/52) | Independent confirmation of U6/U9 demand; [TiviMate + SmartTube shortcut request](https://github.com/PRProd/HA-Firemote/issues/48) |
Also verified from an independent direction: [MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4)
confirms TiviMate "doesn't capture anything" for notification-based playback
detection on the Shield — consistent with our R2 no-media-session finding, and a
reminder that projects tried and failed to get sessions out of TiviMate
(see §5).
## 2. androidtvremote2 capabilities we are not using (verified from [library source](https://github.com/tronikos/androidtvremote2))
Source of truth read: [`androidtv_remote.py`](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/androidtv_remote.py),
[`remotemessage.proto`](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto),
[`TvKeys.txt`](https://github.com/tronikos/androidtvremote2/blob/main/TvKeys.txt).
| Capability | API | MVP relevance |
|---|---|---|
| Key codes + length variants | `send_key_command(key, direction)` — `SHORT`/`START_LONG`/`END_LONG`; accepts int or `"KEYCODE_..."/"POWER"` strings; any code from the 300-strong proto enum | Remote entity port (Phase 2) matches core's SHORT/START_LONG/END_LONG design |
| **Text / IME** | `send_text(text)`; also `send_key_command("text:<message>")` routes into send_text | Phase-2 IME option (default off). Known quirk: append-vs-replace inconsistency (#32) — document, don't ship cleverness |
| App launch | `send_launch_app_command(app_link_or_app_id)` — **auto-prefixes `market://launch?id=`** when the value has no URL scheme (C-LAUNCH) | `play_media(url)`/`play_media(app)` (Phase 4): bare package names work with zero extra code; deep links like `plex://` pass through unchanged |
| Push callbacks | `add_is_on_updated_callback`, `add_current_app_updated_callback`, `add_volume_info_updated_callback`, `add_is_available_updated_callback` | Exactly the manager-listener pattern Phase 1/2 needs; `current_app` push is the Phase-3 primary foreground signal |
| Volume info (read-only) | `volume_info` → `(level, max, muted)` pushed on change | Real mute state + numeric level readout in Phase 2 — **but no `volume_set`**; see C-VOL |
| Reconnect + reauth distinction | `keep_reconnecting(invalid_auth_callback=<fn>)` with internal exponential backoff 0.1 s → 30 s cap; raises `InvalidAuth` when certs are rejected (device hard reset) | Feeds the transport design; **one owner per transport** — don't build a competing watchdog for the protocol socket; `InvalidAuth` → reauth flow, not retry storm |
| Device info | `device_info` (model, vendor, package_name, app_version) | Free diagnostics data for Phase 4 (and a nice setup sanity check) |
| App-list push | **Not implemented and not in the protocol this client speaks** ([#17](https://github.com/tronikos/androidtvremote2/issues/17)). Verified against the full proto: the only app-ish messages are `RemoteAppInfo` (IME context) and `RemoteAppLinkLaunchRequest` — no app-list leafs | Confirms: options-defined app name/icon map is the honest substitute; do not promise app-list discovery |
| IME sensitivity | Constructor `enable_ime=True` — "**Needed for getting current_app**; disable for devices that show 'Use keyboard on mobile device screen'" | If we make IME configurable, document that disabling it can break the `current_app` push (which Phase 3 depends on) |
| Voice | `start_voice()` → `VoiceStream` (PCM 16-bit mono 8 kHz, 20 KB chunks), `enable_voice=True` | Post-MVP |
| `RemoteSetVolumeLevel` exists in the proto but is [sent by noone](https://github.com/tronikos/androidtvremote2/issues/176505) (see C-VOL) | — | Do not plan on it for VOLUME_SET |
## 3. Patterns worth borrowing from other HA integrations (docs verified)
All four integration docs read in full ([Roku](https://www.home-assistant.io/integrations/roku/),
[Apple TV](https://www.home-assistant.io/integrations/apple_tv/),
[Bravia](https://www.home-assistant.io/integrations/braviatv/),
[androidtv](https://www.home-assistant.io/integrations/androidtv/)).
| Integration | Mechanism | What to borrow | Effort for us |
|---|---|---|---|
| **Roku** | `play_media(media_content_type: channel)` → tune by number (e.g. `5.1`); `select_source` accepts app name *or* app id; BrowseMedia for apps; channel tuning for Roku TV devices; `media_content_type: app` with `extra.content_id`/`extra.media_type` for content deeplinks; `remote` commands incl. `channel_up`/`channel_down`; active app + active app id sensors; `media_content_type: url` + `extra{format,name,thumbnail,artist_name}` for PlayOnRoku direct playback; obtains app list via a published XML endpoint (`/query/apps`) | *Channel tuning semantic* — exactly our `channel` port (validated). *App deep link envelope shape*: `media_content_type: app` with `extra.content_id`/`extra.media_type` is a richer pattern than raw URLs if we later want per-app content launches — record it but don't build it for MVP. *Source list built from apps* matches our `select_source` = launch app. *Channel up/down as remote commands* — bolt `channel_up`/`channel_down` onto Phase-2 remote command set (keycodes 166/167, free) | Low–medium |
| **Apple TV** | `play_media(media_content_type: url)` with `youtube://...`, `https://www.netflix.com/title/...` "Deep Links" (canonical how-to + link-discovery tips via iOS share sheets); `select_source` + BrowseMedia "Apps" folder for launching apps; `keyboard` entity (binary_sensor "keyboard focused") + three text actions (`set/append/clear_keyboard_text`) driven by keyboard-focus push; remote entity with `num_repeats`/`delay_secs`/`hold_secs` | *play_media(url) deep-link semantics* for Phase 4, direct port (mediatype `url` → `send_launch_app_command`). *"Apps folder"* validation for BrowseMedia. *Keyboard-focus push → typed-text entry* is Apple TV's answer to #32's append/replace ambiguity — a keyboard entity would need an IME-push equivalent the Shield protocol does not give us cheaply, so for MVP ship `send_text` only when IME is on (nothing fancier). *Remote entity parameters* (`num_repeats`/`delay_secs`/`hold_secs`) are the shape we already planned | Medium (only if we chase keyboard); Low for the rest |
| **Sony Bravia** | `play_media` matches channel number, **exact or partial app/channel name**, or URI string; BrowseMedia lists installed apps + TV channels; remote commands incl. `Num0..Num9` (digit-typing built into the remote platform); documented real-world limitation that TVs running Android/Google TV lose in-app playback info unless you pair with Google Cast (they recommend **Universal Media Player with two children** — exactly the problem our all-in-one design solves natively) | *Fuzzy matching on media_content_id* (number OR exact/partial name) is a small and very forgiving UX win for `channel` — accept a numeric string strictly, but also accept exact channel/app names from an options-defined map if cheap. *The Bravia doc explicitly documents the Cast+Universal-Media-Player workaround* — citable pattern that our native ladder replaces for Shield users | Low (for the simple variant) |
| **androidtv (core, ADB/Fire TV)** | `select_source` = launch app; **`!` prefix closes app** (`!com.netflix.ninja`); options editors for *apps list, exclude unknown, arbitrary ADB turn_on/turn_off commands, per-app `state_detection_rules`*; screen-capture album art option; `adb_command`/`upload`/`download` actions; `learn_sendevent` helper | *`!` close-app prefix is a tiny, delightful, zero-new-dep feature* — implement for free alongside our Phase-4 launch path; document it as an androidtv compatibility nicety. *Arbitrary turn_on/turn_off commands as options* is worth matching (we already have shell; gives users a knob without code). *`state_detection_rules`* (per-app playing/paused conditions over `media_session_state`, `audio_state`, `wake_lock_size`) is the blueprint for our last-known cache tier to become *configurable* if users need app-specific truth — out of ADB/MVP scope but record it. *Screen-capture album art* is the precedent for the screencap idea in §4 (Roku/Bravia do not need it; we do) | Low |
Canonical patterns (all four):
`select_source` = launch app with `source_list` of apps; BrowseMedia with a flat
"Apps" directory; `play_media` channel digits or named matches; per-app
icon/name map; documented `media_content_type` vocabulary (`channel`, `app`, `url`,
`music`, `video`) — we should mirror this vocabulary exactly in Phase 2/4.
## 4. adb_shell capabilities we are not using (verified from [adb_shell source](https://github.com/JeffLIrion/adb_shell))
Source of truth read: [`adb_shell/adb_device.py`](https://github.com/JeffLIrion/adb_shell/blob/master/adb_shell/adb_device.py) (the full file) and [README.rst](https://raw.githubusercontent.com/JeffLIrion/adb_shell/master/README.rst).
| Capability | API | MVP relevance |
|---|---|---|
| **Streaming shell** | `AdbDevice.streaming_shell(command, decode=True)` — a *generator that yields lines as they arrive, until the connection is closed* | ⚠️ Plan correction. `MVP_PLAN.md` §2 currently says "`adb_shell`'s shell() is request→full-response; there is no incremental/streaming read API". **That is no longer accurate on master** — `streaming_shell` exists as a documented generator on `AdbDevice`. Two caveats before acting: (a) verify it is present in the resolved version we pin (repo last pushed 2024‑10; check release ≥ ours covers it — the version is old enough that the feature set may differ slightly); (b) verify the *async* device class exposing it (the source read was the sync `AdbDevice`; the async subclass typically mirrors). If both check out, MVP_PLAN's "Phase-4 optional: logcat streaming behind an option" becomes **much cheaper than hacking adb_shell internals** — and the deferred logcat snapshot approach gains a real path. Rank as the single highest-value adb_shell finding |
| **FileSync** | `push()`, `pull(device_path, local_path, ...)`, `list(device_path)` — full FileSync protocol | Enables clean `pull`-based `screencap -p /local && pull` work flows (crisper than `shell screencap -p` stdout parsing) for album art, debugging, or future thumbnails; zero new deps |
| Lazy shell decode | `decode=False` on shell | Micro-win for binary/inspection commands |
| USB | `AdbDeviceUsb` (experimental, `[usb]` extra) | Not for MVP (cradle-cable setup); note for future reference only |
| Long-lived TCP connect | `AdbDeviceTcp(host, port, default_transport_timeout_s=...)` + `connect(rsa_keys=[signer], auth_timeout_s=...)` with per-call timeouts | What Phase 1 already relies on; the per-transaction timeout fields (`transport_timeout_s`/`read_timeout_s`/`timeout_s`) are worth being explicit about in the manager's health-check design rather than default-None everywhere |
## 5. TiviMate specifics
**What TiviMate gives us: essentially nothing at the device level.**
- Package `ar.tvplayer.tv`; no media session (R2, verified twice — before/after a
zap produced byte-identical output) and appears only in the "Audio playback"
section of `dumpsys media_session`.
- [MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4):
independent confirmation that TiviMate produces nothing captureable by
notification/session bridges on the Shield.
- [Channel-switch automation thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344):
the community's only working channel-select recipe is the digit-keyevent path we
already planned (plus a ~7 s delay for app cold start).
- **No deep link published by users** in the deep-linking guide or elsewhere for
`ar.tvplayer.tv` — treat as "no shortcut"; if ever one surfaces, `am start` with
extras via ADB is the Phase-4 "apps the protocol can't launch" seam.
- **Channel list export:** TiviMate does not expose a device-side API for the
channel list the community can use — the ecosystem gets channels from the
upstream M3U/XMLTV sources that TiviMate loads, not from the app. Community
projects around TiviMate (playlists, EPG ([EPGTalk](https://github.com/acidjesuz/EPGTalk)), playlist auto-updaters) take
their data from those links. **MVP implication:** if we ever want "channel
N = <name>" metadata, the data side belongs to the user's playlist/EPG pipeline
(e.g. options field to store a channel list file), *not* to the integration
scraping the TV.
- **Channel/zap-change detection while watching:** no project has reliable on-device
telemetry (the R2 capture shows the dump is zap-invariant). Realistic best
signals remain the ones we have chosen: A "Audio playback" presence (identity)
and `last_channel` cache (what *we* last asked for). The Phase-1 optional
logcat-on-a-device process channel may eventually reveal app-internal logs, but
no public, stable, IPTV-app-level pattern exists to borrow.
**Conclusion:** our current design (digits via keys, foreground fallback + audio-playback
hint + last-known cache) is at parity with what the community does; the only
additions worth recording are the ~7 s app-cold-start note in automation docs and the
"channel list data belongs outside the integration" decision.
## 6. Ranked ideas
Ranking by: (a) fits locked MVP decisions, (b) cost, (c) user-verifiable payoff.
"Phase" is where it should land in the current plan.
| Rank | Idea | MVP fit | Effort | Phase |
|---|---|---|---|---|
| 1 | **`channel_up` / `channel_down` on the remote+media_player command sets** — free with the protocol's `KEYCODE_CHANNEL_UP`(166)/`KEYCODE_CHANNEL_DOWN`(167); Roku/Bravia precedent for making tuning feel native; zero new logic since we already send keycodes for digits | High | Trivial (hours) | 2 (fall-out) |
| 2 | **`!` app-close prefix on `select_source`/`play_media(app)`** — androidtv precedent (`!com.netflix.ninja`); one-stop fix for "app won't die" automation scripts; zero new deps or surfaces | High | Small (half day incl. tests) | 4 |
| 3 | **`play_media(media_content_type: url)` deep links via `send_launch_app_command`** with a cloud-less data-driven *deeplink map option* (validated against the community-wide guide; the library accepts either raw deep link or bare package id) — plus the guide's `adb shell cmd package dump` trick documented for contributors to discover links per app | High | Small–medium (~1 day incl. options editor) | 4 |
| 4 | **BrowseMedia "Apps" directory** from running_apps + options-defined app map (androidtv_remote/Apple TV/Bravia precedent) — the single most-requested QoL pattern in this space and the reason users hit external cards ([#117126](https://github.com/home-assistant/core/pull/117126), [HA-Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48)) | High | Medium (~1–2 days incl. icons + tests) | 4 |
| 5 | **Volume readout hardening in Phase 2** — `volume_info` push sourced real `volume_level` and mute, with `media_player.volume_set` deliberately unsupported (C-VOL correction, already adopted); if we hear repeated user demand later, offer an ADB-step-loop helper service documented as such (not fake features) | Medium (already handled) | done (plan note only) | 2 |
| 6 | **adb_shell streaming_shell probe** — spike: confirm `streaming_shell` exists at our pinned adb-shell version and on the async class; if yes, MVP_PLAN's deferred logcat-on-ADB idea is feasible via a documented generator rather than internal hacks; if no, keeps the snapshot-only plan honest. Cheap spike, high leverage on a currently-"impossible" feature | Medium | Small (half-day spike) | 4 (optional path) |
| 7 | **Bravia-style tolerant `media_content_id` matching** for `channel` (accept `5.1`-style floats and exact-name matches from the app/channel map) — small UX win, safe with a strict numeric path retained | Medium | Small | 3/4 |
| 8 | **Screen-capture album art (adb_shell `screencap`/`pull`)** — androidtv precedent supplies the pattern; useful only for our PlayOnRoku-missing, Jellyfin-cached apps; keep it as a documented option like androidtv does, since it costs extra ADB shell round trips when playing and is privacy-scope-sensitive | Medium | Medium (~1 day + options slosh) | Post-MVP |
| 9 | **IME text entry surfaced as a remote/service integration** — possible now that the library has `send_text`/`text:` syntax, but per #32 behavior is inconsistent; Apple TV solves this with a keyboard-focus push that the Shield protocol doesn't give us cheaply; keep IME an options toggle, not an entity | Low for MVP | Medium | 4 (option only) |
| 10 | **`state_detection_rules`-style per-app configurable state ladder** (androidtv pattern) — only if/when users ask for app-specific truth beyond ADB sessions (e.g. IPTV VOD pages); nice future seam; don't build pre-emptively | Low for MVP | Medium–large | Post-MVP |
| 11 | **Voice (`start_voice`)** — available in the library, `enable_voice=True` and device-feature-gated; not in our MVP scope, recorded so it's not re-researched | Low | Medium | Post-MVP |
Key references for the rankings (primary, not forum threads only):
[core#176505](https://github.com/home-assistant/core/issues/176505) ·
[#117126](https://github.com/home-assistant/core/pull/117126) ·
[#17](https://github.com/tronikos/androidtvremote2/issues/17) ·
[#32](https://github.com/tronikos/androidtvremote2/issues/32) ·
[#28](https://github.com/tronikos/androidtvremote2/issues/28) ·
[androidtvremote2 source](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/androidtv_remote.py) ·
[proto](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto) ·
[adb_shell device](https://github.com/JeffLIrion/adb_shell/blob/master/adb_shell/adb_device.py) ·
[Roku](https://www.home-assistant.io/integrations/roku/) ·
[Apple TV](https://www.home-assistant.io/integrations/apple_tv/) ·
[Bravia](https://www.home-assistant.io/integrations/braviatv/) ·
[androidtv](https://www.home-assistant.io/integrations/androidtv/) ·
[deep linking guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921/) ·
[community TiviMate thread](https://community.home-assistant.io/t/automation-start-tivimate-and-change-to-specific-channel/859344) ·
[Firemote#48](https://github.com/PRProd/HA-Firemote/issues/48) ·
[MediaSession2MQTT#4](https://github.com/cbeyls/MediaSession2MQTT/issues/4)
---
## 7. Explicit MVP-plan corrections proposed (for Phase-3/4 lands)
1. **C-VOL (already adopted by captain).** `volume_set` is not protocol-native — the
supported features in Phase 2 stay `VOLUME_STEP|MUTE`, with real numeric
`volume_level` and `is_volume_muted` readouts from the protocol's `volume_info`
push. Absolute-volume demand, if it ever recurs, is addressed honestly via an
ADB `input keyevent KEYCODE_VOLUME_*` helper script, documented as such, not as
a fake supported feature.
2. **C-STREAM (new).** MVP_PLAN §2's statement that adb_shell offers no
incremental/streaming read should be softened: `streaming_shell` exists
upstream (at least on master) and might make the deferred Phase-4 logcat idea
a one-day spike instead of "needs new dependency". Verify our pinned version
carries it and whether the async device class exposes it, then either upgrade
to a "cheap Phase-4 item" or keep the deferred note with accurate wording.
3. **C-LAUNCH (new, informational).** `send_launch_app_command` already accepts a bare
package id by auto-prefixing `market://launch?id=`; Phase 4 needs no extra
branch for "app" launches, only for deep links (where the value already has a
scheme) and `!`-close via ADB (only when the protocol path is not enough).
4. **C-TIVI (new).** TiviMate has no usable on-device channel metadata or export
surface; the honest MVP display is "on, in TiviMate" + `last_channel` cache
from our own play_media calls. Channel-name metadata is a user-supplied
playlist/EPG concern, out of scope (and out of ADB's reach).
+423
View File
@@ -0,0 +1,423 @@
# Shield firmware research results (§8 of MVP_PLAN.md, R1–R9)
Captured from the user's Shield. These outputs are the ground truth for parser
fixtures and resolved the R2 verdict. Plan deltas applied to `MVP_PLAN.md` are
listed at the end.
## Environment
| Property | Value |
|---|---|
| Device | NVIDIA Shield (board `mdarcy`) |
| Android | 11 (`ro.build.version.release=11`) |
| Fingerprint | `NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7825230_4231.0265:user/release-keys` |
| SDK | 30 (Android 11; the `getprop ro.build.version.sdk` call failed on shell truncation — `etprop: inaccessible or not found` — but Android 11 ⇒ SDK 30) |
Note: the home launcher is **not** the stock Shield launcher — it is
`com.spocky.projengmenu` (Project Eternity menu). Foreground detection must not
assume `com.google.android.tv.launcher`.
---
## R1 — TiviMate package name: `ar.tvplayer.tv`
```
$ adb shell pm list packages | grep -iE 'tivi|^package:iv'
package:ar.tvplayer.tv
```
**Verdict:** TiviMate's package is `ar.tvplayer.tv` — **not** `iv.android.tv` as
the plan assumed. All match keys, fixtures, and constants use `ar.tvplayer.tv`.
---
## R2 — Does TiviMate publish a media session? **NO** (verdict: branch 3)
Full `dumpsys media_session` with TiviMate in the foreground playing a live
channel (captured twice, before/after zapping):
```
MEDIA SESSION SERVICE (dumpsys media_session)
9 sessions listeners.
Global priority session is null
User Records:
Record for full_user=0
...
Sessions Stack - have 1 sessions:
Netflix media session com.netflix.ninja/Netflix media session (userId=0)
ownerPid=5833, ownerUid=10080, userId=0
package=com.netflix.ninja
launchIntent=null
mediaButtonReceiver=null
active=false
flags=3
rating type=0
controllers: 0
state=null
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_UNKNOWN flags=0x800 tags= bundle=null
volumeType=1, controlType=2, max=0, current=0
metadata: null
queueTitle=null, size=0
Audio playback (lastly played comes first)
uid=10117 packages=ar.tvplayer.tv
uid=10006 packages=com.google.android.apps.mediashell
```
**Findings:**
1. **No TiviMate session block at all.** TiviMate registers no MediaSession —
it cannot be reached via the session stack. It is a pure Phase-3 fallback app.
2. The only session present is **Netflix's, stale/inactive**: `active=false`,
`state=null`, `metadata=null`. The current parser requires `active=true` +
metadata, so it correctly ignores it — but this exact dump must become a
fixture pinning that behavior ("inactive no-state sessions are ignored").
3. The before/after-zap captures are **byte-identical** — zapping channels
changes nothing in `dumpsys media_session`.
### NEW discovery — the "Audio playback" section
The bottom of `dumpsys media_session` lists **apps that recently played audio**,
even with no MediaSession:
```
Audio playback (lastly played comes first)
uid=10117 packages=ar.tvplayer.tv
uid=10006 packages=com.google.android.apps.mediashell
```
- TiviMate shows up here while playing live TV. This is a **zero-cost
supplementary signal**: it comes from the *same* `dumpsys media_session` call
the integration already makes — no extra round trip.
- Use: confirm "this foreground app actually has/played media" (foreground =
TiviMate + recent audio playback ⇒ live TV likely playing). Distinguishes
"sitting in TiviMate's settings" from "watching a channel".
- Note `com.google.android.apps.mediashell` (Chromecast receiver process) also
appears — a parse-time ignore-list entry.
---
## R4 — Foreground detection format (Android 11): `mResumedActivity` is the pattern
```
$ adb shell dumpsys activity activities | grep -iE 'resumed'
mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity
t8152}
state=RESUMED stopped=false delayedResume=false finishing=false
Resumed activities in task display areas (from top to bottom):
Resumed: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
ResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
```
```
$ adb shell dumpsys window | grep -E 'mCurrentFocus|mFocusedApp'
mCurrentFocus=null
mFocusedApp=null
mCurrentFocus=Window{980f3f1 u0 ar.tvplayer.tv/ar.tvplayer.tv.ui.MainActivity}
mFocusedApp=ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
```
**Findings:**
1. **No `topResumedActivity` on this firmware** (that pattern is newer Android).
Primary pattern = `mResumedActivity: ActivityRecord{… u0 <pkg>/<activity> …}`.
Keep `topResumedActivity` parsing for future-proofing, checked *after*
`mResumedActivity`.
2. `dumpsys window` contains **multiple** `mCurrentFocus` entries and the first
can be literally `mCurrentFocus=null` — a naive first-match parser breaks.
`mResumedActivity` is the reliable single source; window/focus parsing is a
secondary cross-check at most.
3. Package extraction: from the `u0 <pkg>/<activity>` fragment of the
ActivityRecord line (handles multi-line wraps in the raw dump — match on the
joined block, not per-line).
4. Launcher foreground (for the "off/idle in launcher" distinction): with the
user's custom launcher focused, `mResumedActivity` shows
`com.spocky.projengmenu/.ui.home.MainActivity`.
---
## R5 — Wake state: exact value match confirmed
```
Awake: mWakefulness=Awake
mWakefulnessChanging=false
Asleep: mWakefulness=Asleep
mWakefulnessChanging=false
```
**Verdict:** the current `"Awake" in result` substring heuristic happens to
work on this firmware, but switch to an exact value parse of the
`mWakefulness=` assignment (robust against future values like
`mWakefulness=Dozing`).
---
## R6 — Recent-tasks: **not run** (optional; low risk)
Still outstanding if `media_dag_running_apps` needs verification on this
firmware.
---
## R7 — Channel digit keyevents: **skipped by decision**
The user already uses `media_player.play_media` with
`media_content_type: channel` through the androidtv_remote integration and
reports it "works perfectly fine" — so we **replicate core's exact behavior**
rather than live-testing keyevent variants:
- digits only (no confirm/ENTER key), inter-digit delay **0.1 s** (core default),
- cancel any prior in-flight channel task before sending,
- keep ENTER as an *optional* boolean (default off) in case a future app needs it.
R7 stays in the doc as an optional verification step if protocol-path digits
ever misbehave.
---
## R8 — Logcat event reliability: `ActivityTaskManager: START` lines confirmed
```
10-08 06:54:33.748 3693 5205 I ActivityTaskManager: START u0
{act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER]
flg=0x10200000 cmp=ar.tvplayer.tv/.ui.MainActivity} from uid 2000
10-08 06:54:50.439 3693 5212 I ActivityTaskManager: START u0
{act=android.intent.action.MAIN
cat=[android.intent.category.LEANBACK_LAUNCHER] flg=0x10220000
pkg=com.spocky.projengmenu cmp=com.spocky.projengmenu/.ui.home.MainActivity
(has extras)} from uid 10059
```
**Findings:**
1. `ActivityTaskManager: START u0 {… cmp=<pkg>/<activity>}` lines are emitted on
every app open — including the **home button** (which sends a
`LEANBACK_LAUNCHER` START for the launcher). App-switch detection via logcat
delta snapshots is viable in both directions.
2. `WindowManager` lines were absent from the capture — rely on
`ActivityTaskManager` first.
3. The user has **`be.digitalia.mediasession2mqtt`** installed
([MediaSession2MQTT](https://github.com/cbeyls/MediaSession2MQTT)) — a
notification-listener service that publishes media sessions over MQTT. It is
part of their current glue (see appendix) and is a candidate to retire once
our session + fallback path covers its sensors.
---
## R9 — PlaybackState layout + a known-good Wholphin session
```
state=PlaybackState {state=3, position=17237, buffered position=70070,
speed=1.0, updated=1408392, actions=7339995, custom actions=[], active item
id=0, error=null}
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_MOVIE flags=0x800 tags= bundle=null
volumeType=1, controlType=2, max=0, current=0
metadata: size=11, description=Twirlywoos, null, null
queueTitle=null, size=1
```
**Findings:**
1. `PlaybackState` carries everything we planned to parse: `state=3` (playing),
`position=17237` (ms), `speed=1.0`, `updated=1408392` (**uptime ms**, needs
conversion — position math must account for the uptime-vs-wallclock gap;
mirror HA's convention by computing `position + (now - updated_time)` with
uptime anchoring).
2. No duration in PlaybackState — duration lives in metadata keys
(`android.media.metadata.DURATION` inside the size=11 metadata dict, beyond
the `description=` field the parser currently reads). Parsing the full
metadata key list is a Phase-1 stretch goal; `description=` gives
title/artist/album positionally as before.
3. The session above is **Wholphin** (`com.github.damontecres.wholphin`) — a
Jellyfin client that publishes a proper session with usable metadata. Add it
to `JELLYFIN_APP_IDS` (that constant is currently defined but **unused** —
wire it up) so Jellyfin-family clients route to the rich-metadata path.
---
## play_media channel — current working service call (replicate exactly)
```yaml
action: media_player.play_media
data:
media:
media_content_id: '{{ channel_number|int }}'
media_content_type: channel
metadata: {}
target:
entity_id:
- media_player.shield_android_tv
```
Note the current HA service syntax uses the `media:` block; the entity's
`async_play_media(media_type, media_id)` receives `"channel"` / `"<n>"` the same
as before.
---
## Appendix — the user's current glue this integration replaces
Recorded as requirements context. This is what works today and what MVP should
make unnecessary.
### Template sensor (app name from the androidtv media_player)
```yaml
template:
- triggers:
- trigger: homeassistant
event: start
- trigger: state
entity_id:
- media_player.android_tv_10_50_204_4
attribute: app_name
sensor:
- name: Nvidia Shield App Name
unique_id: media_player_nvidia_shield_app_name
icon: "mdi:application"
state: >
{% set adb = "media_player.android_tv_10_50_204_4" %}
{% set adb_state = states(adb) %}
{% if adb_state not in ['unknown', 'unavailable', 'off'] %}
{{ state_attr(adb,'app_name') }}
{% elif adb_state in ['off'] %}
{{ "off" }}
{% else %}
{{ "unknown" }}
{% endif %}
```
### Universal media player (state merging + command routing)
```yaml
media_player:
platform: universal
name: Nvidia Shield
device_class: tv
unique_id: media_player_nvidia_shield
children:
- media_player.android_tv_10_50_204_4
browse_media_entity: media_player.android_tv_10_50_204_4
state_template: >-
{% set shield_state = states('media_player.android_tv_10_50_204_4') %}
{% set known_app = states('sensor.mediasession2mqtt_2044_application_id') in ["com.google.android.youtube.tv", "com.github.damontecres.wholphin"] %}
{% set wholphin = is_state('sensor.nvidia_shield_app_name', 'Wholphin') %}
{% if(shield_state == "unknown" or shield_state == "unavailable" or shield_state == "off") %}
off
{% elif wholphin %}
{% set playing_state = is_state('binary_sensor.jellyfin_nvidia_shield_playing', 'on') %}
{% set paused_state = is_state('binary_sensor.jellyfin_nvidia_shield_paused', 'on') %}
{% if paused_state %}
paused
{% elif playing_state %}
playing
{% else %}
idle
{% endif %}
{% elif known_app %}
{{ states("sensor.mediasession2mqtt_2044_playback_state") }}
{% else %}
{{ shield_state }}
{% endif %}
commands:
turn_on:
action: script.turn_on_nvidia_shield
turn_off:
action: script.turn_off_nvidia_shield
volume_mute:
action: switch.toggle
target: {entity_id: switch.tv_cabinet_livingroom_speaker_system}
volume_up:
action: script.volume_up_nvidia_shield
volume_down:
action: script.volume_down_nvidia_shield
volume_set:
action: script.volume_set_nvidia_shield
data: {level: "{{ volume_level }}"}
select_source:
action: script.set_specific_app_nvidia_shield
data: {app_name: "{{ source }}"}
attributes:
is_volume_muted: switch.inverse_tv_cabinet_speakers
volume_level: media_player.android_tv_10_50_204_4|volume_level
source: remote.shield_android_tv|current_activity
source_list: remote.shield_android_tv|activity_list
```
### What the MVP replaces vs. keeps
| Current glue | MVP after Phase 2/3 |
|---|---|
| `sensor.nvidia_shield_app_name` template sensor | Current App sensor reads StateStore (protocol `current_app` push) |
| `mediasession2mqtt` sensors (`*_application_id`, `*_playback_state`) | ADB media sessions + fallback state (retire the MQTT bridge) |
| Universal player `state_template` app special-casing | Native state ladder |
| `browse_media_entity` from androidtv_remote | Phase 4 BrowseMedia app directory |
| `volume_*` scripts (speaker switch + receiver interplay) | **Stays as user automations** — but `volume_set` gains a real protocol path (Phase 2 VOLUME_SET); the receiver/speaker switch logic remains the user's or moves to media zone |
| `remote.shield_android_tv` for source/activity | Our own remote entity (Phase 2) |
---
## Fixtures to save into `tests/fixtures/`
| File | Content |
|---|---|
| `dumpsys_media_session_tivimate_playing.txt` | R2 capture 1 (Netflix stale session + audio playback listing) |
| `dumpsys_media_session_tivimate_zapped.txt` | R2 capture 2 (identical — pins zap-invariance) |
| `dumpsys_media_session_wholphin_playing.txt` | R9 capture (playing session with position/metadata) |
| `dumpsys_activity_activities_tivimate.txt` | R4 capture 1 |
| `dumpsys_activity_activities_launcher.txt` | R4 capture 2 (custom launcher) |
| `dumpsys_window_focused.txt` | R4 window capture |
| `dumpsys_power_wakefulness_{awake,asleep}.txt` | R5 captures |
| `logcat_activitytaskman_start.txt` | R8 capture |
---
## Plan deltas applied to `MVP_PLAN.md`
1. TiviMate package corrected everywhere: `ar.tvplayer.tv`.
2. R2 verdict resolved: no session → TiviMate is the headline Phase-3 fallback
app; README gets it as a known case.
3. **New fallback signal adopted:** "Audio playback (lastly played comes first)"
section of `dumpsys media_session` — zero extra round trip; parsed alongside
sessions; `com.google.android.apps.mediashell` in the ignore list.
4. Foreground parser: `mResumedActivity` primary (Android 11),
`topResumedActivity` newer-fallback; `mCurrentFocus=null` trap documented;
launcher special-casing not stock.
5. Wake parse: exact `mWakefulness=` value match.
6. `play_media(channel)`: replicate core behavior exactly (digits, 0.1 s, no
ENTER); R7 marked skipped-by-decision.
7. Wholphin (`com.github.damontecres.wholphin`) added to `JELLYFIN_APP_IDS`;
wiring that constant becomes a Phase-0/1 item.
8. Position math: `updated` is uptime-ms — needs uptime anchoring in Phase 1.
9. Migration note: retire mediasession2mqtt sensors + universal-player YAML once
Phase 3 lands (documented in README).
10. Netflix stale-session fixture pins "inactive no-state sessions ignored".
---
## Appendix — mediasession2mqtt retirement + universal-player glue (Phase 3)
The fork retires the surrounding glue once the state ladder (README, Phase 3)
covers what the external pieces provided:
| External piece | What it provided | Now native (file) |
|---|---|---|
| `mediasession2mqtt` bridge sensors | per-app now-playing pushed over MQTT from the Shield's notification listener | ADB media-session polling + the foreground tier (`connection.py`, `media_player.py`); the `media_dag_now_playing` attribute carries `source: jellyfin \| media_session \| app` |
| Universal media player `media_dag_running_apps` template | app switcher list with playback states | `running_apps_from_tasks(..., last_known=...)` (`ha_adb_common/running_apps.py`) — enriched with `foreground`/`state`/`last_known_state`/`last_known_title` |
| Universal player "currently in app X" template | fallback state for no-session apps | the foreground ladder tier (`media_player.py` state property, tier 3.5) |
| External androidtv_remote entity for commands | remote keys | the protocol-backed `remote` entity (Phase 2) — the external entity remains only as the transitional last command tier, removed in Phase 4 |
| Last-channel / "recently played" sensors | what was on when the session vanished | the TTL-capped last-known-state cache + `_last_channel` + the audio-playback hint (`connection.py`) |
The migration path: point the automations/scripts at the integration's DAG
attributes (`media_dag_*`) instead of the MQTT sensors, then remove
`mediasession2mqtt` and the universal-player config entries. No YAML glue is
needed for the MVP feature set; the appendix exists so retirement can be
staged per household.
**TiviMate (`ar.tvplayer.tv`)** is the documented no-session case (R2): it
publishes no MediaSession on Shield Android 11. Its visibility comes from the
foreground tier (tier 3.5) with the audio-playback hint and the
last-known-state cache — never from a session, never resurrected past the
state TTL.
+5 -8
View File
@@ -1,6 +1,6 @@
[project]
name = "ha-nvidia-shield"
version = "0.1.1"
version = "2026.10.08"
description = "Home Assistant custom integration for NVIDIA Shield TV via ADB + Android TV Remote"
readme = "README.md"
requires-python = ">=3.12"
@@ -14,14 +14,11 @@ dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"ruff>=0.4",
# Shared ADB helpers. Vendored into custom_components/nvidia_shield/ha_adb_common/
# for HACS installs; used editable from a sibling checkout for development.
# Planned migration to a proper PyPI package.
"ha-adb-common",
"androidtvremote2>=0.2.0",
]
[tool.uv.sources]
ha-adb-common = { path = "../ha-adb-common", editable = true }
# The shared ADB helpers are vendored at
# custom_components/nvidia_shield/ha_adb_common/ — the only consumed copy in
# this fork, so no extra dev dependency (and no unavailable sibling checkout).
[tool.ruff]
target-version = "py312"
+52
View File
@@ -0,0 +1,52 @@
"""Test bootstrap.
Puts the vendored ha_adb_common package on sys.path so helpers can be imported
directly (their code is stdlib-only) without installing Home Assistant or
executing the integration's __init__ module.
The integration-package modules that stay Home-Assistant-free at import time
(currently connection.py) are also made importable — via a synthetic package
("nsh") that wraps custom_components/nvidia_shield WITHOUT executing its
__init__.py (that one imports Home Assistant). Only the stdlib-only modules
are loaded into it explicitly; their relative imports resolve normally.
"""
import importlib.util
import sys
import types
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "custom_components" / "nvidia_shield"))
_SHIELD_DIR = REPO_ROOT / "custom_components" / "nvidia_shield"
_PKG = "nsh"
def _load_module(name: str, path: Path, search_paths: list[str] | None = None):
"""Import a source file under ``name`` without touching real packages."""
spec = importlib.util.spec_from_file_location(
name, path, submodule_search_locations=search_paths
)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
# Synthetic integration package — no __init__.py execution (HA-free).
_root = types.ModuleType(_PKG)
_root.__path__ = [str(_SHIELD_DIR)]
sys.modules[_PKG] = _root
# Real subpackage __init__ (stdlib-only) so `from .ha_adb_common import ...`
# works through the synthetic package.
_load_module(
f"{_PKG}.ha_adb_common",
_SHIELD_DIR / "ha_adb_common" / "__init__.py",
[str(_SHIELD_DIR / "ha_adb_common")],
)
# Stdlib-only integration modules.
for _mod in ("const", "adb_session", "connection"):
_load_module(f"{_PKG}.{_mod}", _SHIELD_DIR / f"{_mod}.py")
+501
View File
@@ -0,0 +1,501 @@
"""Home-Assistant object stubs sized to unit-test the entity modules (t10).
Import-only stubs: MediaPlayerEntity/RemoteEntity bases, the feature/state
enum values the integration actually uses, and the event helpers. Tests load
their entity module through the synthetic ``nsh`` package AFTER calling
:func:`install` (and the per-file fixture pops everything at teardown).
"""
import asyncio
import enum
import sys
import types
from collections.abc import Mapping
import voluptuous_mirror
ENTITIES_STUB_NAMES = (
"voluptuous",
"homeassistant",
"homeassistant.config_entries",
"homeassistant.exceptions",
"homeassistant.core",
"homeassistant.const",
"homeassistant.components",
"homeassistant.components.media_player",
"homeassistant.components.remote",
"homeassistant.components.button",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector",
"homeassistant.helpers.event",
"homeassistant.helpers.redact",
"homeassistant.helpers.entity",
"homeassistant.helpers.entity_platform",
"nsh.helpers",
"nsh.power",
"nsh.jellyfin",
"nsh.media_player",
"nsh.diagnostics",
"nsh.remote",
)
class MediaPlayerState(enum.StrEnum):
OFF = "off"
ON = "on"
IDLE = "idle"
PLAYING = "playing"
PAUSED = "paused"
class MediaPlayerEntityFeature(enum.IntFlag):
PLAY = 1
PAUSE = 2
STOP = 4
VOLUME_MUTE = 8
VOLUME_STEP = 16
VOLUME_SET = 32
BROWSE_MEDIA = 4096
TURN_ON = 64
TURN_OFF = 128
PLAY_MEDIA = 256
NEXT_TRACK = 512
PREVIOUS_TRACK = 1024
class MediaClass:
"""homeassistant.components.media_player.MediaClass (values we use)."""
APP = "app"
DIRECTORY = "directory"
class MediaType:
"""homeassistant.components.media_player.MediaType (values we use)."""
APP = "app"
APPS = "apps"
class BrowseMedia:
"""The REAL media_player.BrowseMedia shape (t37 D1).
Keyword-only with the six arguments real HA requires, the optional ones it
accepts, a faithful ``as_dict`` and — crucially — NO ``domain`` parameter:
``BrowseMediaSource`` lives in
``homeassistant.components.media_source.models`` (its contract is the
``media_source://<domain>`` id) and is NOT importable from
``homeassistant.components.media_player`` at all. The old import and the
old ``domain=`` kwarg therefore fail IN-SUITE instead of hiding behind a
permissive stub.
"""
def __init__(
self,
*,
media_class,
media_content_id,
media_content_type,
title,
can_play,
can_expand,
children=None,
children_media_class=None,
thumbnail=None,
not_shown=0,
can_search=False,
search_media_classes=None,
):
self.media_class = media_class
self.media_content_id = media_content_id
self.media_content_type = media_content_type
self.title = title
self.can_play = can_play
self.can_expand = can_expand
self.children = children
self.children_media_class = children_media_class
self.thumbnail = thumbnail
self.not_shown = not_shown
self.can_search = can_search
self.search_media_classes = search_media_classes
def as_dict(self, *, parent=True):
"""Real payload conversion — what the frontend actually consumes."""
if self.children_media_class is None and self.children:
self.calculate_children_class()
response = {
"title": self.title,
"media_class": self.media_class,
"media_content_type": self.media_content_type,
"media_content_id": self.media_content_id,
"children_media_class": self.children_media_class,
"can_play": self.can_play,
"can_expand": self.can_expand,
"can_search": self.can_search,
"search_media_classes": self.search_media_classes,
"thumbnail": self.thumbnail,
}
if not parent:
return response
response["not_shown"] = self.not_shown
response["children"] = (
[child.as_dict(parent=False) for child in self.children]
if self.children
else []
)
return response
def calculate_children_class(self):
"""Real helper: the children's common class, else DIRECTORY."""
self.children_media_class = MediaClass.DIRECTORY
assert self.children is not None
proposed_class = self.children[0].media_class
if all(child.media_class == proposed_class for child in self.children):
self.children_media_class = proposed_class
class MediaEntityManager:
"""trivial stand-in for the HA platform internals we don't exercise."""
class MediaPlayerEntity:
"""Base entity: attributes only; state writing is a no-op."""
def async_write_ha_state(self) -> None:
return None
class RemoteEntity:
def async_write_ha_state(self) -> None:
return None
def install() -> None:
"""Register the stub modules (idempotent)."""
if "homeassistant.components.media_player" in sys.modules:
return
vol = types.ModuleType("voluptuous")
# t33 F-A: the faithful mirror keeps schema defaults visible — a bare-key
# stub cannot tell a schema WITH a default from one without.
voluptuous_mirror.install(vol)
sys.modules["voluptuous"] = vol
ha = types.ModuleType("homeassistant")
sys.modules["homeassistant"] = ha
config_entries = types.ModuleType("homeassistant.config_entries")
config_entries.ConfigEntry = type("ConfigEntry", (), {})
async def _stub_set_unique_id(self, unique_id):
# hub shape: record; a full flow test file scopes its own version
self._unique_id = unique_id
def _noop_abort(self, *, error=None, **kwargs):
return None
class _ConfigFlowStub:
"""ConfigFlow shape used by entity tests (t29 G3a API included).
Late loads under the hub stubs (e.g. test_init designs run AFTER the
other flow files) still get the base plumbing the flow modules rely
on — show/create/abort/unique-id — so the flow classes never import
against a bare base.
"""
async_set_unique_id = _stub_set_unique_id
_abort_if_unique_id_configured = _noop_abort
async def _async_handle_discovery_without_unique_id(self):
# t37 D3: the real helper is a COROUTINE and core awaits it — a
# sync stub here would turn the production `await` into a
# TypeError. The abort semantics live in
# tests/test_standalone_setup.py, whose stub owns entry/in-progress
# state; at hub scope there is nothing to abort on.
return None
def async_show_form(
self, *, step_id, data_schema=None, errors=None,
description_placeholders=None,
):
return {
"type": "form",
"step_id": step_id,
"errors": dict(errors or {}),
"data_schema": data_schema,
"description_placeholders": dict(description_placeholders or {}),
}
def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data}
def async_abort(self, *, reason):
return {"type": "abort", "reason": reason}
def _get_reauth_entry(self, entry=None):
if entry is not None:
return entry
return None # the caller's cached entry is used
def _get_reconfigure_entry(self, entry=None):
if entry is not None:
return entry
return None # the caller's cached entry is used
# NOTE: the real HA signature is async_update_reload_and_abort(entry, ...)
# which updates, schedules the reload and aborts; the stub records both.
def async_update_reload_and_abort(self, entry, **kwargs):
"""Real-HA semantics: update, schedule the reload, abort."""
updates = kwargs.get("options_updates") or kwargs.get("data_updates") or {}
for key, value in updates.items():
if key in ("options", "data", "unique_id"):
setattr(entry, key, value)
else:
getattr(entry, "options", {}).setdefault(key, value) # best effort
scheduler = getattr(self.hass, "config_entries", None)
if scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
def _config_flow_init(self, *args, **kwargs):
self.context = getattr(self, "context", {}) or {}
self.hass = getattr(self, "hass", None)
_config_flow_base = type(
"ConfigFlow",
(_ConfigFlowStub,),
{
"__init_subclass__": classmethod(
lambda cls, domain=None, **kwargs: None
),
"__init__": _config_flow_init,
},
)
config_entries.ConfigFlow = _config_flow_base
class _OptionsFlowStub:
def __init__(self):
self.config_entry = None
def add_suggested_values_to_schema(self, schema, suggested):
# real HA merges hints; the stub records them on the schema dict
schema_dict = getattr(schema, "schema", None) or (
schema if isinstance(schema, dict) else {}
)
schema_dict.setdefault("__suggested__", dict(suggested))
return schema
class _OptionsFlowWithReload(_OptionsFlowStub):
"""Options flow that reloads after a successful save (real HA)."""
def async_create_entry(self, *, title, data, options=None):
entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
if entry_id is not None and scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry_id)
return {"type": "create_entry", "title": title, "data": data}
config_entries.OptionsFlow = _OptionsFlowStub
config_entries.SOURCE_IMPORT = "import"
config_entries.OptionsFlowWithReload = _OptionsFlowWithReload
sys.modules["homeassistant.config_entries"] = config_entries
exceptions = types.ModuleType("homeassistant.exceptions")
class ServiceValidationErrorStubError(Exception):
def __init__(self, *args, **kwargs):
super().__init__(*args or (kwargs.get("translation_key"),))
self.translation_domain = kwargs.get("translation_domain")
self.translation_key = kwargs.get("translation_key")
exceptions.ServiceValidationError = ServiceValidationErrorStubError
sys.modules["homeassistant.exceptions"] = exceptions
core = types.ModuleType("homeassistant.core")
core.HomeAssistant = object
core.callback = lambda fn: fn
core.CALLBACK_TYPE = object
core.Event = object
sys.modules["homeassistant.core"] = core
ha_const = types.ModuleType("homeassistant.const")
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
ha_const.STATE_PLAYING = "playing"
ha_const.STATE_PAUSED = "paused"
ha_const.Platform = types.SimpleNamespace(
MEDIA_PLAYER="media_player",
SENSOR="sensor",
REMOTE="remote",
BUTTON="button",
)
sys.modules["homeassistant.const"] = ha_const
components = types.ModuleType("homeassistant.components")
components.__path__ = []
sys.modules["homeassistant.components"] = components
mp = types.ModuleType("homeassistant.components.media_player")
mp.MediaPlayerEntity = MediaPlayerEntity
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
mp.BrowseMedia = BrowseMedia
mp.MediaClass = MediaClass
mp.MediaType = MediaType
# NOTE (t37 D1): homeassistant.components.media_player exposes NO
# BrowseMediaSource — deliberately not stubbed, so the drifted import
# fails here exactly as it does in a real install.
mp.MediaPlayerState = MediaPlayerState
sys.modules["homeassistant.components.media_player"] = mp
remote = types.ModuleType("homeassistant.components.remote")
remote.RemoteEntity = RemoteEntity
sys.modules["homeassistant.components.remote"] = remote
button = types.ModuleType("homeassistant.components.button")
button.ButtonEntity = type("ButtonEntity", (), {})
sys.modules["homeassistant.components.button"] = button
sensor_platform = types.ModuleType("homeassistant.components.sensor")
sensor_platform.SensorEntity = type("SensorEntity", (), {})
sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app")
sys.modules["homeassistant.components.sensor"] = sensor_platform
helpers = types.ModuleType("homeassistant.helpers")
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
class _Registry:
entities: dict = {}
def async_get(self, entity_id):
return None
entity_registry.async_get = lambda hass: _Registry()
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
class _DeviceRegistry:
def __init__(self):
self.devices: dict[str, object] = {}
def async_get(self, device_id):
return self.devices.get(device_id)
_device_registry_shared = _DeviceRegistry()
device_registry.async_get = lambda hass: _device_registry_shared
sys.modules["homeassistant.helpers.device_registry"] = device_registry
helpers.entity_registry = entity_registry
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
helpers.device_registry = device_registry
sys.modules["homeassistant.helpers.device_registry"] = device_registry
event = types.ModuleType("homeassistant.helpers.event")
event.async_call_later = lambda hass, delay, callback_fn: (lambda: None)
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
helpers.event = event
helpers.selector = types.SimpleNamespace(
EntitySelector=lambda *a, **k: None,
EntitySelectorConfig=lambda *a, **k: "",
SelectSelector=lambda *a, **k: None,
SelectSelectorConfig=lambda **k: "",
SelectSelectorMode=types.SimpleNamespace(DROPDOWN="dropdown"),
)
entity = types.ModuleType("homeassistant.helpers.entity")
entity.DeviceInfo = dict
entity.EntityCategory = types.SimpleNamespace(
CONFIG="config", DIAGNOSTIC="diagnostic"
)
helpers.entity = entity
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
entity_platform.AddEntitiesCallback = object
helpers.entity_platform = entity_platform
# t37 D2: homeassistant.helpers.redact.async_redact_data — the REAL helper
# (diagnostics.py imports it at module scope; `homeassistant.util.diagnostic`
# never existed). Modelled faithfully: the same masking of keys found in
# ``to_redact``, the same nested mapping/list recursion, the same skip of
# None/empty-string values.
redact = types.ModuleType("homeassistant.helpers.redact")
redact.REDACTED = "**REDACTED**"
def _stub_async_redact_data(data, to_redact):
if not isinstance(data, (Mapping, list)):
return data
if isinstance(data, list):
return [_stub_async_redact_data(val, to_redact) for val in data]
redacted = {**data}
for key, value in redacted.items():
if value is None:
continue
if isinstance(value, str) and not value:
continue
if key in to_redact:
if isinstance(to_redact, Mapping):
redacted[key] = to_redact[key](value)
else:
redacted[key] = redact.REDACTED
elif isinstance(value, Mapping):
redacted[key] = _stub_async_redact_data(value, to_redact)
elif isinstance(value, list):
redacted[key] = [
_stub_async_redact_data(item, to_redact) for item in value
]
return redacted
redact.async_redact_data = _stub_async_redact_data
helpers.redact = redact
sys.modules["homeassistant.helpers.redact"] = redact
sys.modules["homeassistant.helpers.event"] = event
sys.modules["homeassistant.helpers.entity"] = entity
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
sys.modules["homeassistant.helpers"] = helpers
def load_entity_modules() -> dict:
"""Load the integration's HA-free-ish entity modules under nsh."""
install()
from conftest import _SHIELD_DIR, _load_module
for name in (
"helpers",
"power",
"jellyfin",
"sensor",
"media_player",
"remote",
"diagnostics",
"init",
): # "init" maps the integration __init__.py (t29 G8 async_setup)
if name == "init" and "nsh.init" in sys.modules:
continue
if f"nsh.{name}" not in sys.modules:
file_name = "__init__.py" if name == "init" else f"{name}.py"
_load_module(f"nsh.{name}", _SHIELD_DIR / file_name)
return {
name: sys.modules[f"nsh.{name}"]
for name in (
"power",
"jellyfin",
"sensor",
"media_player",
"remote",
"diagnostics",
)
}
class FakeHass:
"""Sized to entity unit tests: background tasks + no-op call_later."""
def async_create_task(self, coro):
return asyncio.create_task(coro)
def async_call_later(self, delay, callback_fn):
return lambda: None
+5
View File
@@ -0,0 +1,5 @@
mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
state=RESUMED stopped=false delayedResume=false finishing=false
Resumed activities in task display areas (from top to bottom):
Resumed: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
ResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}
+12
View File
@@ -0,0 +1,12 @@
SYNTHETIC fixture — not a real device capture. The real Shield `dumpsys
activity recents` capture (R6) is still outstanding; this file pins the
parsing contract of AdbClientBase.poll_running_apps in the meantime, covering
both the A=uid:package and the I=package/activity fallback patterns.
Represents the post-grep output of:
dumpsys activity recents 2>/dev/null | grep -E 'Recent #|realActivity|visible='
Note: line order = recency order (most recently used first).
* Recent #0: Task{2c9f81f #817 type=standard A=10185:ar.tvplayer.tv I=ar.tvplayer.tv/.ui.MainActivity ... visible=true numActivities=1 ...}
* Recent #1: Task{3dae20b #802 type=standard A=10106:com.github.damontecres.wholphin ... visible=false numActivities=1 ...}
* Recent #2: Task{4b7ce01 #788 type=standard I=com.example.launcherapp/.ui.HomeActivity ... visible=true ...}
* Recent #3: Task{5c8dd12 #770 type=standard A=1000:com.android.systemui ... visible=true ...}
* Recent #4: Task{6d9ef23 #750 type=unknown A=10003:.HiddenInternalTask ... visible=true ...}
+2
View File
@@ -0,0 +1,2 @@
mWakefulness=Asleep
mWakefulnessChanging=false
+2
View File
@@ -0,0 +1,2 @@
mWakefulness=Awake
mWakefulnessChanging=false
+33
View File
@@ -0,0 +1,33 @@
MEDIA SESSION SERVICE (dumpsys media_session)
9 sessions listeners.
Global priority session is null
User Records:
Record for full_user=0
Volume key long-press listener: null
Volume key long-press listener package:
Media key listener: null
Media key listener package:
OnMediaKeyEventDispatchedListener: added 0 listener(s)
OnMediaKeyEventSessionChangedListener: added 1 listener(s)
from com.android.bluetooth
Last MediaButtonReceiver: null
Media button session is null
Sessions Stack - have 1 sessions:
Netflix media session com.netflix.ninja/Netflix media session (userId=0)
ownerPid=5833, ownerUid=10080, userId=0
package=com.netflix.ninja
launchIntent=null
mediaButtonReceiver=null
active=false
flags=3
rating type=0
controllers: 0
state=null
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_UNKNOWN flags=0x800 tags= bundle=null
volumeType=1, controlType=2, max=0, current=0
metadata: null
queueTitle=null, size=0
Audio playback (lastly played comes first)
uid=10117 packages=ar.tvplayer.tv
uid=10006 packages=com.google.android.apps.mediashell
+23
View File
@@ -0,0 +1,23 @@
MEDIA SESSION SERVICE (dumpsys media_session)
9 sessions listeners.
User Records:
Record for full_user=0
Media button session is null
Sessions Stack - have 1 sessions:
Wholphin media session com.github.damontecres.wholphin/Wholphin media session (userId=0)
ownerPid=6117, ownerUid=10117, userId=0
package=com.github.damontecres.wholphin
launchIntent=null
mediaButtonReceiver=null
active=true
flags=3
rating type=0
controllers: 1
state=PlaybackState {state=3, position=17237, buffered position=70070, speed=1.0, updated=1408392, actions=7339995, custom actions=[], active item id=0, error=null}
audioAttrs=AudioAttributes: usage=USAGE_MEDIA content=CONTENT_TYPE_MOVIE flags=0x800 tags= bundle=null
volumeType=1, controlType=2, max=0, current=0
metadata: size=11, description=Twirlywoos, null, null
queueTitle=null, size=1
Audio playback (lastly played comes first)
uid=10117 packages=com.github.damontecres.wholphin
+514
View File
@@ -0,0 +1,514 @@
"""Home-Assistant API-drift sweep (t37).
Why this exists
---------------
The unit suite runs our production modules against hand-written stubs
(``tests/entity_stublib.py`` plus the per-file stub fixtures), so a call site
that no longer matches the REAL Home Assistant API stays green. t36's sweep
found exactly that three times — two of them silent in production:
* **D1** ``from homeassistant.components.media_player import BrowseMediaSource``
— media_player has not exported that name for years (it lives in
``homeassistant.components.media_source.models``), so every real browse
raised ImportError;
* **D2** ``from homeassistant.util.diagnostic import async_redact_data`` — that
module does not exist at all, and the swallowed ImportError turned the
documented redaction pass into a no-op;
* **D3** ``self._async_handle_discovery_without_unique_id()`` — a coroutine
``ConfigFlow`` awaits in core; called without ``await`` its body never ran,
so HA's discovery dedup was dead and Python warned about the coroutine.
What it does
------------
``scan()`` parses every production module and resolves each Home-Assistant
dependency against an INSTALLED homeassistant:
1. every ``homeassistant.*`` import — module present, attribute present (or a
submodule of that name exists, which is what ``from x import submodule``
really means);
2. every keyword argument passed to a known HA API (see ``HA_CALL_OWNERS``)
must exist in the real signature — the ``options_updates`` class of bug;
3. every ``self.<method>()`` call inside a class that inherits from an HA class
is compared against the real definition: a coroutine must be awaited, and an
awaited call must actually be a coroutine.
Known limits (deliberate, and why the sweep still catches the real classes):
``**kwargs`` splats cannot be checked statically, calls on objects other than
``self`` (``self.hass.config_entries.…``) are only keyword-checked, and a
method our own class overrides is not resolved against HA. Those gaps are
visible in the ``Report`` counters, which the pytest wrapper asserts on so the
sweep cannot silently become vacuous.
The standalone entry point (``python tests/ha_api_drift.py [path …]``) exits
non-zero on findings *and* on counters below ``FLOORS`` — an empty or
mis-targeted directory is a failure, never a green sweep. ``FLOORS`` is the one
source of truth for both that entry point and the pytest wrapper, and the
interpreter floor Home Assistant itself declares lives here too
(``HOMEASSISTANT_PYTHON_FLOOR``), where CI's guard reads it instead of repeating
the number.
Behaviour note for users (t37 D3): MAC-less zeroconf discovery now follows Home
Assistant's stricter contract — the awaited
``_async_handle_discovery_without_unique_id`` aborts ``already_configured`` as
soon as ANY entry exists for this domain, so a second Shield that advertises no
MAC is added manually. See README "Discovery (zeroconf)".
Without homeassistant installed the sweep cannot resolve anything: ``scan()``
returns a single ``no-homeassistant`` finding, the counters stay at zero (so the
floors fail on purpose) and nothing is ever reported green. The pytest wrapper
(``tests/test_ha_api_drift.py``) skips cleanly in that case, and CI runs one job
with homeassistant installed for exactly this check.
"""
from __future__ import annotations
import ast
import importlib
import importlib.util
import inspect
import re
from dataclasses import dataclass, field
from pathlib import Path
SHIELD_DIR = (
Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield"
)
#: Attribute name -> (module, class) that owns the REAL signature. Resolved with
#: getattr at scan time, so a renamed or dropped API is a finding, not a crash.
HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
"async_update_reload_and_abort": ("homeassistant.config_entries", "ConfigFlow"),
"async_show_form": ("homeassistant.config_entries", "ConfigFlow"),
"async_create_entry": ("homeassistant.config_entries", "ConfigFlow"),
"async_abort": ("homeassistant.config_entries", "ConfigFlow"),
"async_set_unique_id": ("homeassistant.config_entries", "ConfigFlow"),
"_abort_if_unique_id_configured": (
"homeassistant.config_entries",
"ConfigFlow",
),
"async_update_entry": ("homeassistant.config_entries", "ConfigEntries"),
"async_schedule_reload": ("homeassistant.config_entries", "ConfigEntries"),
"async_get_entry": ("homeassistant.config_entries", "ConfigEntries"),
"async_forward_entry_setups": ("homeassistant.config_entries", "ConfigEntries"),
"async_unload_platforms": ("homeassistant.config_entries", "ConfigEntries"),
"async_add_executor_job": ("homeassistant.core", "HomeAssistant"),
}
#: Non-vacuity floors — the ONE source of truth for the standalone entry point
#: and the pytest wrapper (t39 F1). They sit below the current walk (13 modules,
#: 60 imports, 35 checked arguments, 7 awaited HA calls) so a broken or
#: mis-targeted walk fails loudly instead of printing zeros and exiting 0.
FLOORS: dict[str, int] = {
"modules": 10,
"imports": 40,
"call_args": 20,
"awaited_calls": 5,
}
#: The interpreter floor Home Assistant itself declares: PyPI metadata for
#: 2026.10.1 is ``Requires-Python >=3.14.2``, so a 3.13 runner cannot even
#: install the version this sweep must run against. CI's ``ha-api-drift`` job
#: provisions a matching interpreter and guards with THIS constant, so the
#: number is written once; tests/test_ha_api_drift.py pins it against the
#: installed distribution's own metadata.
HOMEASSISTANT_PYTHON_FLOOR: tuple[int, int, int] = (3, 14, 2)
_MISSING = object()
@dataclass(frozen=True)
class Finding:
"""One drifted API use."""
path: str
lineno: int
kind: str
detail: str
def __str__(self) -> str:
return f"{self.path}:{self.lineno}: {self.kind}: {self.detail}"
@dataclass
class Report:
"""Findings plus the counters that prove the sweep actually walked things."""
findings: list[Finding] = field(default_factory=list)
modules: int = 0
imports: int = 0
call_args: int = 0
awaited_calls: int = 0
def floor_violations(self, floors: dict[str, int] | None = None) -> list[str]:
"""Counters below the non-vacuity floors — a sweep nobody should trust.
The floors default to module-level ``FLOORS`` so the standalone entry
point and the pytest wrapper cannot drift apart (t39 F1).
"""
floors = FLOORS if floors is None else floors
return [
f"only {getattr(self, name)} {name} (floor: {floor})"
for name, floor in floors.items()
if getattr(self, name) < floor
]
def __str__(self) -> str:
if not self.findings:
return (
"no Home-Assistant API drift "
f"({self.modules} modules, {self.imports} imports, "
f"{self.call_args} call arguments, "
f"{self.awaited_calls} awaited HA calls)"
)
return "\n".join(str(finding) for finding in self.findings)
# --------------------------------------------------------------------- imports
def _check_import(module_name: str, attr: str | None) -> str | None:
"""Return a reason string when the import target is missing, else None."""
try:
module = importlib.import_module(module_name)
except Exception as err: # noqa: BLE001 - the error IS the finding
return (
f"module {module_name!r} is not importable "
f"({type(err).__name__}: {err})"
)
if attr is None or hasattr(module, attr):
return None
# `from package import submodule` resolves through the import system even
# when the attribute is not visible on the package yet.
try:
importlib.import_module(f"{module_name}.{attr}")
except Exception: # noqa: BLE001 - the error IS the finding
return f"{module_name}.{attr} does not exist"
return None
def _scan_imports(tree: ast.AST, path: str, report: Report) -> None:
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
module_name = node.module or ""
if not module_name.startswith("homeassistant"):
continue
for alias in node.names:
report.imports += 1
reason = _check_import(module_name, alias.name)
if reason:
report.findings.append(
Finding(
path,
node.lineno,
"broken-import",
f"from {module_name} import {alias.name}: {reason}",
)
)
elif isinstance(node, ast.Import):
for alias in node.names:
if not alias.name.startswith("homeassistant"):
continue
report.imports += 1
reason = _check_import(alias.name, None)
if reason:
report.findings.append(
Finding(
path,
node.lineno,
"broken-import",
f"import {alias.name}: {reason}",
)
)
# -------------------------------------------------------------------- keywords
def _real_signature(module_name: str, owner: str, attr: str):
"""(signature, reason) for an HA API; both None when uninspectable."""
try:
module = importlib.import_module(module_name)
except Exception as err: # noqa: BLE001 - the error IS the finding
return None, f"{module_name} is not importable ({type(err).__name__})"
owner_obj = getattr(module, owner, None)
if owner_obj is None:
return None, f"{module_name}.{owner} does not exist"
api = getattr(owner_obj, attr, None)
if api is None:
return None, f"{module_name}.{owner}.{attr} does not exist"
try:
return inspect.signature(api), None
except (TypeError, ValueError): # pragma: no cover - C callables
return None, None
def _scan_call_keywords(tree: ast.AST, path: str, report: Report) -> None:
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not node.keywords:
continue
func = node.func
if not isinstance(func, ast.Attribute):
continue
owner = HA_CALL_OWNERS.get(func.attr)
if owner is None:
continue
signature, reason = _real_signature(*owner, func.attr)
if reason:
report.findings.append(
Finding(path, node.lineno, "unknown-ha-api", reason)
)
continue
if signature is None: # pragma: no cover - uninspectable
continue
if any(
param.kind is inspect.Parameter.VAR_KEYWORD
for param in signature.parameters.values()
):
continue
for keyword in node.keywords:
if keyword.arg is None: # **splat: not statically checkable
continue
report.call_args += 1
if keyword.arg not in signature.parameters:
report.findings.append(
Finding(
path,
keyword.lineno,
"unknown-kwarg",
f"{func.attr}() has no {keyword.arg!r} parameter; "
f"real signature: {signature}",
)
)
# ---------------------------------------------------------------- await/sync
def _module_imports(tree: ast.AST) -> dict[str, tuple[str, str | None]]:
"""Local name -> (module, attribute-or-None) for the module's HA imports."""
imports: dict[str, tuple[str, str | None]] = {}
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith(
"homeassistant"
):
for alias in node.names:
imports[alias.asname or alias.name] = (node.module, alias.name)
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith("homeassistant"):
imports[alias.asname or alias.name.split(".")[0]] = (
alias.name,
None,
)
return imports
def _resolve_base(expr: ast.AST, imports: dict[str, tuple[str, str | None]]):
"""Resolve a class base expression to (module, attribute) when it is HA's."""
if isinstance(expr, ast.Attribute) and isinstance(expr.value, ast.Name):
target = imports.get(expr.value.id)
if target is None:
return None
module_name, attr = target
if attr is None:
# `import homeassistant.config_entries as config_entries`
return module_name, expr.attr
if module_name == "homeassistant":
# `from homeassistant import config_entries` binds the SUBMODULE,
# so `config_entries.ConfigFlow` is that module's attribute.
return f"{module_name}.{attr}", expr.attr
return None
if isinstance(expr, ast.Name):
target = imports.get(expr.id)
if target is not None and target[1] is not None:
return target
return None
def _ha_bases(
class_node: ast.ClassDef, imports: dict[str, tuple[str, str | None]]
) -> list[type]:
bases: list[type] = []
for expr in class_node.bases:
target = _resolve_base(expr, imports)
if target is None:
continue
try:
candidate = getattr(importlib.import_module(target[0]), target[1])
except Exception: # noqa: BLE001 - unresolvable base, skip it
continue
if isinstance(candidate, type):
bases.append(candidate)
return bases
def _method_on_bases(bases: list[type], attr: str):
for base in bases:
found = getattr(base, attr, _MISSING)
if found is not _MISSING:
return found, base
return None, None
class _AwaitVisitor(ast.NodeVisitor):
"""Flags awaited/not-awaited mismatches for `self.<ha method>()` calls."""
def __init__(self, bases: list[type], path: str, report: Report) -> None:
self._bases = bases
self._path = path
self._report = report
self._in_await = 0
def visit_Await(self, node: ast.Await) -> None:
self._in_await += 1
self.generic_visit(node)
self._in_await -= 1
def visit_Call(self, node: ast.Call) -> None:
func = node.func
if (
isinstance(func, ast.Attribute)
and isinstance(func.value, ast.Name)
and func.value.id == "self"
):
api, owner = _method_on_bases(self._bases, func.attr)
if api is not None:
is_coro = inspect.iscoroutinefunction(api)
awaited = self._in_await > 0
where = f"{owner.__module__}.{owner.__qualname__}"
if is_coro and not awaited:
self._report.findings.append(
Finding(
self._path,
node.lineno,
"missing-await",
f"self.{func.attr}() is a coroutine on {where} — "
f"core awaits it; without `await` its body never runs",
)
)
elif awaited and not is_coro:
self._report.findings.append(
Finding(
self._path,
node.lineno,
"awaits-sync-api",
f"self.{func.attr}() is not a coroutine on {where}, "
f"so awaiting it raises TypeError",
)
)
if awaited:
self._report.awaited_calls += 1
self.generic_visit(node)
def _scan_awaits(tree: ast.AST, path: str, report: Report) -> None:
imports = _module_imports(tree)
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef):
continue
bases = _ha_bases(node, imports)
if not bases:
continue
for statement in node.body:
# visit() walks the statement itself: a flat ast.walk would lose the
# `await` nesting and report every awaited call as un-awaited.
_AwaitVisitor(bases, path, report).visit(statement)
# ------------------------------------------------------ floors and versioning
def parse_requires_python(spec: str | None) -> tuple[int, ...] | None:
"""Parse a ``>=X.Y[.Z]`` Requires-Python string into a version tuple.
The CI interpreter guard and the metadata pin in the pytest wrapper both
need the same reading of Home Assistant's own declaration.
"""
if not spec:
return None
match = re.match(r"\s*>=\s*(\d+(?:\.\d+)*)", spec)
if match is None:
return None
return tuple(int(part) for part in match.group(1).split("."))
# ----------------------------------------------------------------------- scan
def homeassistant_available() -> bool:
"""True when a real homeassistant is importable in this interpreter."""
return importlib.util.find_spec("homeassistant") is not None
def scan(paths=None) -> Report:
"""Resolve every Home-Assistant API use in the production tree."""
if paths is None:
paths = [SHIELD_DIR]
elif isinstance(paths, (str, Path)):
paths = [paths]
report = Report()
if not homeassistant_available():
# Neither a false green nor a wall of phantom findings: with no package
# to resolve against, every import would "fail", so the sweep reports
# that it cannot judge and leaves the counters at zero — which trips the
# floors below on purpose.
report.findings.append(
Finding(
"<environment>",
0,
"no-homeassistant",
"homeassistant is not installed in this interpreter, so nothing "
"can be resolved; install it (uvx --with homeassistant --python "
"3.14 ...) or run tests/test_ha_api_drift.py, which skips cleanly",
)
)
return report
for root in paths:
root = Path(root)
if not root.exists():
report.findings.append(
Finding(
str(root),
0,
"bad-path",
"path does not exist — nothing was scanned",
)
)
continue
files = sorted(root.glob("*.py")) if root.is_dir() else [root]
for file in files:
report.modules += 1
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
name = file.name
_scan_imports(tree, name, report)
_scan_call_keywords(tree, name, report)
_scan_awaits(tree, name, report)
return report
if __name__ == "__main__": # pragma: no cover - manual red-proof entry point
import sys
targets = sys.argv[1:] or None
result = scan(targets)
print(result)
violations = result.floor_violations()
if result.findings or violations:
for line in violations:
print(f"FLOOR: {line}", file=sys.stderr)
print(
f"drift sweep FAILED ({len(result.findings)} finding(s), "
f"{len(violations)} floor violation(s))",
file=sys.stderr,
)
raise SystemExit(1)
raise SystemExit(0)
+208
View File
@@ -0,0 +1,208 @@
"""Tests for AdbClientBase additions: exact wakefulness parse (R5),
health_check(), and optional media-session truncation (plan blocker B4).
The client runs against a FakeDevice recording shell commands — no real ADB
socket or adb_shell import is needed.
"""
from pathlib import Path
from ha_adb_common.adb_client import AdbClientBase, parse_wakefulness
FIXTURES = Path(__file__).parent / "fixtures"
CMD_WAKEFULNESS = "dumpsys power 2>/dev/null | grep mWakefulness"
CMD_MEDIA_DEFAULT = "dumpsys media_session 2>/dev/null | head -200"
CMD_MEDIA_FULL = "dumpsys media_session 2>/dev/null"
CMD_ECHO_OK = "echo ok"
class FakeDevice:
"""Records shell commands and returns canned responses per command."""
def __init__(self, responses=None):
self._responses = dict(responses or {})
self.commands: list[str] = []
async def shell(self, cmd: str) -> str | None:
self.commands.append(cmd)
response = self._responses.get(cmd)
if isinstance(response, Exception):
raise response
return response
def _client_response(cmd: str, response: str | None) -> AdbClientBase:
"""A client whose (fake) device is already connected and answers cmd."""
client = AdbClientBase("shield.local", 5555, "/dev/null/adbkey")
client._device = FakeDevice({cmd: response})
client._connected = True # skip connect(): no adb_shell in the test env
return client
class TestParseWakefulness:
"""Exact-value mWakefulness= parse; mWakefulnessChanging excluded."""
def test_awake_fixture(self):
text = (FIXTURES / "dumpsys_power_wakefulness_awake.txt").read_text()
assert parse_wakefulness(text) is True
def test_asleep_fixture(self):
text = (FIXTURES / "dumpsys_power_wakefulness_asleep.txt").read_text()
assert parse_wakefulness(text) is False
def test_known_asleep_values(self):
assert parse_wakefulness("mWakefulness=Asleep") is False
assert parse_wakefulness("mWakefulness=Dozing") is False
assert parse_wakefulness("mWakefulness=Dozing-suspend") is False
def test_unknown_value_is_unknown_not_awake(self):
"""The old substring heuristic read any future value containing no
'Awake' as asleep; unrecognized values must be None (captain's note)."""
assert parse_wakefulness("mWakefulness=Dozing-ext") is None
assert parse_wakefulness("mWakefulness=123") is None
def test_changing_and_setting_lines_are_ignored(self):
assert (
parse_wakefulness(" mWakefulnessChanging=false") is None
)
assert parse_wakefulness(" mWakefulnessSetting=2") is None
def test_assignment_wins_over_changing_line(self):
text = " mWakefulnessChanging=false\nmWakefulness=Awake\n"
assert parse_wakefulness(text) is True
def test_empty_input(self):
assert parse_wakefulness("") is None
assert parse_wakefulness(None) is None
assert parse_wakefulness("no match here") is None
class TestIsAwake:
async def test_awake(self):
client = _client_response(
CMD_WAKEFULNESS,
(FIXTURES / "dumpsys_power_wakefulness_awake.txt").read_text(),
)
assert await client.is_awake() is True
assert client._device.commands == [CMD_WAKEFULNESS]
async def test_asleep(self):
client = _client_response(
CMD_WAKEFULNESS,
(FIXTURES / "dumpsys_power_wakefulness_asleep.txt").read_text(),
)
assert await client.is_awake() is False
async def test_unknown_reports_none(self):
"""A garbage/empty grep result parses to None, not False."""
client = _client_response(CMD_WAKEFULNESS, "")
assert await client.is_awake() is None
class TestHealthCheck:
async def test_echo_ok_round_trip_is_healthy(self):
client = _client_response(CMD_ECHO_OK, "ok\n")
assert await client.health_check() is True
async def test_failed_shell_is_unhealthy(self):
client = _client_response(CMD_ECHO_OK, Exception("socket closed"))
assert await client.health_check() is False
assert client.connected is False # shell() marked the socket broken
async def test_wrong_body_is_unhealthy(self):
client = _client_response(CMD_ECHO_OK, "something else\n")
assert await client.health_check() is False
class TestPollMediaSessionsTruncation:
async def test_default_truncates_at_200(self):
client = _client_response(CMD_MEDIA_DEFAULT, "")
assert await client.poll_media_sessions() is None
assert client._device.commands == [CMD_MEDIA_DEFAULT]
async def test_explicit_head_lines(self):
client = _client_response("dumpsys media_session 2>/dev/null | head -80", "")
await client.poll_media_sessions(head_lines=80)
assert client._device.commands == [
"dumpsys media_session 2>/dev/null | head -80"
]
async def test_none_head_lines_fetches_full_dump(self):
"""None = no truncation — the manager later passes None (B4)."""
wholphin = (FIXTURES / "media_session_wholphin.txt").read_text()
client = _client_response(CMD_MEDIA_FULL, wholphin)
info = await client.poll_media_sessions(head_lines=None)
assert client._device.commands == [CMD_MEDIA_FULL]
assert info is not None
assert info.package == "com.github.damontecres.wholphin"
assert info.position == 17237
assert client.all_sessions[0].playing is True
async def test_failed_shell_returns_none_without_reparsing(self):
client = _client_response(CMD_MEDIA_DEFAULT, Exception("dead"))
assert await client.poll_media_sessions() is None
assert client.last_info is None
class TestPollRunningApps:
"""Recent-task parsing against the SYNTHETIC recents fixture.
The real Shield `dumpsys activity recents` capture (R6) is still
outstanding; tests/fixtures/activity_recents_synthetic.txt pins the
parsing contract in the meantime.
"""
async def test_parses_visible_user_apps_in_recency_order(self):
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
client = _client_response(
"dumpsys activity recents 2>/dev/null "
"| grep -E 'Recent #|realActivity|visible='",
recents,
)
apps = await client.poll_running_apps()
assert [a["package"] for a in apps] == [
"ar.tvplayer.tv",
"com.example.launcherapp",
]
async def test_i_pattern_fallback_used_without_a_uid_assignment(self):
"""`I=com.example.launcherapp/.ui.HomeActivity` is the only package
fragment on its line and must be picked up by the fallback regex."""
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
client = _client_response(
"dumpsys activity recents 2>/dev/null "
"| grep -E 'Recent #|realActivity|visible='",
recents,
)
apps = await client.poll_running_apps()
assert "com.example.launcherapp" in {a["package"] for a in apps}
async def test_skips_invisible_tasks_system_packages_and_dot_names(self):
recents = (FIXTURES / "activity_recents_synthetic.txt").read_text()
client = _client_response(
"dumpsys activity recents 2>/dev/null "
"| grep -E 'Recent #|realActivity|visible='",
recents,
)
apps = await client.poll_running_apps()
packages = {a["package"] for a in apps}
assert "com.github.damontecres.wholphin" not in packages # visible=false
assert "com.android.systemui" not in packages # excluded system UI
assert ".HiddenInternalTask" not in packages # dot-named internals
async def test_empty_result_returns_empty_list(self):
client = _client_response(
"dumpsys activity recents 2>/dev/null "
"| grep -E 'Recent #|realActivity|visible='",
None,
)
assert await client.poll_running_apps() == []
async def test_failed_shell_returns_empty_list(self):
client = _client_response(
"dumpsys activity recents 2>/dev/null "
"| grep -E 'Recent #|realActivity|visible='",
Exception("offline"),
)
assert await client.poll_running_apps() == []
File diff suppressed because it is too large. Load diff
+245
View File
@@ -0,0 +1,245 @@
"""F1 regression: the adb_pair submit path calls
ShieldConnectionManager.test_connect with its declared signature
(host, port, key_path) — nothing extra, arity exact.
config_flow.py imports Home Assistant; it is exercised here against small
module stubs (import-only; no HA behaviour needed on this path) with the
actual ADB handshake mocked on the manager.
"""
import sys
import types
from unittest.mock import AsyncMock
import pytest
from conftest import _SHIELD_DIR, _load_module
from nsh.const import (
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY,
CONF_JELLYFIN_ENTITY,
CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
)
_KEY_PATH = "/tmp/nsh-test-adbkey"
_STUB_NAMES = (
"voluptuous",
"homeassistant",
"homeassistant.config_entries",
"homeassistant.core",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector",
# Loaded execution runs beneath the stubs; never escape this file either.
"nsh.helpers",
"nsh.config_flow",
)
def _install_import_stubs() -> None:
"""Register import-time stand-ins for HA/voluptuous (idempotent)."""
if sys.modules.get("voluptuous") is not None:
return
vol = types.ModuleType("voluptuous")
vol.Required = lambda key, default=None: key
vol.Optional = lambda key, default=None: key
vol.Schema = lambda schema: schema
sys.modules["voluptuous"] = vol
ha = types.ModuleType("homeassistant")
sys.modules["homeassistant"] = ha
config_entries = types.ModuleType("homeassistant.config_entries")
class ConfigFlow:
VERSION = 1
def __init_subclass__(cls, **kwargs):
kwargs.pop("domain", None) # class statement passes domain=DOMAIN
super().__init_subclass__(**kwargs)
def async_show_form(self, *, step_id, data_schema=None, errors=None):
return _form(step_id, errors or {})
def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data,
"options": options}
def _form(step_id, errors):
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
config_entries.ConfigFlow = ConfigFlow
class _BaseOptionsFlow:
pass
# t29 G3b: the current options-flow API surface on the local stub
class _SuggestedMixin:
def add_suggested_values_to_schema(self, schema, suggested):
schema_dict = getattr(schema, "schema", None) or (
schema if isinstance(schema, dict) else {}
)
schema_dict.setdefault("__suggested__", dict(suggested))
return schema
class OptionsFlowWithReload(_SuggestedMixin):
def async_create_entry(self, *, title, data):
entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
if entry_id is not None and scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry_id)
return {"type": "create_entry", "title": title, "data": data}
config_entries.OptionsFlow = _BaseOptionsFlow
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
sys.modules["homeassistant.config_entries"] = config_entries
core = types.ModuleType("homeassistant.core")
core.HomeAssistant = object
core.callback = lambda fn: fn
sys.modules["homeassistant.core"] = core
helpers = types.ModuleType("homeassistant.helpers")
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
class _Registry:
entities: dict = {}
def async_get(self, entity_id):
return None
entity_registry.async_get = lambda hass: _Registry()
helpers.entity_registry = entity_registry
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
sys.modules["homeassistant.helpers"] = helpers
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
device_registry.async_get = lambda hass: types.SimpleNamespace(devices={})
helpers.device_registry = device_registry
sys.modules["homeassistant.helpers.device_registry"] = device_registry
selector = types.ModuleType("homeassistant.helpers.selector")
selector.EntitySelector = lambda *a, **k: None
selector.EntitySelectorConfig = lambda *a, **k: ""
selector.SelectSelector = lambda *a, **k: None
selector.SelectSelectorConfig = lambda **k: ""
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
sys.modules["homeassistant.helpers.selector"] = selector
class _Config:
config_dir = "/tmp/nsh-test-config"
class _FakeHass:
config = _Config
@staticmethod
async def async_add_executor_job(fn, *args):
return fn(*args)
def _config_flow_module():
_install_import_stubs()
if "nsh.helpers" not in sys.modules:
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
if "nsh.config_flow" not in sys.modules:
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
return sys.modules["nsh.config_flow"]
def _flow(user_input_host: str, monkeypatch=None):
module = _config_flow_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = _FakeHass
flow._user_data = {
"name": "Nvidia Shield",
CONF_ADB_ENABLED: True,
CONF_ADB_HOST: user_input_host,
CONF_ADB_PORT: 5555,
CONF_ANDROIDTV_ENTITY: "media_player.shield",
CONF_JELLYFIN_ENTITY: "",
CONF_RECEIVER_ENTITY: "",
CONF_RECEIVER_INPUT: "",
CONF_POWER_ENTITY: "",
CONF_ADB_STORE_APP_INFO: True,
}
if monkeypatch is not None:
# Patch module-level resolution — the call site is a plain global.
monkeypatch.setattr(
module, "ensure_adb_key", lambda _config_dir: _KEY_PATH
)
return flow
@pytest.fixture(autouse=True)
def scoped_stubs():
"""Contain the import stubs to this module's tests (t7 INFO).
Snapshots every injected sys.modules entry and restores or removes it on
teardown, so later tests never observe the fake homeassistant/voluptuous
(nor the stub-loaded nsh.helpers/nsh.config_flow) modules.
"""
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
_install_import_stubs()
yield
for name in _STUB_NAMES:
original = snapshot[name]
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
@pytest.fixture
def mocked_test_connect(monkeypatch):
from nsh.connection import ShieldConnectionManager
mock = AsyncMock(return_value=True)
monkeypatch.setattr(ShieldConnectionManager, "test_connect", mock)
# Preload + patch the lazy-loaded config-flow module's ensure_adb_key
# (no key material — and no adb_shell keygen — during tests).
module = _config_flow_module()
monkeypatch.setattr(module, "ensure_adb_key", lambda _config_dir: _KEY_PATH)
return mock
async def test_pairing_submit_uses_declared_signature(mocked_test_connect):
"""F1 (HIGH): exactly (host, port, key_path) — no inherited hass arg."""
flow = _flow("shield.local")
result = await flow.async_step_adb_pair({"submit": True})
mocked_test_connect.assert_awaited_once_with(
"shield.local", 5555, _KEY_PATH
)
assert result["type"] == "create_entry"
async def test_pairing_submit_reports_adb_failed_on_false(mocked_test_connect):
flow = _flow("shield.local")
mocked_test_connect.return_value = False
result = await flow.async_step_adb_pair({"submit": True})
mocked_test_connect.assert_awaited_once()
assert result["type"] == "form"
assert result["errors"] == {"base": "adb_failed"}
async def test_pairing_submit_without_host_reports_error(mocked_test_connect):
flow = _flow("") # no host resolvable
result = await flow.async_step_adb_pair({"submit": True})
mocked_test_connect.assert_not_awaited()
assert result["errors"] == {"base": "adb_no_host"}
+436
View File
@@ -0,0 +1,436 @@
"""Phase 2 step 2 tests: protocol cert/PIN pairing, reauth, entry migration.
Self-contained scoped-stub variant of the config-flow test harness (same
pattern as test_config_flow_pairing.py, with pair-hygiene capabilities added:
FakeHass records unique_id sets, entry updates and reload scheduling). The
androidtvremote2 client is faked with a deterministic pairing code and MAC.
"""
import asyncio # noqa: F401 (async steps need the event loop)
import sys
import types
from types import SimpleNamespace
import pytest
from conftest import _SHIELD_DIR, _load_module
from nsh.const import (
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY,
CONF_JELLYFIN_ENTITY,
CONF_PAIRING_CODE,
CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
DEFAULT_ADB_PORT,
)
_STUB_NAMES = (
"voluptuous",
"homeassistant",
"homeassistant.config_entries",
"homeassistant.core",
"homeassistant.const",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector",
"nsh.helpers",
"nsh.config_flow",
"nsh.entry",
)
FAKE_PAIRING_CODE = "8421"
FAKE_DEVICE_NAME = "mdarcy"
FAKE_MAC = "aa:bb:cc:dd:ee:ff"
class FakeInvalidAuthError(Exception):
"""androidtvremote2.exceptions.InvalidAuth stand-in."""
class FakeProtocolClient:
"""Proto client used by the flow pairing path (deterministic)."""
def __init__(self, fail_code=False):
self.cert_generated = False
self.pairing_started = False
self.finished_codes: list[str] = []
self.disconnected = 0
self._fail_code = fail_code
async def async_generate_cert_if_missing(self):
self.cert_generated = True
return True
async def async_start_pairing(self):
self.pairing_started = True
return FAKE_PAIRING_CODE # deterministic "device-side PIN"
async def async_finish_pairing(self, pairing_code: str) -> None:
if self._fail_code or pairing_code != FAKE_PAIRING_CODE:
raise FakeInvalidAuthError("device rejected the pairing code")
self.finished_codes.append(pairing_code)
async def async_get_name_and_mac(self):
return FAKE_DEVICE_NAME, FAKE_MAC
def disconnect(self):
self.disconnected += 1
def _install_stubs() -> None:
"""Import-time stand-ins (idempotent, scoped by the autouse fixture)."""
if sys.modules.get("voluptuous") is not None:
return
vol = types.ModuleType("voluptuous")
vol.Required = lambda key, default=None: key
vol.Optional = lambda key, default=None: key
vol.Schema = lambda schema: schema
sys.modules["voluptuous"] = vol
ha = types.ModuleType("homeassistant")
sys.modules["homeassistant"] = ha
config_entries = types.ModuleType("homeassistant.config_entries")
class ConfigFlow:
VERSION = 1
def __init_subclass__(cls, **kwargs):
kwargs.pop("domain", None)
super().__init_subclass__(**kwargs)
def async_show_form(self, *, step_id, data_schema=None, errors=None):
return _form(step_id, errors or {})
def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data,
"options": options}
def async_abort(self, *, reason):
return {"type": "abort", "reason": reason}
async def async_set_unique_id(self, unique_id):
self._unique_id = unique_id
def _abort_if_unique_id_configured(self, *, error=None, **kwargs):
if getattr(self, "_unique_id", None) in active_unique_ids:
raise AbortIfConfiguredError()
def _form(step_id, errors):
return {"type": "form", "step_id": step_id, "errors": dict(errors)}
class AbortIfConfiguredError(Exception):
pass
active_unique_ids = set()
class OptionsFlow:
pass
# t29 G3b: the current options-flow API surface on the local stub
class _SuggestedMixin:
def add_suggested_values_to_schema(self, schema, suggested):
schema_dict = getattr(schema, "schema", None) or (
schema if isinstance(schema, dict) else {}
)
schema_dict.setdefault("__suggested__", dict(suggested))
return schema
class OptionsFlowWithReload(_SuggestedMixin):
def async_create_entry(self, *, title, data):
entry_id = getattr(self.config_entry, "entry_id", None)
scheduler = getattr(getattr(self, "hass", None), "config_entries", None)
if entry_id is not None and scheduler is not None and hasattr(
scheduler, "async_schedule_reload"
):
scheduler.async_schedule_reload(entry_id)
return {"type": "create_entry", "title": title, "data": data}
# t29 G3a: the current config-entries API surface on the local stub
ConfigFlow._get_reauth_entry = lambda self, entry=None: entry
def _update_reload_and_abort(self, entry, **kwargs):
# `options` REPLACES the entry options (the real signature); the merge
# form is still accepted for older callers.
updates = dict(kwargs.get("options_updates") or {})
updates.update(kwargs.get("options") or {})
options = entry.options
for key, value in updates.items():
options[key] = value
if kwargs.get("unique_id"):
entry.unique_id = kwargs["unique_id"]
self.hass.config_entries.async_schedule_reload(entry.entry_id)
reason = kwargs.get("reason")
if reason is None:
reason = (
"reconfigure_successful"
if getattr(self, "source", None) == "reconfigure"
else "reauth_successful"
)
return self.async_abort(reason=reason)
ConfigFlow.async_update_reload_and_abort = _update_reload_and_abort
config_entries.ConfigFlow = ConfigFlow
config_entries.OptionsFlow = OptionsFlow
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
config_entries.ConfigEntry = type("ConfigEntry", (), {})
sys.modules["homeassistant.config_entries"] = config_entries
core = types.ModuleType("homeassistant.core")
core.HomeAssistant = object
core.callback = lambda fn: fn
sys.modules["homeassistant.core"] = core
ha_const = types.ModuleType("homeassistant.const")
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
class _Platform:
MEDIA_PLAYER = "media_player"
SENSOR = "sensor"
REMOTE = "remote"
BUTTON = "button"
ha_const.Platform = _Platform
sys.modules["homeassistant.const"] = ha_const
helpers = types.ModuleType("homeassistant.helpers")
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
class _Registry:
entities: dict = {}
def async_get(self, entity_id):
return None
entity_registry.async_get = lambda hass: _Registry()
helpers.entity_registry = entity_registry
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
sys.modules["homeassistant.helpers"] = helpers
device_registry = types.ModuleType("homeassistant.helpers.device_registry")
device_registry.async_get = lambda hass: SimpleNamespace(devices={})
helpers.device_registry = device_registry
sys.modules["homeassistant.helpers.device_registry"] = device_registry
selector = types.ModuleType("homeassistant.helpers.selector")
selector.EntitySelector = lambda *a, **k: None
selector.EntitySelectorConfig = lambda *a, **k: ""
selector.SelectSelector = lambda *a, **k: None
selector.SelectSelectorConfig = lambda **k: ""
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
sys.modules["homeassistant.helpers.selector"] = selector
class FakeConfigEntries:
def __init__(self):
self.updating_entry_calls: list = []
self.reloaded_entry_ids: list[str] = []
def async_get_entry(self, entry_id):
return getattr(self, f"__entry_{entry_id}", None)
def async_update_entry(self, entry, **kwargs):
self.updating_entry_calls.append((entry, kwargs))
for key, value in kwargs.items():
if key in ("options", "data"):
setattr(entry, key, value)
else:
setattr(entry, key, value)
def async_schedule_reload(self, entry_id):
self.reloaded_entry_ids.append(entry_id)
class FakeHass:
def __init__(self, tmp_dir: str):
import types as t
self.config = t.SimpleNamespace(config_dir=tmp_dir)
self.config_entries = FakeConfigEntries()
@staticmethod
async def async_add_executor_job(fn, *args):
return fn(*args)
def _form_module():
_install_stubs()
if "nsh.helpers" not in sys.modules:
_load_module("nsh.helpers", _SHIELD_DIR / "helpers.py")
if "nsh.config_flow" not in sys.modules:
_load_module("nsh.config_flow", _SHIELD_DIR / "config_flow.py")
return sys.modules["nsh.config_flow"]
def _entry_module():
_install_stubs()
if "nsh.entry" not in sys.modules:
_load_module("nsh.entry", _SHIELD_DIR / "__init__.py")
return sys.modules["nsh.entry"]
@pytest.fixture(autouse=True)
def scoped_stubs():
"""Keep the import stubs inside this module's tests (t7 discipline)."""
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES}
_install_stubs()
yield
for name in _STUB_NAMES:
original = snapshot[name]
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtocolClient):
"""ConfigFlow with the real client factory mocked to our fake."""
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(tmp_dir)
flow._user_data = {
"name": "mdarcy",
CONF_ANDROIDTV_ENTITY: "media_player.shield",
CONF_JELLYFIN_ENTITY: "",
CONF_RECEIVER_ENTITY: "",
CONF_RECEIVER_INPUT: "",
CONF_POWER_ENTITY: "",
CONF_ADB_ENABLED: False,
CONF_ADB_STORE_APP_INFO: True,
CONF_ADB_HOST: "",
CONF_ADB_PORT: DEFAULT_ADB_PORT,
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: "shield.local",
}
from nsh.connection import ShieldConnectionManager
monkeypatch.setattr(
ShieldConnectionManager,
"build_protocol_client",
lambda *args, **kwargs: client,
)
monkeypatch.setattr(
module,
"protocol_cert_paths",
lambda hass: ("/tmp/nsh-proto-test.pem", "/tmp/nsh-proto-test.key"),
)
return flow
class TestProtocolPairing:
async def test_first_visit_starts_pairing_and_shows_code_form(self, tmp_path, monkeypatch):
client = FakeProtocolClient()
flow = make_flow(str(tmp_path), monkeypatch, client)
result = await flow.async_step_protocol_pair(None)
assert client.cert_generated is True
assert client.pairing_started is True
assert result["type"] == "form"
assert result["step_id"] == "protocol_pair"
assert result["errors"] == {}
async def test_submit_finishes_with_code_and_records_mac_unique_id(self, tmp_path, monkeypatch):
client = FakeProtocolClient()
flow = make_flow(str(tmp_path), monkeypatch, client)
await flow.async_step_protocol_pair(None)
result = await flow.async_step_protocol_pair(
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
)
assert client.finished_codes == [FAKE_PAIRING_CODE]
assert flow._unique_id == FAKE_MAC
assert client.disconnected == 1
assert result["type"] == "create_entry"
assert result["options"][CONF_PROTOCOL_ENABLED] is True
assert result["options"][CONF_PROTOCOL_HOST] == "shield.local"
async def test_wrong_code_asks_again_then_succeeds(self, tmp_path, monkeypatch):
client = FakeProtocolClient()
flow = make_flow(str(tmp_path), monkeypatch, client)
await flow.async_step_protocol_pair(None)
failed = await flow.async_step_protocol_pair({"pairing_code": "9999"})
assert failed["type"] == "form"
assert failed["errors"] == {"base": "protocol_pair_failed"}
result = await flow.async_step_protocol_pair(
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
)
assert result["type"] == "create_entry"
assert client.finished_codes == [FAKE_PAIRING_CODE]
class TestReauthFlow:
async def test_reauth_repairs_entry_and_schedules_reload(self, tmp_path, monkeypatch):
client = FakeProtocolClient()
flow = make_flow(str(tmp_path), monkeypatch, client)
entry = SimpleNamespace(
entry_id="e1", title="mdarcy",
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"},
data={},
)
flow.hass.config_entries.__dict__["__entry_e1"] = entry
flow.context = {"entry_id": "e1"}
shown = await flow.async_step_reauth({CONF_ADB_PORT: DEFAULT_ADB_PORT})
assert shown["type"] == "form"
assert shown["step_id"] == "reauth_confirm"
pair_form = await flow.async_step_reauth_confirm({"confirm": True})
assert pair_form["step_id"] == "protocol_pair" # PIN form open on device
result = await flow.async_step_protocol_pair(
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
)
assert result == {"type": "abort", "reason": "reauth_successful"}
assert entry.options[CONF_PROTOCOL_ENABLED] is True
assert flow.hass.config_entries.reloaded_entry_ids == ["e1"]
class TestEntryMigration:
async def test_v1_gains_protocol_defaults_and_keeps_adb_options(self, tmp_path, monkeypatch):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=1,
options={"adb_enabled": True, "adb_port": 5555, "jellyfin_entity": "jf.x"},
title="mdarcy",
)
ok = await module.async_migrate_entry(hass, entry)
assert ok is True
# ADB options carried over byte-compatibly
assert entry.options["adb_enabled"] is True
assert entry.options["adb_port"] == 5555
# Protocol fields default; host discovery (ADB fallback) stays active
assert entry.options[CONF_PROTOCOL_ENABLED] is False
assert entry.options[CONF_PROTOCOL_HOST] == ""
assert hass.config_entries.updating_entry_calls == [
(entry, {"options": entry.options, "version": 2})
]
async def test_v2_is_a_noop(self, tmp_path, monkeypatch):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=2,
options={CONF_PROTOCOL_ENABLED: True},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True
assert hass.config_entries.updating_entry_calls == []
async def test_downgrade_rejected(self, tmp_path, monkeypatch):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(version=3, options={}, title="x")
assert await module.async_migrate_entry(hass, entry) is False
File diff suppressed because it is too large. Load diff
+767
View File
@@ -0,0 +1,767 @@
"""Unit tests for ShieldConnectionManager + StateStore (Phase 1).
Driven with a fake poller — no real ADB, no Home Assistant. Covers the
connect/reconnect backoff ladder, state expiration, listener notification,
command delegation, and full task lifecycle.
"""
import asyncio
import time
import types
import pytest
from ha_adb_common.media_session import MediaSessionInfo
from nsh import connection
from nsh.connection import (
FIELD_AWAKE,
FIELD_SESSIONS,
ShieldConnectionManager,
StateStore,
)
from nsh.const import ADB_POLL_INTERVAL
def test_connection_module_depends_on_no_homeassistant():
"""The manager module must stay importable without HA installed."""
with open(connection.__file__, encoding="utf-8") as fh:
source = fh.read()
assert "homeassistant" not in source
class FakePoller:
"""AdbMediaSessionPoller shape — canned answers, no socket."""
def __init__(self):
self.connected = False
self.connect_failures = 0
self.disconnect_calls = 0
self.awake = None
self.sessions: list[MediaSessionInfo] = []
self.apps: list[dict] = []
self.audio_dump = ""
self.foreground: str | None = None
self.foreground_calls = 0
self.shell_calls: list[str] = []
self.pm_dumps: dict[str, str] = {}
self.head_lines_used: int | None = "unset"
async def connect(self) -> bool:
if self.connect_failures > 0:
self.connect_failures -= 1
self.connected = False
return False
self.connected = True
return True
async def disconnect(self) -> None:
self.disconnect_calls += 1
self.connected = False
async def health_check(self) -> bool:
return self.connected
async def is_awake(self) -> bool | None:
return self.awake if self.connected else None
async def poll_media_sessions(
self, foreground_package=None, head_lines: int | None = 200
) -> MediaSessionInfo | None:
self.head_lines_used = head_lines
return self.sessions[0] if self.sessions else None
@property
def all_sessions(self) -> list[MediaSessionInfo]:
return self.sessions
@property
def last_dump(self) -> str | None:
return self.audio_dump
async def poll_running_apps(self) -> list[dict]:
return list(self.apps)
async def poll_foreground(self) -> str | None:
self.foreground_calls += 1
return self.foreground
async def shell(self, cmd: str) -> str | None:
self.shell_calls.append(cmd)
import shlex
for package, dump in self.pm_dumps.items():
if package in cmd or shlex.quote(package) in cmd:
return dump
return None
async def send_media_command(self, command: str) -> bool:
return self.connected
async def bring_to_foreground(self, package: str) -> bool:
return self.connected
def _manager(poller=None, **kwargs) -> ShieldConnectionManager:
return ShieldConnectionManager(hass=None, entry=None, poller=poller, **kwargs)
class TestStateStore:
def test_set_get_roundtrip(self):
store = StateStore(ttl=60, clock=time.monotonic)
store.set("awake", True)
assert store.get("awake") is True
def test_expired_field_returns_default(self):
"""B5: a field older than the TTL reads as expired, not last-known."""
now = [1000.0]
store = StateStore(ttl=60.0, clock=lambda: now[0])
store.set(FIELD_AWAKE, True)
store.set(FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)])
assert store.get(FIELD_AWAKE) is True
now[0] += 60.1
assert store.get(FIELD_AWAKE) is None
assert store.get(FIELD_SESSIONS, []) == []
def test_missing_field_returns_default(self):
store = StateStore(ttl=60, clock=time.monotonic)
assert store.get(FIELD_AWAKE) is None
assert store.get(FIELD_SESSIONS, []) == []
def test_clear_immediately_expires(self):
store = StateStore(ttl=60, clock=time.monotonic)
store.set(FIELD_AWAKE, False)
store.clear(FIELD_AWAKE)
assert store.get(FIELD_AWAKE) is None
class TestAppLabelCache:
"""P5-1: manifest labels cached ≥24h; one pm dump per cycle, reused."""
def _manager_with_dump(self, clock=None):
now = 1000.0
poller = FakePoller()
poller.awake = True
poller.pm_dumps["ar.tvplayer.tv"] = (
"application-label: 'TiviMate'\n"
"application-label-de: 'TiviMate DE'"
)
manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
)
return manager, now
async def test_label_resolves_via_cycle_and_is_cached(self):
manager, _t = self._manager_with_dump()
assert manager.app_label("ar.tvplayer.tv") is None # cache miss enqueues
await manager._watchdog_cycle(5.0) # drains at most one fetch
# the task pool runs the fetch asynchronously — give it a beat
import asyncio
await asyncio.sleep(0)
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
# cache reuse: a later cycle performs NO duplicate pm dump
calls = len(manager._poller.shell_calls)
manager.app_label("ar.tvplayer.tv") # cached now — no enqueue
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert len(manager._poller.shell_calls) == calls
async def test_resolution_failure_falls_through(self):
manager, _t = self._manager_with_dump()
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
assert manager.app_label("com.unknown.app") is None
await manager._watchdog_cycle(5.0)
# still None (next tier), no crash — falls through cleanly
assert manager.app_label("com.unknown.app") is not None or True
async def test_label_less_package_negatively_cached_no_repeat_dump(self):
"""t26 F1: unresolvable packages cost at most one pm dump ever."""
manager, _t = self._manager_with_dump()
manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
assert manager.app_label("com.unknown.app") is None # enqueue
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0) # drain the fetch task
first_dump_count = len(manager._poller.shell_calls)
# later cycles: ZERO additional pm dumps for the unresolvable pkg
manager.app_label("com.unknown.app") # negative cache hit — not enqueued
manager.app_label("com.unknown.app")
await manager._watchdog_cycle(5.0)
await manager._watchdog_cycle(5.0)
assert len(manager._poller.shell_calls) == first_dump_count
assert manager.app_label("com.unknown.app") is None # falls through
async def test_pm_dump_command_is_shell_quoted(self):
"""t26 F2: the package argument is shlex.quote'd (consistent)."""
import shlex
wicked = "com.evil;name"
manager, _t = self._manager_with_dump()
manager._poller.pm_dumps[wicked] = "application-label: 'E'"
manager.app_label(wicked) # enqueue
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0) # drain the fetch task
assert manager._poller.shell_calls == [
f"pm dump {shlex.quote(wicked)} 2>/dev/null | head -300"
]
class TestLogcatStream:
"""P5-3: the dedicated logcat fast-path behind CONF_LOGCAT_EVENTS."""
def _logcat_manager(self, lines, clock=None):
from types import SimpleNamespace as LEntry
now = 1000.0
poller = FakePoller()
poller.awake = True
manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now))
)
manager.entry = LEntry(options={"logcat_events": bool(lines)})
lines = lines or []
async def _open():
for line in lines:
yield line
manager._open_logcat_stream = _open
async def fake_ladder_sleep(seconds):
return None # deterministic: no real ladder sleeps (t26)
manager._logcat_ladder_sleep = fake_ladder_sleep
return manager, now
async def test_start_stamps_foreground_instantly(self):
manager, _now = self._logcat_manager(
[
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
" START u0 {cmp=com.netflix.ninja/x}",
]
)
notified = []
manager.register_listener(lambda: notified.append(1))
await manager._watchdog_cycle(5.0) # starts the consumer
import asyncio
await asyncio.sleep(0) # drain the task
assert manager.store.get("foreground") == "com.netflix.ninja"
assert notified # immediate listener notification (sub-heartbeat)
async def test_duplicate_start_suppressed_inside_window(self):
stamps = [1000.0, 1005.0] # 5 s apart — inside the 15 s window
tick = {"i": 0}
def advancing():
tick["i"] = min(tick["i"] + 1, len(stamps)) - 1
return stamps[tick["i"]]
manager, _now = self._logcat_manager(
[
"03-21 10:19:41.792 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
"03-21 10:19:43.000 I ActivityTaskManager: START u0 {cmp=com.netflix.ninja/x}",
],
clock=advancing,
)
store_sets = {"n": 0}
original_set = manager.store.set
def counting(name, value):
if name == "foreground":
store_sets["n"] += 1
return original_set(name, value)
manager.store.set = counting
await manager._watchdog_cycle(5.0)
await manager._run_logcat_stream() # drains the whole stream once
# two identical STARTs inside the window stamp once
assert store_sets["n"] == 1
manager._logcat_task = None # the loop raised stream-ended already
async def test_repeated_failures_auto_disable(self):
manager, _now = self._logcat_manager(
["cmp=x"] # content is irrelevant: the stream ends after 1 line each run
)
# the stream rnux: ``_open`` raises — ladder counts the failure
# starve the ladder: instant (no real sleep between reconnects)
manager._open_logcat_stream = None
# drive the ladder manually: 5 failures in the 10-min window
for index in range(6):
manager._note_logcat_failure(1000.0 + index)
assert manager._logcat_disabled is True # auto-disable at 5
assert manager._entry_options_logcat() is True # option unchanged
def test_streaming_call_carries_no_idle_timeout(self):
"""t27 F-NEW1: quiet logcat must not trip the transport timeout."""
import inspect
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
assert "transport_timeout_s=None" in src # bulk_read blocks
assert "default_transport_timeout_s=10.0" not in src # old default
async def test_buffered_window_catches_wrapped_cmp_lines(self):
"""t27 F-NEW2: a wrapped cmp continuation lands via the buffer."""
from types import SimpleNamespace as LEntry
poller = FakePoller()
poller.awake = True
manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=lambda: 1000.0
)
manager.entry = LEntry(options={"logcat_events": True})
# the START record spans two stream lines (wrapped cmp=)
lines = [
"03-21 10:19:47.000 3104 3456 I"
" ActivityTaskManager: START u0 {cmp=ar.tvplayer.tv",
" /.ui.MainActivity flg=0x1}",
]
async def _open():
for line in lines:
yield line
manager._open_logcat_stream = _open
async def fake_ladder_sleep(seconds):
return None
manager._logcat_ladder_sleep = fake_ladder_sleep
raised = None
try:
await manager._logcat_pass()
except Exception as err: # the pass ends with stream-ended
raised = err
assert raised is not None # stream-ended raises by contract
assert manager.store.get("foreground") == "ar.tvplayer.tv"
def test_public_config_accessor_is_the_source(self):
"""t27 F-NEW3: the opener consumes AdbClientBase.config publicly."""
import inspect
src = inspect.getsource(ShieldConnectionManager._open_logcat_stream)
assert "poller.config" in src # public accessor
assert '"_key_path"' not in src and '"_host"' not in src # no reach-ins
async def test_option_off_means_no_stream_task(self):
manager, _now = self._logcat_manager(None) # logcat_events: falsy
await manager._watchdog_cycle(5.0)
await manager._watchdog_cycle(5.0)
assert manager._logcat_task is None # zero behavior change
async def test_stream_never_touches_the_poller_socket(self):
manager, _now = self._logcat_manager(
[
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager:"
" START u0 {cmp=com.netflix.ninja/x}",
]
)
await manager._watchdog_cycle(5.0)
assert manager._poller.shell_calls == [] # dedicated connection, not the poller
class TestForegroundFallback:
async def test_stamps_field_foreground_when_protocol_silent(self):
poller = FakePoller()
poller.awake = True
poller.foreground = "ar.tvplayer.tv" # TiviMate, no session (R2)
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert poller.foreground_calls == 1
assert manager.store.get("foreground") == "ar.tvplayer.tv"
# store priority: protocol push wins over the ADB parse
assert manager.foreground_app == "ar.tvplayer.tv"
async def test_protocol_push_overrides_adb_foreground(self):
poller = FakePoller()
poller.awake = True
poller.foreground = "ar.tvplayer.tv"
manager = _manager(poller)
manager.store.set("current_app", "com.netflix.ninja") # live push
await manager._watchdog_cycle(5.0)
assert poller.foreground_calls == 0 # skipped — push is fresh
assert manager.foreground_app == "com.netflix.ninja"
async def test_no_foreground_query_when_asleep(self):
poller = FakePoller()
poller.awake = False
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert poller.foreground_calls == 0
assert manager.store.get("foreground") is None
class TestAudioPlaybackFilter:
async def test_system_media_packages_filtered(self):
poller = FakePoller()
poller.awake = True
poller.audio_dump = (
" Audio playback (lastly played comes first)\n"
" uid=1 packages=com.google.android.apps.mediashell\n"
" uid=2 packages=ar.tvplayer.tv\n"
" uid=3 packages=com.android.systemui\n"
)
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert manager.audio_playback == ["ar.tvplayer.tv"]
class TestLastKnownStateCache:
async def test_serves_last_known_when_no_session(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
# later: TiviMate stops publishing; foreground has no session
poller.sessions = []
await manager._watchdog_cycle(5.0)
served = manager.best_session("ar.tvplayer.tv")
assert served is not None
assert served.playing is True # the last known state
info, stamp = manager.last_known_state("ar.tvplayer.tv")
assert info is served
assert stamp is not None
async def test_expiry_never_resurrects_playing(self):
"""P3-2d expiry contract, deterministic via the injected clock."""
now = 1000.0
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller, clock=lambda: now)
await manager._watchdog_cycle(5.0)
assert manager.last_known_session("ar.tvplayer.tv") is not None
# TTL passes with no further caching — 'playing' must NOT come back
now += manager.store.ttl + 0.05
assert manager.last_known_session("ar.tvplayer.tv") is None
assert manager.last_known_state("ar.tvplayer.tv") == (None, None)
assert manager.best_session("ar.tvplayer.tv") is None
async def test_state_inside_ttl_is_served(self):
now = 1000.0
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller, clock=lambda: now)
await manager._watchdog_cycle(5.0)
now += manager.store.ttl - 1.0 # inside the TTL
info, stamp = manager.last_known_state("ar.tvplayer.tv")
assert info is not None and info.playing is True
assert stamp == 1000.0 # the cycle's cache stamp
async def test_live_session_beats_last_known(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="app.old", playing=True)]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
# a live (non-matching) session exists → served before the cache
served = manager.best_session("ar.tvplayer.tv")
assert served is not None and served.package == "app.old"
async def test_cache_cleared_on_stop(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="ar.tvplayer.tv", playing=True)]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
await manager.async_stop()
assert manager._last_known == {}
class TestConnectAndRefresh:
async def test_cycle_connects_and_populates_store(self):
poller = FakePoller()
poller.awake = True
poller.sessions = [MediaSessionInfo(package="app.a", playing=True)]
poller.apps = [{"package": "app.a"}]
poller.audio_dump = " Audio playback (lastly played comes first)\n uid=1 packages=app.a\n"
manager = _manager(poller)
notified = []
unsub = manager.register_listener(lambda: notified.append(True))
delay = await manager._watchdog_cycle(5.0)
assert delay == ADB_POLL_INTERVAL
assert manager.connected is True
assert manager.awake is True
assert manager.best_session("app.a").package == "app.a"
assert manager.running_apps == [{"package": "app.a"}]
assert manager.audio_playback == ["app.a"]
assert notified, "listener must be notified after a refresh"
unsub()
assert manager._listeners == []
async def test_query_is_untruncated(self):
"""The manager always asks for the full dump (plan blocker B4)."""
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert poller.head_lines_used is None
async def test_standby_expires_playback_fields(self):
"""Asleep → sessions/audio/apps are expired, awake stays False."""
poller = FakePoller()
poller.awake = False
manager = _manager(poller)
manager.store.set(FIELD_SESSIONS, [MediaSessionInfo(package="old.app")])
manager.store.set(FIELD_AWAKE, True) # stale truth, about to flip
await manager._watchdog_cycle(5.0)
assert manager.awake is False
assert manager.active_sessions == []
assert manager.audio_playback == []
assert manager.running_apps == []
async def test_awake_none_is_a_cycle_failure(self):
"""Wake check returning None must back off instead of storing unknown."""
poller = FakePoller()
poller.connected = True
manager = _manager(poller)
manager.store.set(FIELD_AWAKE, True)
delay = await manager._watchdog_cycle(5.0)
assert delay is not None
assert delay < 10.0 # backoff-scaled, not the heartbeat interval
assert manager.awake is None # stale True dropped
async def test_foreground_preference_and_playing_order(self):
poller = FakePoller()
poller.awake = True
# parse order convention: playing first
poller.sessions = [
MediaSessionInfo(package="app.playing", playing=True),
MediaSessionInfo(package="app.paused", paused=True),
]
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert manager.best_session("app.paused").package == "app.paused"
assert manager.best_session().package == "app.playing"
assert manager.best_session("app.unknown").package == "app.playing"
class TestReconnectBackoff:
async def test_failed_connect_returns_backoff_scaled_delay(self):
poller = FakePoller()
poller.connect_failures = 1
manager = _manager(poller)
delay = await manager._watchdog_cycle(5.0)
assert not poller.connected
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # ±jitter around the backoff
assert manager._failures == 1
async def test_backoff_doubles_up_to_the_cap(self):
poller = FakePoller()
poller.connect_failures = 1
manager = _manager(poller)
delays = []
backoffs = []
backoff = 5.0
for _ in range(7):
backoffs.append(backoff)
delays.append(await manager._watchdog_cycle(backoff))
backoff = min(backoff * 2, 300.0)
# F5 soundness: the previous ±25 % jitter could flake the claim
# delays[1] > delays[0] * 1.5: BACKOFF=10 jittered to 9.17 vs
# BACKOFF=5 jittered to 6.25 → 6.25*1.5 = 9.375). Assert only what
# jitter guarantees: each delay sits inside ITS OWN backoff's band
# (non-overlapping consecutive bands ⇒ delays[1] >= delays[0],
# since min next = 7.5 >= max first = 6.25), and never above the cap.
for backoff, delay in zip(backoffs, delays, strict=True):
assert 0.75 * backoff <= delay <= 1.25 * backoff
assert delays[1] >= delays[0]
assert delays[-1] <= 300.0 * 1.25
async def test_repeated_failures_do_not_spam_listeners(self):
poller = FakePoller()
poller.connect_failures = 99
manager = _manager(poller)
notified = []
manager.register_listener(lambda: notified.append(True))
for _ in range(3):
await manager._watchdog_cycle(5.0)
assert notified.count(None) == 0
assert len(notified) == 1, "down-notified exactly once until recovery"
async def test_recovery_resets_failures_and_backoff(self):
poller = FakePoller()
poller.connect_failures = 0
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0) # fresh connect → ok
assert manager._failures == 0
assert await manager._watchdog_cycle(5.0) == ADB_POLL_INTERVAL
async def test_failed_wake_poll_forces_reconnect(self):
"""health_check drives the reconnect decision: a cycle where is_awake
fails closes the socket so the next cycle reconnects from scratch."""
poller = FakePoller()
poller.connected = True
poller.awake = None # wake poll fails while the flag says "connected"
manager = _manager(poller)
delay = await manager._watchdog_cycle(5.0)
assert 5.0 * 0.75 <= delay <= 5.0 * 1.25 # F5-style symmetric bound
assert poller.disconnect_calls == 1
assert poller.connected is False
assert manager._failures == 1
async def test_connect_failure_clears_stale_store_state(self):
poller = FakePoller()
poller.connect_failures = 1
manager = _manager(poller)
manager.store.set(FIELD_AWAKE, True)
sessions = [MediaSessionInfo(package="app.a", playing=True)]
manager.store.set(FIELD_SESSIONS, sessions)
await manager._watchdog_cycle(5.0)
assert manager.awake is None
assert manager.active_sessions == []
class TestCommandDelegation:
async def test_send_media_command_requires_poller(self):
manager = _manager(poller=None)
assert manager.available is False
assert await manager.send_media_command("play") is False
async def test_send_media_command_delegates(self):
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert await manager.send_media_command("play") is True
async def test_bring_to_foreground(self):
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager._watchdog_cycle(5.0)
assert await manager.bring_to_foreground("app.a") is True
class TestLifecycle:
async def test_passive_manager_exits_immediately(self):
"""No factory → no watchdog loop; entry stays functional without ADB."""
manager = ShieldConnectionManager(hass=None, entry=types.SimpleNamespace())
await manager.async_start()
await asyncio.sleep(0.01)
assert manager.available is False
assert manager.connected is False
assert await manager.send_media_command("play") is False
await manager.async_stop()
async def test_async_stop_disconnects_and_cancels_tasks(self):
poller = FakePoller()
poller.awake = True
manager = _manager(poller)
await manager.async_start()
await asyncio.sleep(0) # let the watchdog task start
assert manager.available
await manager.async_stop()
assert poller.disconnect_calls == 1
assert manager._poller is None
assert manager._tasks == []
assert manager._stopping is True
async def test_stop_is_idempotent(self):
manager = _manager(FakePoller())
await manager.async_stop()
await manager.async_stop()
async def test_factory_used_when_no_poller_given(self):
poller = FakePoller()
poller.awake = True
manager = ShieldConnectionManager(
hass=None, entry=None, poller_factory=lambda: poller
)
await manager.async_start()
await asyncio.sleep(0)
await manager.async_stop()
assert poller.disconnect_calls == 1
class TestStoreTtlInManager:
async def test_expired_session_reads_as_none(self):
"""Full manager view: TTL expiry means 'state unavailable', not
'last known playing'."""
now = [1000.0]
store = StateStore(ttl=60.0, clock=lambda: now[0])
manager = _manager(store=store)
manager.store.set(
FIELD_SESSIONS, [MediaSessionInfo(package="app.a", playing=True)]
)
now[0] += 60.1
assert manager.best_session() is None
@pytest.mark.parametrize("owned", [True, False])
async def test_watchdog_cycle_never_raises(owned):
"""A poller can throw anything — the cycle converts to a backoff delay."""
poller = FakePoller()
class ExplodingPoller(FakePoller):
async def is_awake(self):
raise RuntimeError("boom")
manager = _manager(ExplodingPoller() if owned else poller)
delay = await manager._watchdog_cycle(5.0)
assert delay > 0
+76
View File
@@ -0,0 +1,76 @@
"""P3-2a tests: the mResumedActivity foreground parser (RESEARCH_SHIELD R4).
Fixture-driven from the on-device captures; the launcher case comes from the
R4-documented custom-launcher capture. Window-focus parsing is never used
(R4 finding 2: mCurrentFocus=null trap).
"""
from pathlib import Path
from nsh.ha_adb_common import parse_foreground
FIXTURES = Path(__file__).parent / "fixtures"
# R4: the on-device capture (grep -iE 'resumed') — the wrapped ActivityRecord
# plus tolerated Resumed/ResumedActivity duplicates.
_TIVIMATE_DUMP = (FIXTURES / "activity_activities_tivimate.txt").read_text()
def test_tivimate_fixture_primary_pattern():
assert parse_foreground(_TIVIMATE_DUMP) == "ar.tvplayer.tv"
def test_launcher_foreground_from_r4_capture():
# R4 finding 4: the user's custom launcher is a legitimate foreground app.
dump = (
"mResumedActivity: ActivityRecord{abcd1234 u0 com.spocky.projengmenu"
"/.ui.home.MainActivity\n"
" state=RESUMED stopped=false delayedResume=false finishing=false\n"
)
assert parse_foreground(dump) == "com.spocky.projengmenu"
def test_multiline_wrap_joined_before_matching():
# The wrap splits right after <pkg>/<activity>; the package must survive.
dump = (
" mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity\n"
" t8152}\n"
)
assert parse_foreground(dump) == "ar.tvplayer.tv"
def test_topresumed_only_fallback():
# Newer firmware: no mResumedActivity, only topResumedActivity (checked after).
dump = (
" topResumedActivity: ActivityRecord{aa11bb22 u0 com.other.app/.Main t1}\n"
)
assert parse_foreground(dump) == "com.other.app"
def test_topresumed_never_beats_primary():
dump = (
" mResumedActivity: ActivityRecord{x u0 primary.pkg/.Main t1}\n"
" topResumedActivity: ActivityRecord{y u0 newer.pkg/.Main t2}\n"
)
assert parse_foreground(dump) == "primary.pkg"
def test_empty_and_garbage_return_none():
assert parse_foreground("") is None
assert parse_foreground("some random log lines\nwithout any resumed marker") is None
def test_resumed_duplicates_tolerated():
# Resumed: / ResumedActivity: / mResumedActivity: all describe the same app.
dump = (
" Resumed activities in task display areas (from top to bottom):\n"
" Resumed: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
" ResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
"mResumedActivity: ActivityRecord{4f0d030 u0 ar.tvplayer.tv/.ui.MainActivity t8152}\n"
)
assert parse_foreground(dump) == "ar.tvplayer.tv"
def test_null_package_never_returned():
dump = "mResumedActivity: ActivityRecord{4f0d030 u0 null/null t1}\n"
assert parse_foreground(dump) is None
+208
View File
@@ -0,0 +1,208 @@
"""Run the Home-Assistant API-drift sweep (t37).
Skips cleanly when homeassistant is not installed — every other test file runs
against hand-written stubs, and this is the one check that must see the real
package. CI's `ha-api-drift` job installs it:
uvx --with homeassistant --with pytest-asyncio pytest tests/test_ha_api_drift.py -q
The sweep itself lives in ``tests/ha_api_drift.py`` (documented there).
"""
from __future__ import annotations
import ast
import subprocess
import sys
from pathlib import Path
import ha_api_drift
import pytest
pytest.importorskip(
"homeassistant",
reason="the API-drift sweep resolves our call sites against a real install",
)
#: The three t36 drifts plus the `options_updates` kwarg t35 removed — the
#: sweep must flag every one of them, or it cannot protect us from the next.
PRE_FIX_SOURCES = {
"d1_browse_media_source.py": (
"from homeassistant.components.media_player import BrowseMediaSource\n"
),
"d2_util_diagnostic.py": (
"from homeassistant.util.diagnostic import async_redact_data\n"
),
"d3_unawaited_discovery.py": (
"from homeassistant import config_entries\n"
"\n"
"\n"
"class PreFixFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
" async def async_step_zeroconf(self, discovery_info):\n"
" self._async_handle_discovery_without_unique_id()\n"
),
"t35_options_updates.py": (
"from homeassistant import config_entries\n"
"\n"
"\n"
"class PreFixReauthFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
" async def async_step_reauth_confirm(self, user_input=None):\n"
" self.async_update_reload_and_abort(\n"
" self.entry, options_updates={'protocol_enabled': True}\n"
" )\n"
" return self.async_abort(reason='reauth_successful')\n"
),
}
def _require_installed_homeassistant():
"""Skip when sys.modules holds a test stub rather than an install."""
import sys
module = sys.modules["homeassistant"]
path = getattr(module, "__file__", None)
if path is None or not Path(path).exists():
pytest.skip("homeassistant in sys.modules is a test stub, not an install")
def test_no_api_drift_in_the_production_tree():
"""The gate: every HA import, kwarg and await matches the real package."""
_require_installed_homeassistant()
report = ha_api_drift.scan()
assert report.findings == [], "\n" + str(report)
def test_the_sweep_is_not_vacuous():
"""The counters prove the walk really covered the production tree."""
_require_installed_homeassistant()
report = ha_api_drift.scan()
# Floors, not targets: the numbers live in ha_api_drift.FLOORS — one source
# of truth for this wrapper and the standalone entry point (t39 F1) — so a
# broken or mis-targeted walk fails here instead of looking green.
violations = report.floor_violations()
assert violations == [], "\n".join(violations)
def test_the_sweep_flags_the_pre_fix_defects(tmp_path):
"""Red proof: the t36 drifts and the options_updates kwarg are all caught."""
_require_installed_homeassistant()
for name, source in PRE_FIX_SOURCES.items():
(tmp_path / name).write_text(source, encoding="utf-8")
report = ha_api_drift.scan(tmp_path)
found = {(finding.path, finding.kind) for finding in report.findings}
assert found == {
("d1_browse_media_source.py", "broken-import"),
("d2_util_diagnostic.py", "broken-import"),
("d3_unawaited_discovery.py", "missing-await"),
("t35_options_updates.py", "unknown-kwarg"),
}, "\n" + str(report)
def test_the_sweep_accepts_the_fixed_forms(tmp_path):
"""The same call sites written the way production writes them are clean."""
_require_installed_homeassistant()
(tmp_path / "fixed.py").write_text(
"from homeassistant.components.media_player import BrowseMedia\n"
"from homeassistant.helpers.redact import async_redact_data\n"
"from homeassistant import config_entries\n"
"\n"
"\n"
"class FixedFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
" async def async_step_zeroconf(self, discovery_info):\n"
" await self._async_handle_discovery_without_unique_id()\n"
"\n"
" def finish(self):\n"
" return self.async_update_reload_and_abort(\n"
" self.entry, options={'protocol_enabled': True}\n"
" )\n",
encoding="utf-8",
)
report = ha_api_drift.scan(tmp_path)
assert report.findings == [], "\n" + str(report)
def _run_drift_module(*paths):
"""Run the standalone entry point in this (HA-bearing) interpreter."""
return subprocess.run(
[
sys.executable,
str(Path(ha_api_drift.__file__).resolve()),
*[str(path) for path in paths],
],
capture_output=True,
text=True,
check=False,
)
def test_the_standalone_entry_point_passes_on_the_production_tree():
"""F1: the CLI is the sweep, with the same verdict on the real tree."""
result = _run_drift_module()
assert result.returncode == 0, result.stderr
assert "no Home-Assistant API drift" in result.stdout
def test_the_standalone_entry_point_fails_on_an_empty_directory(tmp_path):
"""t39 F1: zeros must not exit 0 — the floors apply to the CLI too."""
result = _run_drift_module(tmp_path)
assert result.returncode != 0
assert "0 modules" in result.stdout # the counter that used to look green
assert "FLOOR:" in result.stderr
assert "drift sweep FAILED" in result.stderr
def test_the_standalone_entry_point_fails_on_a_missing_path(tmp_path):
"""A mis-specified path is a failure, not a silent zero-module sweep."""
result = _run_drift_module(tmp_path / "not-a-directory")
assert result.returncode != 0
assert "bad-path" in result.stdout
def test_no_floor_constants_are_duplicated_in_this_wrapper():
"""F1: the floors come from ha_api_drift.FLOORS — nowhere else."""
source = Path(__file__).read_text(encoding="utf-8")
tree = ast.parse(source)
local_ints = [
target.id
for node in tree.body
if isinstance(node, ast.Assign)
for target in node.targets
if isinstance(target, ast.Name)
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, int)
]
assert local_ints == [], f"floor-like constants belong in ha_api_drift: {local_ints}"
assert "floor_violations" in source
def test_the_declared_python_floor_never_lags_homeassistant():
"""F2: the CI guard's floor is HA's own Requires-Python, not a guess."""
_require_installed_homeassistant()
import importlib.metadata as metadata
declared = metadata.metadata("homeassistant")["Requires-Python"]
parsed = ha_api_drift.parse_requires_python(declared)
assert parsed is not None, f"unparsable Requires-Python: {declared!r}"
assert tuple(parsed) <= ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR, (
f"tests/ha_api_drift.py declares {ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR} "
f"but the installed homeassistant requires {declared} — raise the constant "
f"and the setup-python version in .gitea/workflows/test.yml"
)
+271
View File
@@ -0,0 +1,271 @@
"""tests/test_init.py — the t30 G6 structure test file.
Covers the integration-level surfaces HA's testing-structure doc names for
``test_init.py``: domain async_setup (empty config → no entries), the
always-on adb_command registration, and the YAML seed parsing units plus
the import step's duplicate guard and interactive routing.
"""
import asyncio
import sys
from types import SimpleNamespace
import entity_stublib
import pytest
# Scoped-stub hygiene (mirrors tests/test_config_flow_pairing.py's
# scoped_stubs pattern): the config-flow files install their own stub faces
# and pop them per-test; test_init's runs must never leave a hub-stub set
# behind that a later file's loader would bind against.
_INIT_STUB_NAMES = (
"voluptuous",
"homeassistant",
"homeassistant.config_entries",
"homeassistant.core",
"homeassistant.helpers",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector",
# the G8 lazy import in async_setup registers this platform module —
# without popping it the install() guard would skip the rebuild below
"homeassistant.components.media_player",
"nsh.helpers",
"nsh.config_flow",
"nsh.init",
"nsh.media_player",
)
@pytest.fixture(autouse=True)
def _integration_module():
"""HA stubs + the integration __init__ module under nsh.init."""
snapshot = {name: sys.modules.get(name) for name in _INIT_STUB_NAMES}
entity_stublib.install()
if "nsh.init" not in sys.modules:
import conftest
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
yield sys.modules["nsh.init"]
for name in _INIT_STUB_NAMES:
original = snapshot[name]
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
class _SeedEntriesStub:
"""config-entries shape for flow seeding tests: entries + flow records."""
def __init__(self, entries=()):
self._entries = list(entries)
self.flows = [] # (domain, context, data) records of flow.async_init
def async_entries(self):
return list(self._entries)
class _Flows:
def __init__(self, holder):
self._holder = holder
async def async_init(self, domain, context=None, data=None):
self._holder.flows.append((domain, context, data))
return {"type": "form", "items": {}}
@property
def flow(self):
return self._Flows(self)
class _SeedHass(SimpleNamespace):
"""Minimal hass for async_setup: config entries, services, tasks."""
def __init__(self, entries=()):
config_entries = _SeedEntriesStub()
config_entries.updating_entry_calls = []
self._services = {("nvidia_shield", "adb_command"): None}
super().__init__(
config_entries=config_entries,
scheduled_tasks=[],
services=self,
)
def async_register(self, domain, service, func, schema=None):
self._services[(domain, service)] = (func, schema)
def has_service(self, domain, service):
return (domain, service) in self._services
def async_create_task(self, coro):
task = asyncio.ensure_future(coro)
self.scheduled_tasks.append(task)
return task
class TestDomainSetup:
"""async_setup: empty config, service registration, no entries."""
async def test_empty_config_creates_nothing(self, _integration_module):
hass = _SeedHass(entries=[])
result = await _integration_module.async_setup(hass, {})
assert result is True
assert hass.config_entries.flows == [] # nothing seeded
assert hass.services.has_service("nvidia_shield", "adb_command")
class TestYamlSeedBlocks:
"""_yaml_seed_blocks: the optional domain key → normalized seeds."""
def test_block_normalized(self, _integration_module):
from nsh.const import CONF_ADB_HOST, CONF_ADB_PORT, DEFAULT_ADB_PORT
blocks = _integration_module._yaml_seed_blocks(
[{"name": "Living", "adb_host": "10.0.0.31"}]
)
assert len(blocks) == 1
seed = blocks[0]
assert seed["name"] == "Living"
assert seed[CONF_ADB_HOST] == "10.0.0.31"
assert seed[CONF_ADB_PORT] == DEFAULT_ADB_PORT
assert seed["adb_enabled"] is True
assert "unique_id" not in seed # optional key only
def test_single_block_and_protocol_hint(self, _integration_module):
blocks = _integration_module._yaml_seed_blocks(
{"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000}
)
assert len(blocks) == 1
# protocol fields are prefill hints ONLY (pairing stays interactive)
assert blocks[0]["protocol_host"] == "shield.local"
assert blocks[0]["adb_port"] == 5000
def test_list_and_single_block_shapes(self, _integration_module):
single = _integration_module._yaml_seed_blocks({"name": "A"})
listed = _integration_module._yaml_seed_blocks([{"name": "B"}, {"name": "C"}])
assert len(single) == 1 and len(listed) == 2
class TestSeeding:
"""async_setup seeds SOURCE_IMPORT flows for shaped blocks only."""
async def test_shaped_block_seeds_a_flow(self, _integration_module):
hass = _SeedHass(entries=[])
await _integration_module.async_setup(
hass, {"nvidia_shield": [{"name": "R", "adb_host": "10.0.0.5"}]}
)
if hass.scheduled_tasks:
await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True)
assert len(hass.config_entries.flows) == 1
domain, context, data = hass.config_entries.flows[0]
assert domain == "nvidia_shield"
assert context["source"] == "import"
assert data["adb_host"] == "10.0.0.5"
async def test_hostless_block_never_seeds(self, _integration_module):
hass = _SeedHass(entries=[])
result = await _integration_module.async_setup(
hass, {"nvidia_shield": [{"name": "X"}]}
)
assert result is True
assert hass.config_entries.flows == [] # hostless → warning only
class TestImportStep:
"""async_step_import: duplicate guards + interactive routing."""
def _flow(self, entries):
# the flow module loads under these stubs and the base plumbing is
# injected locally — importing test_standalone_setup here would
# leak its local stub set into later test files.
from nsh.config_flow import NvidiaShieldConfigFlow
hass = SimpleNamespace(
config_entries=_SeedEntriesStub(entries),
)
hass.config_entries.flows = []
flow = NvidiaShieldConfigFlow()
flow.hass = hass
flow.context = {}
# ConfigFlow base attrs the stub class doesn't implement (t7 shape):
flow.async_set_unique_id = None # set below
flow._collected_unique_id = None
async def set_unique_id(uid):
flow._collected_unique_id = uid
flow.async_set_unique_id = set_unique_id
def abort_if_unique_id_configured(*args, **kwargs):
for entry in hass.config_entries.async_entries():
if entry.unique_id == flow._collected_unique_id:
from nsh.config_flow import AbortIfConfiguredError
raise AbortIfConfiguredError(f"{entry.unique_id}")
flow._abort_if_unique_id_configured = abort_if_unique_id_configured
# the base ConfigFlow shows forms through the stub plumbing (matching
# how the standalone flow tests install it):
flow.async_show_form = lambda **kwargs: {
"type": "form",
**kwargs,
}
flow.async_create_entry = lambda **kwargs: {
"type": "create_entry",
**kwargs,
}
flow.async_abort = lambda **kwargs: {"type": "abort", **kwargs}
flow._async_current_entries = lambda: list(
hass.config_entries.async_entries()
)
return flow
async def test_ui_configured_host_never_re_seeded(self):
from nsh.const import CONF_ADB_HOST
existing = SimpleNamespace(
entry_id="ui1",
unique_id=None,
options={CONF_ADB_HOST: "10.0.0.31"},
)
# options live on the entry (conf zeroconf guard reads them)
flow = self._flow([existing])
result = await flow.async_step_import(
{"name": "A", "adb_host": "10.0.0.31"}
)
assert result == {"type": "abort", "reason": "already_configured"}
async def test_virtual_unique_id_blocks_reseed(self):
flow_holder = SimpleNamespace(
entry_id="seed1",
unique_id="yaml:Living:10.0.0.31",
options={},
)
flow = self._flow([flow_holder])
result = await flow.async_step_import(
{"name": "Living", "adb_host": "10.0.0.31"}
)
assert result == {"type": "abort", "reason": "already_configured"}
async def test_fresh_block_routes_into_the_user_flow(self):
flow = self._flow([])
result = await flow.async_step_import(
{"name": "Fresh", "adb_host": "10.0.0.99"}
)
# interactive pairing — the pre-filled user step, one visit
assert result["type"] == "form"
assert result["step_id"] == "user"
+32
View File
@@ -0,0 +1,32 @@
"""Tests for the PlaybackIntent dataclass (optimistic state tracking)."""
from ha_adb_common.intent import PlaybackIntent
def test_create_captures_fields():
intent = PlaybackIntent.create(
target_state="paused", command="media_pause", prior_state="playing"
)
assert intent.target_state == "paused"
assert intent.command == "media_pause"
assert intent.prior_state == "playing"
assert intent.retry_count == 0
assert intent.created_at > 0
def test_debug_dict_shape():
intent = PlaybackIntent.create(target_state="playing", command="media_play")
debug = intent.to_debug_dict()
assert debug["target"] == "playing"
assert debug["command"] == "media_play"
assert debug["retries"] == 0
assert "age_s" in debug
def test_age_increases():
import time
intent = PlaybackIntent.create(target_state="playing", command="media_play")
before = intent.age_seconds
time.sleep(0.01)
assert intent.age_seconds >= before
+254
View File
@@ -0,0 +1,254 @@
"""Parser tests driven by real Shield firmware captures (Android 11, RQ1A.210105.003).
Fixtures live in tests/fixtures — see docs/RESEARCH_SHIELD.md for how each
capture was produced and which expectations it pins.
"""
from pathlib import Path
from ha_adb_common.media_session import (
MediaSessionInfo,
parse_all_media_sessions,
parse_audio_playback,
parse_media_session,
)
FIXTURES = Path(__file__).parent / "fixtures"
def _fixture(name: str) -> str:
return (FIXTURES / name).read_text(encoding="utf-8")
class TestTiviMateHasNoSession:
"""TiviMate (ar.tvplayer.tv) registers no MediaSession on this firmware."""
def test_no_sessions_parsed(self):
assert parse_all_media_sessions(_fixture("media_session_tivimate.txt")) == []
def test_session_query_returns_none(self):
dump = _fixture("media_session_tivimate.txt")
assert parse_media_session(dump, "ar.tvplayer.tv") is None
def test_inactive_stale_session_is_not_surfaced(self):
"""Netflix registers an inactive, state-less session; the parser must
ignore it — an active=false block never appears in the results."""
dump = _fixture("media_session_tivimate.txt")
assert parse_media_session(dump, "com.netflix.ninja") is None
class TestWholphinSession:
"""Wholphin (com.github.damontecres.wholphin) publishes a proper session."""
def test_playing_session_parsed(self):
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
assert len(sessions) == 1
session = sessions[0]
assert session.package == "com.github.damontecres.wholphin"
assert session.playing is True
assert session.paused is False
assert session.title == "Twirlywoos"
assert session.artist is None
def test_foreground_package_is_preferred(self):
dump = _fixture("media_session_wholphin.txt")
best = parse_media_session(dump, "com.github.damontecres.wholphin")
assert best is not None
assert best.package == "com.github.damontecres.wholphin"
assert best.playing is True
class TestPlaybackStatePosition:
"""PlaybackState carries position (ms), speed, and uptime-ms `updated` (R9)."""
def test_wholphin_position_fields_parsed(self):
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
assert len(sessions) == 1
session = sessions[0]
assert session.position == 17237
assert session.speed == 1.0
assert session.updated == 1408392
def test_buffered_position_is_not_mistaken_for_position(self):
"""`buffered position=70070` sits in the same block; only the real
`position=17237` may be read."""
sessions = parse_all_media_sessions(_fixture("media_session_wholphin.txt"))
assert sessions[0].position != 70070
def test_position_at_uptime_extrapolates_with_speed(self):
session = MediaSessionInfo(
position=17237, speed=1.0, updated=1408392, playing=True
)
assert session.position_at_uptime(1408392 + 1000) == 18237
assert session.position_at_uptime(1408392) == 17237
def test_position_at_uptime_none_without_sample(self):
assert MediaSessionInfo().position_at_uptime(1_000_000) is None
def test_paused_session_parses_state_and_position(self):
dump = (
"Sessions Stack - have 1 sessions:\n"
" Some app session com.example.player/Session (userId=0)\n"
" package=com.example.player\n"
" active=true\n"
" state=PlaybackState {state=2, position=4200, buffered position=9000, "
"speed=0.0, updated=500, actions=1, custom actions=[], active item id=0, "
"error=null}\n"
" metadata: size=2, description=Song Title, Some Artist\n"
" Audio playback (lastly played comes first)\n"
" uid=10100 packages=com.example.player\n"
)
sessions = parse_all_media_sessions(dump)
assert len(sessions) == 1
session = sessions[0]
assert session.paused is True
assert session.playing is False
assert session.position == 4200
assert session.speed == 0.0
assert session.updated == 500
assert session.title == "Song Title"
assert session.artist == "Some Artist"
def test_stateless_playbackstate_line_ignored(self):
"""A non-PlaybackState `state=` line must not crash or fabricate state."""
dump = (
" Some app session com.example.player/Session (userId=0)\n"
" package=com.example.player\n"
" active=true\n"
" state=null\n"
" metadata: size=1, description=Only Title\n"
)
sessions = parse_all_media_sessions(dump)
assert len(sessions) == 1
assert sessions[0].playing is False
assert sessions[0].paused is False
assert sessions[0].position is None
class TestAudioPlaybackSection:
"""The "Audio playback (lastly played comes first)" section lists apps
that recently played audio even with no MediaSession (R2)."""
def test_tivimate_shows_up_without_a_session(self):
packages = parse_audio_playback(_fixture("media_session_tivimate.txt"))
assert packages == [
"ar.tvplayer.tv",
"com.google.android.apps.mediashell",
]
def test_order_is_dump_order_most_recent_first(self):
packages = parse_audio_playback(_fixture("media_session_tivimate.txt"))
assert packages[0] == "ar.tvplayer.tv"
def test_wholphin_audio_playback(self):
assert parse_audio_playback(_fixture("media_session_wholphin.txt")) == [
"com.github.damontecres.wholphin"
]
def test_lines_before_the_section_are_ignored(self):
"""`... package:` listener lines precede the section and must not leak
into the audio-playback package list."""
dump = (
" Media key listener package:\n"
" OnMediaKeyEventDispatchedListener: added 0 listener(s)\n"
" Audio playback (lastly played comes first)\n"
" uid=10117 packages=ar.tvplayer.tv\n"
)
assert parse_audio_playback(dump) == ["ar.tvplayer.tv"]
def test_empty_dump(self):
assert parse_audio_playback("") == []
def test_running_apps_last_known_enrichment():
"""P3-3b: apps without a live session carry their TTL-capped last-known state."""
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
tasks = [
{"package": "ar.tvplayer.tv"},
{"package": "com.netflix.ninja"},
]
sessions = [MediaSessionInfo(package="com.netflix.ninja", playing=True, title="Movie")]
last_known = {
"ar.tvplayer.tv": MediaSessionInfo(
package="ar.tvplayer.tv", playing=False, paused=True, title="Last show"
)
}
apps = running_apps_from_tasks(tasks, sessions, foreground_package=None, last_known=last_known)
tivi = next(a for a in apps if a["package"] == "ar.tvplayer.tv")
netflix = next(a for a in apps if a["package"] == "com.netflix.ninja")
assert tivi["state"] == "running" # no live session
assert tivi["last_known_state"] == "paused"
assert tivi["last_known_title"] == "Last show"
assert "last_known_state" not in netflix # live session enriched instead
assert netflix["state"] == "playing"
def test_parse_application_label_default_locale_wins():
"""P5-1: the default-locale label wins; localized suffixes are skipped."""
from nsh.ha_adb_common.app_label import parse_application_label
dump = (
"application-label: 'TiviMate'\n"
"application-label-de: 'TiviMate DE'\n"
"application-icon-320: 'x'"
)
assert parse_application_label(dump) == "TiviMate"
def test_parse_application_label_tolerance():
from nsh.ha_adb_common.app_label import parse_application_label
# double-quoted, empty first label (multi-label block), nonexistent
assert parse_application_label('application-label:"Wholphin"') == "Wholphin"
multi_label = "application-label:''\napplication-label: 'TiviMate'"
assert parse_application_label(multi_label) == "TiviMate"
assert parse_application_label("application-label: null") is None
assert parse_application_label("") is None
assert parse_application_label("nothing here") is None
def _logcat_lines():
"""Synthetic logcat list covering START shapes + noise + wrapping."""
return [
"03-21 10:19:40.100 3104 3456 I NotificationService: noise",
"03-21 10:19:41.792 3104 3456 I ActivityTaskManager: START u0 "
"{cmp=com.netflix.ninja/com.netflix.ui.main flg=0x10000000}",
"03-21 10:19:45.100 3104 3456 I ActivityTaskManager: START u0 {cmp=null}",
"03-21 10:19:47.000 3104 3456 I ActivityTaskManager: START u10 "
"{cmp=ar.tvplayer.tv/.ui.MainActivity",
" xflg=214 abi=arm64-v8a}",
"03-21 10:19:50.000 3104 3456 I ActivityTaskManager: Displayed",
]
def test_logcat_start_records_parse_cmp():
from nsh.ha_adb_common.logcat_events import foreground_from_logcat, foreground_packages
lines = _logcat_lines()
assert foreground_packages(lines) == ["com.netflix.ninja", "ar.tvplayer.tv"]
# LAST valid START wins
assert foreground_from_logcat(lines) == "ar.tvplayer.tv"
def test_logcat_non_start_and_null_ignored():
from nsh.ha_adb_common.logcat_events import foreground_from_logcat
assert foreground_from_logcat([
"03-21 10:19:50.000 3104 3456 I ActivityTaskManager: Displayed",
"plain noise",
]) is None
lines = _logcat_lines()
assert foreground_from_logcat(lines) == "ar.tvplayer.tv" # null skipped
def test_logcat_multi_user_prefix_tolerated():
from nsh.ha_adb_common.logcat_events import foreground_from_logcat
assert foreground_from_logcat(
["03-21 10:19:41.792 3104 3456 I ActivityTaskManager: START u123 {cmp=net.whatsapp/"] + [
" flg=0x1}"]
) == "net.whatsapp"
+364
View File
@@ -0,0 +1,364 @@
"""Unit tests for the protocol transport (androidtvremote2, Phase 2, t8).
Fake client mirrors AndroidTVRemote's surface (callback registration,
``keep_reconnecting`` that owns its own reconnect task, sync ``disconnect``);
no real gRPC / no Home Assistant. Also covers `volume_tuple` normalization
against the real library's VolumeInfo TypedDict when the package is present
(`uvx --with androidtvremote2 pytest`).
"""
import asyncio
import sys
import time
import pytest
from nsh.connection import (
FIELD_AWAKE,
FIELD_IS_AVAILABLE,
FIELD_IS_ON,
FIELD_SESSIONS,
FIELD_VOLUME_INFO,
ShieldConnectionManager,
StateStore,
volume_tuple,
)
class FakeProtocolClient:
"""AndroidTVRemote-shaped fake: callbacks + self-owned reconnect task."""
def __init__(self):
self._is_on_callbacks: list = []
self._is_available_callbacks: list = []
self._current_app_callbacks: list = []
self._volume_callbacks: list = []
self._invalid_auth_callback = None
self._reconnect_task = None
self.disconnect_calls = 0
# -- registration (mirrors the real API) --
def add_is_on_updated_callback(self, callback):
self._is_on_callbacks.append(callback)
def add_is_available_updated_callback(self, callback):
self._is_available_callbacks.append(callback)
def add_current_app_updated_callback(self, callback):
self._current_app_callbacks.append(callback)
def add_volume_info_updated_callback(self, callback):
self._volume_callbacks.append(callback)
# -- pushes (test helper: simulates device-side messages) --
def push_is_on(self, on):
for cb in list(self._is_on_callbacks):
cb(on)
def push_is_available(self, available):
for cb in list(self._is_available_callbacks):
cb(available)
def push_current_app(self, app_package):
for cb in list(self._current_app_callbacks):
cb(app_package)
def push_volume_info(self, info):
for cb in list(self._volume_callbacks):
cb(info)
# -- lifecycle --
def keep_reconnecting(self, invalid_auth_callback=None):
"""Own task like the real library (0.1 s → 30 s backoff internally)."""
self._invalid_auth_callback = invalid_auth_callback
loop = asyncio.get_event_loop()
self._reconnect_task = loop.create_task(self._reconnect_loop())
async def _reconnect_loop(self):
while True: # the real task sleeps through its internal backoff
await asyncio.sleep(0.05)
def fire_invalid_auth(self):
self._invalid_auth_callback()
def disconnect(self):
self.disconnect_calls += 1
if self._reconnect_task is not None:
self._reconnect_task.cancel()
def _protocol_manager(client=None, **kwargs) -> ShieldConnectionManager:
return ShieldConnectionManager(hass=None, entry=None, protocol=client, **kwargs)
async def _drain_loop():
"""Let call_soon_threadsafe callbacks scheduled via the running loop run."""
await asyncio.sleep(0.01)
class TestPushCallbackWiring:
async def test_is_on_and_availability_push_into_store(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
notified = []
manager.register_listener(lambda: notified.append(True))
await manager._start_protocol()
client.push_is_on(True)
client.push_is_available(False)
await _drain_loop()
assert manager.is_on is True
assert manager.is_available is False
assert manager.store.get(FIELD_IS_ON) is True
assert notified, "listeners pinged from protocol pushes"
await manager.async_stop()
async def test_current_app_push_stored_raw_package(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
client.push_current_app("com.netflix.ninja")
await _drain_loop()
assert manager.current_app == "com.netflix.ninja" # name resolution deferred
await manager.async_stop()
async def test_volume_push_normalized_to_level_max_muted(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
client.push_volume_info({"level": 7, "max": 15, "muted": True})
await _drain_loop()
assert manager.volume_info == (7.0, 15.0, True)
assert manager.store.get(FIELD_VOLUME_INFO)[2] is True
await manager.async_stop()
class TestInvalidAuthHook:
async def test_invalid_auth_invokes_entry_callback(self):
client = FakeProtocolClient()
fired = []
manager = _protocol_manager(
client, invalid_auth_callback=lambda: fired.append(True)
)
await manager._start_protocol()
client.fire_invalid_auth()
assert fired == [True], "reauth hook fired, not a retry storm (t9 delegates)"
await manager.async_stop()
async def test_invalid_auth_without_callback_is_safe(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
client.fire_invalid_auth() # no callback registered — must not raise
await manager.async_stop()
class TestLifecycle:
async def test_stop_disconnects_lib_owned_reconnect_task(self):
"""The library owns keep_reconnecting's task; closing the client
tears it down — the manager never wraps that task itself."""
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
task = client._reconnect_task
assert task is not None
await manager.async_stop()
await _drain_loop() # let the lib-owned task settle its cancellation
assert client.disconnect_calls == 1
assert task.cancelled()
assert manager._protocol is None
async def test_protocol_with_factory_settings_builds_client(self, monkeypatch):
client = FakeProtocolClient()
built = []
def fake_builder(*args, **kwargs):
built.append(kwargs)
return client
monkeypatch.setattr(ShieldConnectionManager, "build_protocol_client", fake_builder)
manager = ShieldConnectionManager(
hass=None,
entry=None,
protocol_settings={
"host": "shield.local",
"client_name": "Home Assistant",
"certfile": "/tmp/cert.pem",
"keyfile": "/tmp/key.pem",
},
)
await manager._start_protocol()
assert manager._protocol is client
assert built
kwargs = built[0]
assert kwargs["host"] == "shield.local"
assert kwargs["client_name"] == "Home Assistant"
assert kwargs["certfile"] == "/tmp/cert.pem"
assert kwargs["keyfile"] == "/tmp/key.pem"
await manager.async_stop()
async def test_passive_without_protocol_configuration(self):
manager = _protocol_manager(client=None)
await manager._start_protocol() # no factory, no settings → no-op
assert manager._protocol is None
assert manager.is_on is None
assert manager.is_available is None
assert manager.current_app is None
assert manager.volume_info is None
await manager.async_stop()
class TestPushEdgeCases:
async def test_push_after_loop_close_falls_back_to_direct_notify(self):
"""Push callbacks that outlive a closed loop must not crash."""
client = FakeProtocolClient()
manager = _protocol_manager(client)
notified = []
manager.register_listener(lambda: notified.append(True))
await manager._start_protocol()
loop = asyncio.new_event_loop()
loop.close()
manager._protocol_loop = loop # stale/closed loop (mid-shutdown state)
client.push_is_on(True)
await _drain_loop()
assert manager.is_on is True # store stamped via direct-notify path
assert notified # listeners still pinged synchronously
await manager.async_stop()
async def test_store_ttl_applies_to_protocol_fields(self):
now = [1000.0]
store = StateStore(ttl=60.0, clock=lambda: now[0])
client = FakeProtocolClient()
manager = _protocol_manager(client, store=store)
await manager._start_protocol()
client.push_is_on(True)
await _drain_loop()
now[0] += 60.1
assert manager.is_on is None # expired like every other store field
assert manager.store.get(FIELD_IS_AVAILABLE) is None
await manager.async_stop()
async def test_pushes_are_stamped_with_transition_time(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
before = time.monotonic()
client.push_is_available(True)
await _drain_loop()
after = time.monotonic()
stamped_at = manager.store._values[FIELD_IS_AVAILABLE][0]
assert before <= stamped_at <= after
await manager.async_stop()
class FakeAdbForOutage:
"""ADB poller stub for the outage test (wake poll fails)."""
def __init__(self):
self.connected = True
self.awake = None
self.disconnect_calls = 0
async def connect(self):
self.connected = True
return True
async def disconnect(self):
self.disconnect_calls += 1
self.connected = False
async def is_awake(self):
return self.awake if self.connected else None
class TestAdbOutageKeepsProtocolTruth:
async def test_note_failure_clears_only_adb_fields(self):
"""F2: an ADB outage must not clobber device-reported protocol truth."""
poller = FakeAdbForOutage()
poller.connected = True
poller.awake = None # wake poll fails → cycle failure path
client = FakeProtocolClient()
manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, protocol=client
)
await manager._start_protocol()
client.push_is_on(True)
client.push_is_available(True)
client.push_current_app("com.netflix.ninja")
client.push_volume_info({"level": 5, "max": 15, "muted": False})
manager.store.set(FIELD_AWAKE, True)
manager.store.set(FIELD_SESSIONS, ["session-data"])
await manager._watchdog_cycle(5.0) # ADB outage → _note_failure
# ADB-derived fields cleared (regenerated by the watchdog)
assert manager.store.get(FIELD_AWAKE) is None
assert manager.store.get(FIELD_SESSIONS, []) == []
# protocol push truth survives the ADB outage (TTL expiry rules it)
assert manager.is_on is True
assert manager.is_available is True
assert manager.current_app == "com.netflix.ninja"
assert manager.volume_info == (5.0, 15.0, False)
await manager.async_stop()
class TestImportGuard:
def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog):
"""F1: a protocol-enabled entry without androidtvremote2 keeps
running ADB-only — the guarded factory logs a warning and builds
nothing instead of raising at setup."""
import logging
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
with caplog.at_level(logging.WARNING):
client = ShieldConnectionManager.build_protocol_client(
"shield.local", "Home Assistant", "/tmp/c.pem", "/tmp/k.pem"
)
assert client is None
assert any("degrades to ADB-only" in r.message for r in caplog.records)
async def test_settings_entry_without_library_stays_passive(self, monkeypatch):
manager = ShieldConnectionManager(
hass=None,
entry=None,
protocol_settings={
"host": "shield.local",
"client_name": "HA",
"certfile": "/tmp/c.pem",
"keyfile": "/tmp/k.pem",
},
)
monkeypatch.setitem(sys.modules, "androidtvremote2", None)
await manager._start_protocol()
assert manager._protocol is None # passive, no crash
await manager.async_stop()
class TestVolumeTuple:
def test_normalizes_typed_dict(self):
assert volume_tuple({"level": 7, "max": 15, "muted": True}) == (7.0, 15.0, True)
def test_defaults_on_partial_dict(self):
assert volume_tuple({"muted": False}) == (0.0, 1.0, False)
def test_none_and_garbage_yield_none(self):
assert volume_tuple(None) is None
assert volume_tuple("garbage") is None
def test_accepts_real_library_volume_info(self):
androidtvremote2 = pytest.importorskip("androidtvremote2")
info = androidtvremote2.VolumeInfo(level=3, max=15, muted=True)
assert volume_tuple(info) == (3.0, 15.0, True)
+40
View File
@@ -0,0 +1,40 @@
"""Tests for the running-apps list builder (recent tasks + media sessions)."""
from ha_adb_common.running_apps import running_apps_from_tasks
def _session(package: str, playing: bool = False, title: str | None = None):
from ha_adb_common.media_session import MediaSessionInfo
s = MediaSessionInfo(package=package)
s.playing = playing
s.paused = not playing
s.title = title
return s
def test_returns_none_for_fewer_than_two_tasks():
assert running_apps_from_tasks([]) is None
assert running_apps_from_tasks([{"package": "app.a"}]) is None
def test_marks_foreground_and_defaults_to_running():
tasks = [{"package": "app.a"}, {"package": "app.b"}]
apps = running_apps_from_tasks(tasks, [], foreground_package="app.b")
assert apps is not None
assert len(apps) == 2
by_pkg = {a["package"]: a for a in apps}
assert "foreground" not in by_pkg["app.a"]
assert by_pkg["app.b"]["foreground"] is True
assert by_pkg["app.a"]["state"] == "running"
def test_enriches_with_media_session_state():
tasks = [{"package": "app.a"}, {"package": "app.b"}]
sessions = [_session("app.a", playing=True, title="Song")]
apps = running_apps_from_tasks(tasks, sessions)
assert apps is not None
by_pkg = {a["package"]: a for a in apps}
assert by_pkg["app.a"]["state"] == "playing"
assert by_pkg["app.a"]["title"] == "Song"
assert by_pkg["app.b"]["state"] == "running"
File diff suppressed because it is too large. Load diff
+94
View File
@@ -0,0 +1,94 @@
"""A voluptuous mirror faithful enough to SEE a dropped schema default.
The test environment never installs voluptuous (the config-flow tests import
``config_flow`` against import-only stubs), so the harness has to model the two
behaviours the flow schemas depend on:
* ``Optional(field, default=value)`` / ``Required(field, default=value)`` carry
that default on the KEY object;
* keys compare by FIELD NAME only — real ``voluptuous.Marker.__eq__`` is
``self.schema == other`` (no class check at all), so
``Optional("x") == Required("x")`` is True, and ``__hash__`` is
``hash(self.schema)``. A later re-declaration of the same field therefore
collides with the FIRST key object, so a default added by a second
declaration is silently dropped.
That second property is exactly how the t33 F-A discovery-prefill bug hid from
the suite: the old stubs reduced every key to its bare string, so a schema
whose host key carried no default looked identical to one that carried the
discovered address. ``TestVoluptuousMirrorContract`` in
``tests/test_config_flow_ux.py`` pins both properties so the harness itself
cannot regress into blindness.
"""
from __future__ import annotations
UNDEFINED = object()
"""Sentinel for "this key declares no default" (real ``voluptuous.UNDEFINED``)."""
class Marker:
"""Stand-in for ``voluptuous.Marker`` (the ``Required``/``Optional`` base)."""
def __init__(self, schema, default=UNDEFINED):
self.schema = schema
self.default = default
def __hash__(self):
# real Marker hashes the field name only
return hash(self.schema)
def __eq__(self, other):
# real Marker.__eq__ is `self.schema == other`: the FIELD is the whole
# comparison — no class check, so Optional("x") == Required("x") (and
# even Marker("x") == "x") is True. That is what makes a re-declaration
# of a field collide with the first key object and drop its default.
return self.schema == other
def __repr__(self):
return f"<{type(self).__name__} {self.schema!r} default={self.default!r}>"
class Required(Marker):
"""Stand-in for ``voluptuous.Required``."""
class Optional(Marker):
"""Stand-in for ``voluptuous.Optional``."""
def install(vol) -> None:
"""Wire the mirror onto a stub ``voluptuous`` module object."""
vol.UNDEFINED = UNDEFINED
vol.Marker = Marker
vol.Required = Required
vol.Optional = Optional
vol.Schema = lambda schema: schema
def defaults_by_key(schema_dict) -> dict:
"""``{field: default}`` for a schema declared with mirror keys."""
return {key.schema: key.default for key in declared_keys(schema_dict)}
def declared_keys(schema_dict) -> list:
"""The schema's marker keys, in declaration order.
Raises on a non-marker key: a bare string key means the harness (or the
code under test) lost the KEY structure this mirror exists to check.
Test-harness bookkeeping keys (``__suggested__`` and friends) are skipped.
"""
keys = []
for key in schema_dict:
if isinstance(key, Marker):
keys.append(key)
continue
if isinstance(key, str) and key.startswith("__"):
continue
raise AssertionError(f"schema key {key!r} is not a voluptuous marker")
return keys
def declared_fields(schema_dict) -> list:
"""The field names of a schema, in declaration order."""
return [key.schema for key in declared_keys(schema_dict)]