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
32 changed files with 5554 additions and 826 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}"
+262 -30
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
@@ -96,17 +103,32 @@ behavior). Both are consumed by `play_media` with
An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an An optional `nvidia_shield:` block in `configuration.yaml` **seeds** an
interactive setup flow — nothing is created without the user walking the interactive setup flow — nothing is created without the user walking the
standard flow, and pairing stays interactive exactly as with UI setup: 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 ```yaml
nvidia_shield: nvidia_shield:
- name: Living Room - name: Living Room # the entry's title (default: NVIDIA Shield)
adb_host: 10.0.0.31 # or protocol_host for the PIN-pairing hint host: 10.0.0.31 # the Shield's IP — the one address field
adb_port: 5555 adb_port: 5555 # optional; the standard ADB port
adb_enabled: true # optional; the block's shape decides (see below)
``` ```
`protocol_host` is a prefill hint only (the PIN pairing still happens in `host` is the current key. `protocol_host` and `adb_host` are **still accepted**
the flow). Blocks without protocol/adb hosts are ignored with a warning. (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 Duplicate guards: a block matching an existing entry's host (or its
virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured virtual `yaml:…` unique_id) aborts and never re-seeds a UI-configured
host. Removing the YAML block creates or deletes nothing — entries live host. Removing the YAML block creates or deletes nothing — entries live
@@ -126,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.)
@@ -169,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
@@ -189,7 +268,7 @@ 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: 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 - **with** the device MAC, a duplicate aborts as "already configured", and an
existing entry's hosts are updated when the address changed; existing entry's address is updated when it changed;
- **without** a MAC, the flow follows HA's stricter - **without** a MAC, the flow follows HA's stricter
`_async_handle_discovery_without_unique_id` contract: **any** existing entry `_async_handle_discovery_without_unique_id` contract: **any** existing entry
for this integration aborts the discovery, and only one discovery flow runs at for this integration aborts the discovery, and only one discovery flow runs at
@@ -205,7 +284,160 @@ helper the flow calls is a coroutine and its dedup only runs when awaited.
- **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
+67 -24
View File
@@ -25,14 +25,22 @@ from .const import (
CONF_ADB_HOST, CONF_ADB_HOST,
CONF_ADB_PORT, 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_ADB_PORT,
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
ENTRY_VERSION,
SERVICE_ADB_COMMAND, SERVICE_ADB_COMMAND,
) )
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config from .helpers import (
entry_host,
protocol_cert_paths,
resolve_adb_config,
seed_adb_enabled,
seed_host,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -50,9 +58,9 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
""" """
domain_config = (config or {}).get(DOMAIN) domain_config = (config or {}).get(DOMAIN)
for block in _yaml_seed_blocks(domain_config or []): for block in _yaml_seed_blocks(domain_config or []):
if not block.get(CONF_ADB_HOST) and not block.get(CONF_PROTOCOL_HOST): if not seed_host(block):
_LOGGER.warning( _LOGGER.warning(
"nvidia_shield YAML block without protocol/adb host ignored: %s", "nvidia_shield YAML block without an address ignored: %s",
block.get("name", "<unnamed>"), block.get("name", "<unnamed>"),
) )
continue continue
@@ -84,15 +92,13 @@ def _yaml_seed_blocks(domain_config) -> list[dict]:
continue continue
name = block.get("name") or DEFAULT_NAME name = block.get("name") or DEFAULT_NAME
# t32 F4 (review rider): the seed honors the block's shape — a # t32 F4 (review rider): the seed honors the block's shape — a
# protocol-only block does NOT force ADB on; ADB comes only when the # protocol-only block does NOT force ADB on. t41: the address is ONE
# block carries an adb host (interactive cosmetics stay in the flow). # field (`host`, or a legacy protocol_host/adb_host).
seed = { seed = {
"name": name, "name": name,
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)), CONF_ADB_ENABLED: seed_adb_enabled(block),
CONF_ADB_HOST: str(block.get(CONF_ADB_HOST) or ""), CONF_HOST: seed_host(block),
CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT), CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
# protocol fields are prefill hints ONLY — pairing stays interactive
CONF_PROTOCOL_HOST: str(block.get(CONF_PROTOCOL_HOST) or ""),
} }
if block.get("unique_id"): if block.get("unique_id"):
seed["unique_id"] = str(block["unique_id"]) seed["unique_id"] = str(block["unique_id"])
@@ -178,15 +184,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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 "
@@ -205,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
+4 -6
View File
@@ -11,13 +11,11 @@ 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__)
@@ -55,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:
+193 -168
View File
@@ -6,7 +6,6 @@ import logging
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import selector from homeassistant.helpers import selector
@@ -16,28 +15,32 @@ from .const import (
ATTR_MEDIA_DAG_INPUT_MAP, ATTR_MEDIA_DAG_INPUT_MAP,
CONF_ADB_COMMAND_ENABLED, CONF_ADB_COMMAND_ENABLED,
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_APP_ICON_MAP, CONF_APP_ICON_MAP,
CONF_APP_NAME_MAP, CONF_APP_NAME_MAP,
CONF_CHANNEL_DIGIT_DELAY, CONF_CHANNEL_DIGIT_DELAY,
CONF_CHANNEL_SEND_ENTER, CONF_CHANNEL_SEND_ENTER,
CONF_COMMAND_SCRIPTS,
CONF_HOST,
CONF_JELLYFIN_ENTITY, CONF_JELLYFIN_ENTITY,
CONF_LOGCAT_EVENTS, CONF_LOGCAT_EVENTS,
CONF_PAIRING_CODE, CONF_PAIRING_CODE,
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
CONF_RECEIVER_ENTITY, CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT, CONF_RECEIVER_INPUT,
CONF_REMOTE_COMMAND_SCRIPT,
CONF_SKIP_PAIRING, CONF_SKIP_PAIRING,
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
ENTRY_VERSION,
SCRIPT_COMMANDS,
SCRIPT_FIELD_PREFIX,
command_scripts,
) )
from .helpers import discover_adb_host, protocol_cert_paths from .helpers import entry_host, protocol_cert_paths, seed_adb_enabled, seed_host
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -53,34 +56,6 @@ def _get_receiver_inputs(hass, receiver_entity_id: str | None) -> list[str]:
return list(input_map.keys()) if isinstance(input_map, dict) else [] return list(input_map.keys()) if isinstance(input_map, dict) else []
def _unique_id_from_atv_device(hass, androidtv_entity: str) -> str | None:
"""A stable unique_id from the atv entity's device MAC (t29 G2).
The device registry holds CONNECTION_NETWORK_MAC for Android TV
entities configured with a host — far stronger than the renameable
entity_id. None (→ no unique_id at all, not an entity-derived
placeholder) when the entity is unregistered or carries no MAC.
"""
if not androidtv_entity:
return None
registry = er.async_get(hass)
entity = registry.entities.get(androidtv_entity)
device_id = getattr(entity, "device_id", None) if entity else None
device = (
dr.async_get(hass).async_get(device_id) if device_id else None
)
if device is None:
return None
if isinstance(dr, dict): # stub shape guard
return None
for connection in device.connections:
# connections are {connection_type, value} pairs; only a REAL mac
# value is usable (an empty placeholder is skipped)
if connection[0] == "mac" and connection[1]:
return connection[1]
return None
def _find_jellyfin_entity(hass, device_name: str) -> str | None: def _find_jellyfin_entity(hass, device_name: str) -> str | None:
"""Auto-detect the Jellyfin media_player entity for a device.""" """Auto-detect the Jellyfin media_player entity for a device."""
registry = er.async_get(hass) registry = er.async_get(hass)
@@ -169,11 +144,12 @@ def _host_field_key(field: str, host: str):
def _user_schema_dict(sel, defaults: dict | None = None) -> dict: def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
"""User-step schema — the androidtv entity is optional (standalone). """User-step schema — every field belongs to THIS integration (t41).
``defaults`` pre-fills name/hosts from a zeroconf discovery event (or a ``defaults`` pre-fills the name and the ONE address (t41) from a zeroconf
YAML seed hint); the ADB port is pre-filled with the standard 5555 so it discovery event or a YAML seed hint; the ADB port is pre-filled with the
reads as an advanced setting rather than required knowledge (t33 F-D). standard 5555 so it reads as an advanced setting rather than required
knowledge (t33 F-D).
""" """
defaults = defaults or {} defaults = defaults or {}
host = str(defaults.get("host") or "") host = str(defaults.get("host") or "")
@@ -182,7 +158,6 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
port = DEFAULT_ADB_PORT port = DEFAULT_ADB_PORT
schema_dict = { schema_dict = {
vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str, vol.Required("name", default=defaults.get("name", DEFAULT_NAME)): str,
vol.Optional(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"], vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"], vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
} }
@@ -193,39 +168,63 @@ def _user_schema_dict(sel, defaults: dict | None = None) -> dict:
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"] schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool # t41: both transports are OURS and both are ON by default — the user opts
# the code's own fallback for this flag is True (async_step_user) — the # out (or skips pairing) rather than opting in. The rendered checkboxes must
# rendered checkbox must agree with it # agree with the code's fallbacks in async_step_user / _create_entry, so all
# three carry True (t33 F-A: a default the schema cannot see is a bug).
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=True)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
schema_dict[_host_field_key(CONF_ADB_HOST, host)] = str schema_dict[_host_field_key(CONF_HOST, host)] = str
schema_dict[vol.Optional(CONF_ADB_PORT, default=port)] = int schema_dict[vol.Optional(CONF_ADB_PORT, default=port)] = int
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED, default=True)] = bool
schema_dict[_host_field_key(CONF_PROTOCOL_HOST, host)] = str
return schema_dict return schema_dict
class _ClearingEntitySelector(selector.EntitySelector):
"""An entity selector that accepts a CLEARED field (t47 F1).
Clearing an optional entity field used to depend on the frontend OMITTING the
key. It does not have to: a submission can carry ``''`` or ``None``, and real
Home Assistant's EntitySelector rejects both — the reviewer's real-HA run
produced "Schema validation failed at script_turn_on" from an explicit clear.
That made clearing look like a broken form rather than a choice, on every
entity field of this page (the script selectors are the newest, not the only
ones).
Returning the empty value unchanged lets the flow read it as "no selection",
while a real entity id still goes through the parent's validation. The class
stays an EntitySelector, so the frontend still renders an entity picker.
"""
def __call__(self, data):
if data is None or (isinstance(data, str) and not data.strip()):
return ""
return super().__call__(data)
def _build_selectors(): def _build_selectors():
"""Build reusable selector instances.""" """Build reusable selector instances."""
return { return {
"androidtv": selector.EntitySelector( "media_player": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain="media_player", device_class="tv")
),
"media_player": selector.EntitySelector(
selector.EntitySelectorConfig(domain="media_player") selector.EntitySelectorConfig(domain="media_player")
), ),
"receiver": selector.EntitySelector( "receiver": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain="media_player", device_class="receiver") selector.EntitySelectorConfig(domain="media_player", device_class="receiver")
), ),
"power": selector.EntitySelector( "power": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"]) selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
), ),
# t44: a script entity per command override
"script": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain="script")
),
} }
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Nvidia Shield.""" """Handle a config flow for Nvidia Shield."""
VERSION = 2 VERSION = ENTRY_VERSION
def __init__(self): def __init__(self):
self._user_data = {} self._user_data = {}
@@ -243,51 +242,32 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None): async def async_step_user(self, user_input=None):
"""Step 1: Basic setup. """Step 1: Basic setup.
Standalone setup (Phase 3): any one of the three state sources is We own the whole stack (t41), so the two state sources are ours: the
enough — the Android TV Remote entity, the Nvidia Remote Protocol Nvidia Remote Protocol (pairing) and ADB media-session mode
(pairing), or ADB media-session mode (CONF_ADB_ENABLED). The (CONF_ADB_ENABLED). The ``no_state_source`` error fires only when
``no_state_source`` error fires only when none of them is present. neither is enabled.
""" """
errors = {} errors = {}
if user_input is not None: if user_input is not None:
androidtv_entity = user_input.get(CONF_ANDROIDTV_ENTITY) has_source = bool(user_input.get(CONF_PROTOCOL_ENABLED)) or bool(
user_input.get(CONF_ADB_ENABLED)
has_source = (
bool(androidtv_entity)
or bool(user_input.get(CONF_PROTOCOL_ENABLED))
or bool(user_input.get(CONF_ADB_ENABLED))
) )
if not has_source: if not has_source:
errors["base"] = "no_state_source" errors["base"] = "no_state_source"
return self._show_user_form(errors) return self._show_user_form(errors)
# t29 G2: the androidtv entity_id is a renameable reference — # t41: nothing here derives a unique_id from another integration.
# never a unique_id. A sqlite/registry MAC is preferred (the # The entry's identity is the protocol MAC learned during pairing
# atv entity's device holds CONNECTION_NETWORK_MAC); without a # (`async_set_unique_id(mac)` in the pairing step) or the config
# resolvable MAC the entry gets NO unique_id (the same # entry itself — never a renameable entity id (t29 G2's
# uniqueness-less semantics _async_handle_discovery_without_ # atv-device derivation went with the entity path).
# unique_id applies to MAC-less discovery) — renames can never
# break uniqueness.
fallback_unique_id = _unique_id_from_atv_device(
self.hass, androidtv_entity
)
if fallback_unique_id:
await self.async_set_unique_id(fallback_unique_id)
self._abort_if_unique_id_configured()
# Auto-detect Jellyfin if not manually specified # Auto-detect Jellyfin from the names setup already knows: the
# entry name the user typed, or the device name from discovery.
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY) jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
if not jellyfin: if not jellyfin:
device_name = user_input.get("name", DEFAULT_NAME) for name in (user_input.get("name", DEFAULT_NAME), self._discovered_name):
atv_state = (
self.hass.states.get(androidtv_entity)
if androidtv_entity
else None
)
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
for name in [device_name, atv_name]:
if not name: if not name:
continue continue
found = _find_jellyfin_entity(self.hass, name) found = _find_jellyfin_entity(self.hass, name)
@@ -298,17 +278,15 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._user_data = { self._user_data = {
"name": user_input.get("name", DEFAULT_NAME), "name": user_input.get("name", DEFAULT_NAME),
CONF_ANDROIDTV_ENTITY: androidtv_entity or "",
CONF_JELLYFIN_ENTITY: jellyfin or "", CONF_JELLYFIN_ENTITY: jellyfin or "",
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""), CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""), CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
CONF_POWER_ENTITY: user_input.get(CONF_POWER_ENTITY, ""), CONF_POWER_ENTITY: user_input.get(CONF_POWER_ENTITY, ""),
CONF_ADB_ENABLED: user_input.get(CONF_ADB_ENABLED, False), CONF_ADB_ENABLED: user_input.get(CONF_ADB_ENABLED, True),
CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True), CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True),
CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""), CONF_HOST: str(user_input.get(CONF_HOST) or "").strip(),
CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT), CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, False), CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, True),
CONF_PROTOCOL_HOST: user_input.get(CONF_PROTOCOL_HOST, ""),
} }
# If ADB is enabled, go to pairing step # If ADB is enabled, go to pairing step
@@ -390,22 +368,15 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
for entry in self._async_current_entries(): for entry in self._async_current_entries():
if getattr(entry, "unique_id", None) != mac: if getattr(entry, "unique_id", None) != mac:
continue continue
# Known device — per-host updates (t21 F2): each host is # Known device — IP change (t21 F2, collapsed by t41): there is
# tracked independently against the discovery address. # ONE address field now, so the discovery address simply IS the
# - a blank (auto-detect) host self-heals from the address; # entry's address. The per-host dance (blank self-heal / stale
# - a stale protocol host is the IP change and is updated; # protocol host / deliberately different ADB host) only existed
# - a deliberately-different ADB host (an explicit override # because the address was stored twice; it went with the
# that differs from both the address and the stored # duplication.
# protocol host) is never clobbered.
protocol_host = entry.options.get(CONF_PROTOCOL_HOST, "")
adb_host = entry.options.get(CONF_ADB_HOST, "")
options = dict(entry.options) options = dict(entry.options)
if protocol_host != host: if entry_host(entry) != host:
options[CONF_PROTOCOL_HOST] = host options[CONF_HOST] = host
if not adb_host or adb_host == protocol_host:
# blank (self-heal) or auto-synced alongside the protocol
# host — safe to follow the device address on discovery
options[CONF_ADB_HOST] = host
if options != entry.options: if options != entry.options:
# t29 G3a: the deprecated update+schedule-reload combo is # t29 G3a: the deprecated update+schedule-reload combo is
# gone (2026.12 error) — the entry updates ONLY here; the # gone (2026.12 error) — the entry updates ONLY here; the
@@ -433,25 +404,26 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_user() return await self.async_step_user()
def _create_entry(self): def _create_entry(self):
"""Create the config entry from stored user data.""" """Create the config entry from stored user data.
Everything the integration needs lives in the OPTIONS (t41: one host
field among them); ``data`` stays empty because no value has to be
immutable.
"""
d = self._user_data d = self._user_data
entry_data = {}
if d.get(CONF_ANDROIDTV_ENTITY):
entry_data[CONF_ANDROIDTV_ENTITY] = d[CONF_ANDROIDTV_ENTITY]
return self.async_create_entry( return self.async_create_entry(
title=d["name"], title=d["name"],
data=entry_data, data={},
options={ options={
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""), CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""), CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
CONF_RECEIVER_INPUT: d.get(CONF_RECEIVER_INPUT, ""), CONF_RECEIVER_INPUT: d.get(CONF_RECEIVER_INPUT, ""),
CONF_POWER_ENTITY: d.get(CONF_POWER_ENTITY, ""), CONF_POWER_ENTITY: d.get(CONF_POWER_ENTITY, ""),
CONF_ADB_ENABLED: d.get(CONF_ADB_ENABLED, False), CONF_ADB_ENABLED: d.get(CONF_ADB_ENABLED, True),
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True), CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""), CONF_HOST: d.get(CONF_HOST, ""),
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT), CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, False), CONF_PROTOCOL_ENABLED: d.get(CONF_PROTOCOL_ENABLED, True),
CONF_PROTOCOL_HOST: d.get(CONF_PROTOCOL_HOST, ""),
}, },
) )
@@ -461,9 +433,6 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_protocol_pair() return await self.async_step_protocol_pair()
return self._create_entry() return self._create_entry()
def _protocol_host(self) -> str | None:
"""Protocol host: explicit override, else the ADB host fallback."""
return self._user_data.get(CONF_PROTOCOL_HOST) or self._resolve_adb_host()
def _discard_protocol_client(self) -> None: def _discard_protocol_client(self) -> None:
"""Drop a pairing client whose session became void (host corrected).""" """Drop a pairing client whose session became void (host corrected)."""
@@ -478,12 +447,20 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
Certificates live under ``.storage/<domain>/`` (core androidtv_remote Certificates live under ``.storage/<domain>/`` (core androidtv_remote
helpers.py pattern) and are shared between pairing and runtime. Returns helpers.py pattern) and are shared between pairing and runtime. Returns
None when no host is resolvable or ``androidtvremote2`` is missing — ``(client, None)`` or ``(None, error_key)`` — no host, a missing
the caller turns that into an error key, never a crash. ``androidtvremote2`` and an unreachable Shield are all keys the form can
show, never an exception out of the step.
t44 F1: androidtvremote2 raises ``CannotConnect`` from both calls below,
and the protocol transport is ON by default, so an asleep, rebooting or
mis-addressed Shield is the ordinary case rather than an edge one. Left
uncaught it escaped the config flow entirely, and the protocol_pair form
— the address field AND the "continue without pairing" escape — never
rendered.
""" """
host = self._protocol_host() host = self._host()
if not host: if not host:
return None return None, "protocol_no_host"
certfile, keyfile = await self.hass.async_add_executor_job( certfile, keyfile = await self.hass.async_add_executor_job(
protocol_cert_paths, self.hass protocol_cert_paths, self.hass
) )
@@ -496,19 +473,26 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if client is None: if client is None:
# build_protocol_client already logged the missing library; # build_protocol_client already logged the missing library;
# pairing cannot start, and the form still offers the skip path # pairing cannot start, and the form still offers the skip path
return None return None, "protocol_unavailable"
await client.async_generate_cert_if_missing() try:
await client.async_start_pairing() await client.async_generate_cert_if_missing()
return client await client.async_start_pairing()
except Exception as err: # noqa: BLE001 - the library's error IS the finding
_LOGGER.warning(
"protocol pairing could not start at %s (%s: %s) — showing the "
"form so the address can be corrected or pairing skipped",
host,
type(err).__name__,
err,
)
with contextlib.suppress(Exception):
client.disconnect()
return None, "protocol_unreachable"
return client, None
async def _async_begin_protocol_pairing(self): async def _async_begin_protocol_pairing(self):
"""Start a PIN pairing session: ``(client, None)`` or ``(None, error)``.""" """Start a PIN pairing session: ``(client, None)`` or ``(None, error)``."""
if not self._protocol_host(): return await self._start_protocol_pairing()
return None, "protocol_no_host"
client = await self._start_protocol_pairing()
if client is None:
return None, "protocol_unavailable"
return client, None
def _show_protocol_pair_form(self, errors=None): def _show_protocol_pair_form(self, errors=None):
"""protocol_pair form: the PIN, the address, and the skip escape. """protocol_pair form: the PIN, the address, and the skip escape.
@@ -522,7 +506,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_PAIRING_CODE): str, vol.Required(CONF_PAIRING_CODE): str,
_host_field_key(CONF_PROTOCOL_HOST, self._protocol_host() or ""): str, _host_field_key(CONF_HOST, self._host()): str,
vol.Optional(CONF_SKIP_PAIRING, default=False): bool, vol.Optional(CONF_SKIP_PAIRING, default=False): bool,
} }
), ),
@@ -582,12 +566,12 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input.get(CONF_SKIP_PAIRING): if user_input.get(CONF_SKIP_PAIRING):
return await self._async_skip_protocol_pairing() return await self._async_skip_protocol_pairing()
typed_host = str(user_input.get(CONF_PROTOCOL_HOST) or "").strip() typed_host = str(user_input.get(CONF_HOST) or "").strip()
if typed_host and typed_host != (self._protocol_host() or ""): if typed_host and typed_host != self._host():
# A host typed HERE is authoritative — the user step is gone, so # A host typed HERE is authoritative — the user step is gone, so
# the session started against the old address is dropped and the # the session started against the old address is dropped and the
# Shield is asked for a NEW code at the corrected address (t35 F1). # Shield is asked for a NEW code at the corrected address (t35 F1).
self._user_data[CONF_PROTOCOL_HOST] = typed_host self._user_data[CONF_HOST] = typed_host
self._discard_protocol_client() self._discard_protocol_client()
code = str(user_input.get(CONF_PAIRING_CODE, "") or "").strip() code = str(user_input.get(CONF_PAIRING_CODE, "") or "").strip()
@@ -636,19 +620,17 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
""" """
block = dict(data or {}) block = dict(data or {})
name = block.get("name") or DEFAULT_NAME name = block.get("name") or DEFAULT_NAME
# the block's address comes from `host`, or from the legacy keys
block_host = seed_host(block)
seed_unique_id = block.get("unique_id") seed_unique_id = block.get("unique_id")
if not seed_unique_id: if not seed_unique_id:
host = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST) seed_unique_id = f"yaml:{name}:{block_host or 'nohost'}"
seed_unique_id = f"yaml:{name}:{host or 'nohost'}"
# duplicate guards # duplicate guards
yhost = block.get(CONF_ADB_HOST) or block.get(CONF_PROTOCOL_HOST)
for existing in self._async_current_entries(): for existing in self._async_current_entries():
ehost = existing.options.get(CONF_PROTOCOL_HOST) or existing.options.get( ehost = entry_host(existing)
CONF_ADB_HOST
)
if existing.unique_id == seed_unique_id or ( if existing.unique_id == seed_unique_id or (
yhost and ehost == yhost block_host and ehost == block_host
): ):
_LOGGER.info( _LOGGER.info(
"nvidia_shield YAML seed for %r matches an existing " "nvidia_shield YAML seed for %r matches an existing "
@@ -664,18 +646,16 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# block actually carries an adb host. # block actually carries an adb host.
self._user_data = { self._user_data = {
"name": name, "name": name,
CONF_ADB_ENABLED: bool(block.get(CONF_ADB_HOST)), # t32 F4's shape rule on the t41 keys: an address names the Shield,
CONF_ADB_HOST: block.get(CONF_ADB_HOST, ""), # and an explicit adb_enabled says otherwise
CONF_ADB_PORT: block.get(CONF_ADB_PORT, DEFAULT_ADB_PORT), CONF_ADB_ENABLED: seed_adb_enabled(block),
# protocol prefill hints ONLY (pairing stays interactive) CONF_HOST: block_host,
CONF_PROTOCOL_HOST: block.get(CONF_PROTOCOL_HOST, ""), CONF_ADB_PORT: int(block.get(CONF_ADB_PORT) or DEFAULT_ADB_PORT),
} }
# t33 F-A: the seed host feeds the same user-step prefill as a # t33 F-A: the seed host feeds the same user-step prefill as a
# zeroconf discovery, but WITHOUT the discovery announcement — the # zeroconf discovery, but WITHOUT the discovery announcement — the
# address came from configuration.yaml, not from the network. # address came from configuration.yaml, not from the network.
self._seed_host = self._user_data[CONF_PROTOCOL_HOST] or self._user_data[ self._seed_host = block_host
CONF_ADB_HOST
]
return await self.async_step_user(None) return await self.async_step_user(None)
async def async_step_reauth(self, entry_data): async def async_step_reauth(self, entry_data):
@@ -696,7 +676,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._user_data = { self._user_data = {
"name": entry.title, "name": entry.title,
CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""), CONF_HOST: entry_host(entry),
} }
return await self.async_step_protocol_pair() # first visit re-pairs return await self.async_step_protocol_pair() # first visit re-pairs
return self.async_show_form( return self.async_show_form(
@@ -722,12 +702,10 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# the entry already knows (and _create_entry is never reached here). # the entry already knows (and _create_entry is never reached here).
self._user_data = { self._user_data = {
"name": entry.title, "name": entry.title,
CONF_ANDROIDTV_ENTITY: entry.data.get(CONF_ANDROIDTV_ENTITY, ""),
CONF_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False), CONF_ADB_ENABLED: entry.options.get(CONF_ADB_ENABLED, False),
CONF_ADB_HOST: entry.options.get(CONF_ADB_HOST, ""), CONF_HOST: entry_host(entry),
CONF_ADB_PORT: entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT), CONF_ADB_PORT: entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: entry.options.get(CONF_PROTOCOL_HOST, ""),
} }
return await self.async_step_protocol_pair() return await self.async_step_protocol_pair()
@@ -744,7 +722,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"options": { "options": {
**entry.options, **entry.options,
CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: self._protocol_host() or "", CONF_HOST: self._host(),
}, },
} }
if mac: if mac:
@@ -767,17 +745,25 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
options={ options={
**entry.options, **entry.options,
CONF_PROTOCOL_ENABLED: True, CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: self._user_data.get(CONF_PROTOCOL_HOST, ""), CONF_HOST: self._host(),
}, },
) )
self._reauth_entry = None self._reauth_entry = None
return self.async_abort(reason="reauth_successful") return self.async_abort(reason="reauth_successful")
def _resolve_adb_host(self) -> str | None: def _host(self) -> str:
"""Resolve ADB host from explicit config or the Android TV entity.""" """The Shield's address as this flow knows it (t41: ONE field).
return self._user_data.get(CONF_ADB_HOST) or discover_adb_host(
self.hass, self._user_data.get(CONF_ANDROIDTV_ENTITY) What the user typed (or a seed carried) lives in ``_user_data``; the
) discovery/seed hints stay as fallbacks for a form that was rendered with
a pre-filled field and submitted unchanged.
"""
return str(
self._user_data.get(CONF_HOST)
or self._discovered_host
or self._seed_host
or ""
).strip()
def _show_adb_pair_form(self, errors=None): def _show_adb_pair_form(self, errors=None):
"""adb_pair form: the address/port (fixable here) and the skip escape. """adb_pair form: the address/port (fixable here) and the skip escape.
@@ -790,7 +776,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="adb_pair", step_id="adb_pair",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
_host_field_key(CONF_ADB_HOST, self._resolve_adb_host() or ""): str, _host_field_key(CONF_HOST, self._host()): str,
vol.Optional( vol.Optional(
CONF_ADB_PORT, CONF_ADB_PORT,
default=self._user_data.get( default=self._user_data.get(
@@ -821,10 +807,10 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# re-adding the integration. # re-adding the integration.
return await self._async_after_pairing() return await self._async_after_pairing()
typed_host = str(user_input.get(CONF_ADB_HOST) or "").strip() typed_host = str(user_input.get(CONF_HOST) or "").strip()
if typed_host: if typed_host:
# a host typed HERE is authoritative (the user step is gone) # a host typed HERE is authoritative (the user step is gone)
self._user_data[CONF_ADB_HOST] = typed_host self._user_data[CONF_HOST] = typed_host
port = user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT) port = user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
try: try:
@@ -833,7 +819,7 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
port = DEFAULT_ADB_PORT port = DEFAULT_ADB_PORT
self._user_data[CONF_ADB_PORT] = port self._user_data[CONF_ADB_PORT] = port
host = self._resolve_adb_host() host = self._host()
if not host: if not host:
errors["base"] = "adb_no_host" errors["base"] = "adb_no_host"
else: else:
@@ -883,6 +869,11 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
"""Single-page options.""" """Single-page options."""
errors = {} errors = {}
if user_input is not None: if user_input is not None:
# t47 F1: a field submitted as None means "not supplied" — only an
# explicit empty value ('' or a cleared selector) clears a setting, so
# the two cannot be confused. Note the schema itself already tolerates
# the empty value (see _ClearingEntitySelector).
user_input = {key: value for key, value in user_input.items() if value is not None}
# P5-2: validate the digit delay (0.05–1.0 s, §7 window) # P5-2: validate the digit delay (0.05–1.0 s, §7 window)
delay = user_input.pop(CONF_CHANNEL_DIGIT_DELAY, None) delay = user_input.pop(CONF_CHANNEL_DIGIT_DELAY, None)
if delay is not None: if delay is not None:
@@ -893,6 +884,23 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
delay = None delay = None
if delay is not None and not (0.05 <= delay <= 1.0): if delay is not None and not (0.05 <= delay <= 1.0):
errors["base"] = "invalid_digit_delay" errors["base"] = "invalid_digit_delay"
# t44: one selector per command, stored as a single dict option so
# the service layer reads one shape. Popped here because _options_dict
# is updated with the rest of user_input verbatim below.
scripts = {
command: script
for command in SCRIPT_COMMANDS
if (
script := str(
user_input.pop(f"{SCRIPT_FIELD_PREFIX}{command}", "") or ""
).strip()
)
}
# t47: one optional script for the remote entity — a plain string
# option, unlike the per-command dict above
remote_script = user_input.get(CONF_REMOTE_COMMAND_SCRIPT)
if remote_script is not None:
user_input[CONF_REMOTE_COMMAND_SCRIPT] = str(remote_script).strip()
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None) app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
icons: dict[str, str] = {} icons: dict[str, str] = {}
if app_map_text is None: if app_map_text is None:
@@ -912,6 +920,8 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
# truth, so a save without icons clears them (and a # truth, so a save without icons clears them (and a
# single-column map stays valid). # single-column map stays valid).
self._options_dict[CONF_APP_ICON_MAP] = icons self._options_dict[CONF_APP_ICON_MAP] = icons
# an empty dict clears every override (the editor is the truth)
self._options_dict[CONF_COMMAND_SCRIPTS] = scripts
return self.async_create_entry(title="", data=self._options_dict) return self.async_create_entry(title="", data=self._options_dict)
sel = _build_selectors() sel = _build_selectors()
@@ -936,6 +946,18 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
recv_key = vol.Optional(CONF_RECEIVER_ENTITY) recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
schema_dict[recv_key] = sel["receiver"] schema_dict[recv_key] = sel["receiver"]
# t44: command script overrides — the escape hatch for hardware we do not
# drive (a speaker switch owning the volume). Unset = native path as before.
schema_dict[vol.Optional(CONF_REMOTE_COMMAND_SCRIPT)] = sel["script"]
configured_scripts = command_scripts(self._options_dict)
suggested_scripts = {}
for command in SCRIPT_COMMANDS:
field = f"{SCRIPT_FIELD_PREFIX}{command}"
schema_dict[vol.Optional(field)] = sel["script"]
if configured_scripts.get(command):
suggested_scripts[field] = configured_scripts[command]
# P5-2: channel digit behavior (the user-validated §7 flow) # P5-2: channel digit behavior (the user-validated §7 flow)
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
@@ -974,12 +996,14 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
pwr_key = vol.Optional(CONF_POWER_ENTITY) pwr_key = vol.Optional(CONF_POWER_ENTITY)
schema_dict[pwr_key] = sel["power"] schema_dict[pwr_key] = sel["power"]
# ADB options # Transport toggles (t41: both transports are ours; the skip paths and
# this page are the way to turn either one off)
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
# ADB host/port (optional overrides, shown when ADB is enabled) # The ONE address field (t41), plus the ADB port
schema_dict[vol.Optional(CONF_ADB_HOST)] = str schema_dict[vol.Optional(CONF_HOST)] = str
schema_dict[vol.Optional(CONF_ADB_PORT)] = int schema_dict[vol.Optional(CONF_ADB_PORT)] = int
# t29 G3b: suggested values come from the entry options via the # t29 G3b: suggested values come from the entry options via the
@@ -987,7 +1011,8 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
data_schema=self.add_suggested_values_to_schema( data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema_dict), self._options_dict vol.Schema(schema_dict),
{**self._options_dict, **suggested_scripts},
), ),
errors=errors, errors=errors,
) )
+84 -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,12 +29,10 @@ 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 # t33 F-B: the "continue without pairing" escape offered on BOTH pairing
# steps — the entry still finishes (passive mode) instead of stranding the # steps — the entry still finishes (passive mode) instead of stranding the
@@ -47,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"
@@ -147,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
@@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.redact import async_redact_data 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**"
@@ -81,7 +82,14 @@ 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,
+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))
@@ -13,7 +13,7 @@
"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."
], ],
+356 -76
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
@@ -53,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,
@@ -66,7 +66,11 @@ from .const import (
DOMAIN, DOMAIN,
PROTOCOL_MEDIA_KEYS, PROTOCOL_MEDIA_KEYS,
PROTOCOL_REMOTE_KEYS, PROTOCOL_REMOTE_KEYS,
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
@@ -102,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,
@@ -121,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
@@ -153,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
@@ -164,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 = []
@@ -195,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:
@@ -275,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
@@ -379,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
) )
@@ -399,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
@@ -568,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 ──
@@ -583,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:
@@ -606,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):
@@ -825,7 +1010,16 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
} }
) )
@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()
@@ -861,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
@@ -894,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)
@@ -936,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)
@@ -977,7 +1256,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
removed a name media_player has not exported for a long time, which removed a name media_player has not exported for a long time, which
made every real browse raise. made every real browse raise.
""" """
app_map = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY app_map = self._app_directory
children = [ children = [
BrowseMedia( BrowseMedia(
title=name, title=name,
@@ -1073,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 (
+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
+43 -40
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__)
@@ -65,9 +66,9 @@ 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_translation_key = "current_app" _attr_translation_key = "current_app"
@@ -75,7 +76,6 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
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:
@@ -83,56 +83,59 @@ 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):
+36 -20
View File
@@ -3,45 +3,41 @@
"step": { "step": {
"user": { "user": {
"title": "NVIDIA Shield", "title": "NVIDIA Shield",
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, the Nvidia Remote Protocol, or ADB. Link the devices and services you want to use.", "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": "Name", "name": "Name",
"androidtv_entity": "Android TV Remote entity",
"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": "Read state over ADB", "adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show title and artist", "adb_store_app_info": "Show title and artist",
"adb_host": "ADB host (IP address)", "host": "The Shield's IP address",
"adb_port": "ADB port", "adb_port": "ADB port",
"protocol_enabled": "Add the Nvidia Remote Protocol", "protocol_enabled": "Add the Nvidia Remote Protocol"
"protocol_host": "Protocol host (IP address)"
}, },
"data_description": { "data_description": {
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.",
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.", "jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
"receiver_entity": "The receiver or soundbar this Shield plays through.", "receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.", "receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.", "power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).", "adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \u2014 change it only if you need a different address.", "host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.", "adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step.", "protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
"protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host."
} }
}, },
"adb_pair": { "adb_pair": {
"title": "Allow ADB access", "title": "Allow ADB access",
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.", "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": { "data": {
"adb_host": "ADB host (IP address)", "host": "The Shield's IP address",
"adb_port": "ADB port", "adb_port": "ADB port",
"skip_pairing": "Continue without ADB pairing" "skip_pairing": "Continue without ADB pairing"
}, },
"data_description": { "data_description": {
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.", "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.", "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." "skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later."
} }
@@ -51,12 +47,12 @@
"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'.", "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",
"protocol_host": "Protocol host (IP address)", "host": "The Shield's IP address",
"skip_pairing": "Continue without protocol pairing" "skip_pairing": "Continue without protocol pairing"
}, },
"data_description": { "data_description": {
"pairing_code": "The code the Shield displays while this step is open.", "pairing_code": "The code the Shield displays while this step is open.",
"protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.", "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." "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."
} }
}, },
@@ -67,11 +63,12 @@
}, },
"error": { "error": {
"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_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
"adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).", "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": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).", "protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.", "protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.", "protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
"no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, 'Add the Nvidia Remote Protocol', or 'Read state over ADB'.", "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.",
"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_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."
}, },
@@ -101,14 +98,23 @@
"receiver_input": "Receiver input", "receiver_input": "Receiver input",
"power_entity": "Power switch", "power_entity": "Power switch",
"adb_enabled": "Read state over ADB", "adb_enabled": "Read state over ADB",
"protocol_enabled": "Add the Nvidia Remote Protocol",
"adb_store_app_info": "Show title and artist", "adb_store_app_info": "Show title and artist",
"adb_host": "ADB host (IP address)", "host": "The Shield's IP address",
"adb_port": "ADB port", "adb_port": "ADB port",
"app_name_map": "App names and icons", "app_name_map": "App names and icons",
"adb_command_enabled": "Enable the adb_command service", "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": "Instant app detection (logcat stream)" "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 Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.", "jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
@@ -117,13 +123,15 @@
"power_entity": "A smart plug switch, if you want reliable on/off detection.", "power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).", "adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.", "host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
"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.",
"adb_port": "The ADB port. The standard value is 5555.", "adb_port": "The ADB port. The standard value is 5555.",
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.", "app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.", "adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.",
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).", "channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).",
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.", "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." "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."
} }
} }
} }
@@ -156,6 +164,14 @@
"adb_unavailable": { "adb_unavailable": {
"title": "ADB unavailable", "title": "ADB unavailable",
"message": "The ADB connection of {entity_id} is down \u2014 command not sent." "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": { "entity": {
@@ -3,45 +3,41 @@
"step": { "step": {
"user": { "user": {
"title": "NVIDIA Shield", "title": "NVIDIA Shield",
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 an Android TV Remote entity, the Nvidia Remote Protocol, or ADB. Link the devices and services you want to use.", "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": "Name", "name": "Name",
"androidtv_entity": "Android TV Remote entity",
"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": "Read state over ADB", "adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show title and artist", "adb_store_app_info": "Show title and artist",
"adb_host": "ADB host (IP address)", "host": "The Shield's IP address",
"adb_port": "ADB port", "adb_port": "ADB port",
"protocol_enabled": "Add the Nvidia Remote Protocol", "protocol_enabled": "Add the Nvidia Remote Protocol"
"protocol_host": "Protocol host (IP address)"
}, },
"data_description": { "data_description": {
"androidtv_entity": "The media_player entity from the Android TV Remote integration. Leave blank if you use the Nvidia Remote Protocol or ADB instead.",
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.", "jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
"receiver_entity": "The receiver or soundbar this Shield plays through.", "receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.", "receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.", "power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).", "adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered, or when an Android TV Remote entity is set \u2014 change it only if you need a different address.", "host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.", "adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step.", "protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs a one-time PIN pairing right after this step."
"protocol_host": "The Shield's IP address for the remote protocol. Pre-filled when the Shield was discovered \u2014 leave blank to use the ADB host."
} }
}, },
"adb_pair": { "adb_pair": {
"title": "Allow ADB access", "title": "Allow ADB access",
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.", "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": { "data": {
"adb_host": "ADB host (IP address)", "host": "The Shield's IP address",
"adb_port": "ADB port", "adb_port": "ADB port",
"skip_pairing": "Continue without ADB pairing" "skip_pairing": "Continue without ADB pairing"
}, },
"data_description": { "data_description": {
"adb_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.", "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.", "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." "skip_pairing": "Finishes setup now and leaves ADB unpaired. The Pair ADB button stays available on the device page, so you can pair later."
} }
@@ -51,12 +47,12 @@
"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'.", "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",
"protocol_host": "Protocol host (IP address)", "host": "The Shield's IP address",
"skip_pairing": "Continue without protocol pairing" "skip_pairing": "Continue without protocol pairing"
}, },
"data_description": { "data_description": {
"pairing_code": "The code the Shield displays while this step is open.", "pairing_code": "The code the Shield displays while this step is open.",
"protocol_host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.", "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." "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."
} }
}, },
@@ -67,11 +63,12 @@
}, },
"error": { "error": {
"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_failed": "The Shield did not accept the ADB connection. Make sure Network debugging is on, approve the Allow prompt on the Shield, then select Submit to try again.",
"adb_no_host": "No IP address for the Shield yet. Fill in the ADB host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).", "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": "No IP address for the Shield yet. Fill in the protocol host field above (a discovered Shield, or one linked to an Android TV Remote entity, is pre-filled automatically).", "protocol_no_host": "No IP address for the Shield yet. Fill in the host field above (a discovered Shield is pre-filled automatically).",
"protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.", "protocol_pair_failed": "The Shield rejected that pairing code. Check the code on the Shield and select Submit to try again.",
"protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.", "protocol_unavailable": "The androidtvremote2 library is not installed, so the remote protocol cannot pair. Install it and restart Home Assistant, or turn on 'Continue without protocol pairing' to finish setup anyway.",
"no_state_source": "No state source selected. Choose at least one: an Android TV Remote entity, 'Add the Nvidia Remote Protocol', or 'Read state over ADB'.", "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.",
"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_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."
}, },
@@ -101,14 +98,23 @@
"receiver_input": "Receiver input", "receiver_input": "Receiver input",
"power_entity": "Power switch", "power_entity": "Power switch",
"adb_enabled": "Read state over ADB", "adb_enabled": "Read state over ADB",
"protocol_enabled": "Add the Nvidia Remote Protocol",
"adb_store_app_info": "Show title and artist", "adb_store_app_info": "Show title and artist",
"adb_host": "ADB host (IP address)", "host": "The Shield's IP address",
"adb_port": "ADB port", "adb_port": "ADB port",
"app_name_map": "App names and icons", "app_name_map": "App names and icons",
"adb_command_enabled": "Enable the adb_command service", "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": "Instant app detection (logcat stream)" "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 Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.", "jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
@@ -117,13 +123,15 @@
"power_entity": "A smart plug switch, if you want reliable on/off detection.", "power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.", "adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).", "adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"adb_host": "The Shield's IP address. Leave blank to use the Android TV Remote entity's address.", "host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
"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.",
"adb_port": "The ADB port. The standard value is 5555.", "adb_port": "The ADB port. The standard value is 5555.",
"app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.", "app_name_map": "One entry per line, package=name (for example ar.tvplayer.tv=TiviMate), with an optional |icon after the name. Leave blank to clear.",
"adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.", "adb_command_enabled": "Adds the nvidia_shield.adb_command service (a raw ADB shell command over the persistent connection). Security-relevant: leave it off unless you need it \u2014 the same opt-in posture as the core androidtv integration.",
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).", "channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).",
"channel_send_enter": "Presses ENTER after the last digit (protocol key first, then the ADB keyevent fallback). Off by default, matching TiviMate's tested behavior.", "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." "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."
} }
} }
} }
@@ -156,6 +164,14 @@
"adb_unavailable": { "adb_unavailable": {
"title": "ADB unavailable", "title": "ADB unavailable",
"message": "The ADB connection of {entity_id} is down \u2014 command not sent." "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": { "entity": {
+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")
+111 -4
View File
@@ -42,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"
@@ -63,6 +94,7 @@ 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:
@@ -172,9 +204,30 @@ 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")
@@ -307,12 +360,19 @@ def install() -> None:
exceptions = types.ModuleType("homeassistant.exceptions") exceptions = types.ModuleType("homeassistant.exceptions")
class ServiceValidationErrorStubError(Exception): class HomeAssistantErrorStubError(Exception):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args or (kwargs.get("translation_key"),)) super().__init__(*args or (kwargs.get("translation_key"),))
self.translation_domain = kwargs.get("translation_domain") self.translation_domain = kwargs.get("translation_domain")
self.translation_key = kwargs.get("translation_key") 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 exceptions.ServiceValidationError = ServiceValidationErrorStubError
sys.modules["homeassistant.exceptions"] = exceptions sys.modules["homeassistant.exceptions"] = exceptions
@@ -400,7 +460,7 @@ def install() -> 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: "",
@@ -455,9 +515,51 @@ def install() -> None:
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()
@@ -471,6 +573,11 @@ def load_entity_modules() -> dict:
"media_player", "media_player",
"remote", "remote",
"diagnostics", "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",
): # "init" maps the integration __init__.py (t29 G8 async_setup) ): # "init" maps the integration __init__.py (t29 G8 async_setup)
if name == "init" and "nsh.init" in sys.modules: if name == "init" and "nsh.init" in sys.modules:
+420 -3
View File
@@ -30,7 +30,24 @@ dependency against an INSTALLED homeassistant:
must exist in the real signature — the ``options_updates`` class of bug; 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 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 is compared against the real definition: a coroutine must be awaited, and an
awaited call must actually be a coroutine. 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): Known limits (deliberate, and why the sweep still catches the real classes):
``**kwargs`` splats cannot be checked statically, calls on objects other than ``**kwargs`` splats cannot be checked statically, calls on objects other than
@@ -99,8 +116,11 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
#: 60 imports, 35 checked arguments, 7 awaited HA calls) so a broken or #: 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. #: mis-targeted walk fails loudly instead of printing zeros and exiting 0.
FLOORS: dict[str, int] = { FLOORS: dict[str, int] = {
"modules": 10, # 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, "imports": 40,
"relative_imports": 100,
"call_args": 20, "call_args": 20,
"awaited_calls": 5, "awaited_calls": 5,
} }
@@ -136,6 +156,7 @@ class Report:
findings: list[Finding] = field(default_factory=list) findings: list[Finding] = field(default_factory=list)
modules: int = 0 modules: int = 0
imports: int = 0 imports: int = 0
relative_imports: int = 0
call_args: int = 0 call_args: int = 0
awaited_calls: int = 0 awaited_calls: int = 0
@@ -157,6 +178,7 @@ class Report:
return ( return (
"no Home-Assistant API drift " "no Home-Assistant API drift "
f"({self.modules} modules, {self.imports} imports, " f"({self.modules} modules, {self.imports} imports, "
f"{self.relative_imports} relative imports, "
f"{self.call_args} call arguments, " f"{self.call_args} call arguments, "
f"{self.awaited_calls} awaited HA calls)" f"{self.awaited_calls} awaited HA calls)"
) )
@@ -166,6 +188,19 @@ class Report:
# --------------------------------------------------------------------- imports # --------------------------------------------------------------------- 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: def _check_import(module_name: str, attr: str | None) -> str | None:
"""Return a reason string when the import target is missing, else None.""" """Return a reason string when the import target is missing, else None."""
try: try:
@@ -223,6 +258,206 @@ def _scan_imports(tree: ast.AST, path: str, report: Report) -> None:
) )
# ------------------------------------------------------- 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 # -------------------------------------------------------------------- keywords
@@ -439,6 +674,183 @@ def parse_requires_python(spec: str | None) -> tuple[int, ...] | None:
return tuple(int(part) for part in match.group(1).split(".")) 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 # ----------------------------------------------------------------------- scan
@@ -455,6 +867,11 @@ def scan(paths=None) -> Report:
paths = [paths] paths = [paths]
report = Report() 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(): if not homeassistant_available():
# Neither a false green nor a wall of phantom findings: with no package # Neither a false green nor a wall of phantom findings: with no package
# to resolve against, every import would "fail", so the sweep reports # to resolve against, every import would "fail", so the sweep reports
@@ -484,7 +901,7 @@ def scan(paths=None) -> Report:
) )
) )
continue continue
files = sorted(root.glob("*.py")) if root.is_dir() else [root] files = _iter_python_files(root)
for file in files: for file in files:
report.modules += 1 report.modules += 1
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file)) tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
+248 -42
View File
@@ -13,7 +13,7 @@ import sys
import entity_stublib import entity_stublib
import pytest import pytest
from conftest import _SHIELD_DIR from conftest import _SHIELD_DIR
from entity_stublib import ENTITIES_STUB_NAMES, install, load_entity_modules 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
@@ -21,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:
@@ -116,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()
@@ -123,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 = []
@@ -248,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:
@@ -346,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)
@@ -393,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
@@ -411,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
@@ -1002,7 +1031,8 @@ class TestEntryDiagnostics:
options={ options={
"adb_command_enabled": True, "adb_command_enabled": True,
"protocol_pairing_code": "4821", "protocol_pairing_code": "4821",
"adb_host": "192.168.1.50", "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", "protocol_host": "192.168.1.50",
}, },
title="Shield", title="Shield",
@@ -1020,6 +1050,7 @@ class TestEntryDiagnostics:
# old import pointed at homeassistant.util.diagnostic, which does not # old import pointed at homeassistant.util.diagnostic, which does not
# exist, so this pass silently did nothing and only the marker scan # exist, so this pass silently did nothing and only the marker scan
# (pairing-/key-shaped names) masked anything. # (pairing-/key-shaped names) masked anything.
assert payload["entry"]["options"]["host"] == "**REDACTED**"
assert payload["entry"]["options"]["adb_host"] == "**REDACTED**" assert payload["entry"]["options"]["adb_host"] == "**REDACTED**"
assert payload["entry"]["options"]["protocol_host"] == "**REDACTED**" assert payload["entry"]["options"]["protocol_host"] == "**REDACTED**"
assert payload["entry"]["data"]["nested"]["adb_host"] == "**REDACTED**" assert payload["entry"]["data"]["nested"]["adb_host"] == "**REDACTED**"
@@ -1172,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"]
+6 -16
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,
@@ -127,11 +127,7 @@ def _install_import_stubs() -> None:
helpers.device_registry = device_registry helpers.device_registry = device_registry
sys.modules["homeassistant.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
@@ -164,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: "",
@@ -189,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
+88 -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,
@@ -219,11 +220,7 @@ def _install_stubs() -> None:
helpers.device_registry = device_registry helpers.device_registry = device_registry
sys.modules["homeassistant.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
@@ -278,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):
@@ -296,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
@@ -350,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()
@@ -374,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
@@ -396,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",
) )
@@ -411,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")) == []
+152
View File
@@ -12,9 +12,12 @@ The sweep itself lives in ``tests/ha_api_drift.py`` (documented there).
from __future__ import annotations from __future__ import annotations
import ast import ast
import importlib
import subprocess import subprocess
import sys import sys
import threading
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
import ha_api_drift import ha_api_drift
import pytest import pytest
@@ -206,3 +209,152 @@ def test_the_declared_python_floor_never_lags_homeassistant():
f"but the installed homeassistant requires {declared} — raise the constant " f"but the installed homeassistant requires {declared} — raise the constant "
f"and the setup-python version in .gitea/workflows/test.yml" 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:]
+37 -14
View File
@@ -39,19 +39,14 @@ _INIT_STUB_NAMES = (
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _integration_module(): def _integration_module():
"""HA stubs + the integration __init__ module under nsh.init.""" """HA stubs + the integration __init__ module under nsh.init."""
snapshot = {name: sys.modules.get(name) for name in _INIT_STUB_NAMES} snapshot = entity_stublib.snapshot_modules()
entity_stublib.install() entity_stublib.install()
if "nsh.init" not in sys.modules: if "nsh.init" not in sys.modules:
import conftest import conftest
conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py") conftest._load_module("nsh.init", conftest._SHIELD_DIR / "__init__.py")
yield sys.modules["nsh.init"] yield sys.modules["nsh.init"]
for name in _INIT_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 _SeedEntriesStub: class _SeedEntriesStub:
@@ -119,8 +114,9 @@ class TestYamlSeedBlocks:
"""_yaml_seed_blocks: the optional domain key → normalized seeds.""" """_yaml_seed_blocks: the optional domain key → normalized seeds."""
def test_block_normalized(self, _integration_module): def test_block_normalized(self, _integration_module):
from nsh.const import CONF_ADB_HOST, CONF_ADB_PORT, DEFAULT_ADB_PORT 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( blocks = _integration_module._yaml_seed_blocks(
[{"name": "Living", "adb_host": "10.0.0.31"}] [{"name": "Living", "adb_host": "10.0.0.31"}]
) )
@@ -128,20 +124,31 @@ class TestYamlSeedBlocks:
assert len(blocks) == 1 assert len(blocks) == 1
seed = blocks[0] seed = blocks[0]
assert seed["name"] == "Living" assert seed["name"] == "Living"
assert seed[CONF_ADB_HOST] == "10.0.0.31" assert seed[CONF_HOST] == "10.0.0.31"
assert seed[CONF_ADB_PORT] == DEFAULT_ADB_PORT assert seed[CONF_ADB_PORT] == DEFAULT_ADB_PORT
assert seed["adb_enabled"] is True assert seed["adb_enabled"] is True
assert "unique_id" not in seed # optional key only 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): def test_single_block_and_protocol_hint(self, _integration_module):
blocks = _integration_module._yaml_seed_blocks( blocks = _integration_module._yaml_seed_blocks(
{"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000} {"name": "Desk", "protocol_host": "shield.local", "adb_port": 5000}
) )
assert len(blocks) == 1 assert len(blocks) == 1
# protocol fields are prefill hints ONLY (pairing stays interactive) # a legacy protocol-only block still seeds, with ADB left OFF (t32 F4)
assert blocks[0]["protocol_host"] == "shield.local" assert blocks[0]["host"] == "shield.local"
assert blocks[0]["adb_port"] == 5000 assert blocks[0]["adb_port"] == 5000
assert blocks[0]["adb_enabled"] is False
def test_list_and_single_block_shapes(self, _integration_module): def test_list_and_single_block_shapes(self, _integration_module):
single = _integration_module._yaml_seed_blocks({"name": "A"}) single = _integration_module._yaml_seed_blocks({"name": "A"})
@@ -166,7 +173,23 @@ class TestSeeding:
domain, context, data = hass.config_entries.flows[0] domain, context, data = hass.config_entries.flows[0]
assert domain == "nvidia_shield" assert domain == "nvidia_shield"
assert context["source"] == "import" assert context["source"] == "import"
assert data["adb_host"] == "10.0.0.5" 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): async def test_hostless_block_never_seeds(self, _integration_module):
hass = _SeedHass(entries=[]) hass = _SeedHass(entries=[])
@@ -229,12 +252,12 @@ class TestImportStep:
return flow return flow
async def test_ui_configured_host_never_re_seeded(self): async def test_ui_configured_host_never_re_seeded(self):
from nsh.const import CONF_ADB_HOST from nsh.const import CONF_HOST
existing = SimpleNamespace( existing = SimpleNamespace(
entry_id="ui1", entry_id="ui1",
unique_id=None, unique_id=None,
options={CONF_ADB_HOST: "10.0.0.31"}, options={CONF_HOST: "10.0.0.31"},
) )
# options live on the entry (conf zeroconf guard reads them) # options live on the entry (conf zeroconf guard reads them)
flow = self._flow([existing]) flow = self._flow([existing])
+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() == ""
+159 -175
View File
@@ -9,6 +9,7 @@ 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 gc
import logging
import sys import sys
import types import types
import warnings import warnings
@@ -16,16 +17,16 @@ from pathlib import Path # noqa: E402 (F1 test key staging)
from types import SimpleNamespace from types import SimpleNamespace
from unittest import mock from unittest import mock
import entity_stublib
import pytest import pytest
import voluptuous_mirror as vol_mirror 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_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT, CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY, CONF_ANDROIDTV_ENTITY,
CONF_HOST,
CONF_PROTOCOL_ENABLED, CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
) )
_STUB_NAMES = ( _STUB_NAMES = (
@@ -214,11 +215,7 @@ def _install_stubs() -> None:
helpers.device_registry = device_registry helpers.device_registry = device_registry
sys.modules["homeassistant.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
@@ -262,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():
@@ -289,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
@@ -306,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()
@@ -345,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:
@@ -380,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,
} }
@@ -398,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):
@@ -517,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."""
@@ -567,69 +620,33 @@ class TestChannelEntryOptions:
assert result["errors"] == {"base": "invalid_digit_delay"} assert result["errors"] == {"base": "invalid_digit_delay"}
class TestUniqueIdSourceG2: class TestUniqueIdSource:
"""t29 G2: no unique_id equals the androidtv entity_id anymore.""" """t41: no unique_id is derived from another integration, at any stage.
def test_entity_unique_id_fallback_is_gone(self): t29 G2 replaced the renameable entity_id with a device-MAC derive; t41
from pathlib import Path removes the entity path entirely, so the ONLY identity source left is the
protocol MAC learned during pairing (``async_set_unique_id(mac)``).
"""
base = Path(__file__).parent.parent / "custom_components" / "nvidia_shield" def test_no_entity_derived_unique_id_remains(self):
flow_src = (base / "config_flow.py").read_text() flow_src = (_SHIELD_DIR / "config_flow.py").read_text(encoding="utf-8")
# the historic renameable fallback is gone from the user step
assert "_unique_id_from_atv_device" not in flow_src
assert "async_set_unique_id(androidtv_entity)" not in flow_src assert "async_set_unique_id(androidtv_entity)" not in flow_src
# the documented replacement: a device-MAC derive, else no unique_id # no registry lookups for another integration's devices either
assert "_unique_id_from_atv_device" in flow_src assert "device_registry" not in flow_src
def test_entity_rename_never_moves_the_unique_id(self):
"""The MAC-derived id is rename-proof; a MAC-less entity has none."""
async def test_the_user_step_sets_no_unique_id(self, tmp_path):
module = _form_module() module = _form_module()
# a registered atv entity whose device carries a MAC flow = module.NvidiaShieldConfigFlow()
entity = SimpleNamespace(domain="media_player", device_id="dev1", platform="lgtv") flow.hass = FakeHass(str(tmp_path))
device = SimpleNamespace( flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
connections=[("mac", None), ("mac", "aa:bb:cc:dd:ee:ff")] recorded = []
) flow.async_set_unique_id = _record(recorded)
er_stub = module.er
er_stub.async_get = lambda hass: SimpleNamespace(
entities={"media_player.shield": entity}
)
class _DevRegistry:
def __init__(self, by_id):
self._by_id = by_id
def async_get(self, device_id): await flow.async_step_user(_user_input(**{CONF_PROTOCOL_ENABLED: True}))
return self._by_id.get(device_id)
module.dr.async_get = lambda hass: _DevRegistry({"dev1": device}) assert recorded == []
# the resolved unique_id is the MAC — not the entity_id
uid = module._unique_id_from_atv_device(
FakeHass("/tmp"), "media_player.shield"
)
assert uid == "aa:bb:cc:dd:ee:ff"
# renaming the entity does not affect the resolved value
er_stub.async_get = lambda hass: SimpleNamespace(
entities={"media_player.renamed": entity}
)
uid_renamed = module._unique_id_from_atv_device(
FakeHass("/tmp"), "media_player.renamed"
)
assert uid_renamed == "aa:bb:cc:dd:ee:ff" # stable across renames
def test_macless_entity_yields_no_unique_id(self):
module = _form_module()
er_stub = module.er
er_stub.async_get = lambda hass: SimpleNamespace(entities={}) # unregistered
assert (
module._unique_id_from_atv_device(FakeHass("/tmp"), "media_player.x")
is None
)
class TestListenerComboGone: class TestListenerComboGone:
@@ -816,10 +833,10 @@ class TestIpChangeSelfHealF1:
return SimpleNamespace( return SimpleNamespace(
entry_id="e1", entry_id="e1",
title="Shield", title="Shield",
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"}, data={},
options={ options={
CONF_ADB_ENABLED: True, CONF_ADB_ENABLED: True,
CONF_ADB_HOST: host, CONF_HOST: host, # t41: the entry's one address
CONF_ADB_PORT: 5555, CONF_ADB_PORT: 5555,
}, },
runtime_data=None, runtime_data=None,
@@ -856,11 +873,7 @@ class TestIpChangeSelfHealF1:
# the config-flow zeroconf path updates the entry's hosts via # the config-flow zeroconf path updates the entry's hosts via
# async_update_entry — the NEXT built poller targets the NEW address # async_update_entry — the NEXT built poller targets the NEW address
entry.options = { entry.options = {**entry.options, CONF_HOST: "192.168.1.77"}
**entry.options,
CONF_ADB_HOST: "192.168.1.77",
CONF_PROTOCOL_HOST: "192.168.1.77",
}
second = factory() second = factory()
assert second is not None assert second is not None
@@ -915,7 +928,7 @@ class TestMacLessDiscoveryF2:
entry = SimpleNamespace( entry = SimpleNamespace(
entry_id="e1", entry_id="e1",
unique_id=None, # MAC-less entry unique_id=None, # MAC-less entry
options={CONF_PROTOCOL_HOST: "192.168.1.50"}, options={CONF_HOST: "192.168.1.50"},
title="Shield", title="Shield",
) )
self._holder().entries[:] = [entry] self._holder().entries[:] = [entry]
@@ -976,7 +989,7 @@ class TestSeedBlockShapeF4:
) )
assert blocks[0]["adb_enabled"] is False assert blocks[0]["adb_enabled"] is False
assert blocks[0]["adb_host"] == "" assert blocks[0]["host"] == "shield.local" # t41: one address field
def test_adb_block_keeps_adb_on(self): def test_adb_block_keeps_adb_on(self):
import conftest import conftest
@@ -1031,8 +1044,7 @@ class TestZeroconfDiscovery:
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert flow._discovered_host == "192.168.1.50" assert flow._discovered_host == "192.168.1.50"
defaults = vol_mirror.defaults_by_key(result["data_schema"]) defaults = vol_mirror.defaults_by_key(result["data_schema"])
assert defaults[CONF_ADB_HOST] == "192.168.1.50" assert defaults[CONF_HOST] == "192.168.1.50"
assert defaults[CONF_PROTOCOL_HOST] == "192.168.1.50"
# ...and a discovery-driven form says the address is already known # ...and a discovery-driven form says the address is already known
note = result["description_placeholders"]["discovery_note"] note = result["description_placeholders"]["discovery_note"]
assert "Discovered Shield at 192.168.1.50" in note assert "Discovered Shield at 192.168.1.50" in note
@@ -1056,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",
) )
@@ -1067,9 +1079,8 @@ 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 == [] # update-only (G3a)
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):
@@ -1078,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",
) )
@@ -1099,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))
@@ -1108,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 == [] # update-only (G3a)
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",
) )
@@ -1150,8 +1143,7 @@ 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 == [] # update-only (G3a)
@@ -1220,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
@@ -1235,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")
@@ -1243,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"