Author SHA1 Message Date
gitea-actions 49bc23ae34 chore(release): v2026.10.11.2 [skip ci] 2026-10-11 07:11:34 +00:00
James Mackay 0357f47284 fix(media_player): dedupe source_list case-insensitively (t53 F1)
Release / release (push) Successful in 7s
Test / test (push) Successful in 31s
Test / ha-api-drift (push) Successful in 21s
t52 F1: the dropdown collapsed exact duplicates but not names that differ only by case.
With a map of {com.a: 'Dup', com.b: 'dup'} the pre-fix tree offered ['Dup', 'dup']
(two rows) while the reverse lookup — case-insensitive by design since t47 — resolved
BOTH spellings to the first entry. So row two was a row that LIED: picking it launched
com.a, the app row one already names. Editor-reachable (two valid lines), unlike the
earlier hand-edit-only edges, which is why it is fixed rather than documented.

source_list now dedupes on name.casefold(), keeping the first spelling in the user's
typed map order. One row per name means a row can only launch the app it shows. The
shadowed package is not lost: the Browse tree still lists it under its own package id
(the t51 rule — both entries kept, each reachable by id) and select_source accepts that
id, pinned by test. The t51 policy is otherwise untouched: names stripped once, blank
name/package entries dropped, exact duplicates already collapsed, well-formed maps
byte-identical to the Browse titles with inner spaces preserved.

README: a short 'Known limits (the Apps map)' note records the two accepted boundaries
so they are known rather than latent — names differing only by case offer one row (the
shadowed app stays reachable by id), and a hand-padded map value is shown cleaned in
the dropdown and the Browse tree while app_name/source/Current App sensor display it
verbatim, so `source` can be a string that is not in source_list (a blank value falls
through to the raw package).

RED PROOF (previous tree swapped in, then restored): 4 failed / 5 passed pre-fix for
the case tests, all green after. The literal pre-fix symptom:
`assert ['Dup', 'dup'] == ['Dup']` — "Left contains one more item: 'dup'"; the
wrong-app half is pinned by `_source_package("dup") == "com.a"`, which holds on BOTH
trees and is exactly the chain that made row two launch row one's app. The new
no-duplicate invariant fails pre-fix with len(['dup', 'dup']) == 2 versus 1.

Verified: 471 passed / 2 skipped, 472 / 1 with androidtvremote2, ruff clean, drift
engine exit 0 under real HA (23 modules, 58 imports, 164 relative imports, 36 call
arguments, 7 awaited HA calls); README YAML blocks still parse (5) and the t47/t48/t51
rig is green.
2026-10-11 07:26:07 +01:00
James Mackay dc3cfbbe3e fix(media_player): normalize the app directory in one place (t51)
t49's two residuals share one cause: `_app_directory` handed the raw options map to
its consumers, so the Browse tree showed a child with a blank title and the reverse
lookup compared the user's padded name against itself. The map is hand-editable, so
the normalization belongs in that one accessor — the browse tree, source_list and the
reverse lookup then cannot disagree.

Policy, now documented in `_app_directory` (the single place):
* names are stripped as the directory is built, so display and match key are one
  string: `com.x=  TiviMate  ` shows as "TiviMate" AND resolves as "TiviMate";
* an entry whose NAME or whose PACKAGE is blank/whitespace-only is dropped, so no
  surface offers a child with no title or no package behind it (the t48 invariant,
  now extended to the Browse tree);
* two packages normalizing to the same name are BOTH KEPT — the tree reaches each by
  its own package id, so neither app is hidden — while the FIRST in map order wins the
  name: that is the single entry source_list offers and the package select_source
  resolves. Map order is the user's order, so the winner does not move between
  rebuilds; reversing the map reverses the winner, which is pinned.
* a well-formed map (what the editor and the curated default produce) is byte-identical
  in and out: stripping touches the EDGES only, so "TiviMate Pro" keeps its inner space.

source_list now just dedupes the normalized values (its own t49 filter moved into the
accessor) and `_source_package` documents the same first-wins rule.

RED PROOFS, both residuals, against the previous tree (HEAD 9761540, swapped in and
restored): 5 failed / 20 passed before, 25 passed after. The pre-fix symptoms are
concrete — the Browse tree for a map with "" and "   " entries and a blank package
returned titles ['', 'TiviMate', '   ', 'Ghost'] (an empty child, a whitespace child
and a child with no package), and a padded entry produced
source_list ['  TiviMate  ', 'Netflix'] with `_source_package("TiviMate")` returning
None (the resolution miss). After the fix both lists are ['TiviMate', 'Netflix'] and
the lookup answers. The well-formed-map test passes on BOTH trees, which is the
"unchanged for well-formed maps" half of the claim; surviving entries still launch
their key packages (protocol launch list asserted) and the curated fallback list is
untouched (its own test).

Deliberately NOT changed: `_app_name_map` (the raw map behind app_name/source and the
Current App sensor) still returns values as configured, so a padded entry can still
display padded in those two surfaces. A blank value already falls through the
resolve_app_name chain (`if mapped:`), so nothing is broken there, and stripping it
would change sensor.py's output — outside this repair's in-scope paths. Flagged in the
task report as a candidate follow-up rather than done quietly.

Verified: 463 passed / 2 skipped, 464 / 1 with androidtvremote2, ruff clean, drift
engine exit 0 under real HA (23 modules, 58 imports, 164 relative imports, 36 call
arguments, 7 awaited HA calls); the t47/t48 rig (sources, routing, command scripts,
both config-flow suites) is 259 passed.
2026-10-11 07:21:33 +01:00
James Mackay 97615408c0 fix(media_player): keep blank app names out of the source list (t49 F1)
t48 F1: source_list stringified the app map's values without filtering, so a
hand-edited map with a blank name ("com.x=") put an empty string in the dropdown.
That entry is worse than useless — it is a dropdown item whose only possible outcome
is an error, because select_source has to reject an empty name as an unknown source.

source_list now drops blank and whitespace-only names. The values themselves are
untouched: no name is rewritten or trimmed here, because the reverse lookup matches on
them, so for a well-formed map the list stays byte-equal to the Browse tree's child
titles (same _app_directory accessor, same order) — pinned by comparing the two
directly, for the user's map and for the curated default. The reverse lookup is
unchanged.

Tests (tests/test_media_sources.py, 3 new; tests/test_standalone_setup.py, 1 new):
a hand-edited map with "" and "   " entries offers only the real names; the offered
list equals the Browse tree titles for well-formed maps (both map and curated
default); the surviving entries still resolve AND launch through select_source, and a
blank name still resolves to nothing; and the editor keeps rejecting "com.x="
(_app_map_from_text returns None, the page answers invalid_app_map and stores nothing
half-way), so a blank name cannot get in that way either.

RED PROOF: with the filter removed, exactly the two blank-name tests fail — the blank
appears in the list, and the entry has no package to resolve to (1 passed / 2 failed;
the byte-equality test passes either way, which is the "well-formed list unchanged"
half of the claim) — while the rest of the file is green; restored, 20 pass.

Verified: 458 passed / 2 skipped, 459 / 1 with androidtvremote2, ruff clean, drift
engine exit 0 under real HA (23 modules, 58 imports, 164 relative imports, 36 call
arguments, 7 awaited HA calls).

Residual, deliberately NOT touched here (out of this repair's scope, and pre-existing):
a blank map entry still reaches the Browse tree as an empty child title, and a PADDED
name ("  TiviMate ", hand-edited only — the editor strips) still fails the reverse
lookup because the map value is matched unstripped. Both are hand-edited-map edges;
say the word and either can be a follow-up.
2026-10-11 07:17:56 +01:00
James Mackay 1c6c223f27 docs(readme): the app list as sources, the remote command script, current versioning (t47 D)
t46 F2: the status line still cited `v2026.10.08`. It now describes the scheme
(`vYYYY.MM.DD`, plus `vYYYY.MM.DD.N` for a second release in the same day) and links
to the Releases section, so it cannot go stale again. The other dated reference stays:
"v2026.10.09 shipped that way" is the historical example that motivated the write-back,
and it is accurate.

New "Sources (the app list in a dropdown)" section: source_list from the Apps map
(with the built-in fallback named, so a card is never empty), `source` resolved exactly
like the Current App sensor, and `media_player.select_source` by name — accepting a raw
package id and any case, erroring on an unknown name — with a copy-pasteable service
call. It also points at the Select source script as the override for the "more than
launching an app" case.

New "Remote command script" subsection: the four variables in a table (command,
num_repeats, delay_secs, hold_secs) with their meanings and defaults, and a
copy-pasteable script that forwards the call to an IR bridge. It states that all four
are always present including defaults, and that unset changes nothing.

Corrected the two claims UNIT A invalidated: the command table no longer says
select_source has no native path, and the paragraph no longer says it is offered only
while a script is configured.

Every fenced YAML block in the README still parses with a real parser (5 now), and
tests/test_readme.py is green.
2026-10-11 07:07:36 +01:00
James Mackay c3e4c7855c feat(remote): optional command script for remote.send_command (t47 B)
One optional 'Remote command script' on the Configure page. Configured, it replaces
the whole key path for remote.send_command; unset, repeats, long presses and delays go
out as protocol keys exactly as before (the existing send_command tests pass
untouched).

A remote command is a SEQUENCE — repeats, a START_LONG/END_LONG pair, an inter-repeat
delay — so the escape hatch is one script for the call rather than per-key overrides:
an IR bridge, a macro, or a second device needs the call, not individual keys. The
script receives the service's own parameters as variables, named as they are on the
service and documented in the README and pinned in tests:

  command       the list of key commands as submitted (e.g. ["HOME", "KEYCODE_DPAD_CENTER"])
  num_repeats   the repeat count (default 1)
  delay_secs    the inter-repeat delay (default 0.0)
  hold_secs     the long-press duration, START_LONG/END_LONG handled by the script
                (default 0.0) — the script owns the key semantics, so it decides what
                "hold" means

All four are always present, including defaults, so a script can rely on the shape.
A raising script surfaces a translated error rather than a silent no-op. That made the
t44 script-failure mechanism consistent in the process: async_call_script() is now the
one caller for both the media player's per-command scripts and the remote's, raising
ServiceValidationError(translation_domain="nvidia_shield",
translation_key="command_script_failed") with {action, script, error} placeholders
instead of a bare HomeAssistantError — the t44 test was updated to assert the
translation contract (domain, key, placeholders) rather than a message string, which is
stronger evidence and gives the user real copy. The new copy lives in BOTH strings
files, as does the unknown_source key from UNIT A.

Option handling reuses the t47 C work: the field is an entity selector (so a cleared
value is tolerated by the schema), the stored value is trimmed, and a blank,
hand-edited or non-string value means "not configured" rather than a call to nowhere.

Verified: 454 passed / 2 skipped, ruff clean, drift engine exit 0 under real HA
(23 modules, 58 imports, 164 relative imports, 36 call arguments, 7 awaited HA calls).
2026-10-11 07:07:06 +01:00
James Mackay b2aab15ebf feat(media_player): source list + native select_source from the app list (t47 A)
The last piece a universal-media-player replacement needs: a dropdown of the apps,
an answer to what is on screen, and a way to pick one — all reading ONE app directory
(the user's Apps map, else DEFAULT_APP_DIRECTORY), so the dropdown can never offer a
name the launch path cannot resolve. That invariant is pinned by a test that walks
source_list and resolves every entry.

* `source_list` — friendly names in directory order (the user's map order, or the
  curated list), from a new `_app_directory` accessor that the browse tree now shares.
* `source` — the foreground app's resolved name via the existing `resolve_app_name`
  chain (map → cached device-manifest label → raw package), i.e. exactly `app_name`,
  so the media player and the Current App sensor cannot disagree.
* `select_source(name)` — a configured select_source script still takes precedence
  (t44, unchanged: selecting a source can be more than launching an app); without one
  the name is resolved back to its package and launched over the shared
  `play_media("app", …)` path, so the native semantics are the ones play_media already
  had. Accepts a raw package id (what the browse tree publishes) and matches names
  case-insensitively; an unknown or empty name raises
  ServiceValidationError(translation_domain="nvidia_shield",
  translation_key="unknown_source", translation_placeholders={"source": …}) — a
  translated error, never a silent no-op — with new copy in BOTH strings files.
* SELECT_SOURCE is advertised when EITHER a select_source script is configured OR
  source_list is non-empty; VOLUME_SET stays script-only (the protocol has no
  absolute-volume key), and the two flags are pinned as independent.

Two t44 tests were updated because this is a deliberate behaviour change: "no native
path" is now only true of volume_set, and SELECT_SOURCE is advertised by default
because the curated directory always offers something.

Also: rewriting the strings files re-indented the seven script labels added in t44
(they were valid JSON but sat two levels shallow); the diff there is that fix plus
the new key.

Verified: 441 passed / 2 skipped, ruff clean, drift engine exit 0 under real HA
(23 modules, 59 imports, 157 relative imports, 36 call arguments, 7 awaited HA calls).
2026-10-11 07:05:57 +01:00
James Mackay b3f897c36e fix(config_flow): clearing an entity field no longer fails validation (t47 C)
t46 F1, reproduced against real Home Assistant: submitting the options page with a
cleared entity field (script_turn_on='') produced "Schema validation failed at
script_turn_on". HA validates the submitted page against the schema the flow last
returned, and real EntitySelector rejects BOTH '' and None — "Entity  is neither a
valid entity ID nor a valid UUID" (verified directly against homeassistant's
EntitySelector). Clearing therefore depended on the frontend OMITTING the key, on
every entity field of the page, not only the script ones t44 added.

_ClearingEntitySelector subclasses EntitySelector and returns '' for None or an
empty/whitespace string, delegating everything else to the parent — so a real entity
id is still domain-checked, and the class is still an EntitySelector: its serialized
form is byte-identical to the real selector's
({"selector": {"entity": {"domain": ["script"], "multiple": false, "reorder": false}}}),
so the frontend keeps rendering an entity picker.

The step also normalizes a submitted None to '' before it acts: an explicit empty
value clears the setting (the script dict drops it, the patched options store '' which
every reader already treats as unset), while an OMITTED key still means "the page did
not touch it" for the patched options — and still means "cleared" for the script dict,
which is rebuilt from the page (t44's editor-is-the-truth rule, unchanged).

The test harness now models the trap instead of hiding it: EntitySelector is a CLASS
(entity_stublib.EntitySelectorStub) that rejects ''/None like real HA, defined once and
shared by all five stub installers through install_selector_faces(), so the stubs
cannot drift apart.

Tests (tests/test_config_flow_ux.py::TestClearingAnEntityField, 8): the schema accepts
omitted/None/'' while passing a real entity id through; the plain selector is pinned as
rejecting the same values (the trap itself); '' and None each clear a script AND a
pre-existing entity field (jellyfin, the older instance of the pattern) while
untouched fields survive; an omitted script field is cleared; an omitted entity field
is kept; and a prefill→submit round trip changes nothing.

RED PROOF: with the five selector sites switched back to the plain EntitySelector,
exactly the five tolerance tests fail (5 failed, 4 passed) and the rest of the file
stays green; restored, 70 pass. Real-HA probe run separately (see the task output):
strict rejects '' and None, the subclass accepts both and returns '', and serialization
is unchanged.

Verified: 423 passed / 2 skipped, ruff clean, drift engine exit 0 under real HA.
2026-10-11 07:04:31 +01:00
James Mackay 0cfa045190 docs(readme): the release write-back and the command scripts (t44 3, R2)
Releases: the section described the manifest/date fallback only, and pointed at
`.github/workflows/release.yml`, which does not exist. It now covers the same-day
`vYYYY.MM.DD.N` suffix (lowest free N, gaps reused), the manifest write-back commit
and WHY it exists (HACS compares against the default-branch manifest, so a tag-only
release is offered to nobody — v2026.10.09 shipped that way), what `[skip ci]` means
and what it does not (release commits only; Gitea's own handling can still skip an
ordinary commit whose body mentions the marker), the refusal to publish when the
default branch disagrees, and the branch-protection failure path.

t44 R2, both halves of the tag's identity: with a write-back the tag names the bot
commit, so `git describe --tags` on a developer commit reports the previous release
(expected, not a missing tag); hand-bumping the manifest keeps the tag on the
developer's own commit, because the workflow then has nothing to write back.

Command scripts: a new section under Options, with the command/variable table
(`level` for volume_set, `is_volume_muted` for volume_mute, `app_name` for
select_source), the "instead of, never both" rule, the fact that an unset selector
means the native path, and a copy-pasteable script block covering the three cases the
user asked for — a speaker switch owning the volume, an AVR owning the input, a mute
that hits the switch too. It says plainly that scripts are set on the Configure page
and not in the YAML seed, which stays address + transport only (the seed example is
unchanged and still the only `nvidia_shield:` block).

Verified: 414 passed / 2 skipped, 415 / 1 with androidtvremote2, test_config_flow_ux
61, test_standalone_setup + test_config_flow_protocol 58, ruff clean, drift engine
exit 0 under real HA, and every fenced YAML block in the README parses with pyyaml.
2026-10-11 06:49:44 +01:00
James Mackay b03cfbae13 fix(release): notes must not open with the bot commit; guard matches the subject (t44 3b/3c)
t45's review of the release versioning found three lows; two touch behaviour here (the
third, README wording, is the next commit).

R1 — with a write-back the tag points at the bot's own commit, so the notes range
`PREV..TAG` published "- chore(release): vX [skip ci]" as the first line of every
release. The range now ends at `${TAG}^` when BUMP=yes (the last commit a developer
wrote) and at `${TAG}` when it is not (there the tag IS the pushed commit, and it
belongs in the notes). `git describe` for the previous tag is unchanged: it already
started from `${TAG}^`, so it keeps naming the previous release.

R3 — the job guard matched the `[skip ci]` marker ANYWHERE in the commit message, so a
developer commit whose body merely mentioned it silently skipped its own release. The
guard is now anchored to the release commit's subject prefix
(`!startsWith(message, 'chore(release):')`), which is a structural property, not a
substring a body can trip. The marker stays in the commit message: it is the
convention Gitea itself may honour to skip the run outright, and the README now says
so, together with the fact that the job guard is the layer that holds when it does not.

3c — the resolver's tests used tag fixtures, so `refs/tags/X^{commit}` was never
exercised, and that dereference is the entire reason an annotated tag is recognised
(for one, `git rev-parse refs/tags/X` is the TAG OBJECT, not the commit, so a
comparison that forgot to dereference would miss a released commit). The resolver now
also runs inside real temporary repositories built with real annotated and lightweight
tags: released-on-HEAD both ways, a tag on an older commit falling back to the date,
no tags at all, the same-day scan walking real tags, and gap reuse. The workflow's own
run: scripts are executed too — extracted from the YAML by hand rather than with a
parser the fast suite does not have — covering the bump/verify/tag path (commit
subject, tag == HEAD, the tag's manifest, the pushed tag), the inconsistent-manifest
refusal, the rejected-push failure, and the notes range: with BUMP=yes the fixed range
yields "- feat: the real change" while the range it replaced demonstrably contains
"- chore(release):". The job-level `if:` itself cannot be executed here (no Gitea
runner) and stays a contract pin; the script-side layer that makes a bypassed guard
harmless is executed instead.

Verified: 414 passed / 2 skipped, ruff clean, release.yml parses.
2026-10-11 06:48:48 +01:00
James Mackay 89c05f1dcd feat(media_player): per-command script overrides (t44)
The user's remaining universal-media-player dependency: some commands need hardware
this integration deliberately does not drive — a speaker switch that owns the
volume, an AVR input that has to move with the Shield, a mute that has to hit two
devices. One script per command is the escape hatch, and the rule is absolute: a
configured command calls its script INSTEAD of the native protocol/ADB path, and
with nothing configured every command behaves exactly as it did (the whole existing
suite passes untouched, which is what "byte-identical" means here).

* const.py owns the option: CONF_COMMAND_SCRIPTS (one dict, keyed by command),
  SCRIPT_COMMANDS (turn_on, turn_off, volume_up, volume_down, volume_set,
  volume_mute, select_source) and command_scripts(), the ONE normalizer of the
  stored shape — options round-trip through HA storage and can be hand-edited, so a
  value that is not a non-empty string for a known command is dropped instead of
  reaching the service layer.
* media_player.py: async_run_command_script() calls script.turn_on with the script
  entity and its variables, blocking=True so a failure surfaces HERE — a mapped
  command that cannot run raises HomeAssistantError naming the command and script
  rather than falling back silently or doing nothing. Every command method checks
  its script first; volume_set passes `level` and select_source passes `app_name`.
* volume_mute also passes `is_volume_muted`: without it a mute script cannot tell
  muting from unmuting, which is the case the user asked for.
* volume_set and select_source have NO native path (C-VOL; app launching goes
  through play_media), so they are advertised only when configured — the feature
  flag is what makes HA offer the command at all. Unset leaves the feature set
  exactly as it was.
* remote.py shares async_run_command_script for POWER: turn_on/turn_off is one
  configuration and the remote entity is the other route to it, so a script that
  owns the power sequence owns it on both.
* config_flow.py: one entity selector per command (domain: script), stored as the
  single dict option, prefilled from the entry's options and cleared by saving
  without a value — the editor is the truth, as with the app map editor.

Two things the harness caught, worth recording: `MediaPlayerEntityFeature` has no
SET_VOLUME (the real member is VOLUME_SET — the stub enum and the drift engine
disagree with a wrong name), and the stub `homeassistant.exceptions` had no
HomeAssistantError, so the stub now mirrors the real inheritance
(ServiceValidationError derives from it).

tests/test_command_scripts.py covers each command mapped and unmapped, the exact
variable payloads, the native path untouched when unmapped (protocol key, then the
ADB fallthrough with the protocol down), the rejecting script's error, hand-edited
option shapes, and the feature set. test_config_flow_ux.py covers the selectors,
the stored shape, prefill and clearing. New tests leaked the harness into the next
file at first (a missing per-file scope); that is fixed, and
tests/test_harness_scoping.py now asserts that every file loading the harness scopes
it, so the next such mistake fails where it is made.

Verified: 401 passed / 2 skipped, 402 / 1 with androidtvremote2, reverse file order
401 / 2, ruff clean, and the drift engine exits 0 on a real-HA interpreter
(23 modules, 59 imports, 157 relative imports, 36 call arguments, 6 awaited HA calls).
2026-10-11 06:47:20 +01:00
James Mackay 2e1d162ef4 test(harness): make the stub scoping independent of file order (t44)
A subset of test files in an order the suite was not written for failed 3
test_init.py::TestImportStep tests with ModuleNotFoundError: voluptuous, while the
full suite stayed green. Two order-only mechanisms, both now fixed at the source:

1. entity_stublib.install() guarded on ONE sentinel
   (`homeassistant.components.media_player`). A file whose teardown popped
   `voluptuous` but left the sentinel turned the next file's install() into a no-op,
   so importing config_flow failed. The guard now checks every face a file scopes on
   (`_STUB_FACES`, derived from ENTITIES_STUB_NAMES so the two cannot drift) and
   rebuilds the set when anything is missing. install() also registers the
   `homeassistant.helpers.selector` face itself, which the per-file installers had
   been adding separately — without it the derived list would never be complete.

2. Teardowns restored only the names their file had listed, so a module the file
   loaded (`nsh.diagnostics`, `nsh.media_player`) survived into the next file still
   holding stub objects — a stub exception class, the redact helper — that had since
   been replaced; comparisons against the new objects then failed (5 tests in
   test_command_routing on an unlucky order). Every file now ends through
   entity_stublib.snapshot_modules()/restore_modules(), which drops EVERYTHING the
   file created under the harness namespaces (homeassistant/voluptuous/nsh) and puts
   back everything it replaced. Hand-written lists were the bug, so there is none to
   keep in sync.

The t7 no-leak discipline is unchanged: a file's stubs still never reach another
file, and a file's loaded modules still do not survive it. A session-scoped baseline
install was tried first and rejected: files whose own installer guards on "the stubs
are already there" skipped installing their richer faces, which produced exactly the
inconsistent state a baseline is meant to prevent (10 test_standalone_setup failures).

tests/test_harness_scoping.py: the sentinel poisoning, the wholesale-rebuild
invariant, a teardown that removes what the file loaded, the stale-object case
(diagnostics vs the redact helper), and — because order is the thing under test —
two subprocess runs of the reported orderings.

Verified: the reported order (test_command_routing, test_protocol_transport,
test_standalone_setup, test_init) 152 passed / 1 skipped; the minimal pair both ways;
the previously failing 6-test order now 167 passed; every test file in REVERSE
alphabetical order 369 passed / 2 skipped; two scrambled thirds green; full suite
369 / 2 and 370 / 1 with androidtvremote2; ruff clean.
2026-10-11 06:41:56 +01:00
James Mackay fbc72dfd9e docs: correct two stale claims about a tier and a red proof (t44 0b/0c)
media_player.py's module docstring still described the command path as
"protocol key push → ADB keyevent → external androidtv_remote", calling the last
tier "transitional; removed in Phase 4". t41 removed it, so the sentence
documented a path that no longer exists; it now names the two real tiers.

tests/test_module_coverage.py recorded the sweeps' red proof as the pre-t41 commit.
That commit cannot prove either sweep: at c588a7c ``helpers.py`` still DEFINED
``discover_adb_host`` (line 45, and button.py imported it successfully), so there
was no broken relative import to find — and that commit's ``ha_api_drift.py``
predates BOTH passes (no entity-contract check, no package-import check), so a
real-HA run there is clean: verified just now, exit 0, "no Home-Assistant API
drift (13 modules, 60 imports, 35 call arguments, 7 awaited HA calls)". The
docstring now says the proof is built rather than borrowed, which is what the two
red-proof tests actually do (a copy of the tree with the reference reverted).

Verified: 362 passed / 2 skipped, ruff clean.
2026-10-11 06:38:48 +01:00
James Mackay 3175582419 fix(config): degrade an unreachable Shield to the pairing form (t44 F1)
`_start_protocol_pairing` called androidtvremote2's cert generation and
`async_start_pairing` with no try/except, so `CannotConnect` propagated straight
out of `async_step_protocol_pair`. The caller already had the shape for a soft
failure — `_async_begin_protocol_pairing()` returns `(client, None) | (None,
error)` and `_async_restart_protocol_pairing` renders `errors["base"]` — the
exception simply bypassed it, and the protocol_pair form never appeared: not the
address field, not the "continue without pairing" escape.

That made it a default-path crash rather than an edge case: t41 turned the
protocol transport ON by default, so an asleep, rebooting or mis-addressed Shield
(whenever someone adds the integration, or skips ADB pairing into this step)
raised out of the flow instead of showing the form. The reviewer reproduced both
under real HA.

Now the step returns `(None, "protocol_unreachable")`, logs the address and the
library error, discards the half-built client so a retry starts clean, and renders
the same form the other failures render — with the address editable and the skip
option intact. `protocol_unreachable` is documented in strings.json AND
translations/en.json (a form error without copy shows a bare key in the UI).

Tests (tests/test_config_flow_ux.py::TestUnreachableShieldDuringPairing): the fake
client can now fail to start pairing, and five cases cover fresh setup, the
ADB-skip route into the protocol step, reconfigure, reauth and the skip path that
still finishes the entry — all as FORMS, never exceptions. Red proof on HEAD:
the same drive raises CannotConnectStubError out of async_step_user.

Verified: 361 passed / 2 skipped, 362 / 1 with androidtvremote2, ruff clean.
2026-10-11 06:37:18 +01:00
James Mackay 7608518d97 feat(release): same-day versions + manifest write-back so HACS sees every release (t42)
The evidence this fixes: v2026.10.09 was published while master's manifest still
read 2026.10.08, so HACS compared every install against a version that never
moved and offered nobody that update. A second release in the same UTC day was
also a silent no-op: the date tag already existed, the run resolved to the same
version again, and it stopped at "already tagged".

* scripts/determine-release-version.sh owns the decision, so it is tested rather
  than hoped for: the manifest version while it is untagged (bump it to force a
  release); else today's UTC date; else YYYY.MM.DD.N with the LOWEST untagged
  N >= 2 (a gap is reused, not burned). An already-released commit, and any
  version whose tag exists, are an explicit RELEASE=no instead of a failure. The
  script only ever reads the clock with `date -u`, and it emits
  VERSION/TAG/SOURCE/BUMP/RELEASE on stdout and into $GITHUB_OUTPUT.
* the workflow writes the resolved version into the manifest whenever it differs,
  commits it as "chore(release): v<VERSION> [skip ci]" with the existing bot
  identity, pushes HEAD:master, and only then tags THAT commit — so the artifact,
  its tag and the default branch agree. A branch-protection rejection fails the
  job with an instruction to bump the manifest manually; nothing is tagged first.
* post-push verification reads the manifest out of origin/master (the copy HACS
  fetches) and refuses to publish when it does not equal the released version.
* no self-trigger loop: the conventional [skip ci] marker on the bot commit plus a
  job-level `if:` that re-checks github.event.head_commit.message. The guard is the
  load-bearing part — it holds even where a Gitea version ignores the marker.
* preserved: manifest-wins precedence, the concurrency group without
  cancel-in-progress, notes from the commit range, zip asset upload and the
  409-tolerant publish path. RELEASE=no replaces the old `exists` output, so an
  already-tagged version still ends as a clean no-op.

Also fixes the data: master's manifest moves 2026.10.08 -> 2026.10.09, the latest
released tag, so the repo stops contradicting its own releases immediately rather
than only after the next successful run (which resolves to v2026.10.11 either way).

tests/test_release_versioning.py drives all of it: the four scenarios, the
lowest-free-suffix rule, UTC determinism under three timezones, the error paths,
the $GITHUB_OUTPUT handoff, and the workflow's own contract — textual, because the
fast suite has no YAML parser; the verify command parses the file with pyyaml.

Verified: 356 passed / 2 skipped, 357 / 1 with androidtvremote2, ruff clean,
release.yml valid, `bash -n` clean, and the script resolves the real repo to
v2026.10.11 (date, bump=yes). The workflow itself cannot run here (no Gitea
runner): its run: steps were extracted from the YAML and executed against scratch
repos with a bare origin, covering the bump+verify+tag path (commit subject, tag
target and tag manifest all agree), the inconsistent-manifest failure and the
rejected-push failure.
2026-10-11 06:32:13 +01:00
James Mackay aea9f9f9d2 test(config): pin the Jellyfin name tiers and the address redaction (t41)
Two acceptance items that had no test of their own:

* the Jellyfin auto-detect tiers. t41 replaced the entity-derived search key
  (the Android TV Remote entity's friendly_name) with the names setup already
  knows — the name the user typed, then the discovery device name. Three tests
  pin it, the third being the regression: a Jellyfin player matching the *atv*
  friendly_name, with a serving state, used to be auto-detected; now only the
  typed/discovered names search. Red proof on the pre-t41 tree (60d884b):
  `_find_jellyfin_entity(hass, "jellyfin shield")` DID match
  media_player.jellyfin_shield there.

* diagnostics redaction of the renamed address field. The G14 field list already
  carried "host" (alongside the legacy spellings), but the test only exercised
  the legacy keys — so a future edit could have dropped the live one unnoticed.
  The fixture and assertion now cover both spellings.

Verified: 337 passed / 2 skipped, 338 / 1 with androidtvremote2, ruff clean.
2026-10-11 06:26:42 +01:00
James Mackay 130dee4bc6 test(ci): sweep every module for dangling imports (t41)
Two defects shipped with a green suite: button.py imported a helper that had
been deleted (no harness ever loaded the file), and the drift sweep only judged
`homeassistant.*`, so a relative import of a name that no longer exists was
invisible to it. Both are now impossible to ship silently.

* tests/test_module_coverage.py loads EVERY *.py file of the package, in a clean
  interpreter, under the stub harness — exhaustive by construction (a new
  platform is covered the moment the file exists; there is no list and no
  special case). It asserts the reported set equals the files on disk, so a
  silently skipped module fails too;
* entity_stublib.load_entity_modules() now also loads button.py (the named gap);
  the sweep is what keeps that list from needing to be complete;
* ha_api_drift gained pass 5: the package's own relative imports. Pure AST,
  no homeassistant required, with its own entry point
  `scan_package_imports()` for the fast suite — it checks the target file exists
  and defines every imported name (submodule imports still resolve), instead of
  trusting that the modules a harness happened to load are the ones that matter;
* the file walk in both AST passes is recursive, and the non-vacuity floors moved
  with it (modules 10 → 20, relative_imports ≥ 100 — a shallow-glob regression
  trips the floor instead of looking green);
* two permanent red proofs, both on a copy of the tree with the reverted
  `from .helpers import discover_adb_host` reference in button.py: the stub-harness
  sweep fails with "FAIL button.py", and the static pass fails with
  "button.py:1: broken-relative-import: … not defined in helpers.py". Manual runs
  confirm the engine's other direction on the real pre-fix tree (c588a7c: exit 1
  with the t40 entity-contract finding) and exit 0 on HEAD.

No Home-Assistant API drift (23 modules, 58 imports, 148 relative imports, 36
call arguments, 6 awaited HA calls). Verified: 334 passed / 2 skipped, 335 / 1
with androidtvremote2, ruff clean.

Note (pre-existing, not introduced here): running a SUBSET of test files in an
order the suite was not written for (e.g. test_init.py alone after
test_standalone_setup.py) fails 3 import-step tests with
"ModuleNotFoundError: No module named 'voluptuous'" — the per-file stub scoping
assumes the suite's order. Verified identical on the pre-t41 tree (60d884b), so
it is left alone here.
2026-10-11 06:25:45 +01:00
James Mackay 151c49a0da docs(readme): replacement guide + a verified seed example (t41)
The README now answers the question a migrator actually has: what do I delete,
and what covers it afterwards.

* a new "Replacing androidtv / androidtv_remote + the glue" section: the
  back-up → remove-the-platform-YAML → remove-mediasession2mqtt → delete the old
  integrations → add this one → update automations checklist, plus a table
  mapping every retired piece to what serves it now. It notes that Wholphin
  publishes no MediaSession of its own and gets its now-playing from the ADB
  media session, so no bridge is needed for it either;
* the setup steps describe the real flow (name + one address, both transports on
  by default, ADB pairing then PIN pairing, each with a skip);
* the YAML seed section carries a complete, copy-pasteable block. It documents
  ONLY the keys `_yaml_seed_blocks` honours (name, host, adb_port, adb_enabled,
  and the still-accepted protocol_host/adb_host) and says explicitly that the
  rest lives on the Configure page — an example that lists ignored keys is worse
  than no example;
* the state-ladder section, the discovery bullet and the options list follow the
  two-transport, one-address reality.

`tests/test_readme.py` pins the doc/code contract: the example's keys are a
subset of what the seed reads, the example uses the current `host` key, and the
guide keeps naming the retired integrations and glues.

Verified: 326 passed / 2 skipped, ruff clean.
2026-10-11 06:21:40 +01:00
James Mackay fb481c3c14 feat(config): enable both native transports by default (t41)
The transports are ours now, so setup offers them ON rather than OFF: a fresh
form renders "Read state over ADB" and "Add the Nvidia Remote Protocol" checked,
with the Shield's address pre-filled by discovery (or typed by the user). The
schema default, the async_step_user fallback and the _create_entry fallback all
carry True, so the checkbox the user sees and the entry that gets written cannot
disagree — the t33 F-A lesson applied to the new defaults.

Still a choice, not a mandate:
* unchecking either toggle routes to the other transport's pairing step;
* each pairing step keeps its "continue without pairing" escape (the transport
  stays off until paired), so a passive entry is still one click away;
* no_state_source now means exactly "neither transport enabled" — and a payload
  that names neither is REJECTED rather than silently enabling both (fail
  closed; a real submission always carries the schema defaults).
* the YAML seed keeps t32 F4's shape rule: an address turns ADB on, a
  protocol-only block leaves it off, an explicit adb_enabled wins.

Verified: 323 passed / 2 skipped, 324 / 1 with androidtvremote2, ruff clean,
drift engine exit 0.
2026-10-11 06:20:41 +01:00
James Mackay 81f70585cd feat(config)!: migrate entries to v3 — one address, no androidtv key (t41)
ENTRY_VERSION = 3, declared once in const.py and reused as the flow's VERSION so
the stored shape and the migration cannot drift.

v2 → v3 mirrors the two shape changes:
* the duplicated address fields collapse into CONF_HOST — the protocol host
  wins, else the ADB host (both name the same Shield), reading options first and
  data second so a hand-written seed is covered too;
* the androidtv-entity key is dropped from data AND options with a logged note
  naming the dropped entity (the old value would only mislead now that nothing
  reads it).

A v1 entry still travels the whole way in one call (the v1→v2 protocol defaults
are applied, then the v3 collapse). Downgrades (version > 3) are rejected so HA
can offer a rollback, and the current version is a no-op.

Existing entries carrying the entity key keep working until the migration runs:
nothing reads it anymore (the c1 commit), which is exactly why the migration can
be a plain removal.

Test note: my first version of the version-parity pin called a bare
entity_stublib.install() and leaked the shallow stub set into the entity-module
loader of later tests (STATE_PAUSED/entity_available import failures). It now
uses the file's scoped harness, like every other test there.

Verified: 316 passed / 2 skipped, 317 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD (36 call arguments — one more awaited/checked call).
2026-10-11 06:19:36 +01:00
James Mackay 75ebeb40b0 refactor(config): one Shield IP field for both transports (t41)
The two address fields were the same Shield spelled twice, and every bug in the
zeroconf/IP-change path came from keeping them in sync. One field now:

* const.CONF_HOST replaces CONF_ADB_HOST / CONF_PROTOCOL_HOST in the setup form,
  the options page, the pairing forms, the reconfigure/reauth seeding and the
  description copy. ADB port 5555 stays the only port field;
* helpers.entry_host(entry) is the single resolver (options first, then data);
  resolve_adb_config and the protocol settings both go through it, so both
  transports read the same value at call time (the t32 F1 factory re-read keeps
  working: the NEXT built poller sees the new address);
* helpers.seed_host / seed_adb_enabled own the YAML seed's shape rule. A block
  may use `host` or the legacy protocol_host/adb_host keys — t41 keeps the old
  spelling working — and t32 F4's outcome is unchanged: a protocol-only block
  leaves ADB off, an address (or an explicit adb_enabled) turns it on;
* the zeroconf IP-change path collapses the t21-F2 per-host dance into one rule:
  the discovery address IS the entry's address, so the entry is updated when it
  differs and left alone when it matches. The blank-self-heal and
  deliberately-different-ADB-host cases are gone with the duplication — a
  second address field cannot express them anymore, and the tests now pin that
  (previously "an explicit override is never clobbered");
* the options page exposes the protocol toggle it never had (the page could turn
  ADB off but not the protocol);
* button.py (granted to this task) reads the entry's own address instead of
  another integration's entity.

The v3 entry migration that maps existing adb_host/protocol_host onto CONF_HOST
and drops the androidtv key lands in the next commit; HA runs migrations before
setup, so an existing entry is never read with the old shape.

Verified: 311 passed / 2 skipped, 312 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD. The UI-copy contract tests (every rendered field
has a label in strings.json AND translations/en.json, key trees identical, host
errors name the field) drove the string edits.
2026-10-11 06:18:22 +01:00
James Mackay 86aa2827a2 refactor(config): drop the androidtv media_player entity path end-to-end (t41)
The user ruling: this integration REPLACES both old integrations, so nothing may
depend on an androidtv/androidtv_remote entity anymore.

Removed:
* the setup-form field + its entity selector (CONF_ANDROIDTV_ENTITY is no longer
  offered, only read by the migration that drops it);
* the entity-derived unique_id derivation (_unique_id_from_atv_device, t29 G2)
  and, with it, every registry lookup for another integration's devices — the
  user step now sets NO unique_id at all; the protocol MAC learned during
  pairing is the identity;
* the Jellyfin auto-detect's second tier (the atv entity's friendly_name) — it
  now uses the entry name the user typed or the device name from discovery;
* the app-name and app-id tiers (media_player.app_name/app_id + the shared
  resolve_app_name chain, the Current App sensor, the sensor's state-change
  listener and its `_androidtv_state` helper) — the manager's store is the only
  source;
* the "transitional external entity" command tier in every path: `_cmd` and the
  pan-media_player service call are gone, and so is the fallback in _media_cmd,
  _remote_key_cmd, the §7 channel-digit sequence and the intent self-heal retry
  (an intent no transport served now surrenders to the truth instead of
  delegating);
* the entity-derived power signal (`_on_source_change`'s "device off clears the
  intent" check no longer watches another integration's entity; the power
  LADDER — protocol is_on → ADB wake → power switch — is unchanged).

turn_on/turn_off are native now: they send the protocol POWER key and, like the
remote entity, send nothing when the known state already matches (no superfluous
toggle). An ADB-only entry (protocol skipped) has no power write path — the
manager owns the socket and exposes no device-keyevent, which is what the old
delegation used to cover.

helpers.discover_adb_host — the one helper that derived a host from another
integration's config entry — is deleted; button.py (granted to this task) reads
the entry's own address fields instead of the entity, so the Pair ADB button
keeps working.

Present-day entries that still carry the key are unaffected (nothing reads it);
the v3 migration that drops it lands with the host unification.

Verified: 309 passed / 2 skipped, 310 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD (13 modules, 58 imports, 35 call arguments, 6
awaited HA calls) and 16 passed in the real-HA drift suite. The stub harness's
legacy service recorder became a watchdog: any HA service call from the media
player now fails the routing tests instead of being the expected outcome.
2026-10-11 06:09:43 +01:00
James Mackay 60d884b76a fix(availability): F3 — any usable transport keeps the entities up (t40)
Reported from a live install: with ADB enabled but unreachable and a paired,
healthy protocol transport, the media player went unavailable even though the
protocol still served power/app/volume.

The old rule was ``manager.connected or manager.awake is not None or not
manager.available`` — i.e. "a configured ADB manager that is down ⇒ the whole
entity is down" (t5-F2), which ignored transport B entirely. The ladder is now:

* protocol transport wired and not reported unreachable — usable;
* or ADB connected — usable;
* or fresh wake data (the poller has spoken) — usable;
* or a passive manager (no ADB configured at all) — usable, because nothing to
  transport is not a broken transport.

Unavailable only when a CONFIGURED transport is actively down and no usable data
exists. One implementation, ``entity_available`` (media_player.py), is used by the
media player, the remote (whose ladder previously duplicated a weaker version of
this rule) and the diagnostics payload.

Diagnostics grew ``transport.entity_available`` plus ``adb.configured`` so a
support dump answers "why is my entity unavailable?" — the ladder inputs and its
verdict are now in the same place.

Pinned by tests: the exact user case (ADB configured+down, protocol up ⇒
available), all-down (⇒ unavailable), wake-data rescue, connected ADB without a
protocol, the passive manager, a configured-but-down transport with no data, the
remote following the same ladder, and the diagnostics verdict.

Verified: 310 passed / 2 skipped, 311 passed / 1 skipped (androidtvremote2),
16 passed in the HA drift suite, ruff clean.
2026-10-11 06:02:46 +01:00
James Mackay dd23d01a88 fix(sensor): F2 — the Current App sensor shows the resolved app name (t40)
Reported from a live install: the sensor showed ``ar.tvplayer.tv`` while the
media player showed "TiviMate" — the same foreground app, two identities.

The sensor's ``native_value`` returned ``manager.foreground_app`` (the raw
package push) or ``running_apps[0]["package"]`` as-is, while the media player
resolved names through apps map → cached manifest label → androidtv entity tier.
Both now call ONE function, ``resolve_app_name`` (media_player.py), so the two
entities cannot disagree again; the sensor keeps the raw package as its
``app_id`` attribute and ``app_name`` carries the shown name.

Per the contract:
* apps map wins, then the cached manifest label, then the androidtv entity's
  ``app_name``, then the package text itself;
* a bare or unknown package falls back to the package text, NOT None (it used to
  be possible for the value to vanish while the app was plainly running);
* pinned by tests: map tier, label tier, entity tier, the ADB-recents source, the
  unknown-package fallback, and sensor == media player for the same inputs.

Verified: 302 passed / 2 skipped, ruff clean.
2026-10-11 06:02:30 +01:00
James Mackay be3abae3e7 test(ci): F4 — entity-contract pass in the drift sweep + real-HA state-write proof (t40)
The F1 blocker was invisible to every existing test: the stub harnesses call the
entity and replace ``async_write_ha_state``, so a non-property
``extra_state_attributes`` looked perfectly healthy. Two new gates close that
hole.

1. ``ha_api_drift.scan_entity_contracts()`` — a pure-AST pass (so it needs no
   homeassistant, unlike passes 1-3) that walks every class inheriting from an
   HA entity base, including our own intermediate ``NvidiaShieldSensorBase``, and
   flags any HA-READ attribute (``extra_state_attributes``, ``available``,
   ``state``, ``native_value``, ``media_*``, …) declared as a plain method
   instead of ``@property``. It is wired into ``scan()`` before the
   homeassistant gate and exported for the fast suite, so ONE implementation
   backs the sweep, the CLI red proof and tests/test_entity_contract.py (the t39
   F1 lesson).
   Red proof on the pre-fix tree:
     media_player.py:828: entity-attribute-is-a-method:
     NvidiaShieldMediaPlayer.extra_state_attributes is a plain method, but HA
     reads it as an attribute (``attr |= self.<name>``) — declare it @property
   …and HEAD is clean (13 modules, 60 imports, 35 call arguments, 7 awaited).

2. tests/test_ha_api_drift.py now builds each entity under REAL homeassistant
   (real ``PlatformData``, real ``async_write_ha_state``) and asserts HA's own
   ``__async_calculate_state`` succeeds and that the media player's app_id/
   app_name survive the merge — for the media player, both sensors, the remote
   and the pairing button. A coverage test compares that matrix against the
   sweep's classification, so a new entity class cannot quietly skip it.

Verified: 296 passed / 2 skipped (fast suite), 16 passed (drift suite under
Python 3.14 + homeassistant 2026.10.1), ruff clean; the entity-contract red
proof above reproduces exactly the reported line.
2026-10-11 06:02:22 +01:00
James Mackay acc7bc3d01 fix(media_player): F1 — extra_state_attributes must be a property (t40 blocker)
Reported from a live install: the entity never rendered, and every state write
raised ``TypeError: 'method' object is not iterable``.

Root cause, from HA's own code (homeassistant/helpers/entity.py,
``__async_calculate_state``):

    if extra_state_attributes := self.extra_state_attributes:
        attr |= extra_state_attributes

``attr`` is a dict, so ``|=`` needs a mapping — and this was declared as a plain
method while the sensors' were properties. Reproduced against the pre-fix tree
with real HA: instantiating the entity and calling ``async_write_ha_state()``
prints ``REPRODUCED TypeError: 'method' object is not iterable``; with the fix
the same call writes the state and the extra attributes through.

The stub suites could never see it: their harness replaces
``async_write_ha_state``, so the method was simply called and returned a dict.
tests/test_ha_api_drift.py now drives HA's real state calculation for every
entity, and the drift sweep grew an entity-contract pass (next commit).
2026-10-11 06:02:08 +01:00
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
37 changed files with 9597 additions and 934 deletions

No files matched your search

+114 -38
View File
@@ -1,10 +1,19 @@
# Publish a date-based release every time master moves. # Publish a date-based release every time master moves — and keep HACS able to
# see every one of them (t42).
# #
# Versioning is date-based: vYYYY.MM.DD. Priority rule — an unreleased `version` # Versioning is date-based: vYYYY.MM.DD, plus a same-day suffix (YYYY.MM.DD.N)
# declared in custom_components/nvidia_shield/manifest.json wins (bump the # for further releases in the same UTC day. The decision lives in
# manifest to force a specific release, e.g. after a meaningful change); otherwise # scripts/determine-release-version.sh so it can be tested locally:
# the version is today's date (UTC), so a plain push to master publishes the # the manifest `version` is untagged -> that version (bump it to force one)
# day's release. Repeated pushes on the same day are deduped by tag existence. # else today's UTC date is untagged -> vYYYY.MM.DD
# else -> vYYYY.MM.DD.N, lowest untagged N >= 2
#
# Why the write-back below exists: the resolved version is written into
# custom_components/nvidia_shield/manifest.json and committed BEFORE the tag, so
# the released artifact and its tag always agree. Without it HACS compares the
# installed version against a manifest that never moves — v2026.10.09 was
# published while master's manifest still said 2026.10.08, so the second release
# of that day was offered to nobody.
# #
# The Gitea release carries a nvidia_shield.zip asset (custom_components/ # The Gitea release carries a nvidia_shield.zip asset (custom_components/
# nvidia_shield/) so the integration is installable manually from any release. # nvidia_shield/) so the integration is installable manually from any release.
@@ -28,15 +37,28 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
permissions: permissions:
# The job token pushes the tag and creates the release. # The job token pushes the manifest commit and the tag, and creates the release.
contents: write contents: write
env: env:
ZIP_NAME: nvidia_shield.zip ZIP_NAME: nvidia_shield.zip
MANIFEST: custom_components/nvidia_shield/manifest.json
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# t42/t44 R3: this job pushes the write-back commit itself, and a run starting
# from that commit would release the same version twice. The guard is anchored to
# the release commit's SUBJECT (`chore(release): …`), not to the `[skip ci]`
# marker anywhere in the message: a developer commit whose body happens to
# mention [skip ci] must still release — with contains() it silently did not.
# The marker stays in the commit message because it is the convention Gitea
# itself may honour to skip the run outright (README documents that platform
# layer, and that the job guard is a no-op there rather than the trigger).
# workflow_dispatch has no head_commit, hence the first clause.
if: >-
github.event_name == 'workflow_dispatch' ||
!startsWith(github.event.head_commit.message || '', 'chore(release):')
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -45,61 +67,115 @@ jobs:
ref: master ref: master
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}
- name: Determine release version - name: Determine the release version
id: version id: version
run: | run: |
set -euo pipefail set -euo pipefail
MANIFEST_VERSION="$(jq -r .version custom_components/nvidia_shield/manifest.json)" # VERSION/TAG/SOURCE/BUMP/RELEASE go to stdout and, because GITHUB_OUTPUT
DATE_VERSION="$(date -u +%Y.%m.%d)" # is set inside a step, to this job's outputs too. RELEASE=no means the
# An unreleased manifest version wins over today's date. # version already exists: tag and publish nothing.
if git rev-parse -q --verify "refs/tags/v${MANIFEST_VERSION}" >/dev/null; then scripts/determine-release-version.sh
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 - name: Write the version back into the manifest
if: steps.version.outputs.exists == 'no' id: bump
if: steps.version.outputs.RELEASE == 'yes' && steps.version.outputs.BUMP == 'yes'
env:
VERSION: ${{ steps.version.outputs.VERSION }}
run: | run: |
set -euo pipefail set -euo pipefail
git config user.name "gitea-actions" git config user.name "gitea-actions"
git config user.email "actions@notexpectedyet.com" 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 }}" python3 - "$MANIFEST" "$VERSION" <<'PY'
import json, pathlib, sys
path, version = pathlib.Path(sys.argv[1]), sys.argv[2]
data = json.loads(path.read_text(encoding="utf-8"))
data["version"] = version
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
PY
git add "$MANIFEST"
git commit -m "chore(release): v${VERSION} [skip ci]"
# Never tag a commit whose manifest the default branch does not carry:
# a branch protection rule (or a push that landed underneath this run)
# rejecting this push makes the release impossible, so stop loudly and
# say what to do instead of publishing an inconsistent artifact.
if ! git push origin HEAD:master; then
echo "::error::could not push the release commit for v${VERSION}: the" \
"default branch rejected it (branch protection, or it moved during" \
"this run). Nothing was tagged or published. Bump ${MANIFEST} to" \
"${VERSION} manually, commit it, and re-run this workflow." >&2
exit 1
fi
- name: Verify the released manifest on master
if: steps.version.outputs.RELEASE == 'yes'
env:
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
set -euo pipefail
# Read the manifest from the REMOTE default branch, not from the
# workspace: that is the copy HACS fetches, and it has to already carry
# the version about to be tagged.
git fetch --quiet origin master
REMOTE_VERSION="$(git show "origin/master:${MANIFEST}" | jq -r '.version')"
if [ "$REMOTE_VERSION" != "$VERSION" ]; then
echo "::error::master's manifest says ${REMOTE_VERSION} but v${VERSION}" \
"is being released — refusing to publish an inconsistent artifact." \
"Re-run once master settles." >&2
exit 1
fi
echo "master's manifest is ${REMOTE_VERSION}, matching v${VERSION}"
- name: Create tag
if: steps.version.outputs.RELEASE == 'yes'
env:
TAG: ${{ steps.version.outputs.TAG }}
run: |
set -euo pipefail
git config user.name "gitea-actions"
git config user.email "actions@notexpectedyet.com"
# HEAD is the commit the manifest was just verified on: the bumped one
# when a write-back was needed, else the pushed commit itself. The tag
# and the artifact therefore name the same version.
git tag -a "${TAG}" -m "Release ${TAG}"
git push origin "refs/tags/${TAG}"
- name: Build the release zip - name: Build the release zip
if: steps.version.outputs.exists == 'no' if: steps.version.outputs.RELEASE == 'yes'
run: | run: |
set -euo pipefail set -euo pipefail
cd custom_components cd custom_components
zip -r "../${ZIP_NAME}" nvidia_shield zip -r "../${ZIP_NAME}" nvidia_shield
- name: Publish the release - name: Publish the release
if: steps.version.outputs.exists == 'no' if: steps.version.outputs.RELEASE == 'yes'
env: env:
VERSION: ${{ steps.version.outputs.version }} VERSION: ${{ steps.version.outputs.VERSION }}
TAG: ${{ steps.version.outputs.tag }} TAG: ${{ steps.version.outputs.TAG }}
BUMP: ${{ steps.version.outputs.BUMP }}
SERVER: ${{ github.server_url }} SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
TOKEN: ${{ secrets.GITEA_TOKEN }} TOKEN: ${{ secrets.GITEA_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
# Notes: subject lines of the commits in the released range. # Notes: subject lines of the commits in the released range — the
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" # commits a DEVELOPER wrote. With a write-back (BUMP=yes) the tag points
if [ -n "$PREV" ]; then # at the bot's own commit, so the range has to stop at its parent or every
RANGE="${PREV}..${TAG}" # release would open with "chore(release): vX [skip ci]". Without one the
# tag is the pushed commit itself and belongs in the notes (t44 R1).
if [ "$BUMP" = "yes" ]; then
NOTES_END="${TAG}^"
else else
RANGE="${TAG}" NOTES_END="${TAG}"
fi
PREV="$(git describe --tags --abbrev=0 "${NOTES_END}^" 2>/dev/null || true)"
if [ -n "$PREV" ]; then
RANGE="${PREV}..${NOTES_END}"
else
RANGE="${NOTES_END}"
fi fi
{ {
echo "Release ${TAG}" echo "Release ${TAG}"
+86 -2
View File
@@ -1,4 +1,4 @@
# Quality gate: lint + unit tests. # Quality gate: lint + unit tests + the HA API-drift sweep.
# #
# The test suite is stdlib-only (parsers, intents, running-apps) and runs the # The test suite is stdlib-only (parsers, intents, running-apps) and runs the
# vendored ha_adb_common helpers without installing Home Assistant — see # vendored ha_adb_common helpers without installing Home Assistant — see
@@ -6,6 +6,11 @@
# volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real # volume/TYPED-dict tests execute (tests/test_protocol_transport.py). Real
# firmware captures live in tests/fixtures # firmware captures live in tests/fixtures
# (how to capture more: docs/RESEARCH_SHIELD.md §8). # (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 name: Test
@@ -21,10 +26,89 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install test tooling - name: Install test tooling
run: python3 -m pip install --quiet pytest pytest-asyncio ruff androidtvremote2 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 - name: Lint
run: ruff check custom_components tests run: ruff check custom_components tests
- name: Unit tests - name: Unit tests
run: python3 -m pytest tests -q 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
+297 -25
View File
@@ -9,7 +9,9 @@ This is a **Shield-only fork** of [ltomes/ha-nvidia-shield](https://github.com/l
ADB as the state source, and no dependency on separate `androidtv` / `androidtv_remote` ADB as the state source, and no dependency on separate `androidtv` / `androidtv_remote`
integrations. integrations.
> **Status:** early fork, actively rebuilt. Releases are date-based (`v2026.10.08`). > **Status:** early fork, actively rebuilt. Releases are date-based
> (`vYYYY.MM.DD`, plus `vYYYY.MM.DD.N` for a second release in the same day) —
> see [Releases](#releases) for how a push becomes one.
> Expect rough edges until the MVP phases land. The build-out plan is in > 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/MVP_PLAN.md`](docs/MVP_PLAN.md); device research is in
> [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md). > [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md).
@@ -79,10 +81,15 @@ 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 no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 6–7 plus
the audio hint are what keep it visible. the audio hint are what keep it visible.
**Standalone setup** accepts any one of the three state sources — the Android **Standalone setup** offers both state sources at once — the Nvidia Remote
TV Remote entity, the Nvidia Remote Protocol (PIN pairing), or ADB Protocol (PIN pairing) and ADB media-session mode — enabled by default, with the
media-session mode (ADB enabled + host). The `no_state_source` form error Shield's address pre-filled when discovery found it. Either can be turned off,
fires only when none of the three is present; ADB-only setups are valid. and each pairing step can be skipped (that transport stays off until you pair
it), so ADB-only, protocol-only and fully passive entries are all one click
apart. The `no_state_source` form error fires only when neither is enabled.
There is no other integration in the loop: one address, two transports, and the
integration reads the device itself.
### Channel entry options ### Channel entry options
@@ -92,6 +99,43 @@ the inter-digit **delay** (0.05–1.0 s, default 0.1) and the optional
behavior). Both are consumed by `play_media` with behavior). Both are consumed by `play_media` with
`media_content_type: channel`. `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. The UI is
the primary path; the block exists so a provisioning/fleet setup can supply the
address and the transport choice.
A complete, copy-pasteable block (every key is optional except an address):
```yaml
nvidia_shield:
- name: Living Room # the entry's title (default: NVIDIA Shield)
host: 10.0.0.31 # the Shield's IP — the one address field
adb_port: 5555 # optional; the standard ADB port
adb_enabled: true # optional; the block's shape decides (see below)
```
`host` is the current key. `protocol_host` and `adb_host` are **still accepted**
(protocol first, then ADB) so an existing `configuration.yaml` keeps seeding:
the address is a prefill hint either way, and the PIN/ADB pairing still happens
in the flow. `adb_enabled` defaults to what the block implies — an address under
`host`/`adb_host` marks ADB as part of the block, a protocol-only block leaves it
off — and an explicit `adb_enabled` wins over the shape. Blocks without any
address are ignored with a warning.
The seed carries the address, the ADB toggle and the entry's name; everything
else (Jellyfin, receiver, power switch, app name/icon maps, channel timing, the
`adb_command` service) is set on the entry's **Configure** page after setup, or
during the interactive flow.
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).
### Raw `adb_command` service (opt-in) ### Raw `adb_command` service (opt-in)
When enabled in the integration's options (`adb_command_enabled`, **default When enabled in the integration's options (`adb_command_enabled`, **default
@@ -104,24 +148,76 @@ documented, opt-in escape hatch for debugging and automation, registered at
setup and unregistered when the last participating entity unloads. It setup and unregistered when the last participating entity unloads. It
rejects (logs a warning, sends nothing) when the ADB connection is down. rejects (logs a warning, sends nothing) when the ADB connection is down.
### Migration from mediasession2mqtt + universal-player glue ## Replacing androidtv / androidtv_remote + the glue
The `mediasession2mqtt` bridge sensors and the universal media player YAML This fork replaces the old `androidtv` media player, the `androidtv_remote`
templates the fork replaces are retired by the ladder above: the media player integration and the surrounding YAML glue — there is nothing to keep alongside
entity already carries the DAG attributes (`media_dag_running_apps` with it: the entry's own two transports cover the whole device.
foreground/last-known enrichment, `media_dag_now_playing` with its source
tag) that media-zone orchestration consumed from those sensors. See the 1. **Back up** your `configuration.yaml` and note the Shield's IP address.
appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) for the 2. **Remove the old platform YAML** — the `media_player:` blocks using
retirement mapping and the universal-player glue it replaces. `platform: androidtv` or `platform: androidtv_remote`, any `universal` media
player template that fanned them in, and the `remote:`/`switch:` shims you
added around them.
3. **Remove the `mediasession2mqtt` bridge** — its sensors and the MQTT topics
feeding them are replaced by this integration (table below).
4. **Delete the old integrations** in Settings > Devices & Services, then
**restart** Home Assistant.
5. **Add this integration** — Settings > Devices & Services > Add Integration >
"NVIDIA Shield" — or seed it from `configuration.yaml` with the block above,
then walk the pairing steps.
6. **Update your automations and dashboards** to the new entities:
`media_player.<entry>`, `remote.<entry>`, `sensor.<entry>_current_app`,
`sensor.<entry>_now_playing`, and the `media_dag_*` attributes.
What each retired piece used to provide, and what covers it now:
| Retired piece | Covered by |
|---------------|------------|
| `androidtv` playback state | the ADB media-session state ladder (`playing` / `paused` / `idle` / `off`) |
| `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the running app |
| `androidtv_remote` power, volume and key commands | the protocol transport: the `remote` entity, `media_player` volume, `KEYCODE_*` commands |
| `androidtv_remote` availability | the availability contract (protocol `is_on`, ADB awake, power switch) |
| `universal` media player templates | the media player entity's own attributes plus the `media_dag_*` attributes |
| `mediasession2mqtt` sensors | `sensor.current_app`, `sensor.now_playing` and the `media_dag_*` attributes |
**Wholphin** publishes no MediaSession metadata of its own; its now-playing
comes from the ADB media session — the same tier every other app uses — so it
needs no bridge either.
The appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) holds the
full retirement mapping this section summarises.
## Releases ## Releases
- **Date-based versioning**: `vYYYY.MM.DD` (e.g. `v2026.10.08`). - **Date-based versioning**: `vYYYY.MM.DD`, and `vYYYY.MM.DD.N` (N from 2) for a
second release in the same UTC day.
- Pushes to `master` on [Gitea](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield) - Pushes to `master` on [Gitea](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield)
run the release workflow (`.github/workflows/release.yml`): run the release workflow (`.gitea/workflows/release.yml`):
- A plain push publishes the current day's release (same-day pushes are deduped). - A plain push publishes today's release. If that date is already tagged it takes
- Bumping `version` in `custom_components/nvidia_shield/manifest.json` forces that the lowest free `.N`, so a gap left by an earlier failure is reused rather than
specific version's release before the date fallback kicks in. skipped.
- Setting `version` in `custom_components/nvidia_shield/manifest.json` to an
untagged value releases exactly that version instead.
- **The release commit.** When the resolved version differs from the manifest, the
workflow writes it back, commits `chore(release): v<VERSION> [skip ci]`, pushes it
to `master`, and tags *that* commit. It has to do this: HACS compares the installed
version against the manifest on the default branch, so a release that only ever
existed as a tag is offered to nobody (v2026.10.09 shipped that way).
- Because the tag names the bot commit, `git describe --tags` on a *developer*
commit reports the **previous** release. That is expected, not a missing tag.
- Bump it by hand instead and the tag lands on your own commit: set `version` in
the manifest, commit, push. The workflow then has nothing to write back (it sees
an untagged manifest version and tags your commit).
- **`[skip ci]`**: the bot commit carries it, and the workflow's job additionally
checks the commit *subject* (`chore(release): …`), so a write-back cannot start a
second release. Both only recognise release commits — a normal commit whose message
body merely mentions `[skip ci]` can still be skipped by Gitea's own handling, so
keep the marker out of ordinary commit messages.
- The workflow refuses to publish unless the default branch's manifest already carries
the version it is about to tag, and fails loudly (with a "bump the manifest by hand"
instruction and nothing tagged) if the write-back push is rejected, for example by
branch protection.
- Releases land on Gitea with the tag and a `nvidia_shield.zip` asset for manual - Releases land on Gitea with the tag and a `nvidia_shield.zip` asset for manual
installs. (No automatic mirroring — see the HACS note below.) installs. (No automatic mirroring — see the HACS note below.)
@@ -147,10 +243,15 @@ directory, restart, then add the integration from **Settings → Devices & Servi
## Configuration ## Configuration
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield" 1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
2. Select the Android TV Remote entity for your Shield 2. Give it a name and its address — the Shield's IP, pre-filled when discovery
3. Enable ADB if you want now-playing from all apps found the device
4. If ADB is enabled, the next step will pair — approve the prompt on the Shield 3. Both transports are on by default: **Read state over ADB** (now-playing from
5. Optionally configure Jellyfin, receiver, power switch every app) and **Add the Nvidia Remote Protocol** (power, availability, app
push, volume readout). Uncheck what you do not want.
4. If ADB is enabled, the next step pairs it — approve the prompt on the Shield
5. The remote protocol pairs next — type the PIN shown on the Shield, or
continue without pairing (that transport stays off until you pair it)
6. Optionally configure Jellyfin, receiver, power switch
### ADB Pairing ### ADB Pairing
@@ -160,20 +261,191 @@ device page in HA.
Requirements: Settings > Developer options > Network debugging enabled on the Shield. 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 address is updated when it 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 ### Options
- **Jellyfin Media Player** — auto-detected or manually selected - **Jellyfin Media Player** — auto-detected or manually selected
- **Receiver + Input** — for media-zone coordination - **Receiver + Input** — for media-zone coordination
- **Power Switch** — smart plug entity - **Power Switch** — smart plug entity
- **ADB Host/Port** — override auto-detection if needed - **Read state over ADB / Add the Nvidia Remote Protocol** — turn either
transport on or off
- **Shield address / ADB port** — the Shield's IP, and the ADB port if you
changed it from 5555
- **Command scripts** — one optional script per command, plus one for the remote
entity; see below
### Command scripts (per-command overrides)
Some commands need hardware this integration deliberately does not drive: a speaker
switch that owns the volume, an AVR input that has to move with the Shield, a mute
that has to hit two devices. For those, the Configure page takes a **script per
command**:
| Command | Runs when | Variables passed |
| --- | --- | --- |
| `turn_on`, `turn_off` | turn on/off, from the media player *or* the remote | — |
| `volume_up`, `volume_down` | volume step | — |
| `volume_set` | absolute volume (there is no native path — the protocol has VOLUME_UP/DOWN/MUTE only) | `level` |
| `volume_mute` | mute/unmute | `is_volume_muted` |
| `select_source` | source selection (the app list drives it natively; a script overrides) | `app_name` |
A command with a script calls the script **instead of** the protocol/ADB path, never
both. Left unset — the default — every command behaves exactly as it did before, and
clearing a selector on the Configure page restores that. Two of the seven appear as
services only when there is something to do: `volume_set`, which has no native path at
all, and `select_source`, which is offered once the integration has sources to offer
(the app list below) — or a script.
The scripts are ordinary Home Assistant scripts. This block covers all three awkward
cases: a speaker switch owning the volume, an AVR owning the input, and a mute that
has to hit the switch as well.
```yaml
script:
shield_volume: # select as "Volume set script"
alias: Shield volume (speaker switch)
sequence:
- action: media_player.volume_set
target:
entity_id: media_player.living_room_speaker
data:
volume_level: "{{ level }}"
shield_source: # select as "Select source script"
alias: Shield source (AVR)
sequence:
- action: media_player.select_source
target:
entity_id: media_player.avr
data:
source: "{{ app_name }}"
shield_mute: # select as "Mute script"
alias: Shield mute (speaker switch)
sequence:
- choose:
- conditions: "{{ is_volume_muted }}"
sequence:
- action: switch.turn_off
target:
entity_id: switch.living_room_speaker
default:
- action: switch.turn_on
target:
entity_id: switch.living_room_speaker
```
Then set `script.shield_volume` as **Volume set script**, `script.shield_source` as
**Select source script** and so on on the Configure page. Scripts are set there, not
in the YAML seed above — the seed only carries the address and the transport choice.
A script that fails surfaces as a translated error on the service call that triggered
it (the command is not retried over protocol/ADB), because a silent no-op is the one
outcome that leaves you guessing.
### Sources (the app list in a dropdown)
The media player's source list is your **Apps** map — the same map the Browse tree and
app-name resolution use. With no map configured it falls back to a small built-in list
(TiviMate, Wholphin, Netflix, YouTube), so a universal-media-player card always has
something to show:
```yaml
# a media-player card, or anything that reads source_list
type: media-control
entity: media_player.living_room_shield
```
- **`source_list`** — the app names. They come from the map (in the order it lists
them), so editing the map renames the dropdown entries.
- **`source`** — the app currently on screen, resolved exactly like the Current App
sensor: your map first, then the cached device label, then the raw package.
- **`media_player.select_source`** — picks one **by name** and launches it over the
protocol, the same path `play_media` uses for apps. A raw package id works too (that
is what the Browse tree publishes), names match case-insensitively, and a name that
is not in the list is an error rather than a silent no-op:
```yaml
action: media_player.select_source
target:
entity_id: media_player.living_room_shield
data:
source: TiviMate
```
Selecting a source is sometimes more than launching an app — an AVR input, a speaker
switch. That is what the **Select source script** above is for: with one configured it
replaces the native launch entirely.
#### Known limits (the Apps map)
The map is your own override, so two shapes are accepted rather than papered over:
- **Names differing only by case** (`com.a=Dup`, `com.b=dup`) offer **one** dropdown
row — the first spelling — because name matching is case-insensitive. The shadowed
app is not lost: the Browse tree still lists it under its own package id, and
`media_player.select_source` accepts that id.
- A **padded value** typed by hand (`com.x= Netflix `) is shown cleaned in the
dropdown and the Browse tree, but `app_name`, `source` and the Current App sensor
display the map value **verbatim** — so `source` can be a string that is not in
`source_list`. A blank value falls through to the raw package instead.
### Remote command script
`remote.send_command` sends protocol keys: repeats, long presses (`START_LONG`/
`END_LONG`) and inter-repeat delays. Some setups need that call to go somewhere else —
an IR bridge, a macro, a second device — so one optional **Remote command script**
replaces the whole key path when it is set. It receives the service's own parameters,
named as they are on the service:
| Variable | Meaning |
| --- | --- |
| `command` | the list of key commands as submitted, e.g. `["HOME", "KEYCODE_DPAD_CENTER"]` |
| `num_repeats` | how many times to send them (default `1`) |
| `delay_secs` | the delay between repeats in seconds (default `0.0`) |
| `hold_secs` | the requested long-press duration (default `0.0`) — the script owns the key semantics, so it decides what "hold" means |
```yaml
script:
shield_remote: # select as "Remote command script"
alias: Shield remote (IR bridge)
sequence:
- action: remote.send_command
target:
entity_id: remote.living_room_bridge
data:
command: "{{ command }}" # a list: pass it through
num_repeats: "{{ num_repeats }}"
delay_secs: "{{ delay_secs }}"
hold_secs: "{{ hold_secs }}"
```
All four variables are always present, including their defaults, so a script can rely
on the shape. Unset — the default — nothing changes: keys go out over the protocol
exactly as before.
## Entities ## Entities
| Entity | Type | Description | | Entity | Type | Description |
|--------|------|-------------| |--------|------|-------------|
| Media Player | `media_player` | Main entity with playback controls and metadata | | Media Player | `media_player` | Main entity with playback controls and metadata |
| Current App | `sensor` | Currently running app | | Current app (`sensor.current_app`) | `sensor` | Currently running app |
| Now Playing | `sensor` | Formatted now-playing string | | Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string |
| Pair ADB | `button` | Initiate or re-pair ADB connection | | Pair ADB | `button` | Initiate or re-pair ADB connection |
## Development ## Development
+138 -22
View File
@@ -13,6 +13,7 @@ passive until the user pairs via the Pair ADB button.
import logging import logging
import os import os
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -21,18 +22,90 @@ from .adb_session import AdbMediaSessionPoller
from .connection import ShieldConnectionManager from .connection import ShieldConnectionManager
from .const import ( from .const import (
CONF_ADB_ENABLED, CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY, CONF_ANDROIDTV_ENTITY,
CONF_HOST,
CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST, CONF_PROTOCOL_HOST,
DEFAULT_ADB_PORT,
DEFAULT_NAME,
DOMAIN, DOMAIN,
ENTRY_VERSION,
SERVICE_ADB_COMMAND,
)
from .helpers import (
entry_host,
protocol_cert_paths,
resolve_adb_config,
seed_adb_enabled,
seed_host,
) )
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, 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 seed_host(block):
_LOGGER.warning(
"nvidia_shield YAML block without an address 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. t41: the address is ONE
# field (`host`, or a legacy protocol_host/adb_host).
seed = {
"name": name,
CONF_ADB_ENABLED: seed_adb_enabled(block),
CONF_HOST: seed_host(block),
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
}
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): async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
"""Poller factory when ADB is configured AND usable today, else None. """Poller factory when ADB is configured AND usable today, else None.
@@ -66,7 +139,18 @@ async def _build_poller_factory(hass: HomeAssistant, entry: ConfigEntry):
) )
return None return None
return lambda: AdbMediaSessionPoller(adb.host, adb.port, adb.key_path) # 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -94,23 +178,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True return True
async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry): async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
"""Manager protocol_settings when the transport is enabled & resolvable. """Manager protocol_settings when the transport is enabled & resolvable.
Falls back to the androidtv-remote host discovery (the ADB host fallback) The address is the entry's own single field (t41). Nothing is derived from
until the user pairs, after which the explicit protocol host persists. another integration.
""" """
if not entry.options.get(CONF_PROTOCOL_ENABLED, False): if not entry.options.get(CONF_PROTOCOL_ENABLED, False):
return None return None
host = entry.options.get(CONF_PROTOCOL_HOST) or discover_adb_host( host = entry_host(entry)
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
)
if not host: if not host:
_LOGGER.info( _LOGGER.info(
"Protocol enabled for entry %s but no host resolvable — continuing " "Protocol enabled for entry %s but no host resolvable — continuing "
@@ -129,23 +209,62 @@ async def _build_protocol_settings(hass: HomeAssistant, entry: ConfigEntry):
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate v1 entries to v2 (adds the remote-protocol fields). """Migrate an older config entry to the current version.
ADB options carry over untouched; the ADB-host discovery fallback keeps v1 → v2 added the remote-protocol fields (ADB options carried over
serving as the protocol host until pairing populates it. Downgrades untouched). v2 → v3 (t41) mirrors the two changes in the stored shape:
(version > 2) are rejected so HA offers a rollback.
* the duplicated address fields collapse into the single CONF_HOST — the
protocol host wins, else the ADB host, since both name the same Shield;
* the androidtv-entity key goes. This integration owns the whole stack now:
the entity it used to configure is neither a state source nor an app-name
or power tier, so keeping the value would only mislead.
Downgrades (version > ENTRY_VERSION) are rejected so HA offers a rollback.
""" """
if config_entry.version > 2: if config_entry.version > ENTRY_VERSION:
return False return False
if config_entry.version >= ENTRY_VERSION:
return True
options = {**config_entry.options}
data = {**config_entry.data}
if config_entry.version < 2: if config_entry.version < 2:
options = {**config_entry.options}
options.setdefault(CONF_PROTOCOL_ENABLED, False) options.setdefault(CONF_PROTOCOL_ENABLED, False)
options.setdefault(CONF_PROTOCOL_HOST, "")
hass.config_entries.async_update_entry( if not options.get(CONF_HOST):
config_entry, options=options, version=2 options[CONF_HOST] = (
options.get(CONF_PROTOCOL_HOST)
or options.get(CONF_ADB_HOST)
or data.get(CONF_PROTOCOL_HOST)
or data.get(CONF_ADB_HOST)
or ""
) )
dropped_entity = options.pop(CONF_ANDROIDTV_ENTITY, None)
dropped_entity = data.pop(CONF_ANDROIDTV_ENTITY, None) or dropped_entity
for legacy_key in (CONF_PROTOCOL_HOST, CONF_ADB_HOST):
options.pop(legacy_key, None)
data.pop(legacy_key, None)
hass.config_entries.async_update_entry(
config_entry, data=data, options=options, version=ENTRY_VERSION
)
if dropped_entity:
_LOGGER.info( _LOGGER.info(
"Migrated %s config entry to version 2", config_entry.title "%s: config entry migrated to version %d — dropped the androidtv "
"entity %s (this integration reads the Shield directly; the entry "
"needs no external media_player)",
config_entry.title,
ENTRY_VERSION,
dropped_entity,
)
else:
_LOGGER.info(
"Migrated %s config entry to version %d",
config_entry.title,
ENTRY_VERSION,
) )
return True return True
@@ -164,6 +283,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok 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)
+6 -7
View File
@@ -5,19 +5,17 @@ import logging
from homeassistant.components.button import ButtonEntity from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .adb_session import AdbMediaSessionPoller, ensure_adb_key from .adb_session import AdbMediaSessionPoller, ensure_adb_key
from .const import ( from .const import (
CONF_ADB_ENABLED, CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT, CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY,
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
DOMAIN, DOMAIN,
) )
from .helpers import discover_adb_host from .helpers import entry_host
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -36,6 +34,7 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = "Pair ADB" _attr_name = "Pair ADB"
_attr_entity_category = EntityCategory.CONFIG
_attr_icon = "mdi:link-variant" _attr_icon = "mdi:link-variant"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -54,9 +53,9 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
async def async_press(self) -> None: async def async_press(self) -> None:
"""Attempt ADB connection. The Shield will show a pairing prompt.""" """Attempt ADB connection. The Shield will show a pairing prompt."""
host = self._entry.options.get(CONF_ADB_HOST) or discover_adb_host( # t41: the entry's own single address — never derived from another
self.hass, self._entry.data.get(CONF_ANDROIDTV_ENTITY) # integration's entity.
) host = entry_host(self._entry)
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT) port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
if not host: if not host:
File diff suppressed because it is too large. Load diff
@@ -215,6 +215,9 @@ class ShieldConnectionManager:
self._logcat_disabled = False self._logcat_disabled = False
self._logcat_failures: list[float] = [] self._logcat_failures: list[float] = []
self._logcat_last_pkg: str | None = None self._logcat_last_pkg: str | None = None
# G10 (t30): protocol device identity captured once per session —
# (name, manufacturer, model, sw) for the registry's device card.
self.protocol_device_data: tuple[str, str, str, str] | None = None
self._logcat_last_stamp: float = -1e9 self._logcat_last_stamp: float = -1e9
# Injectable ladder sleep (t26: deterministic tests / future alt-floors) # Injectable ladder sleep (t26: deterministic tests / future alt-floors)
self._logcat_ladder_sleep = asyncio.sleep self._logcat_ladder_sleep = asyncio.sleep
@@ -565,6 +568,24 @@ class ShieldConnectionManager:
self._app_label_requests.add(package) self._app_label_requests.add(package)
return None return None
async def async_capture_protocol_device_data(self) -> None:
"""Capture (name, manufacturer, model, sw) once (t30 G10)."""
if self._protocol is None or self.protocol_device_data is not None:
return
try:
info = self._protocol.device_info or {} # {manufacturer, model, sw_version}
name, _mac = await asyncio.wait_for(
self._protocol.async_get_name_and_mac(), timeout=10.0
)
self.protocol_device_data = (
name,
info.get("manufacturer", "Google"),
info.get("model", "Shield TV"),
info.get("sw_version", ""),
)
except Exception as err:
_LOGGER.debug("protocol device info unavailable: %s", err)
async def _fetch_app_label(self, package: str) -> None: async def _fetch_app_label(self, package: str) -> None:
"""One `pm dump` label fetch; failure falls through silently.""" """One `pm dump` label fetch; failure falls through silently."""
poller = self._poller poller = self._poller
@@ -997,6 +1018,14 @@ class ShieldConnectionManager:
foreground = await poller.poll_foreground() foreground = await poller.poll_foreground()
if foreground: if foreground:
self.store.set(FIELD_FOREGROUND, foreground) self.store.set(FIELD_FOREGROUND, foreground)
# G10: capture the protocol device identity for the registry card
if self._protocol is not None:
self._tasks.append(
self._create_task(
self.async_capture_protocol_device_data(),
"protocol_device_capture",
)
)
# P5-1: manifest labels resolve on demand, one package per cycle # P5-1: manifest labels resolve on demand, one package per cycle
self._drain_app_label_requests() self._drain_app_label_requests()
# P5-3: ensure the (optional, default-off) logcat fast-path runs # P5-3: ensure the (optional, default-off) logcat fast-path runs
+88 -2
View File
@@ -12,6 +12,16 @@ from .ha_adb_common.const import ( # noqa: F401
DOMAIN = "nvidia_shield" DOMAIN = "nvidia_shield"
# t41: the INTEGRATION's address field — one Shield IP for the whole stack
# (protocol + ADB). The two keys below it are LEGACY: a YAML seed may still
# carry them and the v3 entry migration maps them onto CONF_HOST, but no
# runtime code reads them.
CONF_HOST = "host"
CONF_ADB_HOST = "adb_host"
CONF_PROTOCOL_HOST = "protocol_host"
# t41 legacy: dropped from entries by the v3 migration (the androidtv entity
# path it configured is gone), never read at runtime.
CONF_ANDROIDTV_ENTITY = "androidtv_entity" CONF_ANDROIDTV_ENTITY = "androidtv_entity"
CONF_JELLYFIN_ENTITY = "jellyfin_entity" CONF_JELLYFIN_ENTITY = "jellyfin_entity"
CONF_RECEIVER_ENTITY = "receiver_entity" CONF_RECEIVER_ENTITY = "receiver_entity"
@@ -19,13 +29,15 @@ CONF_RECEIVER_INPUT = "receiver_input"
CONF_POWER_ENTITY = "power_entity" CONF_POWER_ENTITY = "power_entity"
CONF_ADB_ENABLED = "adb_enabled" CONF_ADB_ENABLED = "adb_enabled"
CONF_ADB_STORE_APP_INFO = "adb_store_app_info" CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
CONF_ADB_HOST = "adb_host"
CONF_ADB_PORT = "adb_port" CONF_ADB_PORT = "adb_port"
# Remote-protocol transport (androidtvremote2) — Phase 2 # Remote-protocol transport (androidtvremote2) — Phase 2
CONF_PROTOCOL_ENABLED = "protocol_enabled" CONF_PROTOCOL_ENABLED = "protocol_enabled"
CONF_PROTOCOL_HOST = "protocol_host"
CONF_PAIRING_CODE = "pairing_code" 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_API_PORT = 6466
DEFAULT_PROTOCOL_PAIR_PORT = 6467 DEFAULT_PROTOCOL_PAIR_PORT = 6467
@@ -43,6 +55,11 @@ STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
DEFAULT_NAME = "Nvidia Shield" DEFAULT_NAME = "Nvidia Shield"
# Config-entry schema version (t41): v1 → v2 added the remote-protocol fields,
# v2 → v3 collapsed the two address fields into CONF_HOST and dropped the
# androidtv-entity key. One constant so the flow and the migration cannot drift.
ENTRY_VERSION = 3
# Emotiva DAG attribute we read to get available inputs # Emotiva DAG attribute we read to get available inputs
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map" ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
@@ -143,3 +160,72 @@ SYSTEM_MEDIA_PACKAGES = [
"com.android.tv.settings", "com.android.tv.settings",
"com.nvidia.diagtools", "com.nvidia.diagtools",
] ]
# t44: per-command script overrides — the escape hatch for hardware this
# integration deliberately does not drive itself (a speaker switch that owns the
# volume, an AVR input, a mute that has to hit two devices). One script per
# command: when one is configured, that command calls the script INSTEAD of the
# native protocol/ADB path, and with none configured nothing changes at all.
CONF_COMMAND_SCRIPTS = "command_scripts"
SCRIPT_COMMANDS = (
"turn_on",
"turn_off",
"volume_up",
"volume_down",
"volume_set",
"volume_mute",
"select_source",
)
#: Options-flow field name per command: ``script_turn_on``, ``script_volume_set``…
SCRIPT_FIELD_PREFIX = "script_"
#: Variables a command hands its script (HA script variables). volume_mute passes
#: whether it is muting or unmuting — a script cannot tell those apart otherwise.
SCRIPT_VAR_LEVEL = "level"
SCRIPT_VAR_APP_NAME = "app_name"
SCRIPT_VAR_MUTED = "is_volume_muted"
def command_scripts(options) -> dict[str, str]:
"""The script configured per command, normalized (t44).
The ONE place the stored option's shape is read: the options flow, the media
player and the remote entity all go through it. Options round-trip through HA
storage and can be hand-edited, so a value that is not a non-empty string for a
known command is dropped rather than handed to the service layer.
"""
raw = (options or {}).get(CONF_COMMAND_SCRIPTS)
if not isinstance(raw, dict):
return {}
scripts: dict[str, str] = {}
for command in SCRIPT_COMMANDS:
script = raw.get(command)
if isinstance(script, str) and script.strip():
scripts[command] = script.strip()
return scripts
# t47: ONE optional script for the remote entity's send_command. It replaces the
# whole key path (repeats, hold, delays included) rather than per-key overrides,
# because a remote command is a sequence: an IR bridge, a macro or a second device
# needs the call, not individual keys.
CONF_REMOTE_COMMAND_SCRIPT = "remote_command_script"
#: Variables remote.send_command hands its script — the names are the service's own
#: parameter names, and they are documented in the README and pinned in tests.
SCRIPT_VAR_COMMAND = "command"
SCRIPT_VAR_NUM_REPEATS = "num_repeats"
SCRIPT_VAR_DELAY_SECS = "delay_secs"
SCRIPT_VAR_HOLD_SECS = "hold_secs"
def remote_command_script(options) -> str | None:
"""The script configured for remote.send_command, or None (t47).
Same defensive read as ``command_scripts``: options round-trip through HA
storage and can be hand-edited, so anything that is not a non-empty string is
"not configured" rather than a service call to nowhere.
"""
script = (options or {}).get(CONF_REMOTE_COMMAND_SCRIPT)
if isinstance(script, str) and script.strip():
return script.strip()
return None
+32 -2
View File
@@ -16,14 +16,31 @@ from dataclasses import asdict, is_dataclass
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.redact import async_redact_data
from .connection import ShieldConnectionManager from .connection import ShieldConnectionManager
from .media_player import entity_available
_REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material") _REDACT_KEY_MARKERS = ("pairing", "pin", "secret", "password", "token", "key_material")
_REDACTED = "**REDACTED**" _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): def _redact(obj):
"""Marker-based defensive pass (kept behind the async_redact_data helper)."""
"""Redact sensitive keys from a JSON-ish diagnostics payload.""" """Redact sensitive keys from a JSON-ish diagnostics payload."""
if isinstance(obj, dict): if isinstance(obj, dict):
return { return {
@@ -65,14 +82,27 @@ async def async_get_config_entry_diagnostics(
if isinstance(manager, ShieldConnectionManager): if isinstance(manager, ShieldConnectionManager):
payload["store"] = manager.store_snapshot() payload["store"] = manager.store_snapshot()
payload["transport"] = { payload["transport"] = {
"adb": {"connected": manager.connected, "awake": manager.awake}, # t40: the ladder's verdict, from the same helper the entities use,
# so a support dump answers "why is my entity unavailable?".
"entity_available": entity_available(manager),
"adb": {
"configured": manager.available,
"connected": manager.connected,
"awake": manager.awake,
},
"protocol": { "protocol": {
"active": manager.protocol_active, "active": manager.protocol_active,
"available": manager.is_available, "available": manager.is_available,
"is_on": manager.is_on, "is_on": manager.is_on,
}, },
} }
return _redact(payload) # 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"] __all__ = ["async_get_config_entry_diagnostics"]
+42 -21
View File
@@ -4,12 +4,13 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .const import ( from .const import (
CONF_ADB_ENABLED,
CONF_ADB_HOST, CONF_ADB_HOST,
CONF_ADB_PORT, CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY, CONF_HOST,
CONF_PROTOCOL_HOST,
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
DOMAIN, DOMAIN,
) )
@@ -42,31 +43,51 @@ def protocol_cert_paths(hass: HomeAssistant) -> tuple[str, str]:
return os.path.join(base, "client.pem"), os.path.join(base, "client.key") return os.path.join(base, "client.pem"), os.path.join(base, "client.key")
def discover_adb_host( def entry_host(entry) -> str:
hass: HomeAssistant, androidtv_entity_id: str | None """The Shield's address for an entry (t41: ONE field).
) -> str | None:
"""Best-effort host for an AndroidTV entity, from its owning config entry.""" ``options`` is the source of truth; ``data`` is read as a fallback so an
if not androidtv_entity_id: entry created by an older flow (or a hand-written seed) still resolves.
return None """
registry = er.async_get(hass) options = getattr(entry, "options", None) or {}
entity_entry = registry.async_get(androidtv_entity_id) data = getattr(entry, "data", None) or {}
if not entity_entry or not entity_entry.config_entry_id: return str(options.get(CONF_HOST) or data.get(CONF_HOST) or "").strip()
return None
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if config_entry: def seed_host(block: dict) -> str:
return config_entry.data.get("host") """The address a YAML seed block carries (t41).
return None
``host`` is the current key; the legacy ``protocol_host`` / ``adb_host``
stay accepted so an existing configuration.yaml keeps seeding, protocol
first (that transport is the one that cannot fall back to anything else).
"""
for key in (CONF_HOST, CONF_PROTOCOL_HOST, CONF_ADB_HOST):
value = str((block or {}).get(key) or "").strip()
if value:
return value
return ""
def seed_adb_enabled(block: dict) -> bool:
"""Does a seed block ask for ADB? (t32 F4's shape rule, t41's keys).
An explicit ``adb_enabled`` wins. Otherwise an address that names the Shield
— the current ``host`` key or the legacy ``adb_host`` — turns ADB on, while
a protocol-only block leaves it off.
"""
block = block or {}
if CONF_ADB_ENABLED in block:
return bool(block[CONF_ADB_ENABLED])
return bool(block.get(CONF_HOST) or block.get(CONF_ADB_HOST))
def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None: def resolve_adb_config(hass: HomeAssistant, entry) -> AdbConfig | None:
"""Resolve host/port/key_path for the entry's ADB connection, or None. """Resolve host/port/key_path for the entry's ADB connection, or None.
Priority: explicit ADB host/port options > host from the AndroidTV Priority: the entry's own address (t41: no other integration is consulted)
entity's own config entry. and its ADB port option.
""" """
host = entry.options.get(CONF_ADB_HOST) or discover_adb_host( host = entry_host(entry)
hass, entry.data.get(CONF_ANDROIDTV_ENTITY)
)
if not host: if not host:
return None return None
port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)) port = int(entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT))
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -13,8 +13,13 @@
"adb-shell>=0.4.0", "adb-shell>=0.4.0",
"androidtvremote2>=0.2.0" "androidtvremote2>=0.2.0"
], ],
"version": "2026.10.08", "version": "2026.10.11.2",
"zeroconf": [ "zeroconf": [
"_androidtvremote2._tcp.local." "_androidtvremote2._tcp.local."
],
"integration_type": "device",
"loggers": [
"adb_shell",
"androidtvremote2"
] ]
} }
+428 -134
View File
@@ -9,8 +9,9 @@ State architecture:
- Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off - Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off
- Playback state: Jellyfin (polling, slow 10-30s) or ADB media session - Playback state: Jellyfin (polling, slow 10-30s) or ADB media session
- Metadata: Jellyfin attributes - Metadata: Jellyfin attributes
- Commands: protocol key push → ADB keyevent → external androidtv_remote - Commands: a configured per-command script (t44) → protocol key push → ADB
(the last tier is transitional; removed in Phase 4) keyevent. The external androidtv_remote entity tier that used to back this is
gone — t41 removed it with the entity — so a command has exactly one owner.
Intent tracking bridges the gap between instant commands and slow Jellyfin polling: Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
1. User presses pause → intent recorded → UI shows paused immediately 1. User presses pause → intent recorded → UI shows paused immediately
@@ -26,14 +27,18 @@ import time
import voluptuous as vol import voluptuous as vol
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
BrowseMedia,
MediaClass,
MediaPlayerDeviceClass, MediaPlayerDeviceClass,
MediaPlayerEntity, MediaPlayerEntity,
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
MediaPlayerState, MediaPlayerState,
MediaType,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_PAUSED, STATE_PLAYING from homeassistant.const import STATE_PAUSED, STATE_PLAYING
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later, async_track_state_change_event from homeassistant.helpers.event import async_call_later, async_track_state_change_event
@@ -49,7 +54,6 @@ from .const import (
CHANNEL_DIGIT_DELAY, CHANNEL_DIGIT_DELAY,
CONF_ADB_COMMAND_ENABLED, CONF_ADB_COMMAND_ENABLED,
CONF_ADB_STORE_APP_INFO, CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY,
CONF_APP_ICON_MAP, CONF_APP_ICON_MAP,
CONF_APP_NAME_MAP, CONF_APP_NAME_MAP,
CONF_CHANNEL_DIGIT_DELAY, CONF_CHANNEL_DIGIT_DELAY,
@@ -62,8 +66,11 @@ from .const import (
DOMAIN, DOMAIN,
PROTOCOL_MEDIA_KEYS, PROTOCOL_MEDIA_KEYS,
PROTOCOL_REMOTE_KEYS, PROTOCOL_REMOTE_KEYS,
SERVICE_ADB_COMMAND, SCRIPT_VAR_APP_NAME,
SCRIPT_VAR_LEVEL,
SCRIPT_VAR_MUTED,
TIVIMATE_PACKAGE, TIVIMATE_PACKAGE,
command_scripts,
) )
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
from .jellyfin import fetch_session_media_info from .jellyfin import fetch_session_media_info
@@ -99,6 +106,44 @@ _PLAYBACK_FEATURES = (
) )
async def async_call_script(
hass: HomeAssistant, script: str, action: str, variables: dict | None = None
) -> None:
"""Call a configured script with variables, surfacing failures (t44/t47).
``blocking=True`` so a script that fails surfaces HERE: a command that cannot
run must be a visible, translated error rather than a silent no-op. Module-level
so the media player and the remote entity share one implementation.
"""
data: dict = {"entity_id": script}
if variables:
data["variables"] = variables
try:
await hass.services.async_call("script", "turn_on", data, blocking=True)
except Exception as err: # noqa: BLE001 - re-raised as a user-facing error
_LOGGER.error("the script for %s (%s) failed: %s", action, script, err)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="command_script_failed",
translation_placeholders={
"action": action,
"script": script,
"error": str(err),
},
) from err
async def async_run_command_script(
hass: HomeAssistant, options: dict, command: str, variables: dict | None = None
) -> bool:
"""Run the script configured for ``command``; True when one handled it (t44)."""
script = command_scripts(options).get(command)
if not script:
return False
await async_call_script(hass, script, command, variables)
return True
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: ConfigEntry,
@@ -118,8 +163,12 @@ async def _migrate_legacy_unique_id(hass, entry) -> None:
legacy pattern no longer matches, so this never runs twice for an legacy pattern no longer matches, so this never runs twice for an
entry. No behavior change: the entry's data/options are untouched. entry. No behavior change: the entry's data/options are untouched.
""" """
legacy = entry.data.get(CONF_ANDROIDTV_ENTITY, "") # t41: the entity path is gone, so the SHAPE of the stale id is the marker.
if not legacy or entry.unique_id != legacy: # Legacy entries (pre-t29 G2) carry the androidtv entity id as unique_id;
# every other shape (a MAC, the config-entry id, a yaml: seed id) is left
# exactly as it is.
legacy = entry.unique_id or ""
if not legacy.startswith("media_player."):
return return
manager = getattr(entry, "runtime_data", None) manager = getattr(entry, "runtime_data", None)
new_id = None new_id = None
@@ -150,6 +199,61 @@ async def _migrate_legacy_unique_id(hass, entry) -> None:
_LOGGER.info("P5-1: legacy unique_id %r migrated to %r", legacy, new_id) _LOGGER.info("P5-1: legacy unique_id %r migrated to %r", legacy, new_id)
def entity_available(manager: ShieldConnectionManager | None) -> bool:
"""Is ANY usable transport healthy for this entry? (t40 live install)
The single ladder behind ``media_player.available``, ``remote.available``
and the diagnostics payload, so the three cannot disagree:
* the protocol transport is wired and has not reported the device
unreachable — it serves power/app/volume even when ADB is down;
* or ADB is connected (live state data);
* or fresh wake data exists (the poller has spoken at least once);
* or the manager is passive (no ADB configured at all): nothing to
transport is not a broken transport.
Unavailable only when a CONFIGURED transport is actively down and no usable
data exists. The t5-F2 rule ("an ADB manager that is down means unreliable
state") took the whole entity down while a healthy paired protocol transport
was still serving commands — the reported defect.
"""
if not isinstance(manager, ShieldConnectionManager):
return True
if manager.protocol_active and manager.is_available is not False:
return True
if manager.connected:
return True
if manager.awake is not None:
return True
return not manager.available
def resolve_app_name(
package: str | None,
app_name_map,
app_label,
) -> str | None:
"""Resolve a package id to a friendly name (t40; entity tier removed t41).
The single chain behind ``media_player.app_name`` and the Current App
sensor:
1. the user's explicit apps map (``CONF_APP_NAME_MAP``);
2. the cached device-manifest label (``manager.app_label`` — a cache miss
enqueues the on-demand fetch and falls through);
3. the raw package text — a bare or unknown package falls back to itself,
never to None.
"""
if package:
mapped = (app_name_map or {}).get(package)
if mapped:
return mapped
label = app_label(package) if app_label is not None else None
if label:
return label
return package or None
class NvidiaShieldMediaPlayer(MediaPlayerEntity): class NvidiaShieldMediaPlayer(MediaPlayerEntity):
_attr_device_class = MediaPlayerDeviceClass.TV _attr_device_class = MediaPlayerDeviceClass.TV
@@ -161,7 +265,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass self.hass = hass
self._entry = entry self._entry = entry
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player" self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
self._unsub_listeners: list = [] self._unsub_listeners: list = []
@@ -192,15 +295,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""The bridge entities stay functional without ADB (passive manager). """Available while ANY usable transport is healthy (t40 live install).
Unavailable only while an active ADB manager is down — its data is ADB enabled but unreachable no longer takes the entity down: a paired,
unreliable until the watchdog reconnects (t5-F2). available protocol transport still serves power/app/volume. The ladder
is shared with the remote and diagnostics (``entity_available``).
""" """
manager = getattr(self._entry, "runtime_data", None) return entity_available(getattr(self._entry, "runtime_data", None))
if not isinstance(manager, ShieldConnectionManager):
return True
return manager.connected or manager.awake is not None or not manager.available
@property @property
def _jellyfin_entity(self) -> str | None: def _jellyfin_entity(self) -> str | None:
@@ -216,6 +317,22 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
# G10 (t30): when the protocol transport is live and captured, the
# registry card carries the protocol-pushed identity (name from the
# device itself, manufacturer 'Google' per the audit ruling); the
# pre-capture shape stays the entry-title fallback.
captured = (
self._manager.protocol_device_data if self._manager is not None else None
)
if captured:
name, manufacturer, model, sw = captured
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=name or self._entry.title,
manufacturer=manufacturer,
model=model,
sw_version=sw or None,
)
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)}, identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title, name=self._entry.title,
@@ -243,7 +360,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
getattr(self, "entity_id", None) or self._attr_unique_id getattr(self, "entity_id", None) or self._attr_unique_id
) )
_ADB_COMMAND_TARGETS[self._adb_command_key] = self._manager _ADB_COMMAND_TARGETS[self._adb_command_key] = self._manager
_adb_command_service_ensure(self.hass)
# Discover power sensor siblings from the power switch # Discover power sensor siblings from the power switch
if self._power_entity: if self._power_entity:
@@ -257,11 +373,11 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self._on_manager_update self._on_manager_update
) )
entities = [self._androidtv_entity] entities = [
if self._jellyfin_entity: entity_id
entities.append(self._jellyfin_entity) for entity_id in (self._jellyfin_entity, self._power_entity)
if self._power_entity: if entity_id
entities.append(self._power_entity) ]
self._unsub_listeners.append( self._unsub_listeners.append(
async_track_state_change_event( async_track_state_change_event(
self.hass, entities, self._on_source_change self.hass, entities, self._on_source_change
@@ -280,13 +396,8 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
for task in self._entity_tasks: for task in self._entity_tasks:
task.cancel() task.cancel()
self._entity_tasks.clear() self._entity_tasks.clear()
# P4-2a: drop this entity's service participation (same key as add) # P4-2a/t29 G8: drop this entity's service participation (same key
if ( # as add). The service itself stays registered (always-on setup).
(key := getattr(self, "_adb_command_key", None)) is not None
and _ADB_COMMAND_TARGETS.pop(key, None) is not None
and self.hass
):
_adb_command_service_maybe_remove(self.hass)
# ── Intent management ── # ── Intent management ──
@@ -366,9 +477,18 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self._manager.send_media_command(intent.adb_command) self._manager.send_media_command(intent.adb_command)
) )
else: else:
self._track_task( # t41: neither of OUR transports served this command (both were
self._cmd(intent.command) # down when it was sent), so there is nothing to re-send. Retrying
# by delegating to another integration is exactly what this change
# removes — surrender to the truth instead.
_LOGGER.warning(
"Intent %s had no serving transport to retry — falling back "
"to truth",
intent.command,
) )
self._clear_intent()
self.async_write_ha_state()
return
self._intent_timer_unsub = async_call_later( self._intent_timer_unsub = async_call_later(
self.hass, INTENT_TIMEOUT, self._on_intent_timeout self.hass, INTENT_TIMEOUT, self._on_intent_timeout
) )
@@ -386,13 +506,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@callback @callback
def _on_source_change(self, event: Event) -> None: def _on_source_change(self, event: Event) -> None:
"""React to state changes in androidtv or Jellyfin.""" """React to state changes in Jellyfin or the power switch."""
entity_id = event.data.get("entity_id") entity_id = event.data.get("entity_id")
new_state_obj = event.data.get("new_state") new_state_obj = event.data.get("new_state")
# Power off clears any intent — device is off, intent is moot # Power off clears any intent — device is off, intent is moot
if ( if (
entity_id in (self._androidtv_entity, self._power_entity) entity_id == self._power_entity
and new_state_obj and new_state_obj
and not self._is_power_on() and not self._is_power_on()
and self._intent and self._intent
@@ -555,12 +675,24 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@property @property
def supported_features(self) -> MediaPlayerEntityFeature: def supported_features(self) -> MediaPlayerEntityFeature:
if self._jf_active(): if self._jf_active():
return _BASE_FEATURES | _PLAYBACK_FEATURES features = _BASE_FEATURES | _PLAYBACK_FEATURES
# ADB media session also supports playback controls else:
adb_info = self._manager.best_session(self.app_id) if self._manager else None # ADB media session also supports playback controls
if adb_info and (adb_info.playing or adb_info.paused): adb_info = self._manager.best_session(self.app_id) if self._manager else None
return _BASE_FEATURES | _PLAYBACK_FEATURES if adb_info and (adb_info.playing or adb_info.paused):
return _BASE_FEATURES features = _BASE_FEATURES | _PLAYBACK_FEATURES
else:
features = _BASE_FEATURES
# t44/t47: a command that exists ONLY as a script still has to be
# reachable, and the feature flag is what makes HA offer it. volume_set has
# no native path at all; select_source appears when EITHER a script is
# configured OR there is a source list to offer (t47 gave it a native path).
if self._script_for("volume_set"):
features |= MediaPlayerEntityFeature.VOLUME_SET
if self._script_for("select_source") or self.source_list:
features |= MediaPlayerEntityFeature.SELECT_SOURCE
return features
# ── Metadata from Jellyfin ── # ── Metadata from Jellyfin ──
@@ -570,22 +702,96 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
@property @property
def app_id(self): def app_id(self):
"""Android TV entity first; standalone setups fall back to the """The raw foreground package from the manager's store (t41).
protocol current-app push (raw package — both stamp the store)."""
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None Both native transports stamp it: the protocol current-app push and the
if atv is not None: ADB mResumedActivity parse. None until one of them has reported.
app_id = atv.attributes.get("app_id") """
if app_id: return self._manager.current_app if self._manager is not None else None
return app_id
if self._manager is not None:
return self._manager.current_app
return None
@property @property
def _app_name_map(self) -> dict: def _app_name_map(self) -> dict:
"""The user's apps name/icon map (P4-1 options editor).""" """The user's apps name/icon map (P4-1 options editor)."""
return self._entry.options.get(CONF_APP_NAME_MAP) or {} return self._entry.options.get(CONF_APP_NAME_MAP) or {}
@property
def _app_directory(self) -> dict:
"""Launchable apps: the user's map, else the curated default (P4-1).
ONE source for the browse tree, the source list and the reverse lookup, so
the dropdown can never offer a name the launch path cannot resolve.
The map is hand-editable, so it is NORMALIZED here — once, so that every
consumer sees the same thing (t51):
* a name is stripped, so display and match key are one string: an entry
like ``com.x= TiviMate `` shows as "TiviMate" AND resolves as
"TiviMate", instead of being shown padded and missing every lookup;
* an entry whose name — or whose package — is blank/whitespace-only is
dropped, so no surface can offer a child with no package behind it or a
name no launch can reach (the t48 invariant, extended to the tree);
* two packages normalizing to the same name are BOTH KEPT — the browse
tree reaches each by its own package id, so neither app is hidden — while
the FIRST in map order wins the name: that is the single entry
source_list offers and the package select_source resolves. Map order is
the user's order, so the winner does not move between rebuilds. "The same
name" is judged case-insensitively (t53), because the reverse lookup is:
names differing only by case would otherwise give the dropdown a second
row that launches the FIRST entry's app.
A well-formed map (what the editor and the curated default produce) passes
through byte-identical: stripping only touches the edges, so a name like
"My App" keeps its inner space.
"""
directory = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
normalized: dict[str, str] = {}
for package, name in directory.items():
key = str(package).strip()
label = str(name).strip()
if not key or not label:
continue
normalized[key] = label
return normalized
@property
def source_list(self) -> list[str]:
"""The apps as friendly names — the dropdown a UMP replacement reads (t47).
Directory order (the user's map order, or the curated list), so rebuilding
the page does not shuffle it. Blanks are already gone and names already
stripped by ``_app_directory`` (t49/t51) — the normalization lives in that
one accessor so the dropdown, the browse tree and the reverse lookup cannot
disagree.
Each name is offered ONCE, compared CASE-INSENSITIVELY (t53), keeping the
first spelling the user typed. Exact duplicates were already collapsed, but
``Dup``/``dup`` were not: the dropdown then showed two rows while the
reverse lookup — which is case-insensitive by design (t47) — resolved BOTH
of them to the first entry, so the second row launched the wrong app. One
row per name means a row can only launch the app it shows. The shadowed
package is not lost: the browse tree still lists it under its own id, and
select_source accepts that id.
"""
seen: set[str] = set()
offered: list[str] = []
for name in self._app_directory.values():
folded = name.casefold()
if folded in seen:
continue
seen.add(folded)
offered.append(name)
return offered
@property
def source(self) -> str | None:
"""The foreground app's friendly name — the same chain as app_name (t47).
``resolve_app_name``: the user's map, then the cached device-manifest label,
then the raw package. Same answer as the Current App sensor, so a
universal-media-player replacement cannot see two different truths.
"""
return self.app_name
@property @property
def app_name(self): def app_name(self):
"""P5-1 resolution order — explicit override first: """P5-1 resolution order — explicit override first:
@@ -593,25 +799,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
1. CONF_APP_NAME_MAP (the user's explicit override) 1. CONF_APP_NAME_MAP (the user's explicit override)
2. device manifest label (`pm dump`, cached ≥24 h, resolved on 2. device manifest label (`pm dump`, cached ≥24 h, resolved on
demand — a miss enqueues the fetch and falls through) demand — a miss enqueues the fetch and falls through)
3. androidtv entity tier (when configured) 3. raw package (protocol push / ADB foreground parse)
4. raw package (protocol push / ADB foreground parse)
""" """
package = self._manager.foreground_app if self._manager else None # t40: one implementation, shared with the Current App sensor
if package: # (``resolve_app_name``) — the two can no longer disagree about what
mapped = self._app_name_map.get(package) # is on screen.
if mapped: package = self._manager.foreground_app if self._manager is not None else None
return mapped return resolve_app_name(
label = self._manager.app_label(package) package,
if label: self._app_name_map,
return label self._manager.app_label if self._manager is not None else None,
atv = self._read(self._androidtv_entity) if self._androidtv_entity else None )
if atv is not None:
app_name = atv.attributes.get("app_name")
if app_name:
return app_name
if self._manager is not None and package:
return package
return None
@property @property
def media_content_type(self): def media_content_type(self):
@@ -800,8 +998,28 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
last_known=self._manager.last_known_map(), last_known=self._manager.last_known_map(),
) )
# G12 (t30): attributes that change with every poll must not bloat the
# database (entity doc) — dag internals are declared unrecorded.
_unrecorded_attributes = frozenset(
{
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RUNNING_APPS,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
}
)
@property @property
def extra_state_attributes(self) -> dict: def extra_state_attributes(self) -> dict:
"""Extra state attributes — a PROPERTY, as HA requires (t40 blocker).
``Entity.__async_calculate_state`` merges them with
``attr |= self.extra_state_attributes``; declaring this as a plain
method made every state write raise
``TypeError: 'method' object is not iterable``, so the entity could
never render on a live install.
"""
# Now playing: prefer Jellyfin, fall back to ADB media session, # Now playing: prefer Jellyfin, fall back to ADB media session,
# then the foreground tier (P3-3a). # then the foreground tier (P3-3a).
now_playing = self._get_now_playing() now_playing = self._get_now_playing()
@@ -837,30 +1055,119 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
attrs["_intent"] = self._intent.to_debug_dict() attrs["_intent"] = self._intent.to_debug_dict()
return attrs return attrs
# ── Commands — ALL go to androidtv_remote ── # ── Commands — our own transports only (t41: the external entity is gone) ──
async def _cmd(self, service: str, **extra) -> None: async def _power_key(self, *, send_when_on: bool) -> None:
data = {"entity_id": self._androidtv_entity, **extra} """Send the protocol POWER key when the known state disagrees (t41).
await self.hass.services.async_call("media_player", service, data)
Both native transports are ours now, so this no longer delegates to an
androidtv_remote entity: the protocol POWER key is the path we own. An
unknown state sends nothing (no superfluous toggle — the same rule the
remote entity applies), and without the protocol transport there is
nothing to transport the key over.
"""
manager = self._manager
if manager is None or manager.is_on is not send_when_on:
return
await manager.send_protocol_key(PROTOCOL_REMOTE_KEYS["power"])
def _script_for(self, command: str) -> str | None:
"""The script configured to own ``command``, if any (t44)."""
return command_scripts(self._entry.options).get(command)
async def _script_served(self, command: str, variables: dict | None = None) -> bool:
"""True when a configured script handled ``command`` instead of the transports."""
return await async_run_command_script(
self.hass, self._entry.options, command, variables
)
async def async_turn_on(self): async def async_turn_on(self):
await self._cmd("turn_on") """Wake the Shield (only when it reports OFF) — or run the turn_on script."""
if await self._script_served("turn_on"):
return
await self._power_key(send_when_on=False)
async def async_turn_off(self): async def async_turn_off(self):
await self._cmd("turn_off") """Sleep the Shield (only when it reports ON) — or run the turn_off script."""
if await self._script_served("turn_off"):
return
await self._power_key(send_when_on=True)
async def async_volume_up(self): async def async_volume_up(self):
if await self._script_served("volume_up"):
return
await self._remote_key_cmd("volume_up", "volume_up") await self._remote_key_cmd("volume_up", "volume_up")
async def async_volume_down(self): async def async_volume_down(self):
if await self._script_served("volume_down"):
return
await self._remote_key_cmd("volume_down", "volume_down") await self._remote_key_cmd("volume_down", "volume_down")
async def async_mute_volume(self, mute): async def async_mute_volume(self, mute):
"""Mute via the protocol/ADB key — no protocol VOLUME_SET exists (C-VOL).""" """Mute — via the configured script, else the protocol/ADB key (C-VOL)."""
if await self._script_served("volume_mute", {SCRIPT_VAR_MUTED: mute}):
return
await self._remote_key_cmd("volume_mute", "mute") await self._remote_key_cmd("volume_mute", "mute")
async def async_set_volume_level(self, volume):
"""Set absolute volume — reachable only with a script (t44).
There is no native path to replace: the protocol carries UP/DOWN/MUTE keys
only (C-VOL) and ADB has no absolute volume either. Configured, this is the
hook for a device whose volume is owned elsewhere (a speaker switch);
unconfigured it stays the no-op it has always been.
"""
await self._script_served("volume_set", {SCRIPT_VAR_LEVEL: volume})
def _source_package(self, source) -> str | None:
"""The package behind a source name, or a package id passed through (t47).
Both sides are normalized the same way: the query is stripped here and the
names were stripped when the directory was built (t51), so a padded entry
matches its trimmed spelling. Names are compared case-insensitively — which
is why ``source_list`` collapses names that differ only by case (t53), so
that the row it offers resolves back to the same app it displays. When two
packages share a name the first in map order wins, the same one
``source_list`` offers (see ``_app_directory``); the shadowed package stays
reachable by passing its id.
"""
directory = self._app_directory
wanted = str(source or "").strip()
if not wanted:
return None
if wanted in directory:
# an automation may hand us the package directly; that is what the
# browse tree publishes as media_content_id
return wanted
folded = wanted.casefold()
for package, name in directory.items():
if str(name).casefold() == folded:
return package
return None
async def async_select_source(self, source):
"""Select an app by its friendly name (t47) — script first, else launch it.
A configured ``select_source`` script keeps precedence (t44: selecting a
source can be more than launching an app — an AVR input, a speaker switch).
Without one the name is resolved back to its package through the SAME
directory the source list came from and launched over the shared
``play_media("app", …)`` path, so a universal-media-player replacement gets
the native behaviour. An unknown name is an error, never a silent no-op.
"""
if await self._script_served("select_source", {SCRIPT_VAR_APP_NAME: source}):
return
package = self._source_package(source)
if package is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unknown_source",
translation_placeholders={"source": str(source)},
)
await self.async_play_media(MediaType.APP, package)
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None: async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
"""Simple key command without intent bookkeeping: protocol → ADB → legacy.""" """Simple key command without intent bookkeeping: protocol → ADB keyevent."""
protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key) protocol_key = PROTOCOL_REMOTE_KEYS.get(remote_key)
if ( if (
protocol_key protocol_key
@@ -870,16 +1177,15 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return return
if self._manager is not None and await self._manager.send_media_command(adb_key): if self._manager is not None and await self._manager.send_media_command(adb_key):
return return
# Transitional: external androidtv_remote entity (removed in Phase 4)
await self._cmd(remote_key)
async def _media_cmd( async def _media_cmd(
self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None
) -> None: ) -> None:
"""Send a media command over the fastest available command path. """Send a media command over the fastest available command path.
Order (Phase 2): protocol key push → ADB keyevent → the external Order: protocol key push → ADB keyevent (t41: our own transports only —
androidtv_remote entity (transitional until Phase 4 removes it). the transitional external-entity tier is gone). With neither transport
up the command is a no-op, never a call into another integration.
""" """
if intent_state: if intent_state:
self._set_intent(ha_service, intent_state) self._set_intent(ha_service, intent_state)
@@ -912,9 +1218,6 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self._intent.adb_command = adb_command self._intent.adb_command = adb_command
return return
# 3. Transitional: the external androidtv_remote entity (removed in Phase 4)
await self._cmd(ha_service)
async def async_media_play(self): async def async_media_play(self):
await self._media_cmd("media_play", "play", MediaPlayerState.PLAYING) await self._media_cmd("media_play", "play", MediaPlayerState.PLAYING)
@@ -940,29 +1243,37 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# ── BrowseMedia app directory (P4-2c) ── # ── BrowseMedia app directory (P4-2c) ──
async def async_browse_media(self, media_content_type=None, media_content_id=None): async def async_browse_media(self, media_content_type=None, media_content_id=None):
"""Root lists the apps map (or the curated default) → play_media(app).""" """Root lists the apps map (or the curated default) → play_media(app).
from homeassistant.components.media_player import (
BrowseMediaSource,
MediaClass,
)
app_map = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY t37 D1: the standard media_player tree, exactly as core's own
androidtv_remote/media_player.py builds it — ``BrowseMedia`` from
homeassistant.components.media_player, every node carrying an explicit
media_content_id/media_content_type, and the directory naming the
class of its children. ``BrowseMediaSource`` only ever existed in
homeassistant.components.media_source.models (t30 G18's domain
parameter is ITS contract: it builds ``media_source://<domain>`` ids),
so an app-directory node must never claim one — and the import above
removed a name media_player has not exported for a long time, which
made every real browse raise.
"""
app_map = self._app_directory
children = [ children = [
BrowseMediaSource( BrowseMedia(
title=name, title=name,
media_class=MediaClass.APP, media_class=MediaClass.APP,
media_content_type="app", media_content_type=MediaType.APP,
media_content_id=pkg, media_content_id=pkg,
can_play=True, can_play=True,
can_expand=False, can_expand=False,
) )
for pkg, name in app_map.items() for pkg, name in app_map.items()
] ]
return BrowseMediaSource( return BrowseMedia(
title="Apps", title="Apps",
media_class=MediaClass.DIRECTORY, media_class=MediaClass.DIRECTORY,
media_content_type="apps", media_content_type=MediaType.APPS,
media_content_id="apps", media_content_id="apps",
children_media_class=MediaClass.APP,
can_play=False, can_play=False,
can_expand=True, can_expand=True,
children=children, children=children,
@@ -1041,16 +1352,17 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
send_enter = self._entry.options.get(CONF_CHANNEL_SEND_ENTER, False) send_enter = self._entry.options.get(CONF_CHANNEL_SEND_ENTER, False)
try: try:
for digit in digits: for digit in digits:
# 1. Protocol digit key; 2. ADB keyevent 7..16; 3. legacy entity # 1. Protocol digit key; 2. ADB keyevent 7..16 (t41: our own
# transports only — an unserved digit is dropped, never handed
# to another integration)
served = False served = False
if self._manager is not None: if self._manager is not None:
served = await self._manager.send_protocol_key(f"KEYCODE_{digit}") served = await self._manager.send_protocol_key(f"KEYCODE_{digit}")
if not served: if not served:
served = await self._manager.send_digit_key(digit) served = await self._manager.send_digit_key(digit)
if not served: if not served:
await self._cmd( _LOGGER.warning(
"play_media", "channel digit %s not sent — no available transport", digit
media={"media_content_id": digit, "media_content_type": "channel"},
) )
await asyncio.sleep(delay) await asyncio.sleep(delay)
if ( if (
@@ -1076,53 +1388,35 @@ _ADB_COMMAND_TARGETS: dict[str, ShieldConnectionManager] = {}
_ADB_COMMAND_SCHEMA = vol.Schema( _ADB_COMMAND_SCHEMA = vol.Schema(
{ {
vol.Required("command"): str, vol.Required("command"): str,
vol.Optional("entity_id"): str, vol.Required("entity_id"): str,
} }
) )
async def _async_adb_command_service(call) -> None: async def _async_adb_command_service(call) -> None:
"""Run a raw ADB shell command on the targeted entry's connection.""" """Run a raw ADB shell command on the target connection (t29 G8).
The integration's async_setup registers this ALWAYS; the entry's
adb_command_enabled option gates USE (participants register from
async_added_to_hass). Failures raise ServiceValidationError with
translated keys (action-exceptions) — never warning logs.
"""
command = str(call.data.get("command") or "").strip() command = str(call.data.get("command") or "").strip()
if not command: if not command:
_LOGGER.warning("adb_command: empty command ignored") raise ServiceValidationError(
return translation_domain=DOMAIN, translation_key="invalid_command"
)
entity_id = call.data.get("entity_id") entity_id = call.data.get("entity_id")
pairs = [ manager = _ADB_COMMAND_TARGETS.get(entity_id)
(eid, manager) if manager is None:
for eid, manager in _ADB_COMMAND_TARGETS.items() raise ServiceValidationError(
if entity_id in (None, eid) translation_domain=DOMAIN,
] translation_key="unsupported_target",
if not pairs:
_LOGGER.warning(
"adb_command: no participating entry matches %s (is the option "
"enabled on the entry's options?)",
entity_id,
) )
return poller = manager.adb_poller # public accessor (t21 F6)
for _eid, manager in pairs: if poller is None or not manager.connected:
poller = manager._poller raise ServiceValidationError(
if poller is None or not manager.connected: translation_domain=DOMAIN, translation_key="adb_unavailable"
_LOGGER.warning("adb_command: ADB unavailable — command not sent")
continue
result = await poller.shell(command)
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
def _adb_command_service_ensure(hass) -> None:
"""Register the domain service once the first participant appears."""
if not hass.services.has_service(DOMAIN, SERVICE_ADB_COMMAND):
hass.services.async_register(
DOMAIN,
SERVICE_ADB_COMMAND,
_async_adb_command_service,
schema=_ADB_COMMAND_SCHEMA,
) )
result = await poller.shell(command)
_LOGGER.debug("adb_command %r → output=%s", command, result is not None)
def _adb_command_service_maybe_remove(hass) -> None:
"""Unregister when the last participant unloads."""
if not _ADB_COMMAND_TARGETS and hass.services.has_service(
DOMAIN, SERVICE_ADB_COMMAND
):
hass.services.async_remove(DOMAIN, SERVICE_ADB_COMMAND)
+47 -13
View File
@@ -24,7 +24,16 @@ from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .connection import ShieldConnectionManager from .connection import ShieldConnectionManager
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS from .const import (
DOMAIN,
PROTOCOL_REMOTE_KEYS,
SCRIPT_VAR_COMMAND,
SCRIPT_VAR_DELAY_SECS,
SCRIPT_VAR_HOLD_SECS,
SCRIPT_VAR_NUM_REPEATS,
remote_command_script,
)
from .media_player import async_call_script, async_run_command_script, entity_available
_START_PREFIX = "START_LONG:" _START_PREFIX = "START_LONG:"
_END_PREFIX = "END_LONG:" _END_PREFIX = "END_LONG:"
@@ -93,17 +102,14 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Unavailable while the transport is active but unpaired/down. """Available while ANY usable transport can serve a command (t40).
A passive manager (no protocol transport) keeps the entity available: The same ladder as the media player (``entity_available``): protocol
nothing to transport is not a broken one. wired+available, ADB connected, fresh wake data or a passive manager all
count as usable, and a passive manager stays available — nothing to
transport is not a broken one.
""" """
manager = self._manager_of() return entity_available(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 @property
def activity(self) -> str | None: def activity(self) -> str | None:
@@ -133,7 +139,11 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
self._manager_unregistered = None self._manager_unregistered = None
async def async_turn_on(self, **kwargs) -> None: async def async_turn_on(self, **kwargs) -> None:
"""Wake the device with the POWER key when it reports off.""" """Wake the device — via the configured script, else the POWER key."""
# t44: `turn_on` is one configuration, and the remote entity is the other
# route to it; a script that owns the power sequence owns it here too.
if await async_run_command_script(self.hass, self._entry.options, "turn_on"):
return
manager = self._manager_of() manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager): if not isinstance(manager, ShieldConnectionManager):
return return
@@ -143,7 +153,9 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
await manager.send_protocol_key(_POWER_KEY, "SHORT") await manager.send_protocol_key(_POWER_KEY, "SHORT")
async def async_turn_off(self, **kwargs) -> None: async def async_turn_off(self, **kwargs) -> None:
"""Sleep the device with the POWER key when it reports on.""" """Sleep the device — via the configured script, else the POWER key."""
if await async_run_command_script(self.hass, self._entry.options, "turn_off"):
return
manager = self._manager_of() manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager): if not isinstance(manager, ShieldConnectionManager):
return return
@@ -158,7 +170,29 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
delay_secs: float = 0.0, delay_secs: float = 0.0,
hold_secs: float = 0.0, hold_secs: float = 0.0,
) -> None: ) -> None:
"""Send protocol keys: repeats × (key, or START_LONG/hold/END_LONG).""" """Send protocol keys — or hand the whole command to the configured script.
t47: with a remote command script configured, it replaces the key path
entirely and receives the service call's own parameters, named as they are
on the service: ``command`` (a list of key commands), ``num_repeats``,
``delay_secs`` and ``hold_secs``. Unset, nothing changes: repeats, long
presses and delays go out as protocol keys exactly as before.
"""
script = remote_command_script(self._entry.options)
if script:
await async_call_script(
self.hass,
script,
"remote.send_command",
{
SCRIPT_VAR_COMMAND: command,
SCRIPT_VAR_NUM_REPEATS: num_repeats,
SCRIPT_VAR_DELAY_SECS: delay_secs,
SCRIPT_VAR_HOLD_SECS: hold_secs,
},
)
return
manager = self._manager_of() manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager): if not isinstance(manager, ShieldConnectionManager):
return return
+47 -42
View File
@@ -12,10 +12,11 @@ from homeassistant.helpers.event import async_track_state_change_event
from .connection import ShieldConnectionManager from .connection import ShieldConnectionManager
from .const import ( from .const import (
CONF_ANDROIDTV_ENTITY, CONF_APP_NAME_MAP,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
DOMAIN, DOMAIN,
) )
from .media_player import resolve_app_name
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -39,6 +40,8 @@ class NvidiaShieldSensorBase(SensorEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False _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: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass self.hass = hass
@@ -63,17 +66,16 @@ class NvidiaShieldSensorBase(SensorEntity):
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase): class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
"""Current running app on the Shield. """Current running app on the Shield.
Primary source: the androidtv entity's own state. When that entity is Source: the ShieldConnectionManager's StateStore — the protocol current-app
unavailable, falls back to the ShieldConnectionManager's StateStore push, or the most recent visible ADB recent-task app when that has not
(most recent visible ADB recent-task app). reported (t41: our own transports only).
""" """
_attr_name = "Current App" _attr_translation_key = "current_app"
_attr_icon = "mdi:application" _attr_icon = "mdi:application"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
super().__init__(hass, entry) super().__init__(hass, entry)
self._androidtv_entity = entry.data.get(CONF_ANDROIDTV_ENTITY, "")
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app" self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
def _manager(self) -> ShieldConnectionManager | None: def _manager(self) -> ShieldConnectionManager | None:
@@ -81,62 +83,65 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
return manager if isinstance(manager, ShieldConnectionManager) else None return manager if isinstance(manager, ShieldConnectionManager) else None
async def async_added_to_hass(self) -> 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() manager = self._manager()
if manager is not None: if manager is not None:
# Refresh when the connection manager updates its store. # Refresh when the connection manager updates its store.
self._unsub_listeners.append(manager.register_listener(self._handle_store_update)) 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 @callback
def _handle_store_update(self) -> None: def _handle_store_update(self) -> None:
self.async_write_ha_state() self.async_write_ha_state()
@property def _package(self) -> str | None:
def native_value(self) -> str | None: """The raw foreground package from the manager's store (t41).
state = self.hass.states.get(self._androidtv_entity)
if state:
return state.attributes.get("app_name") or state.attributes.get("app_id")
# androidtv entity unavailable — StateStore priority chain (P3-3c): StateStore priority chain (P3-3c): protocol current_app push → ADB
# protocol current_app push → ADB mResumedActivity → ADB recents mResumedActivity → ADB recents.
"""
manager = self._manager() manager = self._manager()
if manager is not None: if manager is None:
foreground = manager.foreground_app return None
if foreground: foreground = manager.foreground_app
return foreground if foreground:
running = manager.running_apps return foreground
if running: running = manager.running_apps
return running[0].get("package") if running:
return running[0].get("package")
return None return None
@property @property
def extra_state_attributes(self) -> dict: def native_value(self) -> str | None:
state = self.hass.states.get(self._androidtv_entity) """The resolved friendly name (t40), not the raw package id.
if state:
return { Exactly the ``media_player.app_name`` chain via the shared
"app_id": state.attributes.get("app_id"), ``resolve_app_name``: apps map → cached manifest label → the package
"app_name": state.attributes.get("app_name"), text itself. A bare or unknown package shows its package text rather
} than None, like the media player's app_name.
"""
manager = self._manager() manager = self._manager()
if manager is not None: return resolve_app_name(
running = manager.running_apps self._package(),
if running: dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
return {"app_id": running[0].get("package")} manager.app_label if manager is not None else None,
return {} )
@property
def extra_state_attributes(self) -> dict:
"""The raw package stays visible here (t40) next to the shown name."""
package = self._package()
attrs: dict = {}
if package:
attrs["app_id"] = package
name = self.native_value
if name:
attrs["app_name"] = name
return attrs
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase): class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
"""What's currently playing via Jellyfin.""" """What's currently playing via Jellyfin."""
_attr_name = "Now Playing" _attr_translation_key = "now_playing"
_attr_icon = "mdi:play-circle" _attr_icon = "mdi:play-circle"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: 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
+144 -63
View File
@@ -3,104 +3,185 @@
"step": { "step": {
"user": { "user": {
"title": "NVIDIA Shield", "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 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
"data": { "data": {
"name": "Device Name", "name": "Name",
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.", "jellyfin_entity": "Jellyfin media player",
"jellyfin_entity": "Jellyfin Media Player",
"receiver_entity": "Receiver", "receiver_entity": "Receiver",
"receiver_input": "Receiver Input", "receiver_input": "Receiver input",
"power_entity": "Power Switch", "power_entity": "Power switch",
"adb_enabled": "Enable ADB Media Session", "adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show App Media Info", "adb_store_app_info": "Show title and artist",
"adb_host": "ADB Host", "host": "The Shield's IP address",
"adb_port": "ADB Port", "adb_port": "ADB port",
"protocol_enabled": "Pair with Nvidia Remote Protocol", "protocol_enabled": "Add the Nvidia Remote Protocol"
"protocol_host": "Protocol Host"
}, },
"data_description": { "data_description": {
"androidtv_entity": "The media_player entity from the Android TV Remote integration.", "jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.", "receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_entity": "The receiver this device is connected to.", "receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
"receiver_input": "Which input on the receiver (e.g. 'Shield').", "power_entity": "A smart plug switch, if you want reliable on/off detection.",
"power_entity": "Smart plug switch for reliable on/off detection.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"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": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).", "host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity \u2014 pre-filled automatically when the Shield was discovered.", "adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
"adb_port": "ADB port number. Default is 5555.", "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_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.",
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host) \u2014 pre-filled automatically on discovery."
} }
}, },
"adb_pair": { "adb_pair": {
"title": "Pair ADB", "title": "Allow ADB access",
"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." "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": {
"host": "The Shield's IP address",
"adb_port": "ADB port",
"skip_pairing": "Continue without ADB pairing"
},
"data_description": {
"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": { "protocol_pair": {
"title": "Pair Remote Protocol", "title": "Pair the remote protocol",
"description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.", "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": { "data": {
"pairing_code": "Pairing code shown on the Shield" "pairing_code": "Pairing code shown on the Shield",
"host": "The Shield's IP address",
"skip_pairing": "Continue without protocol pairing"
},
"data_description": {
"pairing_code": "The code the Shield displays while this step is open.",
"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": { "reauth_confirm": {
"title": "Re-pair Nvidia Shield", "title": "Re-pair Nvidia Shield",
"description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the 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": { "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_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": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.", "adb_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
"protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.", "protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.", "protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
"protocol_pair_failed": "The Shield rejected that pairing code. Click 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. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host).", "protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again \u2014 or turn on 'Continue without protocol pairing' to finish setup without it.",
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 e.g. ar.tvplayer.tv=TiviMate.", "no_state_source": "No state source selected. Choose at least one: '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." "invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
}, },
"abort": { "abort": {
"already_configured": "This Shield entity is already configured.", "already_configured": "This Shield is already set up.",
"reauth_successful": "Re-paired successfully.", "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.", "reauth_entry_missing": "This entry no longer exists.",
"zeroconf_no_host": "The discovery event carried no usable address." "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": { "options": {
"step": { "step": {
"init": { "init": {
"title": "NVIDIA Shield Settings", "title": "NVIDIA Shield settings",
"description": "Configure linked devices and monitoring.", "description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
"data": { "data": {
"jellyfin_entity": "Jellyfin Media Player", "jellyfin_entity": "Jellyfin media player",
"receiver_entity": "Receiver", "receiver_entity": "Receiver",
"receiver_input": "Receiver Input", "receiver_input": "Receiver input",
"power_entity": "Power Switch", "power_entity": "Power switch",
"adb_enabled": "Enable ADB Media Session", "adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show App Media Info", "protocol_enabled": "Add the Nvidia Remote Protocol",
"adb_host": "ADB Host", "adb_store_app_info": "Show title and artist",
"adb_port": "ADB Port", "host": "The Shield's IP address",
"app_name_map": "Apps name/icon map", "adb_port": "ADB port",
"adb_command_enabled": "Enable raw ADB shell service", "app_name_map": "App names and icons",
"adb_command_enabled": "Enable the adb_command service",
"channel_digit_delay": "Channel digit delay (seconds)", "channel_digit_delay": "Channel digit delay (seconds)",
"channel_send_enter": "Send ENTER after channel digits", "channel_send_enter": "Send ENTER after channel digits",
"logcat_events": "Live events push (logcat)" "script_turn_on": "Turn on script",
"script_turn_off": "Turn off script",
"script_volume_up": "Volume up script",
"script_volume_down": "Volume down script",
"script_volume_set": "Volume set script",
"script_volume_mute": "Mute script",
"script_select_source": "Select source script",
"logcat_events": "Instant app detection (logcat stream)",
"remote_command_script": "Remote command script"
}, },
"data_description": { "data_description": {
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.", "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 this device is connected to.", "receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_input": "Which input on the receiver.", "receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
"power_entity": "Smart plug switch for reliable on/off detection.", "power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.", "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": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.", "host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
"adb_port": "ADB port (default 5555).", "protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.",
"app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear.", "adb_port": "The ADB port. The standard value is 5555.",
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.", "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.",
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).", "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_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.", "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).",
"logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device." "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.",
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
} }
} }
} }
},
"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."
},
"unknown_source": {
"title": "Unknown source",
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
},
"command_script_failed": {
"title": "Command script failed",
"message": "The script configured for {action} ({script}) failed: {error}"
}
},
"entity": {
"sensor": {
"current_app": {
"name": "Current app"
},
"now_playing": {
"name": "Now playing"
}
}
} }
} }
@@ -3,104 +3,185 @@
"step": { "step": {
"user": { "user": {
"title": "NVIDIA Shield", "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 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
"data": { "data": {
"name": "Device Name", "name": "Name",
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Optional for standalone setups that pair with the Nvidia Remote Protocol instead.", "jellyfin_entity": "Jellyfin media player",
"jellyfin_entity": "Jellyfin Media Player",
"receiver_entity": "Receiver", "receiver_entity": "Receiver",
"receiver_input": "Receiver Input", "receiver_input": "Receiver input",
"power_entity": "Power Switch", "power_entity": "Power switch",
"adb_enabled": "Enable ADB Media Session", "adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show App Media Info", "adb_store_app_info": "Show title and artist",
"adb_host": "ADB Host", "host": "The Shield's IP address",
"adb_port": "ADB Port", "adb_port": "ADB port",
"protocol_enabled": "Pair with Nvidia Remote Protocol", "protocol_enabled": "Add the Nvidia Remote Protocol"
"protocol_host": "Protocol Host"
}, },
"data_description": { "data_description": {
"androidtv_entity": "The media_player entity from the Android TV Remote integration.", "jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.", "receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_entity": "The receiver this device is connected to.", "receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
"receiver_input": "Which input on the receiver (e.g. 'Shield').", "power_entity": "A smart plug switch, if you want reliable on/off detection.",
"power_entity": "Smart plug switch for reliable on/off detection.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"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": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).", "host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity \u2014 pre-filled automatically when the Shield was discovered.", "adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
"adb_port": "ADB port number. Default is 5555.", "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_enabled": "Adds the all-in-one remote-protocol transport (power/availability/app push and volume readout). Adds a PIN pairing step; the Shield shows the code.",
"protocol_host": "Override the protocol host IP. Leave blank to auto-detect (same fallback as the ADB host) \u2014 pre-filled automatically on discovery."
} }
}, },
"adb_pair": { "adb_pair": {
"title": "Pair ADB", "title": "Allow ADB access",
"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." "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": {
"host": "The Shield's IP address",
"adb_port": "ADB port",
"skip_pairing": "Continue without ADB pairing"
},
"data_description": {
"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": { "protocol_pair": {
"title": "Pair Remote Protocol", "title": "Pair the remote protocol",
"description": "The Shield is now showing a pairing code. Enter it here. Click Submit to confirm.", "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": { "data": {
"pairing_code": "Pairing code shown on the Shield" "pairing_code": "Pairing code shown on the Shield",
"host": "The Shield's IP address",
"skip_pairing": "Continue without protocol pairing"
},
"data_description": {
"pairing_code": "The code the Shield displays while this step is open.",
"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": { "reauth_confirm": {
"title": "Re-pair Nvidia Shield", "title": "Re-pair Nvidia Shield",
"description": "The Shield reset to factory settings, so the saved certificate is no longer trusted. A pairing code will be shown on the 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": { "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_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": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step.", "adb_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
"protocol_no_host": "Could not determine the Shield's IP address. Set the Protocol Host or ADB Host field.", "protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
"protocol_invalid_code": "Pairing has not started or the code is empty. Click Submit to retry.", "protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
"protocol_pair_failed": "The Shield rejected that pairing code. Click 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. Provide at least one of: the Android TV Remote entity, the Nvidia Remote Protocol pairing, or ADB media-session mode (enable ADB and set the ADB Host).", "protocol_unreachable": "The Shield did not answer at that address. Check the address above and that the Shield is awake, then select Submit to try again \u2014 or turn on 'Continue without protocol pairing' to finish setup without it.",
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 e.g. ar.tvplayer.tv=TiviMate.", "no_state_source": "No state source selected. Choose at least one: '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." "invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
}, },
"abort": { "abort": {
"already_configured": "This Shield entity is already configured.", "already_configured": "This Shield is already set up.",
"reauth_successful": "Re-paired successfully.", "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.", "reauth_entry_missing": "This entry no longer exists.",
"zeroconf_no_host": "The discovery event carried no usable address." "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": { "options": {
"step": { "step": {
"init": { "init": {
"title": "NVIDIA Shield Settings", "title": "NVIDIA Shield settings",
"description": "Configure linked devices and monitoring.", "description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
"data": { "data": {
"jellyfin_entity": "Jellyfin Media Player", "jellyfin_entity": "Jellyfin media player",
"receiver_entity": "Receiver", "receiver_entity": "Receiver",
"receiver_input": "Receiver Input", "receiver_input": "Receiver input",
"power_entity": "Power Switch", "power_entity": "Power switch",
"adb_enabled": "Enable ADB Media Session", "adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show App Media Info", "protocol_enabled": "Add the Nvidia Remote Protocol",
"adb_host": "ADB Host", "adb_store_app_info": "Show title and artist",
"adb_port": "ADB Port", "host": "The Shield's IP address",
"app_name_map": "Apps name/icon map", "adb_port": "ADB port",
"adb_command_enabled": "Enable raw ADB shell service", "app_name_map": "App names and icons",
"adb_command_enabled": "Enable the adb_command service",
"channel_digit_delay": "Channel digit delay (seconds)", "channel_digit_delay": "Channel digit delay (seconds)",
"channel_send_enter": "Send ENTER after channel digits", "channel_send_enter": "Send ENTER after channel digits",
"logcat_events": "Live events push (logcat)" "script_turn_on": "Turn on script",
"script_turn_off": "Turn off script",
"script_volume_up": "Volume up script",
"script_volume_down": "Volume down script",
"script_volume_set": "Volume set script",
"script_volume_mute": "Mute script",
"script_select_source": "Select source script",
"logcat_events": "Instant app detection (logcat stream)",
"remote_command_script": "Remote command script"
}, },
"data_description": { "data_description": {
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator \u2014 no extra config needed.", "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 this device is connected to.", "receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_input": "Which input on the receiver.", "receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
"power_entity": "Smart plug switch for reliable on/off detection.", "power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.", "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": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.", "host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
"adb_port": "ADB port (default 5555).", "protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.",
"app_name_map": "One entry per line, package=name (e.g. ar.tvplayer.tv=TiviMate). The friendly name resolves after the Android TV Remote entity and the protocol push; icons follow the media image lookup by name. Leave blank to clear.", "adb_port": "The ADB port. The standard value is 5555.",
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (raw ADB shell over the persistent connection). Security-relevant: leave off unless you need it \u2014 it is the same opt-in posture as the core androidtv integration.", "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.",
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, matching the validated behavior).", "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_send_enter": "Optionally presses ENTER after the last digit (protocol key, then the ADB keyevent fallback). Off by default \u2014 matches TiviMate's validated behavior.", "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).",
"logcat_events": "Instant app-open detection via a dedicated logcat stream (AdbDevice.streaming_shell). Default off \u2014 watchdog polling keeps the state honest either way; if the stream fails repeatedly the integration disables it for the session and falls back to polling. Flip on and report stability on your device." "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.",
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
} }
} }
} }
},
"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."
},
"unknown_source": {
"title": "Unknown source",
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
},
"command_script_failed": {
"title": "Command script failed",
"message": "The script configured for {action} ({script}) failed: {error}"
}
},
"entity": {
"sensor": {
"current_app": {
"name": "Current app"
},
"now_playing": {
"name": "Now playing"
}
}
} }
} }
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
#
# Resolve the version this push should release (t42).
#
# Why a script: this decision is what makes a release visible to HACS, and every
# wrong answer is silent — the tag and the release still exist, only the manifest
# never moves. So the logic lives here, where tests/test_release_versioning.py
# drives every branch locally, instead of inline in a workflow nobody can run.
#
# Resolution (M = manifest version, D = today in UTC, tags = existing v-tags):
# 1. M is tagged AND that tag is the commit being released
# -> RELEASE=no (SOURCE=released): a re-run, this commit is already out
# 2. M is not tagged -> VERSION=M (SOURCE=manifest)
# an unreleased manifest version wins: bump it to force a release
# 3. D is not tagged -> VERSION=D (SOURCE=date)
# the one automatic release per UTC day
# 4. otherwise -> VERSION=D.N (SOURCE=same-day)
# N is the LOWEST untagged N >= 2, so further releases in the same UTC day
# become 2026.10.11.2, 2026.10.11.3, … instead of silently doing nothing
#
# The workflow writes the resolved VERSION back into the manifest whenever it
# differs, then tags THAT commit, so the released artifact and its tag always
# agree. The defect this fixes: v2026.10.09 shipped while master's manifest still
# said 2026.10.08, leaving HACS comparing against a version that never moved.
#
# Usage: scripts/determine-release-version.sh [--manifest PATH] [--today YYYY.MM.DD]
# [--tags-from FILE] [--head SHA]
#
# --manifest manifest to read (default: custom_components/nvidia_shield/manifest.json)
# --today override the UTC date — for tests and dry runs only
# --tags-from read "TAG [SHA]" lines instead of asking git — for tests and dry runs
# --head the commit being released (default: git rev-parse HEAD)
#
# Stdout, and $GITHUB_OUTPUT as well when it is set (which is what the workflow
# consumes): VERSION, TAG, SOURCE, BUMP, RELEASE. Commentary goes to stderr.
# RELEASE=no is the explicit no-op signal: nothing may be tagged or published.
# Exit status is 1 only for a usage or manifest error, so "no release" is not a
# failed job.
set -euo pipefail
MANIFEST="custom_components/nvidia_shield/manifest.json"
TAGS_FROM=""
HEAD_SHA=""
TODAY=""
usage() {
sed -n '/^# Usage:/,/^# Exit status/p' "$0" | sed 's/^# \{0,1\}//'
}
while [[ $# -gt 0 ]]; do
case "$1" in
--manifest) MANIFEST="${2:?--manifest needs a path}"; shift 2 ;;
--today) TODAY="${2:?--today needs YYYY.MM.DD}"; shift 2 ;;
--tags-from) TAGS_FROM="${2:?--tags-from needs a path}"; shift 2 ;;
--head) HEAD_SHA="${2:?--head needs a sha}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 1 ;;
esac
done
note() { printf '%s\n' "$*" >&2; }
[[ -f "$MANIFEST" ]] || { note "manifest not found: $MANIFEST"; exit 1; }
command -v jq >/dev/null 2>&1 || { note "jq is required to read the manifest"; exit 1; }
MANIFEST_VERSION="$(jq -r '.version // empty' "$MANIFEST")"
[[ -n "$MANIFEST_VERSION" ]] || { note "no .version in $MANIFEST"; exit 1; }
# Tolerate a hand-written leading "v" (the tag adds its own); anything else that
# is not YYYY.MM.DD[.N] is a mistake worth failing for.
MANIFEST_VERSION="${MANIFEST_VERSION#v}"
[[ "$MANIFEST_VERSION" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?$ ]] || {
note "manifest version '$MANIFEST_VERSION' is not YYYY.MM.DD[.N]"; exit 1; }
if [[ -n "$TODAY" ]]; then
[[ "$TODAY" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$ ]] || { note "--today must be YYYY.MM.DD"; exit 1; }
DATE_VERSION="$TODAY"
else
# UTC, always: a runner's local timezone must never decide a version number.
DATE_VERSION="$(date -u +%Y.%m.%d)"
fi
if [[ -n "$TAGS_FROM" ]]; then
[[ -f "$TAGS_FROM" ]] || { note "tag list not found: $TAGS_FROM"; exit 1; }
else
command -v git >/dev/null 2>&1 || { note "git is required to list tags"; exit 1; }
fi
tag_exists() {
if [[ -n "$TAGS_FROM" ]]; then
awk -v wanted="$1" '$1 == wanted { found = 1 } END { exit(found ? 0 : 1) }' "$TAGS_FROM"
else
git rev-parse -q --verify "refs/tags/$1" >/dev/null
fi
}
tag_sha() {
if [[ -n "$TAGS_FROM" ]]; then
awk -v wanted="$1" '$1 == wanted && NF > 1 { print $2; found = 1 } END { exit(found ? 0 : 1) }' "$TAGS_FROM"
else
git rev-parse -q --verify "refs/tags/$1^{commit}"
fi
}
if [[ -z "$HEAD_SHA" ]] && [[ -z "$TAGS_FROM" ]]; then
HEAD_SHA="$(git rev-parse -q --verify HEAD 2>/dev/null || true)"
fi
VERSION=""
SOURCE=""
RELEASE="yes"
if tag_exists "v${MANIFEST_VERSION}" && [[ -n "$HEAD_SHA" ]] \
&& [[ "$(tag_sha "v${MANIFEST_VERSION}" 2>/dev/null || true)" == "$HEAD_SHA" ]]; then
VERSION="$MANIFEST_VERSION"; SOURCE="released"; RELEASE="no"
note "v${VERSION} is already tagged on this commit — nothing to release"
elif ! tag_exists "v${MANIFEST_VERSION}"; then
VERSION="$MANIFEST_VERSION"; SOURCE="manifest"
elif ! tag_exists "v${DATE_VERSION}"; then
VERSION="$DATE_VERSION"; SOURCE="date"
else
N=2
while tag_exists "v${DATE_VERSION}.${N}"; do N=$((N + 1)); done
VERSION="${DATE_VERSION}.${N}"; SOURCE="same-day"
fi
if [[ "$RELEASE" == "yes" ]] && tag_exists "v${VERSION}"; then
# Backstop, not a normal path: the resolution above only ever picks an
# untagged version, so reaching this means the tag set changed underneath us
# (a race with a parallel run — the concurrency group is the primary guard).
# Never tag or publish something that already exists.
RELEASE="no"; SOURCE="tagged"
note "v${VERSION} already exists — nothing to release"
fi
BUMP="no"
if [[ "$VERSION" != "$MANIFEST_VERSION" ]]; then
BUMP="yes"
fi
if [[ "$RELEASE" == "yes" ]]; then
note "releasing v${VERSION} (${SOURCE}); manifest says ${MANIFEST_VERSION}, bump=${BUMP}"
else
note "no release (${SOURCE})"
fi
emit() {
printf '%s=%s\n' "$1" "$2"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
printf '%s=%s\n' "$1" "$2" >> "$GITHUB_OUTPUT"
fi
}
emit VERSION "$VERSION"
emit TAG "v${VERSION}"
emit SOURCE "$SOURCE"
emit BUMP "$BUMP"
emit RELEASE "$RELEASE"
+1
View File
@@ -50,3 +50,4 @@ _load_module(
# Stdlib-only integration modules. # Stdlib-only integration modules.
for _mod in ("const", "adb_session", "connection"): for _mod in ("const", "adb_session", "connection"):
_load_module(f"{_PKG}.{_mod}", _SHIELD_DIR / f"{_mod}.py") _load_module(f"{_PKG}.{_mod}", _SHIELD_DIR / f"{_mod}.py")
+393 -20
View File
@@ -10,11 +10,15 @@ import asyncio
import enum import enum
import sys import sys
import types import types
from collections.abc import Mapping
import voluptuous_mirror
ENTITIES_STUB_NAMES = ( ENTITIES_STUB_NAMES = (
"voluptuous", "voluptuous",
"homeassistant", "homeassistant",
"homeassistant.config_entries", "homeassistant.config_entries",
"homeassistant.exceptions",
"homeassistant.core", "homeassistant.core",
"homeassistant.const", "homeassistant.const",
"homeassistant.components", "homeassistant.components",
@@ -23,8 +27,10 @@ ENTITIES_STUB_NAMES = (
"homeassistant.components.button", "homeassistant.components.button",
"homeassistant.helpers", "homeassistant.helpers",
"homeassistant.helpers.entity_registry", "homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector", "homeassistant.helpers.selector",
"homeassistant.helpers.event", "homeassistant.helpers.event",
"homeassistant.helpers.redact",
"homeassistant.helpers.entity", "homeassistant.helpers.entity",
"homeassistant.helpers.entity_platform", "homeassistant.helpers.entity_platform",
"nsh.helpers", "nsh.helpers",
@@ -36,6 +42,37 @@ ENTITIES_STUB_NAMES = (
) )
class EntitySelectorStub:
"""Stand-in for HA's EntitySelector that REJECTS '' and None, like the real one.
Real HA answers a cleared entity field with "Schema validation failed at …"
(t46 F1), so a stub that accepted anything would hide exactly the trap the flow
has to tolerate; tests/test_config_flow_ux.py validates a schema with both
values to prove it.
"""
def __init__(self, config=None):
self.config = config
def __call__(self, data):
if not isinstance(data, str) or not data.strip():
raise ValueError("Entity is not a valid entity id")
return data
def install_selector_faces(selector_module) -> None:
"""Populate a ``homeassistant.helpers.selector`` stub module.
One definition for every installer (entity_stublib and the four per-file
installers in the flow tests), so the stubs cannot drift apart.
"""
selector_module.EntitySelector = EntitySelectorStub
selector_module.EntitySelectorConfig = lambda *args, **kwargs: ""
selector_module.SelectSelector = lambda *args, **kwargs: None
selector_module.SelectSelectorConfig = lambda **kwargs: ""
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
class MediaPlayerState(enum.StrEnum): class MediaPlayerState(enum.StrEnum):
OFF = "off" OFF = "off"
ON = "on" ON = "on"
@@ -57,36 +94,98 @@ class MediaPlayerEntityFeature(enum.IntFlag):
PLAY_MEDIA = 256 PLAY_MEDIA = 256
NEXT_TRACK = 512 NEXT_TRACK = 512
PREVIOUS_TRACK = 1024 PREVIOUS_TRACK = 1024
SELECT_SOURCE = 2048
class MediaClass: class MediaClass:
"""homeassistant.components.media_player.MediaClass (values we use)."""
APP = "app" APP = "app"
DIRECTORY = "directory" DIRECTORY = "directory"
class BrowseMediaSource: class MediaType:
"""HA BrowseMedia shape used by the app directory tests.""" """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__( def __init__(
self, self,
*, *,
title,
media_class, media_class,
media_content_type,
media_content_id, media_content_id,
media_content_type,
title,
can_play, can_play,
can_expand, can_expand,
children=None, children=None,
children_media_class=None,
thumbnail=None, thumbnail=None,
not_shown=0,
can_search=False,
search_media_classes=None,
): ):
self.title = title
self.media_class = media_class self.media_class = media_class
self.media_content_type = media_content_type
self.media_content_id = media_content_id self.media_content_id = media_content_id
self.media_content_type = media_content_type
self.title = title
self.can_play = can_play self.can_play = can_play
self.can_expand = can_expand self.can_expand = can_expand
self.children = children or [] self.children = children
self.children_media_class = children_media_class
self.thumbnail = thumbnail 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: class MediaEntityManager:
@@ -105,15 +204,36 @@ class RemoteEntity:
return None return None
#: Every face install() registers. The guard below checks the WHOLE set, not one
#: sentinel: test files scope their stubs per file (snapshot + pop at teardown, the
#: t7 discipline), so one file can leave `homeassistant.components.media_player`
#: behind while another name it popped — e.g. `voluptuous` — is gone. A sentinel
#: guard then made install() a no-op in the next file, and whichever module needed
#: the missing face failed with ModuleNotFoundError depending on file order (t44).
#: The faces install() provides, derived from the list every test file scopes on.
#: The ``nsh.*`` entries there are the integration modules the harness loads, not
#: stub faces, so they are filtered out here.
_STUB_FACES = tuple(
name for name in ENTITIES_STUB_NAMES if not name.startswith("nsh.")
)
def install() -> None: def install() -> None:
"""Register the stub modules (idempotent).""" """Register the stub modules (idempotent, and self-healing).
if "homeassistant.components.media_player" in sys.modules:
Callers must be able to rely on this regardless of what a previous test file
popped: a missing face rebuilds the whole set, so any subset or file order
works. A complete set is left alone — a file that installed richer faces of
its own (tests/test_config_flow_ux.py's voluptuous mirror, the discovery
stub) keeps them.
"""
if all(name in sys.modules for name in _STUB_FACES):
return return
vol = types.ModuleType("voluptuous") vol = types.ModuleType("voluptuous")
vol.Required = lambda key, default=None: key # t33 F-A: the faithful mirror keeps schema defaults visible — a bare-key
vol.Optional = lambda key, default=None: key # stub cannot tell a schema WITH a default from one without.
vol.Schema = lambda schema: schema voluptuous_mirror.install(vol)
sys.modules["voluptuous"] = vol sys.modules["voluptuous"] = vol
ha = types.ModuleType("homeassistant") ha = types.ModuleType("homeassistant")
@@ -121,19 +241,141 @@ def install() -> None:
config_entries = types.ModuleType("homeassistant.config_entries") config_entries = types.ModuleType("homeassistant.config_entries")
config_entries.ConfigEntry = type("ConfigEntry", (), {}) 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( _config_flow_base = type(
"ConfigFlow", "ConfigFlow",
(), (_ConfigFlowStub,),
{ {
"__init_subclass__": classmethod( "__init_subclass__": classmethod(
lambda cls, domain=None, **kwargs: None lambda cls, domain=None, **kwargs: None
), ),
"__init__": _config_flow_init,
}, },
) )
config_entries.ConfigFlow = _config_flow_base config_entries.ConfigFlow = _config_flow_base
config_entries.OptionsFlow = type("OptionsFlow", (), {}) 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 sys.modules["homeassistant.config_entries"] = config_entries
exceptions = types.ModuleType("homeassistant.exceptions")
class HomeAssistantErrorStubError(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")
self.translation_placeholders = kwargs.get("translation_placeholders")
class ServiceValidationErrorStubError(HomeAssistantErrorStubError):
"""Real HA: ServiceValidationError derives from HomeAssistantError (t44)."""
# t44: media_player raises HomeAssistantError when a configured command script
# fails, so both names have to exist — and with the real inheritance.
exceptions.HomeAssistantError = HomeAssistantErrorStubError
exceptions.ServiceValidationError = ServiceValidationErrorStubError
sys.modules["homeassistant.exceptions"] = exceptions
core = types.ModuleType("homeassistant.core") core = types.ModuleType("homeassistant.core")
core.HomeAssistant = object core.HomeAssistant = object
core.callback = lambda fn: fn core.callback = lambda fn: fn
@@ -161,9 +403,12 @@ def install() -> None:
mp.MediaPlayerEntity = MediaPlayerEntity mp.MediaPlayerEntity = MediaPlayerEntity
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv") mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
mp.BrowseMedia = BrowseMediaSource # factory used as a constructor mp.BrowseMedia = BrowseMedia
mp.BrowseMediaSource = BrowseMediaSource
mp.MediaClass = MediaClass 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 mp.MediaPlayerState = MediaPlayerState
sys.modules["homeassistant.components.media_player"] = mp sys.modules["homeassistant.components.media_player"] = mp
@@ -174,6 +419,11 @@ def install() -> None:
button = types.ModuleType("homeassistant.components.button") button = types.ModuleType("homeassistant.components.button")
button.ButtonEntity = type("ButtonEntity", (), {}) button.ButtonEntity = type("ButtonEntity", (), {})
sys.modules["homeassistant.components.button"] = button 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") helpers = types.ModuleType("homeassistant.helpers")
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry") entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
@@ -185,15 +435,32 @@ def install() -> None:
return None return None
entity_registry.async_get = lambda hass: _Registry() 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 helpers.entity_registry = entity_registry
sys.modules["homeassistant.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 = types.ModuleType("homeassistant.helpers.event")
event.async_call_later = lambda hass, delay, callback_fn: (lambda: None) event.async_call_later = lambda hass, delay, callback_fn: (lambda: None)
event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None) event.async_track_state_change_event = lambda *args, **kwargs: (lambda: None)
helpers.event = event helpers.event = event
helpers.selector = types.SimpleNamespace( helpers.selector = types.SimpleNamespace(
EntitySelector=lambda *a, **k: None, EntitySelector=EntitySelectorStub,
EntitySelectorConfig=lambda *a, **k: "", EntitySelectorConfig=lambda *a, **k: "",
SelectSelector=lambda *a, **k: None, SelectSelector=lambda *a, **k: None,
SelectSelectorConfig=lambda **k: "", SelectSelectorConfig=lambda **k: "",
@@ -201,27 +468,133 @@ def install() -> None:
) )
entity = types.ModuleType("homeassistant.helpers.entity") entity = types.ModuleType("homeassistant.helpers.entity")
entity.DeviceInfo = dict entity.DeviceInfo = dict
entity.EntityCategory = types.SimpleNamespace(
CONFIG="config", DIAGNOSTIC="diagnostic"
)
helpers.entity = entity helpers.entity = entity
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform") entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
entity_platform.AddEntitiesCallback = object entity_platform.AddEntitiesCallback = object
helpers.entity_platform = entity_platform 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.event"] = event
sys.modules["homeassistant.helpers.entity"] = entity sys.modules["homeassistant.helpers.entity"] = entity
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform sys.modules["homeassistant.helpers.entity_platform"] = entity_platform
# t44: config_flow imports `selector` from this package, and the per-file stub
# installers register it as its own face. Installing it here too keeps
# `_STUB_FACES` honest: install() then really does provide every face a test
# file may have popped, which is what its guard checks.
selector = types.ModuleType("homeassistant.helpers.selector")
install_selector_faces(selector)
selector.SelectSelector = lambda *args, **kwargs: None
selector.SelectSelectorConfig = lambda **kwargs: ""
selector.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
helpers.selector = selector
sys.modules["homeassistant.helpers.selector"] = selector
sys.modules["homeassistant.helpers"] = helpers sys.modules["homeassistant.helpers"] = helpers
#: Namespaces the stub harness owns. Anything under these prefixes that a test
#: file creates is harness state, so a file's teardown must remove it: a module
#: left behind keeps the stub objects it captured (an exception class, the redact
#: helper the diagnostics module holds) after the next file rebuilt them, which is
#: how a file-order-dependent failure looked (t44).
_TRACKED_PREFIXES = ("homeassistant", "voluptuous", "nsh")
def _tracked(name: str) -> bool:
return name.split(".", 1)[0] in _TRACKED_PREFIXES
def snapshot_modules() -> dict:
"""Every stub/module name live right now — for a leak-free teardown."""
return {name: module for name, module in sys.modules.items() if _tracked(name)}
def restore_modules(snapshot: dict) -> None:
"""Undo everything the current test file did to those namespaces.
Two halves, and the first is what a hand-written list of names kept getting
wrong: drop every tracked name the file created (so the next file loads it
fresh, against ITS stubs), then put back every object it replaced.
"""
for name in [name for name in sys.modules if _tracked(name) and name not in snapshot]:
del sys.modules[name]
sys.modules.update(snapshot)
def load_entity_modules() -> dict: def load_entity_modules() -> dict:
"""Load the integration's HA-free-ish entity modules under nsh.""" """Load the integration's HA-free-ish entity modules under nsh."""
install() install()
from conftest import _SHIELD_DIR, _load_module from conftest import _SHIELD_DIR, _load_module
for name in ("helpers", "power", "jellyfin", "media_player", "remote", "diagnostics"): for name in (
"helpers",
"power",
"jellyfin",
"sensor",
"media_player",
"remote",
"diagnostics",
# t41: button.py was the file nobody loaded — a deleted helper it still
# imported shipped silently. It is a platform like the others, so it
# belongs here; tests/test_module_coverage.py sweeps EVERY module, which
# is what keeps this list from having to be complete.
"button",
"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: if f"nsh.{name}" not in sys.modules:
_load_module(f"nsh.{name}", _SHIELD_DIR / f"{name}.py") file_name = "__init__.py" if name == "init" else f"{name}.py"
_load_module(f"nsh.{name}", _SHIELD_DIR / file_name)
return { return {
name: sys.modules[f"nsh.{name}"] name: sys.modules[f"nsh.{name}"]
for name in ("power", "jellyfin", "media_player", "remote", "diagnostics") for name in (
"power",
"jellyfin",
"sensor",
"media_player",
"remote",
"diagnostics",
)
} }
+931
View File
@@ -0,0 +1,931 @@
"""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;
4. every class inheriting from an HA entity base has its HA-READ attributes
(``extra_state_attributes``, ``available``, …) declared as properties — HA
merges them with ``attr |= self.extra_state_attributes``, so a plain method
raises ``TypeError: 'method' object is not iterable`` on every state write and
the entity can never render (t40 live install: ``media_player.py:828``). The
stub suites call the method happily; this pass is pure AST and, unlike 1-3,
needs no homeassistant at all;
5. the package's OWN relative imports (t41): ``from .helpers import X`` must
resolve to a file of this package that defines ``X`` (a submodule counts).
``button.py`` imported a helper that a refactor had deleted, and because no
harness loaded that file the ImportError waited for a user's log — pass 1
cannot see it (it only judges ``homeassistant.*``). Also pure AST, with its
own entry point ``scan_package_imports()`` for the fast suite.
Both AST passes run before the homeassistant availability check, and the file
walk is recursive (``_iter_python_files``), so a platform or helper one
directory down cannot hide from either of them.
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] = {
# t41: the sweep is recursive, so the floor sits above the top-level-only
# count (13) — a regression to the shallow glob trips it.
"modules": 20,
"imports": 40,
"relative_imports": 100,
"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
relative_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.relative_imports} relative 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 _iter_python_files(root: Path) -> list[Path]:
"""Every Python file under a target, recursively (t41).
The sweep used to glob one level, so a platform or helper one directory down
was invisible to both passes. Exhaustive-by-construction beats a list.
"""
if not root.is_dir():
return [root]
return sorted(
path for path in root.rglob("*.py") if "__pycache__" not in path.parts
)
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}",
)
)
# ------------------------------------------------------- the package's own imports
#
# Pure AST, no Home Assistant required — the same contract as the entity pass, so
# the fast suite can prove it even in the androidtvremote2-less variant.
def _package_root(path: Path) -> Path:
"""The integration package a file belongs to.
The topmost ancestor directory that still has an ``__init__.py``: that is
``custom_components/nvidia_shield`` for both its top-level modules and the
vendored ``ha_adb_common`` subpackage, and it holds for a copied tree too
(tests scan temp copies).
"""
root = path.parent
while (root.parent / "__init__.py").exists():
root = root.parent
return root
class _ModuleLevelNames(ast.NodeVisitor):
"""Names a module binds at import time.
Functions, classes, assignments and imports — including the ones inside
``if``/``try`` guards (the optional androidtvremote2 import lives in one).
Function and class BODIES are skipped: a name bound at call time is not
importable, which is exactly the distinction this pass needs.
"""
def __init__(self) -> None:
self.names: set[str] = set()
def visit_FunctionDef(self, node: ast.AST) -> None:
self.names.add(node.name)
def visit_AsyncFunctionDef(self, node: ast.AST) -> None:
self.names.add(node.name)
def visit_ClassDef(self, node: ast.AST) -> None:
self.names.add(node.name)
def visit_Import(self, node: ast.Import) -> None:
for alias in node.names:
self.names.add(alias.asname or alias.name.split(".")[0])
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
for alias in node.names:
if alias.name != "*":
self.names.add(alias.asname or alias.name)
def _bind(self, target: ast.AST) -> None:
if isinstance(target, ast.Name):
self.names.add(target.id)
elif isinstance(target, (ast.Tuple, ast.List)):
for element in target.elts:
self._bind(element)
elif isinstance(target, ast.Starred):
self._bind(target.value)
def visit_Assign(self, node: ast.Assign) -> None:
for target in node.targets:
self._bind(target)
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
self._bind(node.target)
def visit_AugAssign(self, node: ast.AugAssign) -> None:
self._bind(node.target)
def visit_For(self, node: ast.For) -> None:
self._bind(node.target)
self.generic_visit(node)
def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
self._bind(node.target)
self.generic_visit(node)
def visit_With(self, node: ast.With) -> None:
for item in node.items:
if item.optional_vars is not None:
self._bind(item.optional_vars)
self.generic_visit(node)
def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
for item in node.items:
if item.optional_vars is not None:
self._bind(item.optional_vars)
self.generic_visit(node)
def _module_level_names(tree: ast.AST) -> set[str]:
visitor = _ModuleLevelNames()
visitor.visit(tree)
return visitor.names
def _resolve_relative_import(path: Path, node: ast.ImportFrom, root: Path) -> Path | None:
"""The file ``from ... import`` points at, or None when it leaves the package."""
base = path.parent
for _ in range(node.level - 1):
base = base.parent
if not base.is_relative_to(root):
return None
if not node.module:
return base / "__init__.py"
target = base.joinpath(*node.module.split("."))
if target.is_dir():
return target / "__init__.py"
return target.with_suffix(".py")
def _scan_relative_imports(path: Path, tree: ast.AST, report: Report) -> None:
"""t41: resolve the integration's OWN imports, not only ``homeassistant.*``.
``_scan_imports`` proves the HA surface; it says nothing about
``from .helpers import discover_adb_host`` once that helper is deleted — the
exact defect that shipped from ``button.py`` because no harness loaded the
file. This pass is pure AST against the tree, so it needs no importable
Home Assistant and cannot be masked by a missing stub: the target file must
exist and must define every name imported from it (a submodule counts too).
"""
root = _package_root(path)
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom) or not node.level:
continue # absolute imports are _scan_imports' business
spelling = f"{'.' * node.level}{node.module or ''}"
target = _resolve_relative_import(path, node, root)
if target is None:
report.findings.append(
Finding(
str(path),
node.lineno,
"broken-relative-import",
f"from {spelling} import ... escapes the package — the "
"integration may only import from itself",
)
)
continue
if not target.exists():
report.findings.append(
Finding(
str(path),
node.lineno,
"broken-relative-import",
f"from {spelling} import ...: no module at "
f"{target.relative_to(root)}",
)
)
continue
defined = _module_level_names(
ast.parse(target.read_text(encoding="utf-8"), filename=str(target))
)
for alias in node.names:
if alias.name == "*":
continue
report.relative_imports += 1
if alias.name in defined:
continue
# `from .package import submodule` is valid without the name being
# bound in __init__ — the import system resolves the submodule.
sibling = target.parent / f"{alias.name}.py"
sibling_package = target.parent / alias.name / "__init__.py"
if sibling.exists() or sibling_package.exists():
continue
report.findings.append(
Finding(
str(path),
node.lineno,
"broken-relative-import",
f"from {spelling} import {alias.name}: not defined in "
f"{target.relative_to(root)}",
)
)
def _scan_package_imports(paths, report: Report) -> None:
"""Walk the tree and resolve every one of the package's own imports."""
for root in paths:
root = Path(root)
if not root.exists():
continue # scan() reports bad-path for the same target
for file in _iter_python_files(root):
try:
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
except (OSError, SyntaxError):
continue
_scan_relative_imports(file, tree, report)
def scan_package_imports(paths=None) -> list[Finding]:
"""The relative-import pass on its own (t41) — the fast suite's entry point."""
if paths is None:
paths = [SHIELD_DIR]
elif isinstance(paths, (str, Path)):
paths = [paths]
report = Report()
_scan_package_imports(paths, report)
return report.findings
# -------------------------------------------------------------------- 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("."))
# ----------------------------------------------------------- entity contracts
#: Attributes Home Assistant reads as ATTRIBUTES — it never calls them. A plain
#: method with one of these names is invisible to the stub suites (permissive
#: stubs, and the harness replaces ``async_write_ha_state``) and fatal under real
#: HA: ``Entity.__async_calculate_state`` merges ``extra_state_attributes`` with
#: ``attr |= self.extra_state_attributes``, which raises
#: ``TypeError: 'method' object is not iterable`` on every state write. That was
#: the t40 live-install blocker (``media_player.py:828``).
ENTITY_ATTRIBUTES = frozenset(
{
"activity",
"activity_list",
"app_id",
"app_name",
"assumed_state",
"attribution",
"available",
"capability_attributes",
"current_activity",
"device_class",
"device_info",
"entity_category",
"entity_picture",
"extra_state_attributes",
"icon",
"is_volume_muted",
"last_reset",
"media_album_name",
"media_artist",
"media_content_id",
"media_content_type",
"media_duration",
"media_image_url",
"media_position",
"media_title",
"name",
"native_unit_of_measurement",
"native_value",
"options",
"should_poll",
"sound_mode",
"source",
"source_list",
"state",
"state_attributes",
"supported_features",
"translation_key",
"unique_id",
"unit_of_measurement",
"volume_level",
}
)
#: Entity base classes Home Assistant ships. A class counts as an entity when its
#: transitive bases include one of these, so our own intermediate bases
#: (``NvidiaShieldSensorBase``) are covered as well.
HA_ENTITY_BASES = frozenset(
{
"AlarmControlPanelEntity",
"BinarySensorEntity",
"ButtonEntity",
"CalendarEntity",
"CameraEntity",
"ClimateEntity",
"CoverEntity",
"DeviceTrackerEntity",
"Entity",
"EventEntity",
"FanEntity",
"HumidifierEntity",
"ImageEntity",
"LawnMowerEntity",
"LightEntity",
"LockEntity",
"MediaPlayerEntity",
"NotifyEntity",
"NumberEntity",
"RemoteEntity",
"SelectEntity",
"SensorEntity",
"SirenEntity",
"SwitchEntity",
"TextEntity",
"TodoListEntity",
"UpdateEntity",
"VacuumEntity",
"ValveEntity",
"WaterHeaterEntity",
"WeatherEntity",
}
)
def _is_attribute_decorated(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
"""True when the method is declared ``@property`` / ``@cached_property``."""
for decorator in node.decorator_list:
name = decorator.id if isinstance(decorator, ast.Name) else getattr(decorator, "attr", "")
if name in {"property", "cached_property"}:
return True
return False
def _class_bases(node: ast.ClassDef) -> set[str]:
"""The names in a class's base list (``**kwargs``-style bases are ignored)."""
return {
base.id if isinstance(base, ast.Name) else getattr(base, "attr", "")
for base in node.bases
}
def entity_class_names(tree: ast.Module) -> set[str]:
"""Names of the classes in one module that inherit from an HA entity base."""
bases = {
node.name: _class_bases(node) for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
}
entities = {name for name, parents in bases.items() if parents & HA_ENTITY_BASES}
# Transitive: our own intermediate bases (NvidiaShieldSensorBase → SensorEntity)
changed = True
while changed:
changed = False
for name, parents in bases.items():
if name not in entities and parents & entities:
entities.add(name)
changed = True
return entities
def _scan_entity_contracts(paths, report: Report) -> None:
"""t40: flag HA-read attributes declared as plain methods.
The stub suites cannot see this class of defect — they call the method
successfully, because the harnesses replace ``async_write_ha_state`` — and
only real HA turns it into a TypeError. Pure AST, so it also runs when
homeassistant is not installed.
"""
for root in paths:
root = Path(root)
if not root.exists():
continue # scan() reports bad-path for the same target
files = _iter_python_files(root)
for file in files:
try:
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
except (OSError, SyntaxError):
continue
entity_classes = entity_class_names(tree)
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef) or node.name not in entity_classes:
continue
for item in node.body:
if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if item.name not in ENTITY_ATTRIBUTES or _is_attribute_decorated(item):
continue
report.findings.append(
Finding(
str(file),
item.lineno,
"entity-attribute-is-a-method",
f"{node.name}.{item.name} is a plain method, but HA reads it as an "
"attribute (``attr |= self.<name>``) — declare it @property",
)
)
def scan_entity_contracts(paths=None) -> list[Finding]:
"""The entity-contract pass on its own (t40) — the fast suite's entry point."""
if paths is None:
paths = [SHIELD_DIR]
elif isinstance(paths, (str, Path)):
paths = [paths]
report = Report()
_scan_entity_contracts(paths, report)
return report.findings
# ----------------------------------------------------------------------- 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()
# t40/t41: pure-AST checks, so they run even without homeassistant — that is
# the whole point of a contract stubs cannot enforce (and a relative import
# needs no library to be wrong).
_scan_entity_contracts(paths, report)
_scan_package_imports(paths, 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 = _iter_python_files(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)
+509 -94
View File
@@ -6,12 +6,14 @@ legacy external entity, plus the §7 channel digit sequence (protocol digits,
layer marks which tier served each command. layer marks which tier served each command.
""" """
import ast
import asyncio import asyncio
import sys import sys
import entity_stublib import entity_stublib
import pytest import pytest
from entity_stublib import ENTITIES_STUB_NAMES, install, load_entity_modules from conftest import _SHIELD_DIR
from entity_stublib import install, load_entity_modules
from nsh.connection import ShieldConnectionManager from nsh.connection import ShieldConnectionManager
from nsh.const import TIVIMATE_PACKAGE from nsh.const import TIVIMATE_PACKAGE
@@ -19,15 +21,10 @@ from nsh.const import TIVIMATE_PACKAGE
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def scoped_entity_stubs(): def scoped_entity_stubs():
"""Container for the HA stubs + loaded entity modules (t7 discipline).""" """Container for the HA stubs + loaded entity modules (t7 discipline)."""
snapshot = {name: sys.modules.get(name) for name in ENTITIES_STUB_NAMES} snapshot = entity_stublib.snapshot_modules()
install() install()
yield load_entity_modules() yield load_entity_modules()
for name in ENTITIES_STUB_NAMES: entity_stublib.restore_modules(snapshot)
original = snapshot[name]
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
class FakeProtocolClient: class FakeProtocolClient:
@@ -102,7 +99,7 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
mp_module = modules["media_player"] mp_module = modules["media_player"]
if manager is None: if manager is None:
manager = make_manager() manager = make_manager()
entry = SimpleNamespace(options={}, data={}, title="mdarcy") entry = SimpleNamespace(entry_id="e1", options={}, data={}, title="mdarcy")
manager.entry = entry manager.entry = entry
def _set_option(key, value): def _set_option(key, value):
@@ -114,6 +111,7 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
player._entry = entry player._entry = entry
player._manager = manager player._manager = manager
player._intent = None player._intent = None
player._intent_timer_unsub = None # mirrors __init__ (intent self-heal)
player._last_channel = None player._last_channel = None
player.hass = entity_stublib.FakeHass() player.hass = entity_stublib.FakeHass()
player._entity_tasks = set() player._entity_tasks = set()
@@ -121,13 +119,18 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
player.async_write_ha_state = lambda: None player.async_write_ha_state = lambda: None
player._read = lambda eid: None player._read = lambda eid: None
# t41: nothing may leave this integration, so an HA service call is now the
# failure signal rather than a supported tier. `served` stays empty unless a
# regression reintroduces the external-entity delegation.
served = [] served = []
async def _cmd(service, **kwargs): class _RecordingServices:
served.append(("legacy", service)) async def async_call(self, domain, service, data=None, **kwargs):
return None # t44: the payload matters now (command scripts pass HA script
# variables), so record it too — every existing assertion is `== []`.
served.append((domain, service, data))
player._cmd = _cmd player.hass.services = _RecordingServices()
captured_intents = [] captured_intents = []
@@ -246,7 +249,8 @@ class TestChannelDigits:
await player._channel_entry_sequence(["7"]) await player._channel_entry_sequence(["7"])
assert served == [("legacy", "play_media")] # transitional path used # t41: both transports down is a no-op — no other integration is called
assert served == []
class TestAppAndUrls: class TestAppAndUrls:
@@ -344,38 +348,67 @@ class TestRemoteEntity:
assert protocol.key_calls == [] assert protocol.key_calls == []
class TestIntentRetryWithoutATransport:
"""t41: an intent no transport served surrenders instead of delegating."""
def _intent(self, **overrides):
from types import SimpleNamespace
intent = SimpleNamespace(
target_state="playing",
command="media_play",
created_at=0.0,
prior_state=None,
retry_count=0,
via_adb=False,
via_protocol=False,
adb_command=None,
protocol_key=None,
)
for key, value in overrides.items():
setattr(intent, key, value)
return intent
async def test_unserved_intent_surrenders_without_a_service_call(self, modules):
player, served, _intents = make_player(modules)
player.async_write_ha_state = lambda: None
player._intent = self._intent()
player._on_intent_timeout(0.0) # async_call_later passes the timestamp
assert player._intent is None # surrendered to the truth
assert served == [] # and nothing left this integration
async def test_protocol_served_intent_still_retries_over_the_protocol(self, modules):
player, served, _intents = make_player(modules)
player.async_write_ha_state = lambda: None
protocol = player._manager._protocol
player._intent = self._intent(
via_protocol=True, protocol_key="KEYCODE_MEDIA_PLAY"
)
player._on_intent_timeout(0.0) # async_call_later passes the timestamp
await asyncio.sleep(0.05)
assert protocol.key_calls == [("KEYCODE_MEDIA_PLAY", "SHORT")]
assert served == []
class TestAppFallback: class TestAppFallback:
async def test_app_id_prefers_the_androidtv_entity(self, modules): """t41: the app identity comes from OUR transports' store only."""
async def test_app_id_comes_from_the_manager_store(self, modules):
player, _s, _i = make_player(modules) player, _s, _i = make_player(modules)
player._androidtv_entity = "media_player.atv" player._manager._on_current_app("com.plex.hdx")
from types import SimpleNamespace as FakeState
state = FakeState(attributes={"app_id": "com.plex.hdx", "app_name": "Plex"})
states = FakeState(states=FakeState(
get=lambda eid: state if eid == "media_player.atv" else None
))
player.hass = states
player._read = lambda eid: state if eid == "media_player.atv" else None
assert player.app_id == "com.plex.hdx"
assert player.app_name == "Plex"
async def test_app_falls_back_to_protocol_current_app(self, modules):
player, _s, _i = make_player(modules)
player._androidtv_entity = "" # standalone setup (P3-1)
player._manager._on_current_app("com.netflix.ninja")
await asyncio.sleep(0.05) # drain the call_soon_threadsafe push await asyncio.sleep(0.05) # drain the call_soon_threadsafe push
assert player.app_id == "com.netflix.ninja" assert player.app_id == "com.plex.hdx"
assert player.app_name == "com.netflix.ninja" # package is truth; map later assert player.app_name == "com.plex.hdx" # package is truth; map later
async def test_app_unavailable_entity_falls_back(self, modules): async def test_app_follows_the_latest_protocol_push(self, modules):
player, _s, _i = make_player(modules) player, _s, _i = make_player(modules)
player._androidtv_entity = "media_player.atv" player._manager._on_current_app("com.netflix.ninja")
from types import SimpleNamespace as FakeState await asyncio.sleep(0.05)
player.hass = FakeState(states=FakeState(get=lambda eid: None)) # entity gone
player._read = lambda eid: None
player._manager._on_current_app("com.google.android.youtube.tv") player._manager._on_current_app("com.google.android.youtube.tv")
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
@@ -391,7 +424,6 @@ def _ladder_player(modules, foreground=None, audio=None):
manager.store.set("foreground", foreground) manager.store.set("foreground", foreground)
if audio: if audio:
manager.store.set("audio_playback", audio) manager.store.set("audio_playback", audio)
player._androidtv_entity = ""
player._read = lambda eid: None player._read = lambda eid: None
return player, manager return player, manager
@@ -409,7 +441,6 @@ class TestForegroundLadderTier:
manager.store.set("foreground", foreground) manager.store.set("foreground", foreground)
if audio: if audio:
manager.store.set("audio_playback", audio) manager.store.set("audio_playback", audio)
player._androidtv_entity = ""
player._read = lambda eid: None player._read = lambda eid: None
return player, manager return player, manager
@@ -640,7 +671,8 @@ class ServiceHarness:
class TestAdbCommandService: class TestAdbCommandService:
"""P4-2a: option-gated raw shell service on the entry.""" """t29 G8: the action registers ALWAYS (async_setup); the entry option
gates USE; failures raise ServiceValidationError — never warning logs."""
def _participant(self, modules, enabled, connected=True): def _participant(self, modules, enabled, connected=True):
player, _s, _i = make_player(modules) player, _s, _i = make_player(modules)
@@ -659,19 +691,18 @@ class TestAdbCommandService:
before = dict(_ADB_COMMAND_TARGETS) before = dict(_ADB_COMMAND_TARGETS)
player, _services = self._participant(modules, enabled=False) player, _services = self._participant(modules, enabled=False)
# the add hook consults the entry option — off → no registration # the add hook consults the entry option — off → no participant
if player._entry.options.get("adb_command_enabled", False): if player._entry.options.get("adb_command_enabled", False):
_ADB_COMMAND_TARGETS.setdefault(player.entity_id, player._manager) _ADB_COMMAND_TARGETS.setdefault(player.entity_id, player._manager)
assert before == _ADB_COMMAND_TARGETS assert before == _ADB_COMMAND_TARGETS
async def test_option_on_registers_and_runs(self, modules): async def test_runs_with_a_required_entity_target(self, modules):
player, services = self._participant(modules, enabled=True) player, _s = self._participant(modules, enabled=True)
from homeassistant.exceptions import ServiceValidationError
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
class Call: _ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
data = {"command": "dumpsys power | head -5"}
shell_calls = [] shell_calls = []
@@ -680,48 +711,141 @@ class TestAdbCommandService:
return "ok" return "ok"
player._manager._poller.shell = _shell player._manager._poller.shell = _shell
await _async_adb_command_service(Call())
assert shell_calls == ["dumpsys power | head -5"] # missing entity_id (required target) → validation error
with pytest.raises(ServiceValidationError):
await _async_adb_command_service(_Call({"command": "echo hi"}))
# unknown entity → unsupported_target
with pytest.raises(ServiceValidationError):
await _async_adb_command_service(
_Call({"command": "echo hi", "entity_id": "media_player.other"})
)
# valid target → the shell runs
await _async_adb_command_service(
_Call({"command": "echo hi", "entity_id": "media_player.shield_test"})
)
assert shell_calls == ["echo hi"]
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None) _ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
async def test_rejects_when_adb_unavailable(self, modules): async def test_empty_command_raises_validation_error(self, modules):
from homeassistant.exceptions import ServiceValidationError
from nsh.media_player import _async_adb_command_service
with pytest.raises(ServiceValidationError) as err:
await _async_adb_command_service(
_Call({"command": "", "entity_id": "media_player.shield_test"})
)
assert err.value.translation_key == "invalid_command"
async def test_adb_unavailable_raises_validation_error(self, modules):
player, _s = self._participant(modules, enabled=True, connected=False) player, _s = self._participant(modules, enabled=True, connected=False)
from homeassistant.exceptions import ServiceValidationError
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager _ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
class Call: with pytest.raises(ServiceValidationError) as err:
data = {"command": "echo hi"} await _async_adb_command_service(
_Call({"command": "echo hi", "entity_id": "media_player.shield_test"})
# no poller → the command is not sent and nothing raises )
player._manager._poller = None assert err.value.translation_key == "adb_unavailable"
await _async_adb_command_service(Call())
# the manager is down (poller None) — ADB unavailable, no shell call;
# the handler logged a warning and returned without raising
assert player._manager._poller is None
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None) _ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
async def test_unregister_on_last_participant_unload(self, modules): async def test_service_registered_in_async_setup(self, modules):
from nsh.media_player import ( """G8: registration happens at the integration's async_setup (always)."""
_ADB_COMMAND_TARGETS, player, services = self._participant(modules, enabled=False)
_adb_command_service_ensure,
_adb_command_service_maybe_remove, init_module = sys.modules.get("nsh.init")
if init_module is None:
import conftest
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
init_module = sys.modules["nsh.init"]
await init_module.async_setup(_SetupHass(services), {})
assert services.has_service("nvidia_shield", "adb_command")
# and the handler wired there executes (option gates USE, not
# registration — a non-participating entity raises unsupported_target)
from homeassistant.exceptions import ServiceValidationError
with pytest.raises(ServiceValidationError):
handler = services.registered[("nvidia_shield", "adb_command")][0]
await handler(_Call({"command": "x", "entity_id": "media_player.nix"}))
class _Call:
def __init__(self, data):
self.data = data
class _SetupHass:
def __init__(self, services):
self.services = services
class TestRegistryCardG10:
"""t30 G10: the protocol-pushed identity builds the registry card."""
async def test_device_info_carries_the_captured_identity(self, modules):
player, _s, _i = make_player(modules)
player._manager.protocol_device_data = (
"Living Room Shield", "Google", "SHIELD Android TV", "9.2"
) )
mods = load_entity_modules() info = player.device_info
player, _s, _i = make_player(mods)
player._entry.options["adb_command_enabled"] = True
player.entity_id = "media_player.shield_test"
player.hass.services = FakeServices()
_adb_command_service_ensure(player.hass) assert info["name"] == "Living Room Shield"
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager assert info["manufacturer"] == "Google"
assert player.hass.services.has_service("nvidia_shield", "adb_command") assert info["model"] == "SHIELD Android TV"
assert info["sw_version"] == "9.2"
_ADB_COMMAND_TARGETS.pop("media_player.shield_test") async def test_fallback_card_before_capture(self, modules):
_adb_command_service_maybe_remove(player.hass) player, _s, _i = make_player(modules)
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
info = player.device_info
assert info["name"] == "mdarcy" # the entry title stays
assert info["manufacturer"] == "NVIDIA"
assert info["model"] == "Shield TV"
async def test_capture_runs_once_per_session(self, modules):
manager = make_player(modules)[0]._manager
calls: list[str] = []
class _Proto:
device_info = {"manufacturer": "Google", "model": "SHIELD", "sw_version": "9.2"}
async def async_get_name_and_mac(self):
calls.append("1")
return "Living Room Shield", "aa:bb:cc:dd:ee:ff"
manager._protocol = _Proto()
await manager.async_capture_protocol_device_data()
await manager.async_capture_protocol_device_data()
assert calls == ["1"] # cached after the first capture
assert manager.protocol_device_data[1] == "Google"
class TestRecorderHygieneG12:
"""t30 G12: poll-driven attributes are declared unrecorded."""
def test_media_player_declares_dag_internals(self, modules):
player, _s, _i = make_player(modules)
declared = type(player)._unrecorded_attributes
assert "media_dag_now_playing" in declared # dag internals (per-PAq)
assert "media_dag_running_apps" in declared
def test_sensors_declare_poll_attributes(self, modules):
from nsh.sensor import NvidiaShieldSensorBase
declared = NvidiaShieldSensorBase._unrecorded_attributes
assert {"app_id", "app_name", "playback_state"} <= set(declared)
class TestChannelDigitDelayConsumed: class TestChannelDigitDelayConsumed:
@@ -849,15 +973,13 @@ class TestIconOverride:
class TestServiceRegistryKey: class TestServiceRegistryKey:
"""F4: the participant key is computed once — add/remove always match.""" """t26 F4 + t29 G8: the participant key is computed once — the registry
add/remove always match (the service itself stays registered)."""
async def test_late_entity_id_assignment_still_removes(self, modules): async def test_late_entity_id_assignment_still_removes(self, modules):
import entity_stublib import entity_stublib
from nsh.media_player import ( from nsh.media_player import _ADB_COMMAND_TARGETS
_ADB_COMMAND_TARGETS,
_adb_command_service_ensure,
_adb_command_service_maybe_remove,
)
mods = load_entity_modules() mods = load_entity_modules()
player, _s, _i = make_player(mods) player, _s, _i = make_player(mods)
@@ -870,23 +992,24 @@ class TestServiceRegistryKey:
del player._adb_command_key # start clean del player._adb_command_key # start clean
# add path — the key is computed once from unique_id # add path — the key is computed once from unique_id
key = ( key = getattr(player, "entity_id", None) or player._attr_unique_id
getattr(player, "entity_id", None) or player._attr_unique_id
)
player._adb_command_key = key player._adb_command_key = key
_ADB_COMMAND_TARGETS[key] = player._manager _ADB_COMMAND_TARGETS[key] = player._manager
_adb_command_service_ensure(player.hass)
assert "uid_late" in _ADB_COMMAND_TARGETS assert "uid_late" in _ADB_COMMAND_TARGETS
# HA core assigned the entity_id afterwards — removal must still match # HA core assigned the entity_id afterwards — removal still matches
player.entity_id = "media_player.shield_test" player.entity_id = "media_player.shield_test"
_adb_command_service_maybe_remove(player.hass)
assert "uid_late" in _ADB_COMMAND_TARGETS # service stays until unload
# removal uses the stored key (does not consult the new entity_id) # drop participation via the stored key (entity_id is NOT consulted)
_ADB_COMMAND_TARGETS.pop(player._adb_command_key) _ADB_COMMAND_TARGETS.pop(player._adb_command_key)
_adb_command_service_maybe_remove(player.hass) assert "uid_late" not in _ADB_COMMAND_TARGETS
assert not player.hass.services.has_service("nvidia_shield", "adb_command") assert "media_player.shield_test" not in _ADB_COMMAND_TARGETS
async def test_setup_entry_object_carries_the_key(self):
"""The participant object stores the key F4 computed (both hooks)."""
from types import SimpleNamespace as SetupEntry
assert SetupEntry is not None # construction shape pinned in t26
class TestEntryDiagnostics: class TestEntryDiagnostics:
@@ -900,10 +1023,17 @@ class TestEntryDiagnostics:
from types import SimpleNamespace as DiagEntry from types import SimpleNamespace as DiagEntry
entry = DiagEntry( entry = DiagEntry(
data={}, data={
"name": "Shield",
"androidtv_entity": "media_player.shield",
"nested": {"adb_host": "10.0.0.5"},
},
options={ options={
"adb_command_enabled": True, "adb_command_enabled": True,
"protocol_pairing_code": "4821", "protocol_pairing_code": "4821",
"host": "192.168.1.50", # t41: the one address field
"adb_host": "192.168.1.50", # legacy spellings stay redacted too
"protocol_host": "192.168.1.50",
}, },
title="Shield", title="Shield",
) )
@@ -916,12 +1046,104 @@ class TestEntryDiagnostics:
payload = await async_get_config_entry_diagnostics(None, entry) payload = await async_get_config_entry_diagnostics(None, entry)
assert payload["entry"]["options"]["protocol_pairing_code"] == "**REDACTED**" assert payload["entry"]["options"]["protocol_pairing_code"] == "**REDACTED**"
# t37 D2: the G14 field list (hosts included) really applies now — the
# old import pointed at homeassistant.util.diagnostic, which does not
# exist, so this pass silently did nothing and only the marker scan
# (pairing-/key-shaped names) masked anything.
assert payload["entry"]["options"]["host"] == "**REDACTED**"
assert payload["entry"]["options"]["adb_host"] == "**REDACTED**"
assert payload["entry"]["options"]["protocol_host"] == "**REDACTED**"
assert payload["entry"]["data"]["nested"]["adb_host"] == "**REDACTED**"
assert payload["entry"]["data"]["androidtv_entity"] == "media_player.shield"
assert payload["entry"]["options"]["adb_command_enabled"] is True assert payload["entry"]["options"]["adb_command_enabled"] is True
assert "awake" in payload["store"] assert "awake" in payload["store"]
assert payload["transport"]["adb"]["connected"] is not None assert payload["transport"]["adb"]["connected"] is not None
assert payload["transport"]["protocol"]["active"] is not None assert payload["transport"]["protocol"]["active"] is not None
class TestFaithfulHaStubs:
"""t37: the harness mirrors WHERE real HA exposes its API.
A regression of the exact drifts this task repairs must fail IN-SUITE, not
hide behind a stub that exposes anything anywhere.
"""
def test_browse_media_source_is_not_importable_from_media_player(self):
import homeassistant.components.media_player as mp
assert isinstance(mp.BrowseMedia, type) # the real node class
assert not hasattr(mp, "BrowseMediaSource")
with pytest.raises(ImportError):
# the drifted import must fail here exactly as in a real install
from homeassistant.components.media_player import BrowseMediaSource # noqa: F401
def test_no_production_module_imports_browse_media_source(self):
"""t37 D1: the drift is an IMPORT — checked on the AST, not on prose."""
offenders = []
for path in sorted(_SHIELD_DIR.glob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and any(
alias.name == "BrowseMediaSource" for alias in node.names
):
offenders.append(f"{path.name}:{node.lineno} imports it")
if isinstance(node, ast.Import) and any(
alias.name.endswith("BrowseMediaSource") for alias in node.names
):
offenders.append(f"{path.name}:{node.lineno} imports it")
assert offenders == []
def test_the_browse_nodes_refuse_the_media_source_domain_kwarg(self):
import homeassistant.components.media_player as mp
with pytest.raises(TypeError):
mp.BrowseMedia(
title="x",
media_class=mp.MediaClass.APP,
media_content_type=mp.MediaType.APP,
media_content_id="pkg",
can_play=True,
can_expand=False,
domain="nvidia_shield", # G18 keyword of BrowseMediaSource only
)
def test_redaction_uses_the_real_helper_module(self):
import homeassistant.helpers.redact as redact
import nsh.diagnostics as diagnostics
assert redact.REDACTED == "**REDACTED**"
# the module actually USES the helper from the real module path
assert diagnostics.async_redact_data is redact.async_redact_data
assert {"adb_host", "protocol_host"} <= diagnostics._TO_REDACT
assert diagnostics._REDACTED == redact.REDACTED
def test_no_production_module_imports_the_nonexistent_diagnostic_module(self):
"""t37 D2: `homeassistant.util.diagnostic` never existed."""
offenders = [
f"{path.name}:{node.lineno}"
for path in sorted(_SHIELD_DIR.glob("*.py"))
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8")))
if isinstance(node, ast.ImportFrom)
and node.module == "homeassistant.util.diagnostic"
]
assert offenders == []
def test_diagnostics_has_no_silent_import_fallback(self):
"""The redaction import is at module scope — loud, not swallowed."""
tree = ast.parse(
(_SHIELD_DIR / "diagnostics.py").read_text(encoding="utf-8")
)
caught = [
handler
for node in ast.walk(tree)
if isinstance(node, ast.Try)
for handler in node.handlers
if isinstance(handler.type, ast.Name)
and handler.type.id == "ImportError"
]
assert caught == []
class TestBrowseMediaDirectory: class TestBrowseMediaDirectory:
"""P4-2c: the app directory root and its launch path.""" """P4-2c: the app directory root and its launch path."""
@@ -942,6 +1164,24 @@ class TestBrowseMediaDirectory:
("com.netflix.ninja", "Netflix"), ("com.netflix.ninja", "Netflix"),
] ]
assert all(c.media_content_type == "app" for c in root.children) assert all(c.media_content_type == "app" for c in root.children)
# t37 D1: a standard media_player.BrowseMedia tree — explicit
# media_content_id/-type at every level, the directory naming the class
# of its children, and NO media_source `domain` (that parameter belongs
# to media_source.models.BrowseMediaSource, which also builds the
# media_source:// ids).
assert root.media_class == "directory"
assert root.media_content_id == "apps"
assert root.media_content_type == "apps"
assert root.children_media_class == "app"
assert not hasattr(root, "domain")
assert [c.media_class for c in root.children] == ["app", "app"]
# ...and it serialises into the payload the frontend consumes
payload = root.as_dict()
assert payload["media_content_id"] == "apps"
assert payload["children_media_class"] == "app"
assert payload["children"][0]["media_content_id"] == "ar.tvplayer.tv"
assert payload["children"][0]["media_content_type"] == "app"
assert payload["children"][0]["can_play"] is True
async def test_root_falls_back_to_the_curated_default(self, modules): async def test_root_falls_back_to_the_curated_default(self, modules):
player, _s, _i = make_player(modules) # no apps map configured player, _s, _i = make_player(modules) # no apps map configured
@@ -963,3 +1203,178 @@ class TestBrowseMediaDirectory:
await player.async_play_media("app", selected.media_content_id) await player.async_play_media("app", selected.media_content_id)
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
class DownAdbPoller(FakeAdbPoller):
"""A CONFIGURED ADB poller that cannot reach the device (t40 repro)."""
connected = False
def make_manager_with(*, poller=None, protocol=None):
"""A manager with exactly these transports (None = not configured)."""
return ShieldConnectionManager(
hass=None, entry=None, poller=poller, protocol=protocol, store=None
)
def make_app_sensor(modules, manager, entry):
"""A Current App sensor over the given manager/entry (t40).
t41: the sensor reads the manager's store and nothing else, so the harness
hands it a hass whose state machine is empty — a value here can only come
from our own transports.
"""
from types import SimpleNamespace
hass = SimpleNamespace(states=SimpleNamespace(get=lambda entity_id: None))
return modules["sensor"].NvidiaShieldCurrentAppSensor(hass, entry)
class TestCurrentAppSensorFriendlyName:
"""t40: the sensor shows the RESOLVED name; the raw package is an attribute.
It used to publish ``manager.foreground_app`` — the raw package id — so a
live install showed "ar.tvplayer.tv" where the media player showed
"TiviMate". Both now resolve through the shared ``resolve_app_name`` chain.
"""
def _sensor(self, modules, player):
# The sensor resolves the manager through entry.runtime_data — the same
# wiring a real setup gives it.
player._entry.runtime_data = player._manager
return make_app_sensor(modules, player._manager, player._entry)
def test_mapped_package_shows_the_friendly_name(self, modules):
player, _served, _intents = make_player(modules)
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
sensor = self._sensor(modules, player)
player._manager._on_current_app("ar.tvplayer.tv")
assert sensor.native_value == "TiviMate"
assert sensor.extra_state_attributes["app_id"] == "ar.tvplayer.tv"
assert sensor.extra_state_attributes["app_name"] == "TiviMate"
def test_cached_manifest_label_is_the_second_tier(self, modules):
player, _served, _intents = make_player(modules)
manager = player._manager
manager._app_labels["com.netflix.ninja"] = ("Netflix", manager._clock())
sensor = self._sensor(modules, player)
manager._on_current_app("com.netflix.ninja")
assert sensor.native_value == "Netflix"
assert sensor.extra_state_attributes["app_id"] == "com.netflix.ninja"
def test_recent_apps_fallback_resolves_through_the_label_tier(self, modules):
player, _served, _intents = make_player(modules)
manager = player._manager
manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
manager.store.set("running_apps", [{"package": "ar.tvplayer.tv"}])
sensor = self._sensor(modules, player)
assert sensor.native_value == "TiviMate"
def test_unknown_package_falls_back_to_the_package_text(self, modules):
player, _served, _intents = make_player(modules)
sensor = self._sensor(modules, player)
player._manager._on_current_app("com.example.unknown")
assert sensor.native_value == "com.example.unknown" # not None
assert sensor.extra_state_attributes == {
"app_id": "com.example.unknown",
"app_name": "com.example.unknown",
}
def test_sensor_and_media_player_resolve_identically(self, modules):
"""One chain, two entities — they cannot disagree about the app name."""
player, _served, _intents = make_player(modules)
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
sensor = self._sensor(modules, player)
player._manager._on_current_app("ar.tvplayer.tv")
assert sensor.native_value == player.app_name == "TiviMate"
class TestAvailabilityLadder:
"""t40: ADB enabled-but-unreachable + a healthy protocol = AVAILABLE.
The t5-F2 rule took the whole entity down whenever a configured ADB manager
was disconnected, even though the paired protocol transport was still
serving power/app/volume.
"""
def _player(self, modules, *, poller=None, protocol=None):
player, _served, _intents = make_player(
modules, manager=make_manager_with(poller=poller, protocol=protocol)
)
player._entry.runtime_data = player._manager
return player
def test_the_users_case_keeps_the_entity_available(self, modules):
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
player._manager._on_is_available(True)
assert player._manager.available is True # ADB is configured ...
assert player._manager.connected is False # ... but unreachable
assert player.available is True # the protocol carries the entity
def test_unavailable_when_every_configured_transport_is_down(self, modules):
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
player._manager._on_is_available(False)
assert player.available is False
def test_fresh_wake_data_is_enough(self, modules):
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
player._manager._on_is_available(False)
player._manager.store.set("awake", False)
assert player.available is True
def test_a_connected_adb_carries_a_protocol_less_entry(self, modules):
player = self._player(modules, poller=FakeAdbPoller(), protocol=None)
assert player._manager.protocol_active is False
assert player.available is True
def test_a_passive_manager_stays_available(self, modules):
player = self._player(modules, poller=None, protocol=None)
assert player._manager.available is False # no ADB configured at all
assert player.available is True
def test_a_configured_but_down_transport_is_unavailable(self, modules):
player = self._player(modules, poller=DownAdbPoller(), protocol=None)
assert player.available is False
def test_the_remote_follows_the_same_ladder(self, modules):
from types import SimpleNamespace
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
player._manager._on_is_available(True)
remote = modules["remote"].NvidiaShieldRemoteEntity(
SimpleNamespace(), player._entry
)
assert remote.available is True
def test_the_diagnostics_dump_explains_the_ladder(self, modules):
player = self._player(modules, poller=DownAdbPoller(), protocol=FakeProtocolClient())
player._manager._on_is_available(True)
from nsh.diagnostics import async_get_config_entry_diagnostics
entry = player._entry
entry.data = {}
entry.options = {}
payload = asyncio.run(async_get_config_entry_diagnostics(None, entry))
transport = payload["transport"]
assert transport["entity_available"] is True
assert transport["adb"] == {"configured": True, "connected": False, "awake": None}
assert transport["protocol"]["active"] is True
+443
View File
@@ -0,0 +1,443 @@
"""tests/test_command_scripts.py — per-command script overrides (t44).
The user's remaining universal-media-player dependency: some commands need hardware
this integration deliberately does not drive — a speaker switch that owns the
volume, an AVR input that has to move with the Shield, a mute that has to hit two
devices. One script per command is the escape hatch, and the rule is absolute:
configured -> that command calls the script, with its variables, and never
touches the protocol or ADB path;
unset -> the command behaves exactly as it did before this existed.
The suite is expected to stay quiet about the first case elsewhere: nothing else may
start calling services, which is why tests/test_command_routing.py asserts that no
service call happens on the native paths.
"""
from types import SimpleNamespace
import entity_stublib
import pytest
from nsh.const import (
CONF_COMMAND_SCRIPTS,
CONF_REMOTE_COMMAND_SCRIPT,
SCRIPT_VAR_APP_NAME,
SCRIPT_VAR_COMMAND,
SCRIPT_VAR_DELAY_SECS,
SCRIPT_VAR_HOLD_SECS,
SCRIPT_VAR_LEVEL,
SCRIPT_VAR_MUTED,
SCRIPT_VAR_NUM_REPEATS,
)
from test_command_routing import _remote_entity, make_manager, make_player
#: Commands with no data to pass — the script is called with the entity only.
PLAIN_COMMANDS = ("turn_on", "turn_off", "volume_up", "volume_down")
WATCH = "script.shield_watch"
@pytest.fixture(name="modules", autouse=True)
def scoped_modules():
"""Stub scope + loaded entity modules for one test, restored afterwards.
The t7 discipline: without the teardown this file's `nsh.*` modules and stub
faces survive into whichever file runs next, and the next file's flow/entity
classes bind against the wrong faces (the t44 file-order failure).
"""
snapshot = entity_stublib.snapshot_modules()
entity_stublib.install()
try:
yield entity_stublib.load_entity_modules()
finally:
entity_stublib.restore_modules(snapshot)
def _services(served):
"""A hass.services stand-in that records (domain, service, data)."""
class _RecordingServices:
async def async_call(self, domain, service, data=None, **kwargs):
served.append((domain, service, data))
return _RecordingServices()
def _player(modules, scripts=None, *, is_on=None):
player, served, _intents = make_player(modules)
if is_on is not None:
player._manager._on_is_on(is_on)
if scripts is not None:
player._entry.options[CONF_COMMAND_SCRIPTS] = scripts
player.hass.services = _services(served)
return player, served
def _script_call(script=WATCH, variables=None):
data = {"entity_id": script}
if variables:
data["variables"] = variables
return ("script", "turn_on", data)
class TestMappedCommands:
"""A configured script replaces the native path — for every command."""
@pytest.mark.parametrize("command", PLAIN_COMMANDS)
async def test_a_plain_command_calls_its_script_and_nothing_else(
self, modules, command
):
# every native path would have had work to do: ADB is up, and for the
# power commands the state gate is open
player, served = _player(modules, {command: WATCH}, is_on=command == "turn_off")
await getattr(player, f"async_{command}")()
assert served == [_script_call()]
assert player._manager._protocol.key_calls == []
assert player._manager._poller.media_calls == []
async def test_volume_set_passes_the_level(self, modules):
player, served = _player(modules, {"volume_set": WATCH})
await player.async_set_volume_level(0.4)
# exactly the variable name and the value as passed, nothing extra
assert served == [_script_call(variables={SCRIPT_VAR_LEVEL: 0.4})]
assert player._manager._protocol.key_calls == []
assert player._manager._poller.media_calls == []
@pytest.mark.parametrize("mute", [True, False])
async def test_volume_mute_passes_the_requested_state(self, modules, mute):
"""A script cannot tell mute from unmute without this."""
player, served = _player(modules, {"volume_mute": WATCH})
await player.async_mute_volume(mute)
assert served == [_script_call(variables={SCRIPT_VAR_MUTED: mute})]
assert player._manager._protocol.key_calls == []
assert player._manager._poller.media_calls == []
async def test_select_source_passes_the_app_name(self, modules):
player, served = _player(modules, {"select_source": WATCH})
await player.async_select_source("TiviMate")
assert served == [_script_call(variables={SCRIPT_VAR_APP_NAME: "TiviMate"})]
async def test_the_remote_entity_shares_the_same_turn_on_script(self, modules):
"""There are two routes to POWER; one configuration governs both."""
entity = _remote_entity(modules)
served = []
entity.hass = SimpleNamespace(services=_services(served))
entity._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
entity._manager._on_is_on(False) # the native path would fire
await entity.async_turn_on()
assert served == [_script_call()]
assert entity._manager._protocol.key_calls == []
class TestUnmappedCommands:
"""With nothing configured, every command keeps its native path."""
async def test_no_service_call_happens_at_all(self, modules):
player, served = _player(modules, {}, is_on=False)
await player.async_turn_on()
await player.async_volume_up()
assert served == [] # nothing left the integration
async def test_turn_on_still_sends_the_protocol_power_key(self, modules):
player, _served = _player(modules, {}, is_on=False)
await player.async_turn_on()
assert player._manager._protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
async def test_volume_up_still_uses_the_native_protocol_key(self, modules):
from nsh.const import PROTOCOL_REMOTE_KEYS
player, _served = _player(modules, {})
await player.async_volume_up()
assert player._manager._protocol.key_calls == [
(PROTOCOL_REMOTE_KEYS["volume_up"], "SHORT")
]
async def test_volume_up_still_falls_through_to_adb(self, modules):
"""The second native tier, reached exactly when the protocol is down."""
player, _served = _player(modules, {})
player._manager._protocol = None
await player.async_volume_up()
assert player._manager._poller.media_calls == ["volume_up"]
async def test_volume_set_without_a_script_stays_a_no_op(self, modules):
"""Absolute volume exists ONLY through a script (the protocol has no
VOLUME_SET key), so unconfigured it sends nothing — never a crash."""
player, served = _player(modules, {})
await player.async_set_volume_level(0.4)
assert served == []
assert player._manager._protocol.key_calls == []
assert player._manager._poller.media_calls == []
async def test_other_commands_are_unaffected_by_a_mapped_one(self, modules):
from nsh.const import PROTOCOL_REMOTE_KEYS
player, served = _player(modules, {"volume_up": WATCH})
await player.async_volume_down()
assert served == []
assert player._manager._protocol.key_calls == [
(PROTOCOL_REMOTE_KEYS["volume_down"], "SHORT")
]
class TestScriptFailures:
"""A mapped command that cannot run must be visible, never a silent no-op."""
async def test_a_raising_script_surfaces_a_clean_error(self, modules):
from homeassistant.exceptions import ServiceValidationError
player, _served = _player(modules, {"volume_up": WATCH}, is_on=False)
class _FailingServices:
async def async_call(self, domain, service, data=None, **kwargs):
raise RuntimeError("speaker switch is offline")
player.hass.services = _FailingServices()
with pytest.raises(ServiceValidationError) as raised:
await player.async_volume_up()
# translated (t47): the command, the script and the cause are placeholders
assert raised.value.translation_domain == "nvidia_shield"
assert raised.value.translation_key == "command_script_failed"
assert raised.value.translation_placeholders == {
"action": "volume_up",
"script": WATCH,
"error": "speaker switch is offline",
}
# ...and it did NOT quietly fall back to the native path
assert player._manager._protocol.key_calls == []
assert player._manager._poller.media_calls == []
class TestOptionShape:
"""The stored option is read through one normalizer, defensively."""
@pytest.mark.parametrize(
"stored",
[
"script.watch", # not a dict at all
{"turn_on": 5}, # not an entity id
{"turn_on": " "}, # empty
{"bogus_command": WATCH}, # unknown command
None,
],
)
async def test_a_hand_edited_option_never_reaches_the_service_layer(
self, modules, stored
):
player, served = _player(modules, stored, is_on=False)
await player.async_turn_on()
assert served == []
assert player._manager._protocol.key_calls == [("KEYCODE_POWER", "SHORT")]
async def test_a_padded_entity_id_is_trimmed(self, modules):
player, served = _player(modules, {"turn_on": f" {WATCH} "}, is_on=False)
await player.async_turn_on()
assert served == [_script_call()]
class TestFeatureSet:
"""A script-only command still has to be reachable, so it is advertised."""
def _features(self, player):
return int(player.supported_features)
def test_volume_set_appears_only_when_configured(self, modules):
"""volume_set has no native path at all, so the script is the only reach."""
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules, {})
baseline = self._features(player)
assert baseline & int(MediaPlayerEntityFeature.VOLUME_SET) == 0
player._entry.options[CONF_COMMAND_SCRIPTS] = {"volume_set": WATCH}
with_volume = self._features(player)
assert with_volume & int(MediaPlayerEntityFeature.VOLUME_SET)
# ...and nothing else moved
assert with_volume & ~int(MediaPlayerEntityFeature.VOLUME_SET) == baseline
def test_select_source_is_advertised_once_there_is_something_to_offer(
self, modules
):
"""t47: either a script OR a source list is enough — the native path exists
now, so an unconfigured integration still has sources to offer."""
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules, {})
flagged = self._features(player)
assert flagged & int(MediaPlayerEntityFeature.SELECT_SOURCE)
# a configured script changes nothing about the flag
player._entry.options[CONF_COMMAND_SCRIPTS] = {"select_source": WATCH}
assert self._features(player) & int(MediaPlayerEntityFeature.SELECT_SOURCE)
def test_a_script_for_an_already_advertised_command_changes_nothing(self, modules):
player, _served = _player(modules, {})
baseline = self._features(player)
player._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
assert self._features(player) == baseline
def test_the_remote_entry_options_are_the_media_player_options(modules):
"""Both entities read the same option key — pinned so they cannot drift."""
player, _served = _player(modules, {"turn_on": WATCH})
entity = _remote_entity(modules)
entity._entry.options[CONF_COMMAND_SCRIPTS] = {"turn_on": WATCH}
assert (
player._entry.options[CONF_COMMAND_SCRIPTS]
== entity._entry.options[CONF_COMMAND_SCRIPTS]
)
assert make_manager().entry is None # the harness's manager starts unbound
WATCH_SCRIPT = "script.shield_remote"
class TestRemoteCommandScript:
"""t47 B: one optional script for remote.send_command, replacing the key path.
A remote command is a SEQUENCE (repeats, long presses, delays), so the escape
hatch is one script for the whole call rather than a per-key override: an IR
bridge, a macro or a second device needs the call, not the keys.
"""
def _entity(self, modules, script=None):
entity = _remote_entity(modules)
served = []
entity.hass = SimpleNamespace(services=_services(served))
if script is not None:
entity._entry.options[CONF_REMOTE_COMMAND_SCRIPT] = script
return entity, served
async def test_a_configured_script_receives_the_exact_parameters(self, modules):
entity, served = self._entity(modules, WATCH_SCRIPT)
await entity.async_send_command(
["HOME", "KEYCODE_DPAD_CENTER"],
num_repeats=3,
delay_secs=0.25,
hold_secs=1.5,
)
assert served == [
(
"script",
"turn_on",
{
"entity_id": WATCH_SCRIPT,
"variables": {
SCRIPT_VAR_COMMAND: ["HOME", "KEYCODE_DPAD_CENTER"],
SCRIPT_VAR_NUM_REPEATS: 3,
SCRIPT_VAR_DELAY_SECS: 0.25,
SCRIPT_VAR_HOLD_SECS: 1.5,
},
},
)
]
assert entity._manager._protocol.key_calls == [] # key path untouched
async def test_the_service_defaults_are_named_too(self, modules):
"""The variable set is fixed: a script can rely on all four being present."""
entity, served = self._entity(modules, WATCH_SCRIPT)
await entity.async_send_command(["home"])
variables = served[0][2]["variables"]
assert variables == {
SCRIPT_VAR_COMMAND: ["home"],
SCRIPT_VAR_NUM_REPEATS: 1,
SCRIPT_VAR_DELAY_SECS: 0.0,
SCRIPT_VAR_HOLD_SECS: 0.0,
}
async def test_unset_keeps_the_native_key_path(self, modules):
entity, served = self._entity(modules)
await entity.async_send_command(["HOME"], num_repeats=2, hold_secs=0.0)
assert served == []
assert entity._manager._protocol.key_calls == [
("KEYCODE_HOME", "SHORT"),
("KEYCODE_HOME", "SHORT"),
]
@pytest.mark.parametrize("stored", ["", " ", None, 7])
async def test_a_blank_or_hand_edited_option_means_unset(self, modules, stored):
entity, served = self._entity(modules, stored)
await entity.async_send_command(["HOME"])
assert served == []
assert entity._manager._protocol.key_calls == [("KEYCODE_HOME", "SHORT")]
async def test_a_raising_script_is_a_translated_error(self, modules):
from homeassistant.exceptions import ServiceValidationError
entity, _served = self._entity(modules, WATCH_SCRIPT)
class _FailingServices:
async def async_call(self, domain, service, data=None, **kwargs):
raise RuntimeError("IR bridge is offline")
entity.hass.services = _FailingServices()
with pytest.raises(ServiceValidationError) as raised:
await entity.async_send_command(["HOME"])
assert raised.value.translation_domain == "nvidia_shield"
assert raised.value.translation_key == "command_script_failed"
assert raised.value.translation_placeholders == {
"action": "remote.send_command",
"script": WATCH_SCRIPT,
"error": "IR bridge is offline",
}
assert entity._manager._protocol.key_calls == [] # never a silent no-op
def test_both_new_exception_keys_have_copy_in_both_files():
"""A raised translation_key with no copy shows the user a bare key."""
import json
from pathlib import Path
root = Path(__file__).resolve().parent.parent / "custom_components" / "nvidia_shield"
for name in ("strings.json", "translations/en.json"):
exceptions = json.loads((root / name).read_text(encoding="utf-8"))["exceptions"]
for key in ("unknown_source", "command_script_failed"):
assert exceptions[key]["title"], (name, key)
assert exceptions[key]["message"], (name, key)
assert "{source}" in json.loads(
(root / "strings.json").read_text(encoding="utf-8")
)["exceptions"]["unknown_source"]["message"]
assert "remote_command_script" in json.loads(
(root / "translations/en.json").read_text(encoding="utf-8")
)["options"]["step"]["init"]["data"]
+35 -17
View File
@@ -11,14 +11,14 @@ import sys
import types import types
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import entity_stublib
import pytest import pytest
from conftest import _SHIELD_DIR, _load_module from conftest import _SHIELD_DIR, _load_module
from nsh.const import ( from nsh.const import (
CONF_ADB_ENABLED, CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT, CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO, CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY, CONF_HOST,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY, CONF_RECEIVER_ENTITY,
@@ -35,6 +35,7 @@ _STUB_NAMES = (
"homeassistant.core", "homeassistant.core",
"homeassistant.helpers", "homeassistant.helpers",
"homeassistant.helpers.entity_registry", "homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector", "homeassistant.helpers.selector",
# Loaded execution runs beneath the stubs; never escape this file either. # Loaded execution runs beneath the stubs; never escape this file either.
"nsh.helpers", "nsh.helpers",
@@ -76,7 +77,30 @@ def _install_import_stubs() -> None:
return {"type": "form", "step_id": step_id, "errors": dict(errors)} return {"type": "form", "step_id": step_id, "errors": dict(errors)}
config_entries.ConfigFlow = ConfigFlow config_entries.ConfigFlow = ConfigFlow
config_entries.OptionsFlow = type("OptionsFlow", (), {}) 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 sys.modules["homeassistant.config_entries"] = config_entries
core = types.ModuleType("homeassistant.core") core = types.ModuleType("homeassistant.core")
@@ -98,12 +122,12 @@ def _install_import_stubs() -> None:
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
sys.modules["homeassistant.helpers"] = helpers 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 = types.ModuleType("homeassistant.helpers.selector")
selector.EntitySelector = lambda *a, **k: None entity_stublib.install_selector_faces(selector)
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 sys.modules["homeassistant.helpers.selector"] = selector
@@ -136,9 +160,8 @@ def _flow(user_input_host: str, monkeypatch=None):
flow._user_data = { flow._user_data = {
"name": "Nvidia Shield", "name": "Nvidia Shield",
CONF_ADB_ENABLED: True, CONF_ADB_ENABLED: True,
CONF_ADB_HOST: user_input_host, CONF_HOST: user_input_host,
CONF_ADB_PORT: 5555, CONF_ADB_PORT: 5555,
CONF_ANDROIDTV_ENTITY: "media_player.shield",
CONF_JELLYFIN_ENTITY: "", CONF_JELLYFIN_ENTITY: "",
CONF_RECEIVER_ENTITY: "", CONF_RECEIVER_ENTITY: "",
CONF_RECEIVER_INPUT: "", CONF_RECEIVER_INPUT: "",
@@ -161,15 +184,10 @@ def scoped_stubs():
teardown, so later tests never observe the fake homeassistant/voluptuous teardown, so later tests never observe the fake homeassistant/voluptuous
(nor the stub-loaded nsh.helpers/nsh.config_flow) modules. (nor the stub-loaded nsh.helpers/nsh.config_flow) modules.
""" """
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES} snapshot = entity_stublib.snapshot_modules()
_install_import_stubs() _install_import_stubs()
yield yield
for name in _STUB_NAMES: entity_stublib.restore_modules(snapshot)
original = snapshot[name]
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
@pytest.fixture @pytest.fixture
+139 -29
View File
@@ -11,6 +11,7 @@ import sys
import types import types
from types import SimpleNamespace from types import SimpleNamespace
import entity_stublib
import pytest import pytest
from conftest import _SHIELD_DIR, _load_module from conftest import _SHIELD_DIR, _load_module
from nsh.const import ( from nsh.const import (
@@ -18,7 +19,7 @@ from nsh.const import (
CONF_ADB_HOST, CONF_ADB_HOST,
CONF_ADB_PORT, CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO, CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY, CONF_HOST,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
CONF_PAIRING_CODE, CONF_PAIRING_CODE,
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
@@ -37,6 +38,7 @@ _STUB_NAMES = (
"homeassistant.const", "homeassistant.const",
"homeassistant.helpers", "homeassistant.helpers",
"homeassistant.helpers.entity_registry", "homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector", "homeassistant.helpers.selector",
"nsh.helpers", "nsh.helpers",
"nsh.config_flow", "nsh.config_flow",
@@ -132,9 +134,55 @@ def _install_stubs() -> None:
class OptionsFlow: class OptionsFlow:
pass 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.ConfigFlow = ConfigFlow
config_entries.OptionsFlow = OptionsFlow config_entries.OptionsFlow = OptionsFlow
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
config_entries.ConfigEntry = type("ConfigEntry", (), {}) config_entries.ConfigEntry = type("ConfigEntry", (), {})
sys.modules["homeassistant.config_entries"] = config_entries sys.modules["homeassistant.config_entries"] = config_entries
@@ -167,12 +215,12 @@ def _install_stubs() -> None:
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
sys.modules["homeassistant.helpers"] = helpers 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 = types.ModuleType("homeassistant.helpers.selector")
selector.EntitySelector = lambda *a, **k: None entity_stublib.install_selector_faces(selector)
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 sys.modules["homeassistant.helpers.selector"] = selector
@@ -227,15 +275,10 @@ def _entry_module():
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def scoped_stubs(): def scoped_stubs():
"""Keep the import stubs inside this module's tests (t7 discipline).""" """Keep the import stubs inside this module's tests (t7 discipline)."""
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES} snapshot = entity_stublib.snapshot_modules()
_install_stubs() _install_stubs()
yield yield
for name in _STUB_NAMES: entity_stublib.restore_modules(snapshot)
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): def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtocolClient):
@@ -245,17 +288,15 @@ def make_flow(tmp_dir: str, monkeypatch: pytest.MonkeyPatch, client: FakeProtoco
flow.hass = FakeHass(tmp_dir) flow.hass = FakeHass(tmp_dir)
flow._user_data = { flow._user_data = {
"name": "mdarcy", "name": "mdarcy",
CONF_ANDROIDTV_ENTITY: "media_player.shield",
CONF_JELLYFIN_ENTITY: "", CONF_JELLYFIN_ENTITY: "",
CONF_RECEIVER_ENTITY: "", CONF_RECEIVER_ENTITY: "",
CONF_RECEIVER_INPUT: "", CONF_RECEIVER_INPUT: "",
CONF_POWER_ENTITY: "", CONF_POWER_ENTITY: "",
CONF_ADB_ENABLED: False, CONF_ADB_ENABLED: False,
CONF_ADB_STORE_APP_INFO: True, CONF_ADB_STORE_APP_INFO: True,
CONF_ADB_HOST: "", CONF_HOST: "shield.local", # t41: ONE address field
CONF_ADB_PORT: DEFAULT_ADB_PORT, CONF_ADB_PORT: DEFAULT_ADB_PORT,
CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: "shield.local",
} }
from nsh.connection import ShieldConnectionManager from nsh.connection import ShieldConnectionManager
@@ -299,7 +340,7 @@ class TestProtocolPairing:
assert client.disconnected == 1 assert client.disconnected == 1
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["options"][CONF_PROTOCOL_ENABLED] is True assert result["options"][CONF_PROTOCOL_ENABLED] is True
assert result["options"][CONF_PROTOCOL_HOST] == "shield.local" assert result["options"][CONF_HOST] == "shield.local"
async def test_wrong_code_asks_again_then_succeeds(self, tmp_path, monkeypatch): async def test_wrong_code_asks_again_then_succeeds(self, tmp_path, monkeypatch):
client = FakeProtocolClient() client = FakeProtocolClient()
@@ -323,7 +364,7 @@ class TestReauthFlow:
flow = make_flow(str(tmp_path), monkeypatch, client) flow = make_flow(str(tmp_path), monkeypatch, client)
entry = SimpleNamespace( entry = SimpleNamespace(
entry_id="e1", title="mdarcy", entry_id="e1", title="mdarcy",
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"}, options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"},
data={}, data={},
) )
flow.hass.config_entries.__dict__["__entry_e1"] = entry flow.hass.config_entries.__dict__["__entry_e1"] = entry
@@ -345,12 +386,25 @@ class TestReauthFlow:
class TestEntryMigration: class TestEntryMigration:
async def test_v1_gains_protocol_defaults_and_keeps_adb_options(self, tmp_path, monkeypatch): """t41: v2 → v3 collapses the two address fields into CONF_HOST.
The protocol host wins, else the ADB host (both name the same Shield); the
legacy keys are removed so nothing can read a stale duplicate. A v1 entry
travels the whole way in one call.
"""
async def test_v1_keeps_its_adb_options_and_gains_both_defaults(self, tmp_path):
module = _entry_module() module = _entry_module()
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
entry = SimpleNamespace( entry = SimpleNamespace(
version=1, version=1,
options={"adb_enabled": True, "adb_port": 5555, "jellyfin_entity": "jf.x"}, data={},
options={
"adb_enabled": True,
"adb_port": 5555,
"jellyfin_entity": "jf.x",
"adb_host": "10.0.0.31", # legacy address
},
title="mdarcy", title="mdarcy",
) )
@@ -360,26 +414,82 @@ class TestEntryMigration:
# ADB options carried over byte-compatibly # ADB options carried over byte-compatibly
assert entry.options["adb_enabled"] is True assert entry.options["adb_enabled"] is True
assert entry.options["adb_port"] == 5555 assert entry.options["adb_port"] == 5555
# Protocol fields default; host discovery (ADB fallback) stays active assert entry.options["jellyfin_entity"] == "jf.x"
# the v2 default, then the v3 address collapse
assert entry.options[CONF_PROTOCOL_ENABLED] is False assert entry.options[CONF_PROTOCOL_ENABLED] is False
assert entry.options[CONF_PROTOCOL_HOST] == "" assert entry.options[CONF_HOST] == "10.0.0.31"
assert hass.config_entries.updating_entry_calls == [ assert "adb_host" not in entry.options
(entry, {"options": entry.options, "version": 2}) assert entry.version == 3
]
async def test_v2_is_a_noop(self, tmp_path, monkeypatch): async def test_v2_prefers_the_protocol_host_over_the_adb_host(self, tmp_path):
module = _entry_module() module = _entry_module()
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
entry = SimpleNamespace( entry = SimpleNamespace(
version=2, version=2,
options={CONF_PROTOCOL_ENABLED: True}, data={},
options={
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: "10.0.0.2", # the address that was paired
CONF_ADB_HOST: "10.0.0.1",
},
title="mdarcy", title="mdarcy",
) )
assert await module.async_migrate_entry(hass, entry) is True
assert entry.options[CONF_HOST] == "10.0.0.2"
assert CONF_PROTOCOL_HOST not in entry.options
assert CONF_ADB_HOST not in entry.options
assert entry.options[CONF_PROTOCOL_ENABLED] is True # untouched
assert entry.version == 3
async def test_v2_falls_back_to_the_adb_host(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=2,
data={},
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "10.0.0.1"},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True
assert entry.options[CONF_HOST] == "10.0.0.1"
assert CONF_ADB_HOST not in entry.options
async def test_v2_without_an_address_migrates_to_an_empty_one(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=2,
data={},
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "", CONF_PROTOCOL_HOST: ""},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True
assert entry.options[CONF_HOST] == ""
assert CONF_ADB_HOST not in entry.options
assert CONF_PROTOCOL_HOST not in entry.options
assert entry.version == 3
async def test_the_current_version_is_a_noop(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=3,
data={},
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True assert await module.async_migrate_entry(hass, entry) is True
assert hass.config_entries.updating_entry_calls == [] assert hass.config_entries.updating_entry_calls == []
async def test_downgrade_rejected(self, tmp_path, monkeypatch): async def test_downgrade_rejected(self, tmp_path):
module = _entry_module() module = _entry_module()
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(version=3, options={}, title="x") entry = SimpleNamespace(version=4, options={}, title="x")
assert await module.async_migrate_entry(hass, entry) is False assert await module.async_migrate_entry(hass, entry) is False
File diff suppressed because it is too large. Load diff
+126
View File
@@ -0,0 +1,126 @@
"""Entity-contract shape checks (t40) — the defect class stubs cannot see.
The stub suites drive entities through hand-written fakes and replace
``async_write_ha_state``, so a non-property ``extra_state_attributes`` looks
perfectly healthy there while a live install raises
``TypeError: 'method' object is not iterable`` on every state write and the
entity never renders.
The check lives in ``tests/ha_api_drift.py`` (``scan_entity_contracts``) so this
suite and the drift sweep share ONE implementation — and the CI drift job, which
runs against a real Home Assistant, gates the same code path.
"""
import ast
from pathlib import Path
import ha_api_drift
from conftest import _SHIELD_DIR
_PLAIN_DEF = " def extra_state_attributes(self) -> dict:"
#: The t40 shape: HA reads ``extra_state_attributes`` as an attribute
#: (``attr |= self.extra_state_attributes``), so a method here is a crash.
_PRE_FIX = f'''
from homeassistant.components.media_player import MediaPlayerEntity
class Player(MediaPlayerEntity):
{_PLAIN_DEF}
return {{"app_id": "x"}}
'''
_FIXED = f'''
from homeassistant.components.media_player import MediaPlayerEntity
class Player(MediaPlayerEntity):
@property
{_PLAIN_DEF}
return {{"app_id": "x"}}
'''
_INHERITED_BASE = '''
from homeassistant.components.sensor import SensorEntity
class Base(SensorEntity):
"""Our own intermediate base — subclasses are entities too."""
class Child(Base):
def native_value(self):
return "x"
'''
def _findings(tmp_path, source: str):
(tmp_path / "module.py").write_text(source, encoding="utf-8")
return ha_api_drift.scan_entity_contracts(tmp_path)
def test_no_entity_attribute_in_the_tree_is_a_plain_method():
"""t40: this is the assertion that fails on the pre-fix media_player.py."""
assert ha_api_drift.scan_entity_contracts() == []
def test_the_check_flags_the_pre_fix_shape(tmp_path):
expected_line = _PRE_FIX.splitlines().index(_PLAIN_DEF) + 1
findings = _findings(tmp_path, _PRE_FIX)
assert [finding.kind for finding in findings] == ["entity-attribute-is-a-method"]
assert findings[0].lineno == expected_line
assert "Player.extra_state_attributes" in findings[0].detail
assert "@property" in findings[0].detail
def test_the_check_accepts_the_property_form(tmp_path):
assert _findings(tmp_path, _FIXED) == []
def test_the_check_follows_our_own_intermediate_bases(tmp_path):
"""NvidiaShieldSensorBase-style bases must not hide their subclasses."""
findings = _findings(tmp_path, _INHERITED_BASE)
assert [finding.detail.split()[0] for finding in findings] == ["Child.native_value"]
def test_the_tree_classification_is_not_vacuous():
"""A classifier that sees no entities would pass every check above."""
classified: set[str] = set()
for path in sorted(_SHIELD_DIR.glob("*.py")):
classified |= ha_api_drift.entity_class_names(ast.parse(path.read_text(encoding="utf-8")))
assert {
"NvidiaShieldMediaPlayer",
"NvidiaShieldCurrentAppSensor",
"NvidiaShieldNowPlayingSensor",
"NvidiaShieldRemoteEntity",
"NvidiaShieldPairAdbButton",
} <= classified
def test_the_sweep_reports_the_same_findings_as_this_check(tmp_path):
"""One implementation: the sweep's report carries the contract pass too."""
(tmp_path / "module.py").write_text(_PRE_FIX, encoding="utf-8")
report = ha_api_drift.scan(tmp_path)
kinds = [finding.kind for finding in report.findings]
# The contract pass runs FIRST (pure AST). What the API passes add after it
# depends on the interpreter: with homeassistant installed they judge the
# module's imports, without it the sweep says it cannot judge (both are
# covered here so the check holds in either variant).
assert kinds[0] == "entity-attribute-is-a-method"
rest = set(kinds[1:])
if ha_api_drift.homeassistant_available():
# the API passes ran and found nothing wrong with this module
assert rest == set(), rest
else:
assert "no-homeassistant" in rest
def test_the_check_ignores_missing_paths_instead_of_raising():
"""scan() owns the bad-path finding; the pass just skips."""
assert ha_api_drift.scan_entity_contracts(Path("/nonexistent-does-not-exist")) == []
+360
View File
@@ -0,0 +1,360 @@
"""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 importlib
import subprocess
import sys
import threading
from pathlib import Path
from types import SimpleNamespace
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"
)
# ------------------------------------------------- entity contract (t40, real HA)
#
# The stub suites cannot see this class of defect: their harness replaces
# ``async_write_ha_state``, so a non-property ``extra_state_attributes`` looks
# fine there. Only HA's own ``Entity.__async_calculate_state`` — which does
# ``attr |= self.extra_state_attributes`` — turns it into
# ``TypeError: 'method' object is not iterable`` and stops the entity from ever
# rendering. These tests drive that real path.
_REPO_ROOT = Path(__file__).resolve().parent.parent
class _RecordingStates:
"""Minimal ``hass.states``: keeps what HA's entity writes."""
def __init__(self):
self.registry: dict = {}
self.written: list = []
def get(self, entity_id):
return self.registry.get(entity_id)
def async_set_internal(self, entity_id, state, attr, *args):
self.written.append((entity_id, state, dict(attr)))
def _real_ha_entity(module_name, class_name, *, options=None, entity_id=None):
"""Build a production entity under real homeassistant (no platform)."""
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
module = importlib.import_module(f"custom_components.nvidia_shield.{module_name}")
states = _RecordingStates()
hass = SimpleNamespace(
loop_thread_id=threading.get_ident(),
states=states,
data={},
config=SimpleNamespace(components=set()),
)
entry = SimpleNamespace(
entry_id="e1", data={}, options=dict(options or {}), title="Shield"
)
entity = getattr(module, class_name)(hass, entry)
entity.entity_id = entity_id
# HA's real translation-key paths (``unit_of_measurement``, device-class
# names) need the platform's translation data; without it a translation-keyed
# entity raises "cannot have a translation key ... before being added to the
# entity platform". PlatformData is the real container, so the entity is
# exercised with the same shape it sees in production.
from homeassistant.helpers.entity_platform import PlatformData
entity.platform_data = PlatformData(
hass, domain=module_name, platform_name="nvidia_shield"
)
# Not added through an EntityComponent: HA would warn once via
# _suggest_report_issue (which needs hass.data["integrations"]) — set the
# flag it uses to report that once instead of faking a whole platform.
entity._no_platform_reported = True
return entity, states
def _write_ha_state(entity, states):
"""Run HA's real state write and return what reached the state machine."""
entity.async_write_ha_state()
assert states.written, f"{type(entity).__name__} wrote no state"
return states.written[-1]
#: Every concrete entity class in the integration, exercised above against the
#: REAL state machine. t40: the media player's defect was a state-write crash, so
#: "the whole tree renders" is the contract under test, not a single entity.
_REAL_HA_MATRIX = (
("media_player", "NvidiaShieldMediaPlayer", None, "media_player.shield"),
("sensor", "NvidiaShieldCurrentAppSensor", None, "sensor.shield_current_app"),
(
"sensor",
"NvidiaShieldNowPlayingSensor",
{"jellyfin_entity": "media_player.jellyfin"},
"sensor.shield_now_playing",
),
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"),
)
#: Classified as an entity by the sweep, but an abstract intermediate base HA
#: never instantiates — its concrete subclasses are in the matrix.
_INTERMEDIATE_BASES = frozenset({"NvidiaShieldSensorBase"})
@pytest.mark.parametrize(
("module_name", "class_name", "options", "entity_id"), _REAL_HA_MATRIX
)
def test_entities_calculate_state_under_real_homeassistant(
module_name, class_name, options, entity_id
):
"""t40 blocker: HA's own state write must not raise for ANY entity.
``Entity.async_write_ha_state`` → ``__async_calculate_state`` merges the
extra attributes with ``attr |= ...``; the pre-fix media player raised
``TypeError: 'method' object is not iterable`` here, so it could never render.
"""
_require_installed_homeassistant()
entity, states = _real_ha_entity(
module_name, class_name, options=options, entity_id=entity_id
)
written_id, state, attrs = _write_ha_state(entity, states)
assert written_id == entity_id
assert isinstance(state, str)
assert isinstance(attrs, dict)
def test_the_media_player_extra_attributes_reach_the_state_machine():
"""The property's payload must survive HA's merge, not merely not raise."""
_require_installed_homeassistant()
entity, states = _real_ha_entity(
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
)
_entity_id, _state, attrs = _write_ha_state(entity, states)
assert {"app_id", "app_name"} <= set(attrs), sorted(attrs)
def test_the_real_ha_matrix_covers_every_entity_class_in_the_tree():
"""A new entity class must join the matrix above, or this test fails.
The sweep classifies entities from the AST; this keeps the real-HA coverage
in step with that classification instead of silently shrinking.
"""
_require_installed_homeassistant()
classified: set[str] = set()
for path in sorted(ha_api_drift.SHIELD_DIR.glob("*.py")):
classified |= ha_api_drift.entity_class_names(
ast.parse(path.read_text(encoding="utf-8"))
)
covered = {class_name for _module, class_name, _options, _entity_id in _REAL_HA_MATRIX}
uncovered = classified - covered - _INTERMEDIATE_BASES
assert uncovered == set(), (
f"entity classes never exercised against real HA: {sorted(uncovered)} — "
f"add them to _REAL_HA_MATRIX"
)
+203
View File
@@ -0,0 +1,203 @@
"""tests/test_harness_scoping.py — the stub harness must not depend on file order (t44).
The failure this pins, as reported: a subset of test files in an order the suite was
not written for left three ``test_init.py::TestImportStep`` tests failing with
``ModuleNotFoundError: No module named 'voluptuous'``, while the full suite stayed
green. Two mechanisms produced it, and both are order-only:
1. ``entity_stublib.install()`` trusted a single sentinel
(``homeassistant.components.media_player``). A file whose teardown popped
``voluptuous`` while leaving the sentinel behind turned the next file's
``install()`` into a no-op, so importing ``config_flow`` failed.
2. A file's teardown restored only the names it had listed. A module it loaded
(``nsh.diagnostics``, ``nsh.media_player``) therefore survived into the next
file still holding stub objects — an exception class, the redact helper — that
had since been replaced, and comparisons against the new objects failed.
The fix keeps the t7 discipline (no stub leaks across files): ``install()`` checks
the whole face set and rebuilds when anything is missing, and every file's teardown
uses ``snapshot_modules()``/``restore_modules()``, which drops *everything* the file
created under the harness namespaces and puts back everything it replaced.
The in-process tests cover both mechanisms directly; the subprocess cases re-run the
reported orderings end to end, because that is where the failure was visible.
"""
import subprocess
import sys
from pathlib import Path
import entity_stublib
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
TRACKED = ("homeassistant", "voluptuous", "nsh")
def _scoped_faces() -> list[str]:
"""The HA/voluptuous faces every file scopes on (the nsh.* names are modules)."""
return [name for name in entity_stublib.ENTITIES_STUB_NAMES if not name.startswith("nsh.")]
def _run(*paths: str) -> subprocess.CompletedProcess:
return subprocess.run(
[
sys.executable,
"-m",
"pytest",
*paths,
"-q",
"--no-header",
"-p",
"no:cacheprovider",
],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=300,
)
class TestStubFaceGuard:
"""Mechanism 1: a partially popped face set must be repaired, not trusted."""
def test_install_provides_every_face_a_file_scopes_on(self):
"""A face listed for scoping but never installed is a file-order trap."""
snapshot = entity_stublib.snapshot_modules()
try:
entity_stublib.install()
missing = [name for name in _scoped_faces() if name not in sys.modules]
assert missing == []
finally:
entity_stublib.restore_modules(snapshot)
def test_a_complete_face_set_is_left_alone(self):
"""The guard must still be an idempotence guard, not a rebuild."""
snapshot = entity_stublib.snapshot_modules()
try:
entity_stublib.install()
before = {name: sys.modules[name] for name in _scoped_faces()}
entity_stublib.install()
assert {name: sys.modules[name] for name in _scoped_faces()} == before
finally:
entity_stublib.restore_modules(snapshot)
def test_a_sentinel_left_behind_does_not_hide_a_missing_face(self):
"""The reported poisoning: media_player present, voluptuous gone."""
snapshot = entity_stublib.snapshot_modules()
try:
entity_stublib.install()
kept = sys.modules["homeassistant.components.media_player"]
del sys.modules["voluptuous"]
entity_stublib.install()
assert "voluptuous" in sys.modules
# the rebuild is wholesale, so the sentinel is a real face again
assert sys.modules["homeassistant.components.media_player"] is not None
assert kept is not None
finally:
entity_stublib.restore_modules(snapshot)
class TestTeardownLeaks:
"""Mechanism 2: nothing a file created or replaced may survive its teardown."""
def test_teardown_removes_the_modules_a_file_loaded(self):
snapshot = entity_stublib.snapshot_modules()
try:
entity_stublib.install()
entity_stublib.load_entity_modules()
created = [
name
for name in sys.modules
if name.startswith("nsh.") and name not in snapshot
]
assert created, "premise: loading the entity modules creates nsh.* modules"
entity_stublib.restore_modules(snapshot)
assert [name for name in sys.modules if name.startswith("nsh.")] == [
name for name in snapshot if name.startswith("nsh.")
]
assert {
name: sys.modules.get(name)
for name in snapshot
if name.split(".", 1)[0] in TRACKED
} == snapshot
finally:
entity_stublib.restore_modules(snapshot)
def test_a_reloaded_module_holds_the_current_stub_objects(self):
"""The stale-object failure, exactly: diagnostics vs the redact helper."""
snapshot = entity_stublib.snapshot_modules()
try:
entity_stublib.install()
stale_module = entity_stublib.load_entity_modules()["diagnostics"]
stale = stale_module.async_redact_data
entity_stublib.restore_modules(snapshot) # ...the file ends
# ...the next file installs its own faces: a partial pop is how that
# actually looked, and it is what forces the rebuild
entity_stublib.install()
del sys.modules["homeassistant.helpers.redact"]
entity_stublib.install()
fresh_modules = entity_stublib.load_entity_modules()
current = sys.modules["homeassistant.helpers.redact"].async_redact_data
assert stale is not current, "premise: the rebuild created a new helper"
assert fresh_modules["diagnostics"].async_redact_data is current
# ...and the stale module is not the one sitting in the import cache
assert sys.modules["nsh.diagnostics"] is fresh_modules["diagnostics"]
finally:
entity_stublib.restore_modules(snapshot)
def test_every_file_that_loads_the_harness_scopes_it():
"""A missing teardown is invisible until the NEXT file runs — so check for it.
This is the mistake the fix itself invites: a new test file calls
load_entity_modules() for convenience and forgets the per-file scope, and the
failure surfaces somewhere else entirely (t44 added exactly that file).
"""
#: Files that exercise the harness in a CHILD process: they leave nothing in
#: this process's sys.modules, so they need no teardown.
child_process_only = {"test_module_coverage.py"}
offenders = []
for path in sorted((REPO_ROOT / "tests").glob("test_*.py")):
if path.name in child_process_only:
continue
text = path.read_text(encoding="utf-8")
loads = "load_entity_modules(" in text or "entity_stublib.install()" in text
if loads and "snapshot_modules()" not in text:
offenders.append(path.name)
assert offenders == []
@pytest.mark.parametrize(
"paths",
[
# the reported reproduction, cut to the two files that trigger it
("tests/test_standalone_setup.py", "tests/test_init.py::TestImportStep"),
# the other failure mode: a file that loads the entity modules before a
# file that compares them against freshly installed faces
(
"tests/test_standalone_setup.py",
"tests/test_config_flow_protocol.py",
"tests/test_init.py",
"tests/test_command_routing.py::TestAdbCommandService",
"tests/test_command_routing.py::TestFaithfulHaStubs",
"tests/test_command_routing.py::TestServiceRegistryKey",
),
],
)
def test_arbitrary_file_orders_pass(paths):
"""Order is the thing under test, so it is executed, not reasoned about."""
result = _run(*paths)
assert result.returncode == 0, result.stdout[-3000:]
+294
View File
@@ -0,0 +1,294 @@
"""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 = entity_stublib.snapshot_modules()
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"]
entity_stublib.restore_modules(snapshot)
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_PORT, CONF_HOST, DEFAULT_ADB_PORT
# t41: a LEGACY block (adb_host) still seeds — normalized onto CONF_HOST
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_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_the_current_host_key_seeds_too(self, _integration_module):
from nsh.const import CONF_HOST
seed = _integration_module._yaml_seed_blocks(
[{"name": "Desk", CONF_HOST: "shield.local"}]
)[0]
assert seed[CONF_HOST] == "shield.local"
assert seed["adb_enabled"] is True # an address names the Shield
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
# a legacy protocol-only block still seeds, with ADB left OFF (t32 F4)
assert blocks[0]["host"] == "shield.local"
assert blocks[0]["adb_port"] == 5000
assert blocks[0]["adb_enabled"] is False
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["host"] == "10.0.0.5" # t41: one address field
class TestSeedingCurrentKey:
"""The t41 `host` key seeds exactly like the legacy one."""
async def test_host_key_seeds_a_flow(self, _integration_module):
hass = _SeedHass(entries=[])
await _integration_module.async_setup(
hass, {"nvidia_shield": [{"name": "H", "host": "10.0.0.7"}]}
)
if hass.scheduled_tasks:
await asyncio.gather(*hass.scheduled_tasks, return_exceptions=True)
assert len(hass.config_entries.flows) == 1
assert hass.config_entries.flows[0][2]["host"] == "10.0.0.7"
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_HOST
existing = SimpleNamespace(
entry_id="ui1",
unique_id=None,
options={CONF_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"
+474
View File
@@ -0,0 +1,474 @@
"""tests/test_media_sources.py — source_list, source and native select_source (t47).
The last piece a universal-media-player replacement needs from this integration:
a dropdown of the apps (``source_list``), an answer to "what is on screen"
(``source``), and a way to pick one (``media_player.select_source``). All three read
the SAME app directory — the user's Apps map, else the curated default — so the
dropdown cannot offer a name the launch path cannot resolve.
The t44 rule still holds: a configured ``select_source`` script takes precedence,
because selecting a source can be more than launching an app. Without one, the name
resolves back to its package and goes out over the shared ``play_media("app", …)``
path; an unknown name is a translated error, never a silent no-op.
"""
import entity_stublib
import pytest
from nsh.const import CONF_APP_NAME_MAP, CONF_COMMAND_SCRIPTS, SCRIPT_VAR_APP_NAME
from test_command_routing import make_player
TIVIMATE = "ar.tvplayer.tv"
SCRIPT = "script.shield_source"
@pytest.fixture(name="modules", autouse=True)
def scoped_modules():
"""Stub scope + loaded entity modules for one test (t7 discipline)."""
snapshot = entity_stublib.snapshot_modules()
entity_stublib.install()
try:
yield entity_stublib.load_entity_modules()
finally:
entity_stublib.restore_modules(snapshot)
def _player(modules, *, app_map=None, scripts=None, foreground=None):
player, served, _intents = make_player(modules)
if app_map is not None:
player._entry.options[CONF_APP_NAME_MAP] = app_map
if scripts is not None:
player._entry.options[CONF_COMMAND_SCRIPTS] = scripts
if foreground is not None:
player._manager._on_current_app(foreground)
return player, served
class TestSourceList:
"""The dropdown: friendly names, in a stable order, from the one directory."""
def test_it_comes_from_the_apps_map(self, modules):
player, _served = _player(
modules,
app_map={"com.custom.one": "One", "com.custom.two": "Two"},
)
assert player.source_list == ["One", "Two"] # map order, not shuffled
def test_it_falls_back_to_the_curated_directory(self, modules):
player, _served = _player(modules) # no map configured
assert player.source_list == ["TiviMate", "Wholphin", "Netflix", "YouTube"]
def test_a_blank_name_never_reaches_the_dropdown(self, modules):
"""t49: the map is hand-editable, so 'com.x=' must not offer an empty entry.
An empty entry is worst than useless: it is a dropdown item that
select_source has to reject as an unknown source.
"""
player, _served = _player(
modules,
app_map={
"com.blank": "",
TIVIMATE: "TiviMate",
"com.spaces": " ",
"com.netflix.ninja": "Netflix",
},
)
assert player.source_list == ["TiviMate", "Netflix"]
assert "" not in player.source_list
async def test_the_list_matches_the_browse_titles_exactly(self, modules):
"""Same accessor, same order, same bytes — a blank entry excepted (t49)."""
for app_map in (
None, # the curated default
{"ar.tvplayer.tv": "TiviMate", "com.netflix.ninja": "Netflix"},
):
player, _served = _player(modules, app_map=app_map)
root = await player.async_browse_media()
assert player.source_list == [child.title for child in root.children]
async def test_the_survivors_still_resolve_and_launch(self, modules):
"""The filter must not cost a resolvable name (t49)."""
player, _served = _player(
modules,
app_map={"com.blank": "", TIVIMATE: "TiviMate", "com.blank2": " "},
)
for name in player.source_list:
assert player._source_package(name) is not None
await player.async_select_source("TiviMate")
assert player._manager._protocol.launch_calls == [TIVIMATE]
# the blank entries are not selectable, and never launch
assert player._source_package("") is None
assert player._source_package(" ") is None
def test_every_offered_name_resolves_to_a_package(self, modules):
"""The invariant the shared directory buys: no name without a destination.
t51 extends it to the hand-edited shapes: blanks, padded names and two
entries normalizing to one name all still leave only resolvable offers.
"""
for app_map in (
None,
{"com.custom.one": "One"},
{
"com.blank": "",
"com.blank.package": " ",
TIVIMATE: " TiviMate ",
"com.a": "Netflix",
"com.b": " Netflix ",
"": "Ghost",
},
):
player, _served = _player(modules, app_map=app_map)
for name in player.source_list:
assert player._source_package(name) is not None, name
class TestSource:
"""`source` is the foreground app's resolved name — the app_name chain."""
def test_it_resolves_through_the_apps_map(self, modules):
player, _served = _player(
modules, app_map={TIVIMATE: "TiviMate"}, foreground=TIVIMATE
)
assert player.source == "TiviMate"
assert player.source == player.app_name # one truth, not two
def test_it_uses_the_cached_device_label_when_unmapped(self, modules):
player, _served = _player(modules, foreground="com.netflix.ninja")
player._manager._app_labels["com.netflix.ninja"] = (
"Netflix",
player._manager._clock(),
)
assert player.source == "Netflix"
def test_it_falls_back_to_the_raw_package(self, modules):
player, _served = _player(modules, foreground="com.unknown.app")
assert player.source == "com.unknown.app"
def test_it_is_unknown_when_nothing_has_reported(self, modules):
player, _served = _player(modules)
assert player.source is None
class TestSelectSource:
"""Picking a source: script first, else the native launch, else an error."""
async def test_a_name_launches_its_package(self, modules):
player, served = _player(
modules, app_map={TIVIMATE: "TiviMate", "com.netflix.ninja": "Netflix"}
)
await player.async_select_source("TiviMate")
assert player._manager._protocol.launch_calls == [TIVIMATE]
assert served == [] # no script was configured, and nothing else left
async def test_the_curated_names_launch_too(self, modules):
player, _served = _player(modules)
await player.async_select_source("Netflix")
assert player._manager._protocol.launch_calls == ["com.netflix.ninja"]
async def test_a_raw_package_and_any_case_are_accepted(self, modules):
player, _served = _player(
modules, app_map={TIVIMATE: "TiviMate", "com.netflix.ninja": "Netflix"}
)
await player.async_select_source(TIVIMATE)
await player.async_select_source("tivimate")
await player.async_select_source(" NETFLIX ")
assert player._manager._protocol.launch_calls == [
TIVIMATE,
TIVIMATE,
"com.netflix.ninja",
]
async def test_a_configured_script_still_wins(self, modules):
player, served = _player(
modules,
app_map={TIVIMATE: "TiviMate"},
scripts={"select_source": SCRIPT},
)
await player.async_select_source("TiviMate")
assert served == [
(
"script",
"turn_on",
{"entity_id": SCRIPT, "variables": {SCRIPT_VAR_APP_NAME: "TiviMate"}},
)
]
assert player._manager._protocol.launch_calls == [] # native path untouched
async def test_an_unknown_name_raises_a_translated_error(self, modules):
from homeassistant.exceptions import ServiceValidationError
player, _served = _player(modules, app_map={TIVIMATE: "TiviMate"})
with pytest.raises(ServiceValidationError) as raised:
await player.async_select_source("Not An App")
assert raised.value.translation_domain == "nvidia_shield"
assert raised.value.translation_key == "unknown_source"
assert raised.value.translation_placeholders == {"source": "Not An App"}
assert player._manager._protocol.launch_calls == [] # nothing was sent
async def test_an_empty_name_is_an_error_too(self, modules):
from homeassistant.exceptions import ServiceValidationError
player, _served = _player(modules)
with pytest.raises(ServiceValidationError):
await player.async_select_source(" ")
class TestAdvertisedFeatures:
"""HA routes a service by its feature flag, so the flag has to be honest."""
def test_a_source_list_advertises_select_source(self, modules):
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules)
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
def test_a_script_advertises_it_even_with_no_directory(self, modules, monkeypatch):
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules, scripts={"select_source": SCRIPT})
monkeypatch.setattr(type(player), "_app_directory", property(lambda self: {}))
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
def test_neither_means_it_is_not_offered(self, modules, monkeypatch):
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules)
monkeypatch.setattr(type(player), "_app_directory", property(lambda self: {}))
assert not player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
def test_select_source_is_not_gated_on_the_volume_set_script(self, modules):
"""The two script-only commands stay independent."""
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules, scripts={"volume_set": "script.volume"})
assert player.supported_features & MediaPlayerEntityFeature.VOLUME_SET
assert player.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE
class TestNormalizedDirectories:
"""t51: a hand-edited map is normalized ONCE, in the shared accessor.
The two residuals t49 left share one cause — `_app_directory` handed the raw map
out, so the Browse tree showed a child with a blank title and the reverse lookup
compared the user's padded name against itself. The policy now lives in that one
accessor (strip names and keys, drop blanks, first-in-map-order wins a shared
name) and is pinned here, together with the promise that a well-formed map is
untouched.
"""
BLANKED = {
"com.blank": "",
TIVIMATE: "TiviMate",
"com.spaces": " ",
"": "Ghost",
}
async def test_a_blank_entry_never_reaches_the_browse_tree(self, modules):
"""The Browse tree and the dropdown are the same, resolved offer."""
player, _served = _player(modules, app_map=dict(self.BLANKED))
root = await player.async_browse_media()
assert [child.title for child in root.children] == ["TiviMate"]
assert [child.media_content_id for child in root.children] == [TIVIMATE]
assert player.source_list == ["TiviMate"]
async def test_a_blank_package_is_dropped_too(self, modules):
"""An entry with no package is a child that cannot be launched."""
player, _served = _player(
modules, app_map={"": "Ghost", " ": "Ghost 2", TIVIMATE: "TiviMate"}
)
root = await player.async_browse_media()
assert [child.media_content_id for child in root.children] == [TIVIMATE]
assert player.source_list == ["TiviMate"]
async def test_a_padded_name_is_stripped_once_and_then_resolves(self, modules):
player, _served = _player(
modules,
app_map={TIVIMATE: " TiviMate ", "com.netflix.ninja": "Netflix"},
)
root = await player.async_browse_media()
# display and match key are one string, everywhere
assert player.source_list == ["TiviMate", "Netflix"]
assert [child.title for child in root.children] == ["TiviMate", "Netflix"]
# ...so every spelling the user may type finds the package
assert player._source_package("TiviMate") == TIVIMATE
assert player._source_package(" TiviMate ") == TIVIMATE
assert player._source_package("tivimate") == TIVIMATE
await player.async_select_source("TiviMate")
assert player._manager._protocol.launch_calls == [TIVIMATE]
async def test_two_packages_with_one_name_keep_a_deterministic_winner(self, modules):
"""Both apps stay reachable; the NAME goes to the first in map order."""
player, _served = _player(
modules, app_map={"com.a": "Netflix", "com.b": " Netflix "}
)
root = await player.async_browse_media()
# both are still offered in the tree, each by its own package id
assert [(c.media_content_id, c.title) for c in root.children] == [
("com.a", "Netflix"),
("com.b", "Netflix"),
]
# the dropdown offers the name once, and resolves to the first entry
assert player.source_list == ["Netflix"]
assert player._source_package("Netflix") == "com.a"
await player.async_select_source("Netflix")
assert player._manager._protocol.launch_calls == ["com.a"]
# map order decides — the user's order, not chance
swapped, _served_swapped = _player(
modules, app_map={"com.b": "Netflix", "com.a": "Netflix"}
)
assert swapped._source_package("Netflix") == "com.b"
async def test_a_wellformed_map_passes_through_unchanged(self, modules):
"""Stripping touches the EDGES: an inner space is part of the name."""
player, _served = _player(
modules, app_map={TIVIMATE: "TiviMate Pro", "com.netflix.ninja": "Netflix"}
)
root = await player.async_browse_media()
assert player.source_list == ["TiviMate Pro", "Netflix"]
assert [child.title for child in root.children] == ["TiviMate Pro", "Netflix"]
assert player._source_package("TiviMate Pro") == TIVIMATE
class TestCaseInsensitiveNames:
"""t53: one row per NAME, compared case-insensitively — as the lookup does.
The dropdown and the reverse lookup disagreed about case: two entries called
`Dup` and `dup` survived an exact-match dedupe as two rows, while
`_source_package` — case-insensitive by design (t47) — resolved BOTH to the
first entry. So the second row launched the first entry's app: a row that lies
about what it does, and the one shape here the editor can produce by itself.
"""
async def test_names_differing_only_by_case_are_offered_once(self, modules):
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
# one row, keeping the first spelling the user typed
assert player.source_list == ["Dup"]
# ...and the Browse tree still holds both apps, each under its own id
root = await player.async_browse_media()
assert [(c.media_content_id, c.title) for c in root.children] == [
("com.a", "Dup"),
("com.b", "dup"),
]
async def test_the_offered_row_launches_the_app_it_shows(self, modules):
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
await player.async_select_source(player.source_list[0])
assert player._manager._protocol.launch_calls == ["com.a"] # the row it shows
async def test_the_shadowed_app_stays_reachable_by_id(self, modules):
player, _served = _player(modules, app_map={"com.a": "Dup", "com.b": "dup"})
assert player._source_package("com.b") == "com.b"
await player.async_select_source("com.b")
assert player._manager._protocol.launch_calls == ["com.b"]
# the shadowed NAME still resolves to the one the list offers (first wins)
assert player._source_package("dup") == "com.a"
async def test_the_first_spelling_wins_in_map_order(self, modules):
player, _served = _player(modules, app_map={"com.b": "dup", "com.a": "Dup"})
assert player.source_list == ["dup"]
await player.async_select_source("dup")
assert player._manager._protocol.launch_calls == ["com.b"]
async def test_a_mixed_map_offers_each_name_once(self, modules):
player, _served = _player(
modules,
app_map={
"com.a": "Dup",
"com.b": "dup",
"com.netflix.ninja": "Netflix",
"com.google.android.youtube.tv": "netflix",
TIVIMATE: "TiviMate Pro",
},
)
assert player.source_list == ["Dup", "Netflix", "TiviMate Pro"]
assert player._source_package("Netflix") == "com.netflix.ninja"
def test_the_offered_list_never_repeats_a_name(self, modules):
"""The invariant behind the fix, over every map shape in this file."""
for app_map in (
None,
{"com.a": "Dup", "com.b": "dup"},
{"com.a": "Dup", "com.b": "DUP", "com.c": "dup"},
{"com.blank": "", TIVIMATE: " TiviMate ", "com.a": "Netflix", "com.b": "netflix"},
):
player, _served = _player(modules, app_map=app_map)
folded = [name.casefold() for name in player.source_list]
assert len(folded) == len(set(folded)), app_map
assert len(folded) == len(player.source_list)
def test_a_padded_map_value_is_displayed_verbatim_by_app_name(self, modules):
"""The README's boundary, pinned: the list cleans it, app_name does not.
The name map is the user's own override, so it is used as typed — which is
why `source` can be a string that is not in source_list.
"""
player, _served = _player(
modules, app_map={TIVIMATE: " TiviMate "}, foreground=TIVIMATE
)
assert player.source_list == ["TiviMate"] # the dropdown cleans it
assert player.source == " TiviMate " # this surface shows it as typed
assert player.source not in player.source_list
def test_a_blank_map_value_falls_through_to_the_raw_package(self, modules):
"""Blanks are not displayed as nothing — the chain continues past them."""
player, _served = _player(modules, app_map={TIVIMATE: ""}, foreground=TIVIMATE)
assert player.source == TIVIMATE
+204
View File
@@ -0,0 +1,204 @@
"""tests/test_module_coverage.py — nothing in the package may dangle (t41).
Two defects motivated this file, and both shipped with a green suite:
1. ``button.py`` imported a helper that had been deleted. No harness ever loaded
that file (``entity_stublib.load_entity_modules`` lists the platforms it
knows), so the ImportError would have appeared only in a user's log. The fix
is exhaustive by construction: the sweep walks the package for ``*.py`` files
in a clean interpreter, under the stub harness, and loads every one of them.
A new platform is covered the moment the file exists — there is no list to
update and no special case.
2. The drift sweep resolved ``homeassistant.*`` imports only, so it could not
see a relative import of a name that no longer exists — the same defect one
layer up. ``ha_api_drift.scan_package_imports`` now resolves the package's own
imports from the AST, with no Home Assistant and therefore no stub needed.
Each sweep carries its own red proof, and nothing about the pre-t41 tree supplies
it. At ``c588a7c`` ``helpers.py`` still DEFINED ``discover_adb_host``, so there was
no broken relative import to find; and that commit's engine predates BOTH passes
(its ``ha_api_drift.py`` has no entity-contract check and no package-import check
at all), so a real-HA run there is simply clean — exit 0, "no Home-Assistant API
drift (13 modules …)", no findings. The proof is therefore built, never borrowed:
a copy of the tree with the ``discover_adb_host`` reference actually reverted must
fail each sweep, or the check is decoration.
"""
import shutil
import subprocess
import sys
from pathlib import Path
import ha_api_drift
import pytest
from conftest import _SHIELD_DIR
TESTS_DIR = Path(__file__).resolve().parent
REPO_ROOT = _SHIELD_DIR.parent.parent
#: Loads every module of a package under the stub harness, in a clean process.
#: ``argv``: 1 = this tests directory, 2 = the package directory to sweep.
_DRIVER = r"""
import sys
from pathlib import Path
tests_dir, package_root = Path(sys.argv[1]), Path(sys.argv[2])
sys.path.insert(0, str(tests_dir))
sys.path.insert(0, str(package_root))
import entity_stublib # noqa: E402
from conftest import _load_module # noqa: E402
# conftest eagerly loads a few stdlib-only modules from the REAL tree (it cannot
# know about a copied one): point the synthetic package at the tree under test
# and drop what it imported, so every relative import resolves inside the copy.
sys.modules["nsh"].__path__ = [str(package_root)]
for name in [n for n in list(sys.modules) if n.startswith("nsh.")]:
del sys.modules[name]
entity_stublib.install()
loaded, failures = [], []
for path in sorted(package_root.rglob("*.py")):
if "__pycache__" in path.parts:
continue
rel = path.relative_to(package_root)
if rel.name == "__init__.py":
dotted = ".".join(rel.parent.parts)
name = "nsh.init" if dotted in ("", ".") else f"nsh.{dotted}"
else:
name = "nsh." + ".".join(rel.with_suffix("").parts)
search = [str(path.parent)] if rel.name == "__init__.py" else None
try:
_load_module(name, path, search)
except Exception as exc: # noqa: BLE001 - the error IS the finding
failures.append(f"{rel}: {type(exc).__name__}: {exc}")
else:
loaded.append(str(rel))
for rel in loaded:
print(f"OK {rel}")
for failure in failures:
print(f"FAIL {failure}")
print(f"SUMMARY loaded={len(loaded)} failed={len(failures)} total={len(loaded) + len(failures)}")
sys.exit(1 if failures else 0)
"""
def _package_modules(root: Path) -> set[str]:
"""Every Python file of a package, as the sweep reports them."""
return {
str(path.relative_to(root))
for path in root.rglob("*.py")
if "__pycache__" not in path.parts
}
def _sweep(root: Path) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-c", _DRIVER, str(TESTS_DIR), str(root)],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
check=False,
)
def _reverted_copy(tmp_path: Path) -> Path:
"""A copy of the package where button.py imports the deleted helper again."""
target = tmp_path / "nvidia_shield"
shutil.copytree(
_SHIELD_DIR,
target,
ignore=shutil.ignore_patterns("__pycache__", "*.json", "*.png"),
)
button = target / "button.py"
button.write_text(
"from .helpers import discover_adb_host # the reverted reference\n"
+ button.read_text(encoding="utf-8"),
encoding="utf-8",
)
return target
def test_every_module_imports_under_the_stub_harness():
"""The whole package loads in a clean interpreter — no module is skipped."""
result = _sweep(_SHIELD_DIR)
output = result.stdout + result.stderr
assert result.returncode == 0, output
reported = {
line.split(" ", 1)[1]
for line in result.stdout.splitlines()
if line.startswith(("OK ", "FAIL "))
}
assert reported == _package_modules(_SHIELD_DIR), "the sweep skipped or invented a file"
assert "failed=0" in result.stdout, output
def test_the_sweep_fails_on_a_reverted_helper_reference(tmp_path):
"""Red proof 1: the exact defect that motivated this check.
button.py imported ``discover_adb_host`` from helpers after that helper was
deleted (t41 removed it with the entity path). The sweep must fail loudly.
"""
result = _sweep(_reverted_copy(tmp_path))
output = result.stdout + result.stderr
assert result.returncode != 0, output
assert "discover_adb_host" in output
assert "FAIL button.py" in result.stdout
def test_every_relative_import_resolves():
"""The same contract, statically: the package's own imports all resolve."""
findings = ha_api_drift.scan_package_imports()
assert findings == [], "\n".join(str(finding) for finding in findings)
def test_the_relative_import_pass_is_not_vacuous():
"""A pass that resolved nothing would be green for the wrong reason."""
report = ha_api_drift.Report()
ha_api_drift._scan_package_imports([_SHIELD_DIR], report)
assert report.relative_imports >= ha_api_drift.FLOORS["relative_imports"]
# and the walk itself reached every module, not only the top level (the
# floor sits above the top-level-only count, so a shallow glob trips it)
assert len(_package_modules(_SHIELD_DIR)) >= ha_api_drift.FLOORS["modules"]
def test_the_static_sweep_fails_on_a_reverted_helper_reference(tmp_path):
"""Red proof 2: the AST pass sees the same defect, with no Home Assistant."""
findings = ha_api_drift.scan_package_imports(_reverted_copy(tmp_path))
assert [finding.kind for finding in findings] == ["broken-relative-import"]
assert "discover_adb_host" in findings[0].detail
assert findings[0].path.endswith("button.py")
@pytest.mark.parametrize(
("source", "expected"),
[
# a name nobody defines
("from .helpers import no_such_helper\n", "broken-relative-import"),
# a relative import that leaves the package
("from ..sibling import thing\n", "broken-relative-import"),
# a submodule import is valid without a binding in __init__
("from .ha_adb_common import adb_client\n", None),
],
)
def test_the_pass_judges_the_cases_that_matter(tmp_path, source, expected):
package = tmp_path / "nvidia_shield"
(package / "ha_adb_common").mkdir(parents=True)
(package / "__init__.py").write_text("", encoding="utf-8")
(package / "helpers.py").write_text("def entry_host(entry):\n return ''\n", encoding="utf-8")
(package / "ha_adb_common" / "__init__.py").write_text("", encoding="utf-8")
(package / "ha_adb_common" / "adb_client.py").write_text("", encoding="utf-8")
(package / "module.py").write_text(source, encoding="utf-8")
findings = ha_api_drift.scan_package_imports(package)
assert [finding.kind for finding in findings] == ([expected] if expected else [])
+63
View File
@@ -0,0 +1,63 @@
"""tests/test_readme.py — the README's user-facing contracts (t41).
Documentation drifts silently: the seed example is read by people who then
report "the key did nothing". These pins are deliberately textual — no YAML
parser dependency — but they check the two things that actually hurt:
* every key in the `nvidia_shield:` example is a key ``_yaml_seed_blocks``
honours (nothing documented that the integration ignores);
* the replacement guide names the integrations and the glue it retires, so the
t41 ruling stays visible to users, not only in the changelog.
"""
import re
from pathlib import Path
import pytest
README = Path(__file__).resolve().parent.parent / "README.md"
# helpers.seed_host / helpers.seed_adb_enabled read these; nothing else in a
# seed block reaches the entry (the rest is the interactive flow's or the
# options page's to set).
SEEDED_KEYS = {
"name",
"host",
"adb_port",
"adb_enabled",
"protocol_host", # legacy, still accepted
"adb_host", # legacy, still accepted
}
@pytest.fixture(scope="module")
def readme_text() -> str:
return README.read_text(encoding="utf-8")
def _seed_example(text: str) -> str:
match = re.search(r"```yaml\n(nvidia_shield:\n(?:.*\n)*?)```", text)
assert match, "the README has no `nvidia_shield:` example block"
return match.group(1)
def test_the_example_block_only_uses_seeded_keys(readme_text):
keys = set(re.findall(r"^\s+([a-z_]+):", _seed_example(readme_text), re.M))
assert keys, "the example block declares no keys"
assert keys <= SEEDED_KEYS, f"documented but ignored: {sorted(keys - SEEDED_KEYS)}"
def test_the_example_block_documents_the_one_address_field(readme_text):
example = _seed_example(readme_text)
assert "host:" in example
# the legacy spellings are still explained, as back-compat
assert "protocol_host" in readme_text and "adb_host" in readme_text
def test_the_replacement_guide_retires_the_old_stack(readme_text):
for needle in ("androidtv_remote", "mediasession2mqtt", "universal", "Wholphin"):
assert needle in readme_text, needle
# and it says the UI is the primary path
assert "primary path" in readme_text
+654
View File
@@ -0,0 +1,654 @@
"""tests/test_release_versioning.py — the release version decision (t42).
Why this file exists: the version the workflow picks decides whether HACS ever
offers a user the update. Every wrong answer is silent — the tag and the release
still appear, only the manifest never moves — and the real evidence is in the
repo history: v2026.10.09 was published while master's manifest still read
2026.10.08. The decision therefore lives in ``scripts/determine-release-version.sh``
and every branch below is driven directly, rather than trusted to a CI run.
The four scenarios the task names, plus the boundary cases that made the old
inline shell wrong:
(a) manifest version untagged -> manifest wins (BUMP=no)
(b) manifest tagged elsewhere, date free -> date
(c) manifest AND date tagged -> same-day CALVER suffix, lowest free N
(d) the commit is already released -> RELEASE=no, explicit no-op signal
The script is also the only place that reads the clock, and it reads it in UTC.
"""
import json
import os
import re
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT = REPO_ROOT / "scripts" / "determine-release-version.sh"
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "release.yml"
MANIFEST = REPO_ROOT / "custom_components" / "nvidia_shield" / "manifest.json"
HEAD = "a" * 40
OTHER = "b" * 40
TODAY = "2026.10.11"
def _resolve(
tmp_path: Path,
*,
manifest_version: str,
tags=(),
today: str | None = TODAY,
head: str | None = HEAD,
env: dict | None = None,
):
"""Run the resolver against a fixture manifest and tag list."""
tmp_path.mkdir(parents=True, exist_ok=True)
manifest = tmp_path / "manifest.json"
manifest.write_text(
json.dumps({"domain": "nvidia_shield", "version": manifest_version}) + "\n",
encoding="utf-8",
)
tag_list = tmp_path / "tags"
tag_list.write_text(
"".join(f"{tag} {sha}\n" for tag, sha in tags), encoding="utf-8"
)
argv = [
"bash",
str(SCRIPT),
"--manifest",
str(manifest),
"--tags-from",
str(tag_list),
]
if today is not None:
argv += ["--today", today]
if head is not None:
argv += ["--head", head]
result = subprocess.run(
argv,
capture_output=True,
text=True,
env={**os.environ, **(env or {})},
check=False,
)
values = dict(
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
)
return result, values
# ── (a) the manifest wins while its version is untagged ──────────────────────
def test_an_untagged_manifest_version_is_released_as_is(tmp_path):
result, values = _resolve(
tmp_path, manifest_version="2026.10.11", tags=[("v2026.10.09", OTHER)]
)
assert result.returncode == 0, result.stderr
assert values["VERSION"] == "2026.10.11"
assert values["TAG"] == "v2026.10.11"
assert values["SOURCE"] == "manifest"
assert values["RELEASE"] == "yes"
# the manifest already says it: no write-back commit, so the tag lands on
# the pushed commit itself
assert values["BUMP"] == "no"
def test_a_leading_v_in_the_manifest_is_tolerated(tmp_path):
"""The tag adds the `v`; a hand-written one must not become the version."""
_result, values = _resolve(tmp_path, manifest_version="v2026.10.11")
assert values["VERSION"] == "2026.10.11"
assert values["TAG"] == "v2026.10.11"
# ── (b) once the manifest version is tagged, the date takes over ─────────────
def test_a_tagged_manifest_version_falls_back_to_the_utc_date(tmp_path):
result, values = _resolve(
tmp_path, manifest_version="2026.10.09", tags=[("v2026.10.09", OTHER)]
)
assert result.returncode == 0, result.stderr
assert values["VERSION"] == TODAY
assert values["SOURCE"] == "date"
assert values["RELEASE"] == "yes"
# the manifest still says the old version: the workflow must write the new
# one back, or HACS keeps comparing against 2026.10.09 forever
assert values["BUMP"] == "yes"
# ── (c) a second release in one UTC day ─────────────────────────────────────
def test_a_tagged_date_falls_back_to_a_same_day_suffix(tmp_path):
result, values = _resolve(
tmp_path,
manifest_version="2026.10.09",
tags=[("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)],
)
assert result.returncode == 0, result.stderr
assert values["VERSION"] == f"{TODAY}.2"
assert values["SOURCE"] == "same-day"
assert values["RELEASE"] == "yes"
assert values["BUMP"] == "yes"
def test_the_suffix_is_the_lowest_untagged_n_from_two(tmp_path):
"""Not "highest + 1": a gap is filled, so a failed run does not burn a number."""
_result, values = _resolve(
tmp_path,
manifest_version="2026.10.09",
tags=[
("v2026.10.09", OTHER),
(f"v{TODAY}", OTHER),
(f"v{TODAY}.2", OTHER),
],
)
assert values["VERSION"] == f"{TODAY}.3"
_result, values = _resolve(
tmp_path / "second",
manifest_version="2026.10.09",
tags=[
("v2026.10.09", OTHER),
(f"v{TODAY}", OTHER),
(f"v{TODAY}.2", OTHER),
(f"v{TODAY}.3", OTHER),
(f"v{TODAY}.5", OTHER), # a gap at .4 must be reused
],
)
assert values["VERSION"] == f"{TODAY}.4"
# ── (d) nothing to do ───────────────────────────────────────────────────────
def test_an_already_released_commit_is_an_explicit_no_op(tmp_path):
"""A re-run (manual dispatch, a retried job) must not invent a new version."""
result, values = _resolve(
tmp_path,
manifest_version="2026.10.09",
tags=[("v2026.10.09", HEAD)], # this very commit is the release
head=HEAD,
)
assert result.returncode == 0, result.stderr
assert values["RELEASE"] == "no"
assert values["VERSION"] == "2026.10.09"
assert values["SOURCE"] == "released"
assert values["BUMP"] == "no"
assert "nothing to release" in result.stderr
def test_a_release_is_never_planned_for_a_tag_that_exists(tmp_path):
"""The property the backstop defends, over a spread of tag sets.
RELEASE=yes must always mean "this version is not tagged yet": that is what
makes the workflow's tag step safe without re-checking.
"""
scenarios = [
("2026.10.09", []),
("2026.10.09", [("v2026.10.09", OTHER)]),
("2026.10.09", [("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)]),
("2026.10.11", [("v2026.10.08", OTHER)]),
(f"{TODAY}.2", [("v2026.10.09", OTHER), (f"v{TODAY}", OTHER)]),
("2026.10.09", [("v2026.10.09", HEAD)]), # already released
]
for index, (manifest_version, tags) in enumerate(scenarios):
_result, values = _resolve(
tmp_path / f"case{index}", manifest_version=manifest_version, tags=tags
)
tagged = {tag for tag, _sha in tags}
if values["RELEASE"] == "yes":
assert values["TAG"] not in tagged, (manifest_version, tags, values)
else:
assert values["SOURCE"] in {"released", "tagged"}
# ── (e) UTC determinism ─────────────────────────────────────────────────────
def test_the_date_is_utc_whatever_the_runner_timezone_is(tmp_path):
"""A version number must not depend on where the job happened to run."""
tags = [("v2026.09.30", OTHER)] # the manifest version is tagged -> date
runs = []
for timezone in ("Pacific/Kiritimati", "Etc/GMT+12", "America/Anchorage"):
_result, values = _resolve(
tmp_path / timezone.replace("/", "_"),
manifest_version="2026.09.30",
tags=tags,
today=None, # exercise the real clock
env={"TZ": timezone},
)
runs.append(values["VERSION"])
utc_today = subprocess.run(
["date", "-u", "+%Y.%m.%d"], capture_output=True, text=True, check=True
).stdout.strip()
assert len(set(runs)) == 1, runs
assert runs[0] == utc_today
# ── usage and error handling ────────────────────────────────────────────────
def test_a_missing_manifest_fails_loudly(tmp_path):
result = subprocess.run(
["bash", str(SCRIPT), "--manifest", str(tmp_path / "nope.json"), "--today", TODAY],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 1
assert "manifest not found" in result.stderr
def test_a_non_calver_manifest_version_fails_loudly(tmp_path):
result, _values = _resolve(tmp_path, manifest_version="1.2.3")
assert result.returncode == 1
assert "not YYYY.MM.DD" in result.stderr
def test_an_unknown_argument_is_a_usage_error(tmp_path):
result = subprocess.run(
["bash", str(SCRIPT), "--nonsense"], capture_output=True, text=True, check=False
)
assert result.returncode == 1
assert "unknown argument" in result.stderr
def test_github_output_receives_the_same_values(tmp_path):
"""The workflow consumes $GITHUB_OUTPUT, so the script must fill it too."""
output = tmp_path / "github_output"
output.write_text("", encoding="utf-8")
result, values = _resolve(
tmp_path, manifest_version="2026.10.11", env={"GITHUB_OUTPUT": str(output)}
)
assert result.returncode == 0
written = dict(
line.split("=", 1)
for line in output.read_text(encoding="utf-8").splitlines()
if "=" in line
)
assert written == values
assert set(written) == {"VERSION", "TAG", "SOURCE", "BUMP", "RELEASE"}
# ── the workflow contract (textual: the fast suite has no YAML parser) ───────
@pytest.fixture(scope="module")
def workflow_text() -> str:
return WORKFLOW.read_text(encoding="utf-8")
def test_the_workflow_uses_the_tested_script(workflow_text):
assert "scripts/determine-release-version.sh" in workflow_text
def test_the_workflow_writes_the_manifest_back_and_tags_that_commit(workflow_text):
# the exact commit message the task pins, with the skip-ci marker
assert "chore(release): v${VERSION} [skip ci]" in workflow_text
# the push happens before the tag, so the tag names the bumped commit
assert "git push origin HEAD:master" in workflow_text
assert 'git push origin "refs/tags/${TAG}"' in workflow_text
def test_a_rejected_bump_push_fails_with_a_manual_instruction(workflow_text):
assert "could not push" in workflow_text
assert "bump" in workflow_text.lower() and "manually" in workflow_text.lower()
def test_the_workflow_verifies_the_pushed_manifest_before_publishing(workflow_text):
assert "origin/master:${MANIFEST}" in workflow_text
assert "inconsistent" in workflow_text.lower()
def test_the_bot_commit_cannot_trigger_another_release(workflow_text):
# two layers: the conventional skip-ci marker on the commit, and a job-level
# guard that re-checks the message (the guard is what must hold even if a
# future Gitea stops honouring the marker)
assert "[skip ci]" in workflow_text
assert "github.event.head_commit.message" in workflow_text
assert "workflow_dispatch" in workflow_text
def test_the_skip_guard_matches_the_subject_not_the_whole_message(workflow_text):
"""t44 R3: `contains(message, '[skip ci]')` skipped a release silently.
A developer commit whose BODY mentions the marker is not a release commit, so
the guard is anchored to the release bot's subject prefix instead.
"""
assert "!startsWith(github.event.head_commit.message || '', 'chore(release):')" in (
workflow_text
)
assert "!contains(github.event.head_commit.message" not in workflow_text
def test_the_release_notes_exclude_the_write_back_commit(workflow_text):
"""t44 R1: with a write-back the tag IS the bot commit, so notes must skip it."""
assert 'if [ "$BUMP" = "yes" ]; then' in workflow_text
assert 'NOTES_END="${TAG}^"' in workflow_text
assert 'RANGE="${PREV}..${NOTES_END}"' in workflow_text
assert 'BUMP: ${{ steps.version.outputs.BUMP }}' in workflow_text
def test_the_preserved_release_behaviour_is_still_there(workflow_text):
# one release at a time, never cancelled mid-flight
assert "group: release-master" in workflow_text
assert "cancel-in-progress: false" in workflow_text
# notes from the commit range
assert "git log --pretty=format" in workflow_text
# the 409-tolerant publish path
assert "409 is" in workflow_text or "409" in workflow_text
assert "/releases/tags/" in workflow_text
# the zip asset
assert "attachment=@${ZIP_NAME}" in workflow_text
def test_the_script_reads_the_manifest_the_workflow_bumps():
"""One path, so a moved manifest cannot silently release a stale version."""
assert MANIFEST.is_file()
assert "custom_components/nvidia_shield/manifest.json" in SCRIPT.read_text(
encoding="utf-8"
)
manifest_version = json.loads(MANIFEST.read_text(encoding="utf-8"))["version"]
# the script rejects anything else, so a typo here would fail every release
assert re.fullmatch(r"[0-9]{4}\.[0-9]{2}\.[0-9]{2}(\.[0-9]+)?", manifest_version)
# ── t44 3c: real repositories, and the workflow's own steps by execution ─────
MANIFEST_REL = "custom_components/nvidia_shield/manifest.json"
def _git(repo, *args) -> str:
return subprocess.run(
["git", *args], cwd=repo, capture_output=True, text=True, check=True
).stdout
def _repo(tmp_path, *, version="2026.10.09", commits=("a feature commit",)):
"""A real repository: the resolver's tag lookups really are git commands."""
repo = tmp_path / "repo"
repo.mkdir(parents=True)
_git(repo, "init", "-q", "-b", "master")
_git(repo, "config", "user.email", "dev@example.com")
_git(repo, "config", "user.name", "dev")
manifest = repo / MANIFEST_REL
manifest.parent.mkdir(parents=True, exist_ok=True)
for index, subject in enumerate(commits):
manifest.write_text(
json.dumps(
{"domain": "nvidia_shield", "version": version, "note": index}
)
+ "\n"
)
_git(repo, "add", "-A")
_git(repo, "commit", "-qm", subject)
return repo
def _resolve_in(repo, *, today=TODAY):
"""Run the resolver inside a real repo — no tag fixtures, the real git paths."""
result = subprocess.run(
["bash", str(SCRIPT), "--manifest", MANIFEST_REL, "--today", today],
cwd=repo, capture_output=True, text=True, check=False,
)
values = dict(
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
)
return result, values
class TestRealRepositories:
"""The tagged-version branches against real git (t44 3c).
The fixture-based tests above never exercise `refs/tags/X^{commit}`, and that
dereference is the whole reason an annotated tag is recognised: for one,
`git rev-parse refs/tags/X` is the TAG OBJECT, not the commit, so a comparison
that forgot to dereference would miss a released commit entirely.
"""
def test_an_annotated_tag_on_head_is_already_released(self, tmp_path):
repo = _repo(tmp_path)
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09")
result, values = _resolve_in(repo)
assert result.returncode == 0, result.stderr
assert values["SOURCE"] == "released"
assert values["RELEASE"] == "no"
assert "nothing to release" in result.stderr
# premise: the tag object really is not the commit
assert _git(repo, "rev-parse", "v2026.10.09").strip() != _git(
repo, "rev-parse", "HEAD"
).strip()
def test_a_lightweight_tag_on_head_is_also_already_released(self, tmp_path):
repo = _repo(tmp_path)
_git(repo, "tag", "v2026.10.09")
_result, values = _resolve_in(repo)
assert values["SOURCE"] == "released"
assert values["RELEASE"] == "no"
def test_a_tag_on_an_older_commit_falls_back_to_the_date(self, tmp_path):
repo = _repo(tmp_path, commits=("the released work", "later work"))
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09", "HEAD~1")
_result, values = _resolve_in(repo)
assert values["VERSION"] == TODAY
assert values["SOURCE"] == "date"
assert values["BUMP"] == "yes"
assert values["RELEASE"] == "yes"
def test_no_tags_at_all_means_the_manifest_wins(self, tmp_path):
repo = _repo(tmp_path, version="2026.10.10")
_result, values = _resolve_in(repo)
assert values["VERSION"] == "2026.10.10"
assert values["SOURCE"] == "manifest"
assert values["BUMP"] == "no"
def test_the_same_day_scan_walks_real_tags(self, tmp_path):
repo = _repo(tmp_path, commits=("released", "more"))
for tag in ("v2026.10.09", f"v{TODAY}", f"v{TODAY}.2"):
_git(repo, "tag", "-a", tag, "-m", f"Release {tag}", "HEAD~1")
_result, values = _resolve_in(repo)
assert values["VERSION"] == f"{TODAY}.3"
assert values["SOURCE"] == "same-day"
def test_a_gap_in_the_suffixes_is_reused(self, tmp_path):
repo = _repo(tmp_path, commits=("released", "more"))
for tag in ("v2026.10.09", f"v{TODAY}", f"v{TODAY}.3"):
_git(repo, "tag", "-a", tag, "-m", f"Release {tag}", "HEAD~1")
_result, values = _resolve_in(repo)
assert values["VERSION"] == f"{TODAY}.2"
def test_the_guard_has_a_script_side_layer(self, tmp_path):
"""Even with the job guard bypassed, a released commit releases nothing.
The job-level `if:` cannot be executed here (no Gitea runner), but the
decision it protects can: this is the layer that makes a bypass harmless.
"""
repo = _repo(tmp_path)
_git(repo, "tag", "-a", "v2026.10.09", "-m", "Release v2026.10.09")
# a re-run of the same push, guard or no guard
for _attempt in range(2):
_result, values = _resolve_in(repo)
assert values["RELEASE"] == "no"
assert values["BUMP"] == "no"
def _step_script(name: str) -> str:
"""The `run:` body of one workflow step, read by hand.
The fast suite has no YAML parser (the verify command parses the file with
pyyaml instead), and executing these steps is worth more than matching their
text, so the block is taken verbatim and dedented.
"""
lines = WORKFLOW.read_text(encoding="utf-8").splitlines()
start = next(i for i, line in enumerate(lines) if line.strip() == f"- name: {name}")
run_at = next(
i for i in range(start, len(lines)) if lines[i].strip() in {"run: |", "run: >-"}
)
indent = len(lines[run_at + 1]) - len(lines[run_at + 1].lstrip())
body = []
for line in lines[run_at + 1 :]:
if line.strip() and not line.startswith(" " * indent):
break
body.append(line[indent:] if len(line) >= indent else "")
return "\n".join(body)
def _run_step(name: str, cwd, env):
return subprocess.run(
["bash", "-euo", "pipefail", "-c", _step_script(name)],
cwd=cwd, capture_output=True, text=True, env={**os.environ, **env},
)
class TestWorkflowStepsByExecution:
"""Run the workflow's own steps against a bare origin (t44 3c).
Textual pins cannot tell whether the bump lands on the commit the tag names;
these do, and they are what the dry run in the task output was.
"""
VERSION = "2026.10.11"
TAG = "v2026.10.11"
def _clone(self, tmp_path, *, protect=False):
origin = tmp_path / "origin.git"
subprocess.run(
["git", "init", "--bare", "-q", "-b", "master", str(origin)], check=True
)
work = tmp_path / "work"
subprocess.run(["git", "clone", "-q", str(origin), str(work)], check=True)
_git(work, "config", "user.email", "dev@example.com")
_git(work, "config", "user.name", "dev")
manifest = work / MANIFEST_REL
manifest.parent.mkdir(parents=True, exist_ok=True)
manifest.write_text(
json.dumps({"domain": "nvidia_shield", "version": "2026.10.08"}) + "\n"
)
_git(work, "add", "-A")
_git(work, "commit", "-qm", "a feature commit")
_git(work, "push", "-q", "origin", "master")
if protect:
hook = origin / "hooks" / "pre-receive"
hook.write_text("#!/bin/sh\necho 'protected branch' >&2\nexit 1\n")
hook.chmod(0o755)
return work
def _env(self):
return {
"MANIFEST": MANIFEST_REL,
"VERSION": self.VERSION,
"TAG": self.TAG,
}
def test_the_bump_verify_and_tag_steps_agree_on_one_commit(self, tmp_path):
work = self._clone(tmp_path)
bump = _run_step("Write the version back into the manifest", work, self._env())
assert bump.returncode == 0, bump.stderr
assert _git(work, "log", "-1", "--pretty=%s").strip() == (
f"chore(release): {self.TAG} [skip ci]"
)
verify = _run_step("Verify the released manifest on master", work, self._env())
assert verify.returncode == 0, verify.stderr
assert f"matching {self.TAG}" in verify.stdout
tag = _run_step("Create tag", work, self._env())
assert tag.returncode == 0, tag.stderr
head = _git(work, "rev-parse", "HEAD").strip()
assert _git(work, "rev-parse", f"{self.TAG}^{{commit}}").strip() == head
tagged_manifest = json.loads(_git(work, "show", f"{self.TAG}:{MANIFEST_REL}"))
assert tagged_manifest["version"] == self.VERSION
assert _git(work, "ls-remote", "--tags", "origin", self.TAG).split()[1] == (
f"refs/tags/{self.TAG}"
)
def test_the_verify_step_refuses_an_inconsistent_manifest(self, tmp_path):
work = self._clone(tmp_path)
_run_step("Write the version back into the manifest", work, self._env())
# someone else's release lands on master before this one is verified
(work / MANIFEST_REL).write_text(
json.dumps({"domain": "nvidia_shield", "version": "2026.10.99"}) + "\n"
)
_git(work, "add", "-A")
_git(work, "commit", "-qm", "a competing release")
_git(work, "push", "-q", "origin", "master")
verify = _run_step("Verify the released manifest on master", work, self._env())
assert verify.returncode == 1
assert "inconsistent" in verify.stderr
assert "2026.10.99" in verify.stderr
def test_the_notes_range_describes_the_developers_commits(self, tmp_path):
"""t44 R1, executed: the range the notes use must not open with the bot commit.
With a write-back the tag IS the bot's commit, so `PREV..TAG` (the range
before this fix) published "- chore(release): vX [skip ci]" as the first
line of every release. The range is built from ``BUMP``, and this runs both
halves through real git.
"""
work = self._clone(tmp_path)
_git(work, "tag", "-a", "v2026.10.08", "-m", "previous release")
_git(work, "commit", "--allow-empty", "-qm", "feat: the real change")
_run_step("Write the version back into the manifest", work, self._env())
_run_step("Create tag", work, self._env())
def subjects(rev_range):
return _git(work, "log", "--pretty=format:- %s", rev_range)
notes_end = f"{self.TAG}^" # BUMP=yes: the write-back commit is skipped
prev = _git(work, "describe", "--tags", "--abbrev=0", f"{notes_end}^").strip()
assert prev == "v2026.10.08"
assert subjects(f"{prev}..{notes_end}") == "- feat: the real change"
# ...and the range this replaced really did include the bot's own subject
assert "- chore(release):" in subjects(f"{prev}..{self.TAG}")
def test_a_rejected_bump_push_fails_and_tags_nothing(self, tmp_path):
work = self._clone(tmp_path, protect=True)
bump = _run_step("Write the version back into the manifest", work, self._env())
assert bump.returncode == 1
assert "could not push" in bump.stderr
assert "manually" in bump.stderr
assert MANIFEST_REL in bump.stderr
assert _git(work, "tag", "-l").strip() == ""
+493 -123
View File
@@ -8,16 +8,25 @@ migration validity for v2 entries without the field.
""" """
import asyncio # noqa: F401 (async steps need the event loop) import asyncio # noqa: F401 (async steps need the event loop)
import gc
import logging
import sys import sys
import types import types
import warnings
from pathlib import Path # noqa: E402 (F1 test key staging)
from types import SimpleNamespace from types import SimpleNamespace
from unittest import mock
import entity_stublib
import pytest import pytest
import voluptuous_mirror as vol_mirror
from conftest import _SHIELD_DIR, _load_module from conftest import _SHIELD_DIR, _load_module
from nsh.const import ( from nsh.const import (
CONF_ADB_ENABLED,
CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY, CONF_ANDROIDTV_ENTITY,
CONF_HOST,
CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
) )
_STUB_NAMES = ( _STUB_NAMES = (
@@ -28,6 +37,7 @@ _STUB_NAMES = (
"homeassistant.const", "homeassistant.const",
"homeassistant.helpers", "homeassistant.helpers",
"homeassistant.helpers.entity_registry", "homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.selector", "homeassistant.helpers.selector",
"nsh.helpers", "nsh.helpers",
"nsh.config_flow", "nsh.config_flow",
@@ -35,15 +45,36 @@ _STUB_NAMES = (
) )
class DiscoveryDedupAbortError(Exception):
"""Stands in for data_entry_flow.AbortFlow inside the discovery stub.
HA's `ConfigFlow._async_handle_discovery_without_unique_id` RAISES on a
duplicate (the flow manager turns that into an abort result); modelling it
as a raise is what makes an un-awaited call fail loudly here.
"""
def __init__(self, reason):
super().__init__(reason)
self.reason = reason
def _install_stubs() -> None: def _install_stubs() -> None:
"""Import-time stand-ins (idempotent; same shape as the pairing tests).""" """Import-time stand-ins (idempotent; same shape as the pairing tests).
if sys.modules.get("voluptuous") is not None:
t30: the guard rebuilds whenever the module-level holder is missing —
a later file's scoped teardown may pop the shared stubs this file's
tests rely on (the flow-class surfaces live on the local base which we
MUST rebind against)."""
if sys.modules.get("voluptuous") is not None and hasattr(
sys.modules.get(__name__), "_zc_holder"
):
return return
vol = types.ModuleType("voluptuous") vol = types.ModuleType("voluptuous")
vol.Required = lambda key, default=None: key # t33 F-A: a faithful mirror — the default stays ON the key, so a schema
vol.Optional = lambda key, default=None: key # whose host default was dropped FAILS here instead of looking identical
vol.Schema = lambda schema: schema # to one that carries it (the old bare-key stub hid exactly that bug).
vol_mirror.install(vol)
sys.modules["voluptuous"] = vol sys.modules["voluptuous"] = vol
ha = types.ModuleType("homeassistant") ha = types.ModuleType("homeassistant")
@@ -58,12 +89,16 @@ def _install_stubs() -> None:
kwargs.pop("domain", None) kwargs.pop("domain", None)
super().__init_subclass__(**kwargs) super().__init_subclass__(**kwargs)
def async_show_form(self, *, step_id, data_schema=None, errors=None): def async_show_form(
self, *, step_id, data_schema=None, errors=None,
description_placeholders=None,
):
return { return {
"type": "form", "type": "form",
"step_id": step_id, "step_id": step_id,
"errors": dict(errors or {}), "errors": dict(errors or {}),
"data_schema": data_schema, "data_schema": data_schema,
"description_placeholders": dict(description_placeholders or {}),
} }
def async_create_entry(self, *, title, data, options=None): def async_create_entry(self, *, title, data, options=None):
@@ -82,6 +117,24 @@ def _install_stubs() -> None:
def _async_current_entries(self): def _async_current_entries(self):
return _zc_holder.entries return _zc_holder.entries
def _async_in_progress(self, include_uninitialized=False, **kwargs):
return _zc_holder.in_progress
# t37 D3: HA's own helper, modelled faithfully — a COROUTINE that
# dedups uniqueness-less discovery by raising. An un-awaited call
# therefore does nothing at all, which is exactly the production bug
# this replaces.
async def _async_handle_discovery_without_unique_id(self):
_zc_holder.discovery_without_unique_id_calls += 1
if getattr(self, "_unique_id", None) is not None:
return
if self._async_current_entries():
raise DiscoveryDedupAbortError("already_configured")
await self.async_set_unique_id(_zc_holder.discovery_unique_id)
self._abort_if_unique_id_configured()
if self._async_in_progress(include_uninitialized=True):
raise DiscoveryDedupAbortError("already_in_progress")
class OptionsFlow: class OptionsFlow:
def async_show_form(self, *, step_id, data_schema=None, errors=None): def async_show_form(self, *, step_id, data_schema=None, errors=None):
return { return {
@@ -94,9 +147,33 @@ def _install_stubs() -> None:
def async_create_entry(self, *, title, data): def async_create_entry(self, *, title, data):
return {"type": "create_entry", "title": title, "data": data} return {"type": "create_entry", "title": title, "data": data}
# 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}
# Shared holder so tests can swap the current-entry list through the # Shared holder so tests can swap the current-entry list through the
# module object (the stub closure reads this same object). # module object (the stub closure reads this same object).
_zc_holder = types.SimpleNamespace(entries=[]) _zc_holder = types.SimpleNamespace(
entries=[],
in_progress=[],
discovery_unique_id="default_discovery_unique_id",
discovery_without_unique_id_calls=0,
)
sys.modules[__name__]._zc_holder = _zc_holder sys.modules[__name__]._zc_holder = _zc_holder
class AbortIfConfiguredError(Exception): class AbortIfConfiguredError(Exception):
@@ -104,6 +181,8 @@ def _install_stubs() -> None:
config_entries.ConfigFlow = ConfigFlow config_entries.ConfigFlow = ConfigFlow
config_entries.OptionsFlow = OptionsFlow config_entries.OptionsFlow = OptionsFlow
OptionsFlowWithReload.async_show_form = OptionsFlow.async_show_form # form plumbing
config_entries.OptionsFlowWithReload = OptionsFlowWithReload
config_entries.ConfigEntry = type("ConfigEntry", (), {}) config_entries.ConfigEntry = type("ConfigEntry", (), {})
sys.modules["homeassistant.config_entries"] = config_entries sys.modules["homeassistant.config_entries"] = config_entries
@@ -131,12 +210,12 @@ def _install_stubs() -> None:
sys.modules["homeassistant.helpers.entity_registry"] = entity_registry sys.modules["homeassistant.helpers.entity_registry"] = entity_registry
sys.modules["homeassistant.helpers"] = helpers 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 = types.ModuleType("homeassistant.helpers.selector")
selector.EntitySelector = lambda *a, **k: None entity_stublib.install_selector_faces(selector)
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 sys.modules["homeassistant.helpers.selector"] = selector
@@ -180,15 +259,10 @@ class FakeHass:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def scoped_stubs(): def scoped_stubs():
"""Keep the import stubs inside this module's tests (t7 discipline).""" """Keep the import stubs inside this module's tests (t7 discipline)."""
snapshot = {name: sys.modules.get(name) for name in _STUB_NAMES} snapshot = entity_stublib.snapshot_modules()
_install_stubs() _install_stubs()
yield yield
for name in _STUB_NAMES: entity_stublib.restore_modules(snapshot)
original = snapshot[name]
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
def _form_module(): def _form_module():
@@ -207,12 +281,21 @@ def _entry_module():
return sys.modules["nsh.entry"] return sys.modules["nsh.entry"]
def _record(sink):
"""An async_set_unique_id stand-in that records what the flow tried."""
async def _set(unique_id):
sink.append(unique_id)
return _set
def _user_input(**overrides) -> dict: def _user_input(**overrides) -> dict:
base = { base = {
"name": "mdarcy", "name": "mdarcy",
CONF_ANDROIDTV_ENTITY: "media_player.shield",
CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_ENABLED: False,
CONF_PROTOCOL_HOST: "", CONF_HOST: "",
CONF_ADB_ENABLED: False,
} }
base.update(overrides) base.update(overrides)
return base return base
@@ -224,21 +307,25 @@ class TestStandaloneValidation:
flow = module.NvidiaShieldConfigFlow() flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
result = await flow.async_step_user(_user_input(**{CONF_ANDROIDTV_ENTITY: ""})) result = await flow.async_step_user(_user_input())
assert result["type"] == "form" assert result["type"] == "form"
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert result["errors"] == {"base": "no_state_source"} assert result["errors"] == {"base": "no_state_source"}
async def test_androidtv_entity_alone_is_a_valid_source(self, tmp_path): async def test_the_androidtv_field_is_gone_from_the_form(self, tmp_path):
"""t41: we own the stack — no other integration is a state source."""
module = _form_module() module = _form_module()
flow = module.NvidiaShieldConfigFlow() flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
result = await flow.async_step_user(_user_input()) result = await flow.async_step_user(None)
assert result["type"] == "create_entry" fields = vol_mirror.declared_fields(result["data_schema"])
assert result["data"] == {CONF_ANDROIDTV_ENTITY: "media_player.shield"} assert CONF_ANDROIDTV_ENTITY not in fields
# the two native transports are what is left to choose from
assert CONF_PROTOCOL_ENABLED in fields
assert CONF_ADB_ENABLED in fields
async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch): async def test_standalone_protocol_goes_to_pairing(self, tmp_path, monkeypatch):
module = _form_module() module = _form_module()
@@ -263,29 +350,28 @@ class TestStandaloneValidation:
) )
result = await flow.async_step_user( result = await flow.async_step_user(
_user_input(**{CONF_ANDROIDTV_ENTITY: "", CONF_PROTOCOL_ENABLED: True}) _user_input(**{CONF_PROTOCOL_ENABLED: True})
) )
assert result["type"] == "form" assert result["type"] == "form"
assert result["step_id"] == "protocol_pair" # routed into pairing, no error assert result["step_id"] == "protocol_pair" # routed into pairing, no error
async def test_standalone_entry_data_omits_the_field(self, tmp_path, monkeypatch): async def test_entry_data_is_empty_so_options_own_the_config(self, tmp_path, monkeypatch):
"""No androidtv entity → the key is absent from entry data (not "").""" """t41: every value lives in options; `data` carries no leftovers."""
module = _form_module() module = _form_module()
flow = module.NvidiaShieldConfigFlow() flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
flow._user_data = { flow._user_data = {
"name": "mdarcy", "name": "mdarcy",
CONF_ANDROIDTV_ENTITY: "",
CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: "shield.local", CONF_HOST: "shield.local",
} }
# skip the pairing route: drive _create_entry directly # skip the pairing route: drive _create_entry directly
result = flow._create_entry() result = flow._create_entry()
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert CONF_ANDROIDTV_ENTITY not in result["data"] assert result["data"] == {}
class TestAdbOnlyStandalone: class TestAdbOnlyStandalone:
@@ -298,7 +384,6 @@ class TestAdbOnlyStandalone:
result = await flow.async_step_user( result = await flow.async_step_user(
_user_input( _user_input(
**{ **{
CONF_ANDROIDTV_ENTITY: "",
CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_ENABLED: False,
"adb_enabled": True, "adb_enabled": True,
} }
@@ -316,54 +401,79 @@ class TestAdbOnlyStandalone:
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
result = await flow.async_step_user( result = await flow.async_step_user(
_user_input(**{CONF_ANDROIDTV_ENTITY: "", "adb_enabled": False}) _user_input(**{"adb_enabled": False})
) )
assert result["errors"] == {"base": "no_state_source"} assert result["errors"] == {"base": "no_state_source"}
class TestMigrationKeepsOrAllowsAbsence: class TestMigrationDropsTheEntityKey:
async def test_v1_to_v2_keeps_the_androidtv_field(self, tmp_path): """t41: the androidtv key is dropped from BOTH dicts with a logged note.
An entry that still carries it keeps working until then (nothing reads it) —
the migration is what removes the last trace of the entity path.
"""
async def test_v1_entry_reaches_v3_without_the_entity_key(self, tmp_path, caplog):
module = _entry_module() module = _entry_module()
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
entry = SimpleNamespace( entry = SimpleNamespace(
version=1, version=1,
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"}, data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
options={}, options={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
title="mdarcy", title="mdarcy",
) )
ok = await module.async_migrate_entry(hass, entry) with caplog.at_level(logging.INFO):
ok = await module.async_migrate_entry(hass, entry)
assert ok is True assert ok is True
assert entry.data[CONF_ANDROIDTV_ENTITY] == "media_player.shield" # kept assert CONF_ANDROIDTV_ENTITY not in entry.data
# options were rewritten with the protocol defaults; the data dict — assert CONF_ANDROIDTV_ENTITY not in entry.options
# including the androidtv field — was never touched assert entry.options == {CONF_PROTOCOL_ENABLED: False, CONF_HOST: ""}
assert hass.config_entries.updating_entry_calls == [ assert entry.data == {}
( # the note names what was dropped, so the log is actionable
entry, notes = [record.getMessage() for record in caplog.records]
{ assert any("media_player.shield" in note for note in notes), notes
"options": {CONF_PROTOCOL_ENABLED: False, CONF_PROTOCOL_HOST: ""}, assert hass.config_entries.updating_entry_calls[0][1]["version"] == 3
"version": 2,
},
)
]
async def test_v2_without_the_field_is_valid(self, tmp_path): async def test_an_entry_without_the_key_logs_plainly(self, tmp_path, caplog):
"""Standalone entries born v2 must pass through migration untouched.""" module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(version=2, data={}, options={}, title="mdarcy")
with caplog.at_level(logging.INFO):
ok = await module.async_migrate_entry(hass, entry)
assert ok is True
notes = [record.getMessage() for record in caplog.records]
assert any("Migrated mdarcy config entry to version 3" in note for note in notes), notes
async def test_v2_entry_with_a_legacy_address_migrates_to_the_one_field(self, tmp_path):
"""An entry written before the unification lands on CONF_HOST."""
module = _entry_module() module = _entry_module()
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
entry = SimpleNamespace( entry = SimpleNamespace(
version=2, version=2,
data={}, data={},
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "shield.local"}, options={CONF_PROTOCOL_ENABLED: True, "protocol_host": "shield.local"},
title="mdarcy", title="mdarcy",
) )
ok = await module.async_migrate_entry(hass, entry) ok = await module.async_migrate_entry(hass, entry)
assert ok is True assert ok is True
assert hass.config_entries.updating_entry_calls == [] # no rewrite needed assert entry.options[CONF_HOST] == "shield.local"
assert "protocol_host" not in entry.options
def test_the_stored_version_and_the_flow_cannot_drift(self, tmp_path):
# _form_module owns the scoped stub install/pop — never a bare
# entity_stublib.install() here: a shallow stub set leaks into the
# entity-module loader of later tests.
module = _form_module()
from nsh.const import ENTRY_VERSION
assert module.NvidiaShieldConfigFlow.VERSION == ENTRY_VERSION == 3
def _cmd_player(modules): def _cmd_player(modules):
@@ -380,7 +490,8 @@ class TestAppIconColumn:
async def test_two_column_line_stores_the_icon(self, tmp_path): async def test_two_column_line_stores_the_icon(self, tmp_path):
module = _form_module() module = _form_module()
entry = SimpleNamespace(options={}, title="Shield") entry = SimpleNamespace(options={}, title="Shield")
flow = module.NvidiaShieldOptionsFlow(entry) flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = entry # t29 G3b: HA assigns config_entry
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
@@ -393,7 +504,8 @@ class TestAppIconColumn:
async def test_single_column_map_stays_valid(self, tmp_path): async def test_single_column_map_stays_valid(self, tmp_path):
module = _form_module() module = _form_module()
entry = SimpleNamespace(options={}, title="Shield") entry = SimpleNamespace(options={}, title="Shield")
flow = module.NvidiaShieldOptionsFlow(entry) flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = entry # t29 G3b: HA assigns config_entry
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
@@ -422,7 +534,8 @@ class TestAppIconColumn:
async def test_malformed_icon_invalidates_the_row(self, tmp_path): async def test_malformed_icon_invalidates_the_row(self, tmp_path):
module = _form_module() module = _form_module()
entry = SimpleNamespace(options={}, title="Shield") entry = SimpleNamespace(options={}, title="Shield")
flow = module.NvidiaShieldOptionsFlow(entry) flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = entry # t29 G3b: HA assigns config_entry
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs} flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs} flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
@@ -432,6 +545,31 @@ class TestAppIconColumn:
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_app_map"} assert result["errors"] == {"base": "invalid_app_map"}
async def test_a_nameless_row_is_rejected_by_the_editor(self, tmp_path):
"""t49: the editor already refuses 'com.x=' — a blank name cannot get in.
The source list filters blanks anyway, because the map can also be
hand-edited; this pins the other half: the page will not STORE one.
"""
module = _form_module()
assert module._app_map_from_text("com.blank=", {}) is None
assert module._app_map_from_text("com.blank= ", {}) is None
flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = SimpleNamespace(options={}, title="Shield")
flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init(
{"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"}
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_app_map"}
assert "app_name_map" not in flow._options # nothing half-stored
class TestChannelEntryOptions: class TestChannelEntryOptions:
"""P5-2: digit-delay + optional send-enter on the options page.""" """P5-2: digit-delay + optional send-enter on the options page."""
@@ -441,7 +579,8 @@ class TestChannelEntryOptions:
module = _form_module() module = _form_module()
entry = DefaultEntry(options=dict(options) if options else {}) entry = DefaultEntry(options=dict(options) if options else {})
flow = module.NvidiaShieldOptionsFlow(entry) flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = entry # t29 G3b: HA assigns config_entry
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
flow.hass = hass flow.hass = hass
flow._options = dict(options) if options else {} flow._options = dict(options) if options else {}
@@ -481,6 +620,60 @@ class TestChannelEntryOptions:
assert result["errors"] == {"base": "invalid_digit_delay"} assert result["errors"] == {"base": "invalid_digit_delay"}
class TestUniqueIdSource:
"""t41: no unique_id is derived from another integration, at any stage.
t29 G2 replaced the renameable entity_id with a device-MAC derive; t41
removes the entity path entirely, so the ONLY identity source left is the
protocol MAC learned during pairing (``async_set_unique_id(mac)``).
"""
def test_no_entity_derived_unique_id_remains(self):
flow_src = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8")
assert "_unique_id_from_atv_device" not in flow_src
assert "async_set_unique_id(androidtv_entity)" not in flow_src
# no registry lookups for another integration's devices either
assert "device_registry" not in flow_src
async def test_the_user_step_sets_no_unique_id(self, tmp_path):
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
recorded = []
flow.async_set_unique_id = _record(recorded)
await flow.async_step_user(_user_input(**{CONF_PROTOCOL_ENABLED: True}))
assert recorded == []
class TestListenerComboGone:
"""t29 G3a: the deprecated listener+reload combo is gone (grep pins)."""
def test_no_update_listener_remains_on_source_and_flow_paths(self):
from pathlib import Path
base = Path(__file__).parent.parent / "custom_components" / "nvidia_shield"
init_src = (base / "__init__.py").read_text()
flow_src = (base / "config_flow.py").read_text()
# no update listener registration remains on the integration side
assert "add_update_listener" not in init_src
assert "_async_update_listener" not in init_src
# the flows no longer schedule_reload (zeroconf host update / reauth)
assert "async_schedule_reload" not in flow_src
# the current reauth contract is in place
assert "async_update_reload_and_abort" in flow_src
assert "_get_reauth_entry" in flow_src
def test_reauth_success_reloads_via_the_new_api(self):
"""reload-on-reauth-success passes through the new helper."""
# the stub records a scheduled reload on the reauth path
import entity_stublib
assert entity_stublib is not None # the stub API exists (config flow class)
class TestLegacyUniqueIdMigration: class TestLegacyUniqueIdMigration:
"""P5-1: one-time legacy androidtv-entity-id unique_id migration.""" """P5-1: one-time legacy androidtv-entity-id unique_id migration."""
@@ -631,6 +824,202 @@ class TestLegacyUniqueIdMigration:
assert hass.config_entries.updating_entry_calls == [] # untouched assert hass.config_entries.updating_entry_calls == [] # untouched
class TestIpChangeSelfHealF1:
"""t32 F1 (HIGH): the poller factory re-reads entry.options at call
time, so the NEXT built poller targets the NEW address after a
zeroconf IP change (the audit-sanctioned factory re-read option)."""
def _adb_entry(self, host="192.168.1.50"):
return SimpleNamespace(
entry_id="e1",
title="Shield",
data={},
options={
CONF_ADB_ENABLED: True,
CONF_HOST: host, # t41: the entry's one address
CONF_ADB_PORT: 5555,
},
runtime_data=None,
)
def _hass_with_key(self, tmp_path):
import conftest
import entity_stublib
entity_stublib.install()
if "nsh.init" not in sys.modules:
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
init_module = sys.modules["nsh.init"]
hass = FakeHass(str(tmp_path))
key_dir = Path(hass.config.config_dir) / ".storage" / "nvidia_shield"
key_dir.mkdir(parents=True, exist_ok=True)
(key_dir / "adbkey").write_text("test-key")
return init_module, hass
async def test_built_poller_targets_the_new_address(self, tmp_path):
"""The delivered self-heal: after the zeroconf host update the
NEXT built poller targets the new address (no reload involved)."""
init_module, hass = self._hass_with_key(tmp_path)
entry = self._adb_entry()
hass.config_entries.entries = [entry]
factory = await init_module._build_poller_factory(hass, entry)
assert factory is not None
first = factory()
assert first is not None
# AdbClientBase exposes the connection config as a dict (t27 F-NEW3)
assert first.config["host"] == "192.168.1.50"
# the config-flow zeroconf path updates the entry's hosts via
# async_update_entry — the NEXT built poller targets the NEW address
entry.options = {**entry.options, CONF_HOST: "192.168.1.77"}
second = factory()
assert second is not None
assert second.config["host"] == "192.168.1.77"
async def test_unresolvable_call_stays_passive(self, tmp_path):
"""A momentary unresolvable host yields None (passive cycle) — the
watchdog's existing None handling stays intact."""
init_module, hass = self._hass_with_key(tmp_path)
entry = self._adb_entry(host="10.255.255.1")
factory = await init_module._build_poller_factory(hass, entry)
assert factory is not None
with mock.patch("nsh.init.resolve_adb_config", return_value=None):
assert factory() is None
class TestMacLessDiscoveryF2:
"""t32 F2 (rider): MAC-less zeroconf discovery records the
uniqueness-less discovery and can't stack a duplicate prefill."""
def _discovery(self, host="192.168.1.50", props=None):
return SimpleNamespace(
ip_address=host, host=host, properties=props or {}
)
def _holder(self):
return sys.modules[__name__]._zc_holder
async def test_mac_less_discovery_awaits_the_helper(self, tmp_path):
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
self._holder().discovery_without_unique_id_calls = 0
result = await flow.async_step_zeroconf(self._discovery())
assert result["type"] == "form" # prefill still offered once
# t37 D3: only an AWAITED coroutine body runs — before the fix this
# counter stayed at 0 and Python warned "coroutine ... was never
# awaited", while HA's dedup never happened
assert self._holder().discovery_without_unique_id_calls == 1
# the helper claims HA's discovery sentinel for the flow
assert flow._unique_id == self._holder().discovery_unique_id
async def test_mac_less_discovery_with_entries_aborts(self, tmp_path):
"""Real contract: any entry for the handler → already_configured."""
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
entry_id="e1",
unique_id=None, # MAC-less entry
options={CONF_HOST: "192.168.1.50"},
title="Shield",
)
self._holder().entries[:] = [entry]
with pytest.raises(DiscoveryDedupAbortError) as raised:
await flow.async_step_zeroconf(self._discovery())
assert raised.value.reason == "already_configured"
# the entries list is untouched: one-entry outcome
assert len(self._holder().entries) == 1
async def test_mac_less_discovery_while_another_flow_runs_aborts(self, tmp_path):
"""The other half of the real contract: one flow at a time."""
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
self._holder().in_progress[:] = [{"flow_id": "other"}]
with pytest.raises(DiscoveryDedupAbortError) as raised:
await flow.async_step_zeroconf(self._discovery())
assert raised.value.reason == "already_in_progress"
async def test_mac_less_discovery_leaves_no_un_awaited_coroutine(
self, tmp_path
):
"""t37 D3: the helper must never be left un-awaited."""
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path))
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
await flow.async_step_zeroconf(self._discovery())
del flow
gc.collect()
never_awaited = [
str(w.message) for w in caught if "never awaited" in str(w.message)
]
assert never_awaited == []
class TestSeedBlockShapeF4:
"""t32 F4 (rider): protocol-only seed blocks don't force ADB on."""
def test_protocol_only_block_keeps_adb_off(self):
import conftest
import entity_stublib
entity_stublib.install()
if "nsh.init" not in sys.modules:
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
init_module = sys.modules["nsh.init"]
blocks = init_module._yaml_seed_blocks(
{"name": "Proto", "protocol_host": "shield.local"}
)
assert blocks[0]["adb_enabled"] is False
assert blocks[0]["host"] == "shield.local" # t41: one address field
def test_adb_block_keeps_adb_on(self):
import conftest
import entity_stublib
entity_stublib.install()
if "nsh.init" not in sys.modules:
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
init_module = sys.modules["nsh.init"]
blocks = init_module._yaml_seed_blocks(
{"name": "Adb", "adb_host": "10.0.0.31"}
)
assert blocks[0]["adb_enabled"] is True
async def test_protocol_only_import_prefills_adb_off(self):
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass("/tmp/nsh-f4")
flow._async_current_entries = lambda: []
result = await flow.async_step_import(
{"name": "Proto", "protocol_host": "shield.local"}
)
assert result["type"] == "form"
assert flow._user_data["adb_enabled"] is False
class TestZeroconfDiscovery: class TestZeroconfDiscovery:
"""P4-1a: _androidtvremote2._tcp discovery → pre-filled user step.""" """P4-1a: _androidtvremote2._tcp discovery → pre-filled user step."""
@@ -640,6 +1029,11 @@ class TestZeroconfDiscovery:
) )
async def test_discovery_prefills_the_user_step(self, tmp_path): async def test_discovery_prefills_the_user_step(self, tmp_path):
"""t33 F-A: the discovered host is the DEFAULT OF THE SCHEMA KEYS.
The old check (the attribute was stored) stayed green while the
rendered form was blank — this one reads the schema itself.
"""
module = _form_module() module = _form_module()
flow = module.NvidiaShieldConfigFlow() flow = module.NvidiaShieldConfigFlow()
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
@@ -648,9 +1042,12 @@ class TestZeroconfDiscovery:
assert result["type"] == "form" assert result["type"] == "form"
assert result["step_id"] == "user" assert result["step_id"] == "user"
# pre-fill landed: the discovered host is recorded for the schema
assert flow._discovered_host == "192.168.1.50" assert flow._discovered_host == "192.168.1.50"
assert result["data_schema"] is not None defaults = vol_mirror.defaults_by_key(result["data_schema"])
assert defaults[CONF_HOST] == "192.168.1.50"
# ...and a discovery-driven form says the address is already known
note = result["description_placeholders"]["discovery_note"]
assert "Discovered Shield at 192.168.1.50" in note
async def test_discovery_without_address_aborts(self, tmp_path): async def test_discovery_without_address_aborts(self, tmp_path):
module = _form_module() module = _form_module()
@@ -671,7 +1068,7 @@ class TestZeroconfDiscovery:
entry = SimpleNamespace( entry = SimpleNamespace(
unique_id="aa:bb:cc:dd:ee:ff", unique_id="aa:bb:cc:dd:ee:ff",
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "10.0.0.1"}, options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.1"},
entry_id="e1", entry_id="e1",
title="Shield", title="Shield",
) )
@@ -682,10 +1079,9 @@ class TestZeroconfDiscovery:
) )
assert result == {"type": "abort", "reason": "already_configured"} assert result == {"type": "abort", "reason": "already_configured"}
# the hosts moved to the discovery address # t41: the ONE address field moved to the discovery address
assert entry.options[CONF_PROTOCOL_HOST] == "192.168.1.50" assert entry.options[CONF_HOST] == "192.168.1.50"
assert entry.options["adb_host"] == "192.168.1.50" assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
assert hass.config_entries.reloaded_entry_ids == ["e1"]
async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path): async def test_known_mac_same_ip_skips_the_rewrite(self, tmp_path):
module = _form_module() module = _form_module()
@@ -693,14 +1089,10 @@ class TestZeroconfDiscovery:
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
flow.hass = hass flow.hass = hass
# both hosts already hold the discovered address: nothing changes # the address already holds the discovered value: nothing changes
entry = SimpleNamespace( entry = SimpleNamespace(
unique_id="aa:bb:cc:dd:ee:ff", unique_id="aa:bb:cc:dd:ee:ff",
options={ options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "192.168.1.50"},
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: "192.168.1.50",
"adb_host": "192.168.1.50",
},
entry_id="e1", entry_id="e1",
title="Shield", title="Shield",
) )
@@ -714,8 +1106,24 @@ class TestZeroconfDiscovery:
assert hass.config_entries.updating_entry_calls == [] # no IP change assert hass.config_entries.updating_entry_calls == [] # no IP change
assert hass.config_entries.reloaded_entry_ids == [] assert hass.config_entries.reloaded_entry_ids == []
async def test_blank_adb_host_self_heals_from_the_discovery_address(self, tmp_path): async def test_the_per_host_heal_cases_collapsed_into_one_rule(self):
"""F2: a blank (auto-detect) adb host follows a same-IP rediscovery.""" """t41: the blank / self-heal / deliberate-override cases are gone.
They only existed because the address was stored twice (t21 F2). With one
field the discovery address simply IS the entry's address, so this pins
the simplification: a second address field cannot quietly come back.
"""
source = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8")
assert "CONF_ADB_HOST" not in source
assert "CONF_PROTOCOL_HOST" not in source
async def test_an_explicit_address_follows_the_device(self, tmp_path):
"""t41: one Shield, one address — discovery is its current source.
The old rule kept a "deliberately different ADB host" untouched; that
override is precisely what the single-field ruling removes.
"""
module = _form_module() module = _form_module()
flow = module.NvidiaShieldConfigFlow() flow = module.NvidiaShieldConfigFlow()
hass = FakeHass(str(tmp_path)) hass = FakeHass(str(tmp_path))
@@ -723,37 +1131,7 @@ class TestZeroconfDiscovery:
entry = SimpleNamespace( entry = SimpleNamespace(
unique_id="aa:bb:cc:dd:ee:ff", unique_id="aa:bb:cc:dd:ee:ff",
options={CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_HOST: "192.168.1.50"}, options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "10.0.0.9"}, # stale
entry_id="e1",
title="Shield",
)
sys.modules[__name__]._zc_holder.entries[:] = [entry]
result = await flow.async_step_zeroconf(
self._discovery(props={"mac": "aa:bb:cc:dd:ee:ff"})
)
assert result == {"type": "abort", "reason": "already_configured"}
# only the adb host was filled (self-heal), protocol already current:
update = hass.config_entries.updating_entry_calls[0][1]["options"]
assert update["adb_host"] == "192.168.1.50"
assert update["protocol_host"] == "192.168.1.50"
assert hass.config_entries.reloaded_entry_ids == ["e1"]
async def test_deliberately_different_adb_host_is_never_clobbered(self, tmp_path):
"""F2: an explicit ADB override survives a protocol-host IP change."""
module = _form_module()
flow = module.NvidiaShieldConfigFlow()
hass = FakeHass(str(tmp_path))
flow.hass = hass
entry = SimpleNamespace(
unique_id="aa:bb:cc:dd:ee:ff",
options={
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: "10.0.0.1", # stale (IP moved)
"adb_host": "10.0.0.9", # deliberate ADB override
},
entry_id="e1", entry_id="e1",
title="Shield", title="Shield",
) )
@@ -765,9 +1143,8 @@ class TestZeroconfDiscovery:
assert result == {"type": "abort", "reason": "already_configured"} assert result == {"type": "abort", "reason": "already_configured"}
update = hass.config_entries.updating_entry_calls[0][1]["options"] update = hass.config_entries.updating_entry_calls[0][1]["options"]
assert update["protocol_host"] == "192.168.1.50" # protocol followed assert update[CONF_HOST] == "192.168.1.50"
assert update["adb_host"] == "10.0.0.9" # override untouched assert hass.config_entries.reloaded_entry_ids == [] # update-only (G3a)
assert hass.config_entries.reloaded_entry_ids == ["e1"]
class TestAppsMapOptionsEditor: class TestAppsMapOptionsEditor:
@@ -776,7 +1153,8 @@ class TestAppsMapOptionsEditor:
def _options_flow(self, tmp_path, options): def _options_flow(self, tmp_path, options):
module = _form_module() module = _form_module()
entry = SimpleNamespace(options=dict(options), title="Shield") entry = SimpleNamespace(options=dict(options), title="Shield")
flow = module.NvidiaShieldOptionsFlow(entry) flow = module.NvidiaShieldOptionsFlow()
flow.config_entry = entry # t29 G3b: HA assigns config_entry
flow.hass = FakeHass(str(tmp_path)) flow.hass = FakeHass(str(tmp_path))
return flow return flow
@@ -834,7 +1212,6 @@ class TestAppNameMapResolution:
modules = entity_stublib.load_entity_modules() modules = entity_stublib.load_entity_modules()
player, _s, _i = _cmd_player(modules) player, _s, _i = _cmd_player(modules)
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"} player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
player._androidtv_entity = "" # standalone
manager = player._manager manager = player._manager
manager._on_current_app("ar.tvplayer.tv") # protocol push manager._on_current_app("ar.tvplayer.tv") # protocol push
@@ -849,7 +1226,6 @@ class TestAppNameMapResolution:
modules = entity_stublib.load_entity_modules() modules = entity_stublib.load_entity_modules()
player, _s, _i = _cmd_player(modules) player, _s, _i = _cmd_player(modules)
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"} player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
player._androidtv_entity = ""
manager = player._manager manager = player._manager
manager._on_current_app("com.google.android.youtube.tv") manager._on_current_app("com.google.android.youtube.tv")
@@ -857,23 +1233,17 @@ class TestAppNameMapResolution:
assert player.app_name == "com.google.android.youtube.tv" assert player.app_name == "com.google.android.youtube.tv"
async def test_entity_tier_serves_when_no_higher_tier_matches(self): async def test_cached_manifest_label_is_the_second_tier(self):
"""P5-1: the entity tier is below map/manifest — it serves when the """P5-1: below the explicit map sits the device manifest label."""
package has no explicit override and no manifest label yet."""
from types import SimpleNamespace as SimpleNS
import entity_stublib import entity_stublib
modules = entity_stublib.load_entity_modules() modules = entity_stublib.load_entity_modules()
player, _s, _i = _cmd_player(modules) player, _s, _i = _cmd_player(modules)
player._entry.options["app_name_map"] = {} # no override player._entry.options["app_name_map"] = {} # no override
player._androidtv_entity = "media_player.atv" manager = player._manager
state = SimpleNS(attributes={"app_id": "ar.tvplayer.tv", "app_name": "ATV Tivi"}) manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
player.hass = SimpleNS(states=SimpleNS(get=lambda eid: state))
player._read = lambda eid: state if eid == "media_player.atv" else None
player._manager._on_current_app("ar.tvplayer.tv") manager._on_current_app("ar.tvplayer.tv")
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
# no cached label yet → falls through to the entity tier assert player.app_name == "TiviMate"
assert player.app_name == "ATV Tivi"
+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)]